Redis 实用型限流与延时队列:从 Lua 固定/滑动窗口到 Streams 消费组(含脚本与压测)

发布于:2025-08-16 ⋅ 阅读:(19) ⋅ 点赞:(0)

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
消费者循环

  1. 原子claim:把 score <= now 的任务移动processing,设置可见期(visibility timeout);
  2. 处理成功 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. 可靠性与重试策略(强烈建议照搬)

  1. 幂等:业务侧必须可重试(订单号/请求号唯一);Redis 脚本只保证原子性,不保证业务 Exactly-once。
  2. 可见期:ZSet 队列设置 processingTTL;Streams 用 XREADGROUP + XACK + XCLAIM
  3. 退避重试:指数退避 backoff = min(base*2^retry, max),超过 maxRetry 进入 DLQ
  4. 监控阈值
    • ZSet:ZCARD(delayQ)ZCARD(processing)
    • Streams:XLENXPENDING、各消费者 idle 时间;
    • 限流:允许/拒绝比(命中率)、拒绝分布。

7. 监控与报警

  • INFO stats|memory|keyspaceinstantaneous_ops_per_sec、内存、命中率;
  • 慢脚本:SLOWLOG,Lua 不要执行耗时 I/O;
  • Prometheus Exporter:抓 redis_exporter 指标;自定义业务指标(允许/拒绝/重试次数)写 Prom
  • 仪表盘建议
    • 限流:allow_rateblock_ratep50/p99 延迟
    • 队列:ready_len / processing_len / XPENDINGack 率requeue 率
    • 容量:used_memoryevicted_keyshit_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.luarate_limit_sliding.luadelayq_claim/ack/requeue.luabench_fixed/bench_sliding/bench_delayq.jsdocker-compose.yml 与 README。解压后按 README 即可跑通。


12. 小结

  • 限流:固定窗口够快、滑动窗口够准;二者按接口价值取舍。
  • 队列:延时需求用 ZSet 简洁好用;需要可靠消费与重投,用 Streams 消费组更稳。
  • 工程化:幂等、可见期、退避与监控缺一不可;上生产前把容量和压测做扎实。

网站公告

今日签到

点亮在社区的每一天
去签到