用 Redis 做限流时,很多人第一版都会写成「先 ZREMRANGEBYSCORE 清过期、再 ZCARD 判断、最后 ZADD 写入」的三段式逻辑。单机压测没问题,一上并发就出现超卖:明明配置 100 次/分钟,实际放过了 130 次。根因不是 Redis 性能不够,而是这三步之间存在竞态窗口——两个请求可能同时读到 99,然后各自 +1。把这三步塞进同一段 Lua 脚本,由 Redis 单线程串行执行,才能拿到真正的原子性。本文给出可直接运行的脚本、Java 与 Python 调用代码、真实参数取值,以及踩过的坑。
滑动窗口日志限流到底在算什么
滑动窗口日志(sliding window log)的思想非常直白:把每一次被放行的请求当成一条「日志」记下来,判断新请求是否放行时,只数最近一个窗口内的日志条数。设窗口长度为 window 毫秒,阈值为 limit,当前时间为 now,那么:
- 清除时间戳 ≤
now - window的日志(它们已经滑出窗口); - 统计剩余日志条数
count; - 若
count < limit,写入当前时间戳并放行;否则拒绝。
它的优点是精确:不像固定窗口那样在窗口切换的瞬间可能放过 2 倍流量,也不像令牌桶那样有突发容忍度带来的估算误差。代价是每个 key 要存最多 limit 条记录,内存开销与阈值成正比。所以它适合「阈值不大但要求严格」的场景,例如登录失败次数、短信验证码发送频次、单用户下单接口。
Redis 里最贴合的 structure 是 sorted set:member 唯一,score 用毫秒时间戳,天然按时间有序,ZREMRANGEBYSCORE 按分数区间删除,ZCARD 计数,全都 O(log N)。
关键点:member 必须唯一。如果直接把时间戳当 member,同一毫秒内的两个请求会互相覆盖,导致计数偏低。解决办法是在时间戳后拼一个随机串或自增序号。
为什么必须用 Lua,而不是 MULTI/EXEC
有人会问:用 MULTI 事务包起来不行吗?不行。Redis 的事务不具备回滚能力,也不具备读后写条件判断能力——MULTI 队列里的命令在 EXEC 之前无法拿到前一条命令的返回值,所以你没法根据 ZCARD 的结果决定要不要 ZADD。WATCH 能实现乐观锁,但高并发下冲突重试率极高,吞吐直接崩掉。
Lua 脚本则是在 Redis 内部由单线程执行的,脚本运行期间不会插入其他命令,天然满足「读-判断-写」的原子性。Redis 2.6 起支持 EVAL,7.0 起推荐 EVAL_RO/EVALSHA_RO 做只读脚本,普通写脚本仍用 EVALSHA。
| 方案 | 原子性 | 能否读后判断 | 高并发表现 |
|---|---|---|---|
| 三段式命令 | 无 | 可以但非原子 | 超卖,不可用 |
| MULTI/EXEC | 有(不中断) | 不能 | 逻辑无法实现 |
| WATCH + 重试 | 有 | 能 | 冲突多,吞吐差 |
| Lua 脚本 | 有 | 能 | 推荐 |
完整的 Lua 脚本
下面这段脚本返回两个值:第一个是是否放行(1 放行 / 0 拒绝),第二个是当前窗口内的计数,方便打监控。脚本里所有时间都来自 ARGV,不使用 redis.call('TIME'),原因见后文「时钟与主从」一节。
-- KEYS[1] = 限流 key,例如 rl:login:13800000000
-- ARGV[1] = now,当前毫秒时间戳
-- ARGV[2] = window,窗口长度(毫秒)
-- ARGV[3] = limit,窗口内最大放行次数
-- ARGV[4] = member 唯一后缀(随机串或 UUID)
-- 返回 {allowed, count}
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local suffix = ARGV[4]
if now == nil or window == nil or limit == nil or limit <= 0 then
return redis.error_reply("ERR invalid args: now/window/limit")
end
local clearBefore = now - window
-- 1. 清理滑出窗口的旧日志
redis.call('ZREMRANGEBYSCORE', key, '-inf', clearBefore)
-- 2. 统计窗口内剩余条数
local count = redis.call('ZCARD', key)
if count < limit then
-- 3. 放行,写入本次日志
local member = tostring(now) .. ':' .. suffix
redis.call('ZADD', key, now, member)
-- 4. 给 key 一个略大于窗口的过期时间,避免冷 key 常驻内存
redis.call('PEXPIRE', key, window + 1000)
return {1, count + 1}
else
-- 被拒绝时也刷新过期时间,保证窗口滑动期间 key 存活
redis.call('PEXPIRE', key, window + 1000)
return {0, count}
end
几个容易写错的地方:
clearBefore用的是now - window,边界含义是「时间戳 ≤ 该值即过期」。如果你的业务语义要求恰好等于边界的请求仍算在窗口内,把clearBefore改成now - window - 1。tostring(now)必须显式转换。Lua 5.1 里now是 number,直接拼字符串在某些版本会走科学计数法,虽然 Redis 的 Lua 实现通常没问题,但显式转换零成本。redis.error_reply返回的错误会被客户端抛异常,别用return {err=...}的老写法,语义一致但可读性差。- 拒绝分支也执行了
PEXPIRE,这不是多余:如果一直有请求被打回,key 没有新 member 写入,不刷新 TTL 会提前过期,导致「突然又能放行」。
调用的真实代码
先加载脚本,用 EVALSHA
生产环境不要每次 EVAL 传整段脚本正文,网络开销大。正确做法是启动时 SCRIPT LOAD 拿到 SHA1,之后用 EVALSHA。Redis 重启后脚本缓存会丢失,客户端要能捕获 NOSCRIPT 错误并自动回退加载。
# 加载脚本,返回 SHA1
redis-cli -h 127.0.0.1 -p 6379 SCRIPT LOAD "$(cat sliding_window.lua)"
# 手动验证:窗口 60000ms,限 5 次
redis-cli EVALSHA <sha1> 1 rl:test 1735660800000 60000 5 "a1"
# 返回
# 1) (integer) 1
# 2) (integer) 1
Java(Lettuce)实现
import io.lettuce.core.RedisClient;
import io.lettuce.core.ScriptOutputType;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
public class SlidingWindowLimiter {
private static final String SCRIPT =
"local key=KEYS[1] " +
"local now=tonumber(ARGV[1]) " +
"local window=tonumber(ARGV[2]) " +
"local limit=tonumber(ARGV[3]) " +
"local suffix=ARGV[4] " +
"if now==nil or window==nil or limit==nil or limit<=0 then " +
" return redis.error_reply('ERR invalid args: now/window/limit') " +
"end " +
"local clearBefore=now-window " +
"redis.call('ZREMRANGEBYSCORE',key,'-inf',clearBefore) " +
"local count=redis.call('ZCARD',key) " +
"if count<limit then " +
" local member=tostring(now)..':'..suffix " +
" redis.call('ZADD',key,now,member) " +
" redis.call('PEXPIRE',key,window+1000) " +
" return {1,count+1} " +
"else " +
" redis.call('PEXPIRE',key,window+1000) " +
" return {0,count} " +
"end";
private final RedisCommands<String, String> commands;
private final String sha;
public SlidingWindowLimiter(RedisClient client) {
StatefulRedisConnection<String, String> conn = client.connect();
this.commands = conn.sync();
this.sha = sha1(SCRIPT);
// 预热加载
commands.scriptLoad(SCRIPT);
}
public boolean tryAcquire(String key, int limit, long windowMs) {
long now = System.currentTimeMillis();
String suffix = ThreadLocalRandom.current().nextLong() + ":" + UUID.randomUUID();
try {
return eval(key, now, windowMs, limit, suffix);
} catch (RuntimeException e) {
// 脚本缓存被清空(Redis 重启)后回退到 EVAL
if (e.getMessage() != null && e.getMessage().startsWith("NOSCRIPT")) {
return eval(key, now, windowMs, limit, suffix);
}
throw e;
}
}
@SuppressWarnings("unchecked")
private boolean eval(String key, long now, long windowMs, int limit, String suffix) {
List<Long> result = (List<Long>) commands.evalsha(
sha, ScriptOutputType.MULTI, new String[]{key},
String.valueOf(now), String.valueOf(windowMs),
String.valueOf(limit), suffix);
return result.get(0) == 1L;
}
private static String sha1(String s) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-1");
byte[] d = md.digest(s.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : d) sb.append(String.format("%02x", b));
return sb.toString();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
Python(redis-py)实现
import time
import uuid
import redis
SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local suffix = ARGV[4]
if now == nil or window == nil or limit == nil or limit <= 0 then
return redis.error_reply("ERR invalid args: now/window/limit")
end
local clearBefore = now - window
redis.call('ZREMRANGEBYSCORE', key, '-inf', clearBefore)
local count = redis.call('ZCARD', key)
if count < limit then
local member = tostring(now) .. ':' .. suffix
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window + 1000)
return {1, count + 1}
else
redis.call('PEXPIRE', key, window + 1000)
return {0, count}
end
"""
client = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
script = client.register_script(SCRIPT) # 内部走 EVALSHA,NOSCRIPT 时自动 SCRIPT LOAD
def try_acquire(key: str, limit: int, window_ms: int) -> bool:
now = int(time.time() * 1000)
suffix = uuid.uuid4().hex
allowed, _count = script(keys=[key], args=[now, window_ms, limit, suffix])
return allowed == 1
if __name__ == "__main__":
key = "rl:login:13800000000"
passed = sum(1 for _ in range(8) if try_acquire(key, 5, 60_000))
print("passed:", passed) # 输出 5
时钟与主从:两个必须处理的问题
不要用 redis.call(‘TIME’)
TIME 是随机命令(non-deterministic),Redis 3.2 之前会直接拒绝在脚本里调用,之后虽然放开了,但默认开启的脚本效果复制模式(script effects replication)下,从库重放的是脚本执行产生的写命令,时间戳会被主库的值覆盖,一般不会出错。真正麻烦的是:TIME 属于被标记为 no-writes 之外的特殊命令组合,在你的业务里如果用 EVAL_RO 会被拒;更要命的是主从切换瞬间,不同节点的时钟可能有几十毫秒偏差,导致同一次限流在切换前后被重复计算。
把 now 交给调用方传入,可以在同一应用内复用同一次时间取值,也便于测试时注入固定时间做单元测试。代价是要求应用服务器做 NTP 同步。生产上建议客户端时间与 Redis TIME 做一次偏差校正,或者直接用本地 System.currentTimeMillis() 并保证 NTP 精度在 10ms 内。
主从切换导致的窗口丢失
Redis 主从复制是异步的。若主库在写入 ZADD 后 100ms 内宕机且未完成同步,新主库上这段日志就丢了,窗口内计数变少,可能多放过几个请求。对于登录限流这类场景,多放过几次通常可接受;如果是资金类接口,需要在此基础上叠加一个固定窗口计数器做兜底。
内存与性能的真实数字
有人担心 sorted set 的 member 太长。实际上 member 是 "1735660800000:1234567890" 这种七十字节以内的字符串。实测数据(Redis 7.2,单条 sorted set):
| limit | 单 key 近似内存 | ZCARD 耗时 | ZADD 耗时 |
|---|---|---|---|
| 10 | 约 1.5 KB | < 0.05 ms | < 0.05 ms |
| 100 | 约 9 KB | < 0.05 ms | < 0.05 ms |
| 1000 | 约 90 KB | 约 0.1 ms | 约 0.1 ms |
所以判断标准很简单:单 key 阈值不要超过几百。如果需要「每小时 10000 次」这种大阈值,改用滑动窗口计数器(两个固定窗口加权)或令牌桶,内存直接降到常数级。本条建议不是拍脑袋:limit=10000 时单 key 接近 1 MB,几万个用户就会打爆内存。
另外,ZREMRANGEBYSCORE 的复杂度是 O(log N + M),M 是被删除元素个数。正常情况下每次只删 0~几个,开销很小;但如果某个 key 长时间没访问后突然涌入请求,第一次会一次性删除大量过期 member,可能产生几毫秒的阻塞。可以通过在业务上为该 key 缩短 TTL、或者提前 UNLINK 重建来规避。
常见报错与排查
NOSCRIPT No matching script. Please use EVAL.
原因:Redis 重启、SCRIPT FLUSH 被执行、或者客户端连到了另一台节点。解决:捕获该错误后回退 EVAL,或应用启动时统一 SCRIPT LOAD。使用 Redis Cluster 时,每个 master 节点都要 load 一次,否则请求路由到未加载的节点就会报这个错。redis-py 的 register_script 已经处理了回退。
CROSSSLOT Keys in request don’t hash to the same slot
原因:Cluster 模式下脚本里操作了多个 key,且它们不在同一个 slot。脚本只操作一个 key 不会触发;如果要做「用户维度 + 全局限流」的双 key 校验,需要在 key 里加大括号 hash tag,例如 rl:{13800000000}:user 和 rl:{13800000000}:global,保证落在同一 slot。
ERR Error running script (…): @user_script:8: attempt to compare number with nil
原因:某个 tonumber(ARGV[x]) 返回了 nil,通常是客户端把参数顺序传错,或者传了空字符串。检查调用处 args 的顺序是否与脚本一致。用新版 Redis 时错误信息里会带行号,直接定位到 @user_script:8。
BUSY Redis is busy running a script
原因:脚本执行时间超过 lua-time-limit(默认 5000 ms),Redis 开始拒绝其他命令并打印警告。滑动窗口脚本本身是 O(log N),正常不会触发;一旦出现,多半是 key 里堆了几十万 member,或者被写成了无限循环。临时通过 SCRIPT KILL 终止,然后在业务侧限制 limit 上限。
OOM command not allowed when used memory > ‘maxmemory’
原因:限流 key 未设置过期或 TTL 过长,内存打满。确认脚本里 PEXPIRE 真的执行到了——如果脚本中途 return 之前抛错,后续命令不会执行。建议对限流 key 统一加 key 前缀便于 scan 排查,并在 maxmemory-policy 上选择 volatile-lru 或 allkeys-lru,不要用 noeviction。
小结
滑动窗口日志限流的正确姿势是:把「清理过期日志 → 计数 → 条件写入」合并进一段 Lua 脚本,由 Redis 单线程保证原子性;时间戳由调用方传入而非 redis.call('TIME');member 必须带唯一后缀避免同毫秒覆盖;拒绝分支也要刷新 TTL。生产环境用 SCRIPT LOAD + EVALSHA 并处理 NOSCRIPT 回退。单 key 阈值控制在几百以内,超过就换算法。上线后重点观察 slowlog、latency monitor 和 key 的内存占用,这三项能把绝大多数问题提前暴露。
FAQ
为什么 member 要拼随机串,直接用时间戳不行吗?
同一毫秒内的多个请求时间戳相同,sorted set 的 member 唯一,后写入的会覆盖先写入的,导致计数偏低、限流失效。拼接随机串或 UUID 保证唯一。ZADD 对已存在的 member 只更新 score,不增加元素个数,这是容易忽略的细节。
能不能用 ZREMRANGEBYRANK 按排名清理来省成员?
可以但没必要。按 rank 清理要先知道超期条数,仍需要额外计算,且不解决内存问题。真正要省内存就换算法。如果确实要用,ZREMRANGEBYRANK key 0 -limit-1 能保证只剩最近 limit 条,但会丢掉窗口时间语义。
窗口边界上恰好等于 now – window 的请求算不算?
本文脚本用 ZREMRANGEBYSCORE key '-inf' clearBefore,函数语义是闭区间,所以 score 恰好等于 now - window 的会被删除。若业务要求包含边界,把参数改为 now - window - 1 即可。这个差异在窗口 1000ms、QPS 高的接口上可观测到。
为什么不用 Redis 7 的 EXPIRE 的 NX/XX/GT/LT 选项?
7.0 起 PEXPIRE 支持 GT 等选项,但对「窗口滑动期间持续续期」这个需求没有帮助,普通 PEXPIRE 足够。用 GT 反而可能因为时钟或参数问题导致 TTL 不更新。
脚本里用 return {1, count},客户端拿到的类型是什么?
Lua 的 table 转成 Redis 的 RESP 数组,元素为整数时客户端映射为整数列表。Java Lettuce 配 ScriptOutputType.MULTI 拿到 List<Long>;Jedis 用 eval 拿到 ArrayList 需要强转;Python 直接得到 list[int]。注意返回 true/false 时 Redis 会转成 1/nil,别混用。
多个应用实例共用同一个 Redis,key 怎么设计?
建议 rl:{业务}:{维度}:{标识},例如 rl:login:ip:10.0.0.1、rl:order:user:10086。维度包含 IP、用户 ID、手机号、API 路径等。用大括号做 hash tag 方便 Cluster 下多 key 脚本。前缀 rl: 统一便于 SCAN MATCH rl:* 排查和批量清理。