☰
Lua脚本实现滑动窗口:限流、数据聚合与滤波实战
2026/10/5 8:09:46 网站建设 项目流程

我第一次认真从头实现滑动窗口,是在给一个内部接口做限流的时候。当时业务量一上来,固定窗口限流每到时间片临界点就会明显出现一波“双倍放行”,接口直接被瞬时流量打出了大量超时。折腾到半夜才意识到,光换算法还不够,还得用Lua脚本把“判断当前窗口计数”和“写入新记录”放进同一段原子操作里。也就是从那次开始,我才算真正把“滑动窗口”从面经里的一个名词,变成了一行行能跑的Lua代码。

这篇文章聊的就是Lua脚本实现滑动窗口这个主题。我会按自己在生产环境里的落地经验,把滑动窗口在限流、数据流聚合、数值滤波这三类最常见的场景分别拆开来讲,给出可以直接抄作业的代码,再把时间精度、内存、并发这些容易埋雷的细节单独拎出来说一遍。适合正在被限流算法困扰的后端开发,也适合在Lua/OpenResty环境里做数据统计、监控面板或者信号处理的同学参考。

1. 滑动窗口要解决的核心问题

1.1 滑动窗口到底是什么

滑动窗口的核心其实很简单:在一条不断往前推进的时间轴或者数据流上,划定一个固定宽度的区间,只关心这个区间里的数据。窗口每前进一格,就会把已经移出区间的旧内容丢掉,同时纳入新到的内容。这个“只盯着最近一小块,其他全部忽略”的思路,可以把无穷无尽的数据流,变成有边界、可计算、可预测的有限集合。

很多人第一次接触这个概念是在算法题里:给定一个数组,找出每个长度为k的子数组的最大值或最小值;还有一些人是在信道的滑动窗口重传协议里认识它的;更多做后端的人,则是在做系统限流时发现固定窗口的边界问题,才想起来用滑动窗口做平滑统计。这几个场景看起来风马牛不相及,但底层其实都是同一个抽象——在动态序列上维护一个固定大小的子集,随着时间或者数据索引的推进不断更新这个子集。

1.2 时间窗口和计数窗口怎么选

按实现维度来分,滑动窗口通常有两条路。一种是按事件数量来划窗口,比如“最近10个请求内最多允许3次”;另一种是按时间来划窗口,比如“最近5秒内最多30次”。前者适合数据量有限、节奏相对稳定的场景,用队列就能搞定;后者更适合线上流量的限流控制,因为时间粒度能更真实地还原压力分布。

在Lua脚本里,这两种路径的实现思路是一样的,只是窗口的“边界”从一条条数据变成了时间戳。按事件数量的窗口,用数组或者队列存最近N次事件的索引;按时间划的窗口,则要依赖时钟,把当前时间戳和窗口长度相减得到一个下界,凡是比这个下界更老的记录都可以清掉。很多人在时间窗口上踩坑,不是算法不会写,而是时间单位、时间来源没统一,后面第4节会详细展开。

1.3 滑动窗口和固定窗口的本质差异

还有个特别容易被忽略的点:滑动窗口和固定窗口之间有本质区别。固定窗口是每个时间片独立计数,比如“每秒最多100次”,用Redis的INCR加EXPIRE就能实现,简单粗暴。但它天生有一个漏洞:如果上一秒的最后10毫秒已经用了100次,下一秒的前10毫秒又用了100次,那这20毫秒内实际通过了200次,相当于把限制放大了一倍。

滑动窗口的价值就在于,它不会因为时间片边界而放松统计。统计口径变成“从当前时刻回看一个窗口长度”,等于把边界从“整秒的墙”换成了“流动的水”。流量控制这类讲究精确的场景,几乎只能用滑动窗口,这也是它常驻限流方案列表的根本原因。

2. 限流场景:基于时间戳的滑动窗口实现

限流是Lua脚本实现滑动窗口最经典的生产场景。在OpenResty环境里,Lua脚本既是业务的入口,也是做限流的天然位置——它处在请求处理的早期阶段,拦截成本最低。更重要的是,OpenResty的Lua模块天生就能和Redis配合,滑动窗口的计数可以统一存在Redis里,多个worker、多台机器之间共享状态,不会出现单机计数各算各的尴尬。

2.1 为什么固定窗口限流不够用

想理解滑动窗口限流为什么一定要写成Redis脚本,可以先看看固定窗口的边界问题。假设限流策略是“每秒最多100次”,常规做法是用INCR和EXPIRE,在某个key上累加计数,到点自动过期。这个方案足够简单,但它的统计范围是死的,从某个整秒起点到整秒终点。线上的请求分布不可能像节拍器一样均匀,它总会集中在某些瞬间,一旦相邻两个窗口边缘的数据叠加,瞬时流量就会超过预期。

