如何利用 Redis 的 Lua 脚本配合 Java 业务逻辑实现强一致性的分布式限流
Redis配合Lua脚本通过原子性执行实现分布式限流的强一致性,Java仅负责安全调用,不可参与状态判断。生产环境推荐EVALSHA并提前预热脚本。令牌桶限流时,客户端传入时间戳等参数。Java层需正确配置序列化、返回值类型和连接池,否则易破坏一致性。
分布式限流这件事,技术方案看起来不少,但要真正实现“强一致性”,路其实挺窄的。Redis 配合 Lua 脚本是目前比较成熟的解法,但这里面有个关键前提——限流的全部判断逻辑,必须老老实实缩在 Lua 脚本里完成,Ja va 层只负责“安全调用”,不要掺和任何状态判断。
问题的核心就几句话:Redis + Lua 脚本本身具备原子性,能保证强一致;而 Ja va 嘛,离了脚本,任何“先查后判再写”的操作都会破坏原子性,漏放或误拒只是时间问题。
为什么不能在 Ja va 里做 if-else 判断是否超限
实践中经常能看到这样的场景:Ja va 先 stringRedisTemplate.opsForValue().get() 拿到当前计数,接着 if (count >= max) throw new RuntimeException() 做个判断,最后才 incr() 加一。这三步之间,竞态窗口敞开着,多个请求可能同时读到旧值、同时判定未超限、同时写入,结果就是实际 QPS 轻松翻倍。
差别在哪里?
- Redis 单线程执行 Lua,一个脚本里所有
redis.call()天然是原子的,不会被打断。 - Ja va 层的网络往返、本地计算、条件分支全在 Redis 外部,天然非原子。
- 哪怕是
pipeline或者事务(multi/exec),也只保证命令序列不被穿插,却无法保证“读-算-写”这个逻辑在并发下不被干扰。
简单说,Lua 才是集装箱,Ja va 只该是运货的卡车司机,不用替集装箱验货。
EVAL 与 EVALSHA 的选择要点
到了生产环境,关于脚本调用方式,有个很实际的建议:用 EVALSHA,别每次都传完整 Lua 长串。
EVAL每次发送整段脚本,网络包体积不小,尤其脚本里塞了注释和空格的时候更加明显。- Redis 会缓存已经执行过的 Lua 脚本 SHA1 值,
EVALSHA只传 40 个字符的哈希,带宽和解析开销都降下来了。 - 不过第一次调用前,需要先
SCRIPT LOAD注册一下脚本,否则EVALSHA返回NOSCRIPT错误——这个错误容易被忽略,可一旦忽略,限流就形同虚设。 - Spring Data Redis 的
RedisTemplate.execute(RedisScript, …)默认走EVALSHA,但它要求脚本对象已经预热,也就是要提前执行一次loadScript()。
总结下来就是:EVALSHA 省带宽、省解析,但预热这件事千万别省。
令牌桶 Lua 脚本中几个关键参数含义
拿最常见的令牌桶限流来说,KEYS[1] 自然就是限流 key,比如 "rate:uid:123"。而 ARGV 通常按这样几个参数传进去:
ARGV[1]:当前毫秒时间戳,用System.currentTimeMillis()传进去,用来计算这段时间内生成了多少令牌。ARGV[2]:桶的容量max_permits,比如 100。ARGV[3]:令牌生成速率rate,单位是“个/秒”,比如 50 就表示每 20 毫秒补一个。ARGV[4]:本次申请的令牌数,通常为 1。虽然支持批量获取,但大多数场景固定传 1 就够。
这里有个容易被忽略的细节:有人喜欢用 redis.call('TIME') 在 Redis 内部获取时间,但返回的是秒 + 微秒。且不说时区、精度的问题,它和业务系统的时钟很难对齐,时间漂移会直接干扰限流的准确度。所以更推荐的做法是,让客户端(Ja va 端)把时间戳作为参数传进去。
Ja va 调用时最容易被忽略的细节
脚本写对了,这还只是第一步。Ja va 层配置上稍有不慎,强一致性照样会崩溃。
StringRedisTemplate的序列化器必须明确设为StringRedisTemplate.setDefaultSerializer(new StringRedisSerializer()),不然传进去的KEYS和ARGV可能变成乱码,Lua 里tonumber(ARGV[1])返回nil,限流直接就垮了。- Lua 脚本的返回值通常是
Long类型,比如 1 表示通过,0 表示拒绝。Ja va 接收时泛型必须用Long.class,要是图省事用了Integer.class,ClassCastException就会找上门。 - Redis 连接池的配置也至关重要。
max-active设得太小,会导致 EVAL 请求排队,表面上看起来限流在生效,其实是连接池成了新瓶颈。timeout设得太短,正常脚本执行到一半就被中断,直接抛RedisCommandTimeoutException。 - 另外,不要在 Lua 脚本里写
redis.log()。虽然它不阻塞,但高频打日志会严重拖慢 Redis 主线程,而且日志内容也传不回 Ja va,调试时反而帮倒忙。
说来也怪,写出正确的 redis.call('zadd', ...) 并不难,真正的难点在于让整个链路——从 Ja va 时钟、序列化、连接池,到 Redis 的 script cache 和 clock drift——全都对齐在同一个语义下。稍微偏差一点,“强一致”就真的只剩一个名字了。


































