必须用lua脚本原子执行incr、expire和阈值判断,避免incr成功但expire失败导致key永久残留;脚本需用redis.call('incr')、条件设expire、返回0/1/-1,java层封装调用并记录拒绝日志。

为什么不能用 INCR + EXPIRE 两步操作
直接用 StringRedisTemplate 先 incr 再 expire 看似简单,但服务可能在中间崩溃——比如 incr 成功了,expire 还没发出去,这个 key 就永久留在 Redis 里,后续所有请求都被误限流。这不是偶发问题,而是高并发下必然出现的竞态漏洞。
Redis 单线程执行 Lua 脚本,整个逻辑(计数、设过期、判断阈值)一次原子完成,不存在中间状态。所以必须把这三件事写进同一个脚本,不能拆。
- 脚本里用
redis.call('INCR', KEYS[1]),别用GETSET,否则覆盖旧值会丢计数 - 首次计数时才调用
EXPIRE:用if count == 1 then redis.call('expire', ...),避免重复重设 TTL - 返回值统一为整数:
0拒绝、1放行、-1异常,方便 Java 层判断
固定窗口限流 Lua 脚本怎么写才安全
下面这个脚本是经过线上验证的最小可用版本,适用于接口级 QPS 限流:
local count = redis.call('incr', KEYS[1])
if count == 1 then
redis.call('expire', KEYS[1], ARGV[1])
end
if count > tonumber(ARGV[2]) then
return 0
else
return 1
end
注意几个硬性约束:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
-
KEYS[1]必须由 Java 层拼好传入(如"rate:api:/order/create:192.168.1.100"),脚本里不做字符串拼接 -
ARGV[1]是窗口秒数(如"60"),ARGV[2]是最大请求数(如"100"),都转成字符串传 - 不要在脚本里用
SETEX:它只在 key 不存在时设值+过期,已存在时不更新 TTL,会导致过期时间漂移
Spring Boot 里怎么调用才不踩坑
别在 Controller 里硬写 redisTemplate.execute(),应该封装成 @Component Bean,支持按 key、速率、窗口动态限流。
- 用
DefaultRedisScript<long></long>预加载脚本,设置setScriptText()或从 classpath 读rate_limit.lua -
redisTemplate必须是StringRedisTemplate,或显式配置StringRedisSerializer,否则KEYS[1]可能序列化成乱码 - 执行时传参:keys 用
Arrays.asList(key),args 用Arrays.asList(String.valueOf(seconds), String.valueOf(maxCount)) - 必须捕获
RedisSystemException和LuaScriptException,Redis 不可用时不能让整个接口挂掉
tryAcquire 返回 false 却没日志?查这三点
很多团队发现限流“好像生效了”,但监控里看不到拒绝记录,实际是调用链路断在了中间:
- Controller 里调了
tryAcquire()但没检查返回值,直接往下走了 - 用了 AOP 拦截器,但拦截器吞掉了
false的响应,没写日志也没返回 429 - Redis 连接池耗尽或超时,
execute()抛异常被静默 catch,结果默认放行
真正可靠的限流,得让拒绝行为可观察、可追溯。脚本返回 0 的那一刻,Java 层就得打一条明确日志,比如 "Rate limit rejected for key: {}",否则等于没限。










