Redis 实用型限流与延时队列:从 Lua 固定/滑动窗口到 Streams 消费组(含脚本与压测)
本文是能直接落地的一篇:讲清算法差异,给出可复用的 Lua 脚本 & Node/Python 调用示例,并教你做可靠消费、重试、监控与容量估算。文末提供可下载脚本包与压测脚本。
1. 选型速览(什么时候用哪种)
场景 | 推荐 | 说明 |
---|---|---|
API 限流,按用户/令牌/接口,每分钟 N 次 | 固定窗口(Fixed Window) | 简单高效;边界时刻有突刺(burst) |
更平滑、按任意滑动窗口统计 | 滑动窗口(Sliding Window,ZSet/Lua) | 精确统计,写放大;适合高价值接口 |
服务端排队、定时任务按时间触发 | ZSet 延时队列 | score=readyAt,配合原子 claim/ack |
多消费者、可靠消费与重投 | Streams 消费组 | 自带 PEL/ack/reclaim,近似 MQ |
2. 固定窗口限流(Fixed Window)
思想:某个维度(如 uid
)在窗口期 t
内计数,超过阈值拒绝。
Lua(原子)
-- KEYS[1]=key ARGV[1]=limit ARGV[2]=windowSec
local c = redis.call('INCR', KEYS[1])
if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
if c > tonumber(ARGV[1]) then
return {0, c, redis.call('PTTL', KEYS[1])} -- {允许?, 当前计数, 剩余TTL(ms)}
else
return {1, c, redis.call('PTTL', KEYS[1])}
end
Node.js 调用示例(ioredis)
import fs from 'node:fs'
import Redis from 'ioredis'
const r = new Redis(process.env.REDIS_URL)
const sha = await r.script('load', fs.readFileSync('rate_limit_fixed.lua','utf8'))
const key = `rl:uid:${uid}:60`
const [ok, count, ttl] = await r.evalsha(sha, 1, key, 100, 60)
if (!ok) throw new Error('Too Many Requests')
优缺点
- ✅ 极快、占用小;
- ⚠️ 窗口边界可能出现“59s 内 0 次 + 1s 内 100 次”的突刺(可接受则用它)。
3. 滑动窗口限流(Sliding Window)
思想:用 ZSet 存最近 t
内的时间戳,实时淘汰过期项;当前元素数即窗口内请求数。
Lua(精确滑窗)
-- KEYS[1]=zset; ARGV: now(ms), window(ms), limit, memberId(unique)
local now = tonumber(ARGV[1]); local win = tonumber(ARGV[2]); local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - win)
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
local ttl = redis.call('PTTL', KEYS[1]); if ttl < 0 then redis.call('PEXPIRE', KEYS[1], win) end
return {0, count, ttl}
end
redis.call('ZADD', KEYS[1], now, ARGV[4]); redis.call('PEXPIRE', KEYS[1], win)
return {1, count + 1, win}
何时选它
- 金融/库存等高价值接口;
- 需要“随时点往前
t
秒都不超过N
次”的严格语义。
成本提示:每次写 1 个元素,并维护 ZSet;高 QPS 下注意内存(见 §8)。
4. 延时队列(ZSet 版)
生产者:ZADD delayQ <readyAtMillis> jobJSON
消费者循环:
- 原子claim:把
score <= now
的任务移动到processing
,设置可见期(visibility timeout); - 处理成功
ACK
;失败或超时由requeue脚本放回。
Lua 片段(claim/ack/requeue)
-- claim: KEYS[1]=ready, KEYS[2]=processing; ARGV[1]=now, ARGV[2]=limit, ARGV[3]=processingTTLms
-- 返回被领取的 job 列表(字符串)
-- ack: KEYS[1]=processing; ARGV[1]=job -> ZREM
-- requeue_expired: KEYS[1]=processing, KEYS[2]=ready; ARGV[1]=now
特性
- ✅ 简单、可做延迟/重试退避(把 score 往后推);
- ⚠️ 需要自己维护可见期与幂等(见下一节)。
5. Streams 消费组(可靠消费)
概念:XADD
写入;XGROUP
创建消费组;消费者 XREADGROUP GROUP g c ...
读取,处理后 XACK
。未确认消息记录在 Pending Entries List (PEL) 中,可用 XCLAIM
抢回超时消息。
最小流程
XADD order:stream * orderId 1001 status CREATED
XGROUP CREATE order:stream g1 $ MKSTREAM
XREADGROUP GROUP g1 c1 COUNT 10 BLOCK 5000 STREAMS order:stream >
XACK order:stream g1 1692340123-0
XPENDING order:stream g1 # 看积压
XCLAIM order:stream g1 c2 60000 1692340123-0 # 抢回超时消息
适用:需要至少一次消费、可重放/补偿的队列场景;天然多消费者与追踪“未确认”。
6. 可靠性与重试策略(强烈建议照搬)
- 幂等:业务侧必须可重试(订单号/请求号唯一);Redis 脚本只保证原子性,不保证业务 Exactly-once。
- 可见期:ZSet 队列设置
processingTTL
;Streams 用XREADGROUP
+XACK
+XCLAIM
。 - 退避重试:指数退避
backoff = min(base*2^retry, max)
,超过maxRetry
进入 DLQ。 - 监控阈值:
- ZSet:
ZCARD(delayQ)
与ZCARD(processing)
; - Streams:
XLEN
、XPENDING
、各消费者idle
时间; - 限流:允许/拒绝比(命中率)、拒绝分布。
- ZSet:
7. 监控与报警
INFO stats|memory|keyspace
:instantaneous_ops_per_sec
、内存、命中率;- 慢脚本:SLOWLOG,Lua 不要执行耗时 I/O;
- Prometheus Exporter:抓
redis_exporter
指标;自定义业务指标(允许/拒绝/重试次数)写 Prom; - 仪表盘建议:
- 限流:
allow_rate
、block_rate
、p50/p99 延迟
; - 队列:
ready_len / processing_len / XPENDING
、ack 率
、requeue 率
; - 容量:
used_memory
、evicted_keys
、hit_ratio
。
- 限流:
8. 容量估算(算清楚再上)
8.1 滑动窗口(ZSet)内存
- 每个请求写入一条 ZSet 记录;估算:
Mem ≈ 用户数 * (QPS_user * 窗口秒) * bytes_per_entry
bytes_per_entry
依 value 长度而定,50–120B 粗估(含元数据)。- 例:2 万用户高峰每人 5 QPS、窗口 60s → 2e4560=6e6 条 → 约 6e6*100B ≈ 600MB。
8.2 ZSet 延时队列
ready + processing
的元素个数近似为待处理任务 + 正在处理任务;按 payload 大小估计。- 控制保留:处理完成后删除/归档;或保留最近 N 分钟做幂等对照。
8.3 Streams
- 使用
XTRIM MAXLEN ~ N
控制长度:
XTRIM order:stream MAXLEN ~ 1e6
(近似裁剪,开销更低)。 - 单条大小≈字段名+值长度;payload 建议压缩或用字段化结构。
9. 压测方法与参考脚本
9.1 一键启动
docker run -d --name redis -p 6379:6379 redis:7
9.2 固定/滑动窗口压测(Node)
# 需要脚本:rate_limit_fixed.lua / rate_limit_sliding.lua + ioredis
node bench_fixed.js
node bench_sliding.js
# 输出示例(JSON):{ "algo": "fixed", "allowed": 10000, "blocked": 10000, "ms": 320 }
9.3 延时队列吞吐压测(ZSet)
node bench_delayq.js
# 输出示例:{ "delayq":"zset","tasks":10000,"workers":8,"ms":1450 }
观察指标:整体耗时、每秒处理数、CPU 占用、Redis 慢日志、ready/processing 长度变化。
10. 最佳实践清单(上线前逐条勾)
- 限流 key 规范:
rl:<algo>:<dim>:<id>
;随机TTL 抖动防同刻雪崩。 - Lua 返回结构明确,错误码与剩余 TTL都给到。
- 队列消费幂等:业务层唯一键/去重表;失败 N 次入 DLQ 并告警。
- Streams 开启最小空闲时间(
XCLAIM ... MINIDLE
),定时XPENDING
/XINFO
巡检。 - 监控就绪:Prometheus + Grafana;关键指标报警。
- 容量评估 + 压测通过,内存上限
maxmemory
与淘汰策略已配置。 - 预案:Redis 宕机/主从切换时的限流降级与队列积压处理策略。
11. 附:完整脚本与基准工具包(可直接用)
我已把文中用到的 Lua 脚本 + Node 基准脚本 + Docker Compose 打成一个可下载工具包:
下载:redis-rlq-toolkit.zip(CSDN下载)
包含:
rate_limit_fixed.lua
、rate_limit_sliding.lua
、delayq_claim/ack/requeue.lua
、bench_fixed/bench_sliding/bench_delayq.js
、docker-compose.yml
与 README。解压后按 README 即可跑通。
12. 小结
- 限流:固定窗口够快、滑动窗口够准;二者按接口价值取舍。
- 队列:延时需求用 ZSet 简洁好用;需要可靠消费与重投,用 Streams 消费组更稳。
- 工程化:幂等、可见期、退避与监控缺一不可;上生产前把容量和压测做扎实。