更麻烦的是,这种超出在监控面板上根本看不出来,因为按秒拆开统计时,每一秒的计数都没有超过100。只有把统计粒度细化到“从当前时间往前推5秒一共处理了多少请求”,才能真正把“瞬时流量超过限流阈值”这个问题暴露出来并拦住。

2.2 用Redis有序集合实现原子滑动窗口脚本

实际生产里,我最推荐用Redis的有序集合ZSET来存请求的时间戳。ZSET的score天然适合存时间,ZREMRANGEBYSCORE可以一行命令删掉窗口之外的数据,ZCARD能在常数时间内拿到窗口内记录数,整套逻辑写成一段EVAL脚本交给Redis执行,Redis单线程的特性会保证脚本原子运行,不会出现两个请求同时读到同一个count然后双双放行的竞态。

直接看代码,这段Lua脚本可以作为EVAL的参数直接跑在Redis上:

-- KEYS[1]: 限流key,通常取 "rl:" + 用户ID或接口名 -- ARGV[1]: 当前时间(推荐毫秒) -- ARGV[2]: 窗口长度(毫秒) -- ARGV[3]: 窗口内最大请求数 local key = KEYS[1] local current = tonumber(ARGV[1]) local window_ms = tonumber(ARGV[2]) local max_count = tonumber(ARGV[3]) -- 1. 清理窗口之外的所有历史记录 redis.call('ZREMRANGEBYSCORE', key, 0, current - window_ms) -- 2. 统计窗口内的请求数 local count = redis.call('ZCARD', key) -- 3. 判断是否允许通过 if count < max_count then -- 加入一条新记录:时间戳作为score,member用"时间戳:随机后缀" -- 避免同一毫秒内多个请求被当成同一个成员而互相覆盖 local tick = redis.call('TIME') local now_ms = tonumber(tick[1]) * 1000 + math.floor(tonumber(tick[2]) / 1000) redis.call('ZADD', key, now_ms, current .. ":" .. math.random(1, 99999)) -- 给key设置一个略大于窗口长度的过期时间,防止长期无流量时内存堆积 redis.call('PEXPIRE', key, window_ms * 2) return 1 end return 0

调用方式很简单,假设窗口5秒、最多30次,命令行执行:

redis-cli EVAL "$(cat sliding_limit.lua)" 1 rl:user_123 1735689600000 5000 30

返回1代表放行,返回0代表本次请求被限流。

这段脚本的核心步骤其实就是“清老记录、查数量、决定是否放行”这三步。有两个细节值得注意。第一,ZSET的member必须唯一,如果直接用当前时间戳做member,同一毫秒两个并发请求会生成相同的时间戳,ZADD就会用后一个覆盖前一个,导致计数莫名丢失。第二,过期时间我一般设置为窗口长度的2倍,防止窗口边界处恰好key过期,也防止没有流量的key一直占着内存。

2.3 部署到OpenResty里的完整过程

如果你用的是OpenResty,可以把上面的脚本内嵌到content_by_lua_block里,通过resty.redis模块执行。下面这段代码基本可以直接用作一个限流接口的骨架:

location /api/limited { content_by_lua_block { local redis = require "resty.redis" local red = redis.new() red:set_timeout(100) local ok, err = red:connect("127.0.0.1", 6379) if not ok then ngx.say("限流服务不可用") return end local user_id = ngx.var.arg_user or "anonymous" local script = [[ local key = KEYS[1] local current = tonumber(ARGV[1]) local window_ms = tonumber(ARGV[2]) local max_count = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', key, 0, current - window_ms) local count = redis.call('ZCARD', key) if count < max_count then redis.call('ZADD', key, current, current .. ':' .. math.random(1, 999999)) redis.call('PEXPIRE', key, window_ms * 2) return 1 end return 0 ]] local current_ms = ngx.now() * 1000 local res, err = red:evalsha_or_eval(script, 1, "rl:" .. user_id, current_ms, 5000, 30) if res == 1 then ngx.say("ok") else ngx.status = 429 ngx.say("too many requests") end } }

这里千万要注意一点:脚本内部的current时间,应该从OpenResty的ngx.now()获取并统一转换成毫秒,不要又用os.time()又用ngx.now()混着来。我曾经在真实项目里见过脚本里用os.time()取秒,外层Redis的过期时间用毫秒,最后限流完全失灵的例子。所有时间相关变量,统一单位是最基本的要求。

2.4 高并发场景下的“时间桶”改造

ZSET这套方案精确度高,但有一个小代价:每个请求都要往ZSET里插入一条member,窗口内请求数一多,内存会跟着涨。如果窗口很短,比如几秒钟,那么窗口内的记录一般也就几百个,完全没压力。但如果窗口是一小时甚至一天,又希望能精确限流,那ZSET的钱包会有点hold不住。

这时候可以牺牲一点精度,把时间离散成固定大小的桶,比如每100毫秒一桶,每桶只记一个计数器。窗口内需要维护的元素数量就从“请求数”降到了“桶数”,内存占用会小很多。具体实现如下:

-- KEYS[1]: 限流key -- ARGV[1]: 当前毫秒 -- ARGV[2]: 窗口长度(毫秒) -- ARGV[3]: 最大请求数 local key = KEYS[1] local current = tonumber(ARGV[1]) local window_ms = tonumber(ARGV[2]) local max_count = tonumber(ARGV[3]) local bucket_ms = 100 -- 每100毫秒一个桶 local current_bucket = math.floor(current / bucket_ms) local oldest_bucket = math.floor((current - window_ms) / bucket_ms) -- 清理窗口外的旧桶 redis.call('ZREMRANGEBYSCORE', key, 0, oldest_bucket) local count = redis.call('ZCARD', key) if count < max_count then -- 当前桶计数+1 redis.call('ZINCRBY', key, 1, current_bucket) redis.call('PEXPIRE', key, window_ms * 2) return 1 end return 0

时间桶方案的本质是“用桶的粒度换内存”,统计误差最多一个桶(100毫秒),绝大多数业务完全可以接受。我在高并发接口上用的就是这套,实测下来稳定性比全量ZSET好不少,Redis的CPU占用也降下来了。

3. 数据流场景:滑动窗口的聚合计算与滤波

除了限流,另一类常见场景是用滑动窗口处理数值流。比如监控指标里,计算最近N个数据点的平均值,判断当前链路是否抖动;比如传感器波形、股票K线,需要在一串连续数据里找到滑动窗口的最大值、最小值。这一节我把三种最常用的运算单独讲清楚。

3.1 先看最直白的遍历法

最直接的做法是,每来一个新数据,就把窗口里所有元素重新遍历一遍。窗口大小为k,每个数据点要付出O(k)的计算成本。数据量小、窗口短的时候没问题,但数据量一大,这个开销会让人看得心慌。以滑动窗口最大值为例:

-- 朴素版本:每来一个新元素,遍历整个窗口 local function sliding_max_naive(nums, k) local result = {} for i = k, #nums do local m = nums[i - k + 1] for j = i - k + 2, i do if nums[j] > m then m = nums[j] end end result[#result + 1] = m end return result end

这个版本正确性没问题,但窗口长度一旦上到几千,每来一个数据都要几千次比较,处理几百万条数据基本就跑不动了。所以在数据量大的场景里,我不会直接用这个版本。

3.2 用单调队列在O(1)内算出滑动最大值和最小值

更高效的方法是维护一个单调双端队列,让队首始终是当前窗口的最大值。每个元素最多入队一次、出队一次,整体均摊复杂度是O(1)。Lua里没有原生的双端队列,我用两个索引指针head和tail模拟,效果一样,代码也容易看懂:

-- 用单调队列计算滑动窗口最大值 local function sliding_max(nums, k) local result = {} local q = {} -- 队列存的是nums的下标 local head, tail = 1, 0 for i = 1, #nums do -- 1. 弹出已经滑出窗口的下标 while head <= tail and q[head] <= i - k do head = head + 1 end -- 2. 从队尾依次弹出所有比当前元素小或等于的下标 -- 保证队列内元素值严格递减 while head <= tail and nums[q[tail]] <= nums[i] do tail = tail - 1 end -- 3. 当前下标入队 tail = tail + 1 q[tail] = i -- 4. 窗口形成后,队首就是当前窗口最大值 if i >= k then result[#result + 1] = nums[q[head]] end end return result end -- 验证 local nums = {1, 3, -1, -3, 5, 3, 6, 7} local maxs = sliding_max(nums, 3) -- 输出: 3, 3, 5, 5, 6, 7

这段代码的核心思想是:如果一个新到来的元素比队尾的某些元素都大,那么那些老元素在后续滑动过程中永远不可能成为最大值了,因为它们既更小又更早过期,留着纯属浪费空间,直接弹掉。维护这个单调递减队列之后,队首永远指向窗口最大值,代价极小。

如果你要的是滑动窗口最小值,就把第2步的条件从<=改成>=,让队列变成单调递增即可。整个模板只有一个符号的差别,非常方便。

3.3 滑动平均值和指数加权滤波

滑动平均也是最常见的滑动窗口应用之一,比如仪表盘上的最近5分钟CPU平均值。用Lua实现时,不需要维护队列,只需要用一个累加器,每次加新值、减掉滑出窗口的旧值:

-- 计算滑动窗口均值,返回长度等于输入减去k+1的结果数组 local function sliding_mean(values, k) local sum, result = 0, {} for i = 1, #values do sum = sum + values[i] if i > k then sum = sum - values[i - k] end if i >= k then result[#result + 1] = sum / k end end return result end

这种等权滑动平均对窗口内所有数据一视同仁。但如果你希望越近的数据权重越大,比如实时监控告警响应速度要求高,那就更适合用指数加权滑动平均(EWMA)。它在Lua里实现起来更简单:

-- 指数加权滑动平均(EWMA) -- alpha取值0~1,越大代表越偏向新数据 local function ewma(values, alpha) local result = {} local avg = values[1] result[1] = avg for i = 2, #values do avg = alpha * values[i] + (1 - alpha) * avg result[i] = avg end return result end

EWMA本质上是一个“权重按指数衰减”的滑动窗口,旧数据永远留在平均值里,只是权重越来越小。它在很多实时指标计算里非常实用,代码量还少得惊人。

3.4 窗口大小和权重怎么给定

窗口大小是关键参数,但很多人第一次直接拍脑袋定一个。窗口偏大,数据趋势更平滑,但滞后更明显;窗口偏小,响应更快,但噪声滤不干净。做监控的时候,我一般用两三秒的窗口做实时告警,用三十到六十秒的窗口做趋势展示。如果是传感器滤波,窗口大小通常由信号的频率决定,比如采样频率是100Hz,那10个点的窗口对应100毫秒,足够滤掉一部分高频噪声。

如果拿不准,最务实的做法是把窗口大小和EWMA的alpha都做成配置项,拿到真实数据以后再调参,不要一开始就写死在代码里。阈值调参这种事,纸上谈兵永远比不过实际跑出来的曲线。

4. 写Lua滑动窗口时容易忽略的四个坑

Lua滑动窗口看似代码量不大,但真正上了生产环境,坑全在细节里。时间精度、内存复用、并发竞态、数值误差……任何一个没处理到位,窗口就是不“滑”的。

4.1 时间精度不统一

限流场景里,窗口是“时间长度”,必然要跟时间戳打交道。如果只精确到秒,高并发限流就会出大问题。同一秒内两个请求如果被当成同一个时刻,ZSET的member会冲突,窗口内的计数也会失真。比如窗口5秒,前4秒已经放了40个请求,最后一秒突然进来100个请求,秒级时间戳根本区分不了这100个请求的先后,ZADD时大量member重复,计数直接丢失。

解决办法就是统一用毫秒,甚至微秒。OpenResty的ngx.now()返回的是秒,要乘以1000再取整。Redis脚本里也可以用redis.call('TIME')拿到服务器的秒数和微秒数,自己拼一个毫秒时间戳,这样整个判定过程用的是同一把“时钟”,而不是传进来的外部时间。系统时钟和业务逻辑混用之前,先对齐单位。

4.2 用table.remove模拟队列的代价

Lua没有原生队列,很多新手习惯用table.remove(t, 1)把队首弹掉。这个写法在小窗口下没问题,但窗口大、数据量大时,table.remove会把数组中后面的所有元素整体前移,时间复杂度是O(n),整个滑动窗口计算直接退化到O(n*k)。

我在3.2节中用head和tail两个指针就是为了避开这个问题,队首元素用指针移动代替物理删除。不过指针方案有个隐形成本:head和tail会不断增大,数组里会留下一些永远用不到的“空洞”。解决办法是定期重建数组,比如每处理1万个元素,就把q[head..tail]拷贝到新表,然后重置指针。这个重建操作本身是O(窗口长度),均摊下来非常便宜。

4.3 原子性、member唯一性和Redis阻塞

回到限流场景,很多人写Redis Lua脚本时容易漏掉一个细节:同一毫秒两个并发请求会生成相同的时间戳member,ZADD会互相覆盖,计数丢失,限流失效。所以member里一定要拼随机后缀,或者其他唯一因子。

另一个常见误解是对Redis原子性的理解。Redis是单线程执行EVAL脚本的,脚本运行期间不会插入其他命令,“检查count、ZADD”这两步天然是原子的,不需要额外的分布式锁。但也正因为单线程,如果脚本里有大循环或者一次处理几十万条ZSET记录,整个Redis会被阻塞,其他请求全部排队。滑动窗口限流脚本本身很轻,问题不大;可一旦你为了精确把窗口拉长到一小时,ZSET里堆积的记录数以十万计,每次请求都做ZREMRANGEBYSCORE加ZCARD,CPU开销还是会涨上来。真到那一步,就该果断换第2.4节的时间桶方案。

4.4 浮点累加误差

Lua里所有数字都是double,做滑动均值时,如果持续用sum = sum - old + new这种增量更新,浮点误差会一点一点累积。数据跨度一大,时序图上就会看到本不应该出现的“阶梯状”毛刺。这在监控场景里非常容易误导人。

我的处理方式是:每处理N个数据点,主动重算一次窗口内的全量sum,把误差周期性清零。N的选择取决于你对精度的容忍度,一般取窗口长度的10倍即可。这样一个微小的重建代价,换来了长期的数值稳定。

5. 常见问题与排查实录

5.1 先看一张问题速查表

我把多个项目里遇到的典型问题整理成了一张速查表,排查时基本可以按图索骥:

现象可能原因快速排查方式
限流窗口刚过一半就大量拒绝请求时间戳单位不一致,秒和毫秒混用打印ARGV[1]与Redis内的时间戳对比
计数不准,窗口内请求数明显偏少ZSET的member冲突,同一时刻被覆盖ZADD时拼上随机后缀
高频下Redis CPU飙升ZSET记录太多,每请求全量清理改用时间桶方案
滑动均值曲线出现台阶状毛刺浮点累加误差累积每N个点重算一次全量sum
滑动最大值结果不正确单调队列维护方向反了输出每个i的q内容,检查单调方向
Redis偶发key提前过期过期时间设得比窗口短PEXPIRE设为窗口长度的2倍以上

5.2 案例:限流“失效”竟是时钟不同步

有次上线滑动窗口限流,测试环境一切正常,放量后某个时段的瞬时流量却完全没被拦住。排查了很久,逻辑没有任何问题,最后发现是调用方传的“当前毫秒”用的是应用服务器的本地时间,而Redis里存的时间戳是另一台机器的时间,两台机器时钟差了200多毫秒。窗口边界一错位,清理老记录的时候把不该清的清了,计数统计自然就失效了。

从那以后我养成了两个习惯:一是所有机器统一做NTP对时,偏差控制在几十毫秒内;二是限流脚本里不再透传外部时间,直接在Redis脚本里用redis.call('TIME')拿服务器时间,保证整个判定过程用的是同一把“时钟”。时间来源不统一这个坑,比算法本身的难度大得多。

5.3 案例:滑动平均曲线出现阶梯毛刺

做某个传感器数据平滑时,滑动平均曲线每隔一段时间就会出现一个不太明显的向上跳变。数据源是稳定的,不是信号问题,最后定位到是累加器一直在做sum = sum - old + new,长时间跑下来浮点误差越积越大。我在窗口每推进1000个点后,主动重新遍历窗口内数据算一次sum,毛刺立刻消失了。很多时候,看起来是数据问题,实际是数值计算的细节问题。

6. 最后的几点实操体会

最后分享几个我觉得价值比较高的经验。第一,Lua脚本里不要做“教科书式”的过度抽象。滑动窗口的核心逻辑就那么几段,用函数包起来、参数暴露出来就够了,没必要为了照顾通用性加一堆配置层,后期反而没人敢改。第二,任何跟时间相关的滑动窗口,都要在脚本开头统一单位,注释写清楚“本脚本默认毫秒”,同事接手时就不用靠猜。第三,加监控,把“窗口内当前计数”作为一个指标暴露出来,配合限流拒绝数,你才能真正看清流量在窗口内的真实分布,而不是等线上出了故障再去翻日志。

在限流场景里,如果你的量级不大,滑动窗口甚至可以不用Redis,直接在OpenResty的共享字典里存时间戳也能跑起来,代码更轻。但要跨多个worker或者多台机器,还是建议用Redis,毕竟共享字典是每worker一份的,多worker之间天然存在偏差。说到底,滑动窗口只是一种算法,怎么把它嵌进具体业务,选什么存储、什么粒度、什么权重,才是真实工程里更值得琢磨的部分。

这个主题的后续扩展方向也很多:给均值加上不同权重变成加权滑动平均,配合时间桶降低高频场景的内存消耗,在OpenResty里把整套逻辑封装成通用限流模块,或者跟消息队列的消费速率联动做动态限流。我这边最近正在把手里的限流模块改成动态窗口,窗口长度和阈值会根据系统当前负载自动调整,基础逻辑已经跑通,等稳定了再单独写一篇分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询