不能直接用Go原生Redis客户端做原子限流,因为GET+INCR+EXPIRE三步非原子,易被并发插队导致超发;必须用Lua脚本将令牌桶逻辑(校准时间、补充令牌、判断放行)封装为单次原子操作。

为什么不能直接用 Go 原生 Redis 客户端做原子限流
因为 GET + INCR + EXPIRE 三步在并发下不是原子的,中间可能被其他请求插队,导致超发。Redis 单线程执行 Lua 脚本才是唯一靠谱的原子边界。
常见错误现象:rate limit exceeded 报错不稳定,压测时偶尔放行超额请求;或者反过来,同一用户连续请求被误拦截。
- 必须把令牌桶逻辑(计算剩余令牌、更新时间戳、判断是否允许)全部写进一个 Lua 脚本里
- Go 端只负责传参(key、capacity、rate、window)和接收返回值(0 或 1)
- 不要在 Go 里做任何“先查再判再写”的分支逻辑
怎么写一个安全的令牌桶 Lua 脚本
脚本要解决三个问题:时间漂移校准、令牌动态补充、过期自动重建。别抄网上简单版 DECR 脚本——它不处理漏桶/令牌补充,也不兼容 Redis 集群模式下的 key hash tag。
示例脚本核心逻辑(传入参数:KEYS[1] 是限流 key,ARGV[1] 是容量,ARGV[2] 是每秒补充令牌数,ARGV[3] 是窗口秒数):
立即学习“go语言免费学习笔记(深入)”;
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local window = tonumber(ARGV[3])
<p>local now = tonumber(redis.call('TIME')[1])
local bucket = redis.call('HMGET', key, 'last_time', 'tokens')
local last_time = tonumber(bucket[1]) or now
local tokens = math.min(capacity, tonumber(bucket[2]) or capacity)</p><p>-- 补充令牌:按时间差 × rate,但不超过 capacity
local delta = math.max(0, now - last_time)
local new_tokens = math.min(capacity, tokens + delta * rate)</p><p>-- 判断是否允许
if new_tokens >= 1 then
redis.call('HMSET', key, 'last_time', now, 'tokens', new_tokens - 1)
redis.call('EXPIRE', key, window)
return 1
else
return 0
end- 用
HMGET/HMSET而不是GET/SET,避免 key 冲突和字段扩展困难 -
redis.call('TIME')比客户端传时间更可靠,规避系统时钟不同步问题 - 必须加
EXPIRE,否则冷 key 永久残留;且 expire 时间要覆盖整个窗口,不能只设几秒
Go 侧如何正确调用并处理返回值
别用 Eval 硬拼字符串,要用 Script.Load 预编译,提升性能并避免注入风险。返回值是整数 1 或 0,不是字符串。
典型错误:把 redis.Int(cmd.Result()) 写成 redis.String(cmd.Result()),导致类型断言 panic。
- 用
redis.NewScript加载 Lua 脚本,复用 script 实例,不要每次新建 - 调用时用
script.Eval(ctx, rdb, []string{key}, capacity, rate, window),注意参数顺序和类型 - 检查
err是否为redis.Nil(key 不存在)或真实网络错误,二者处理方式不同 - 如果 Redis 返回
(nil),说明脚本没执行完或被中断,应视为限流拒绝,不要重试
集群模式下 key 设计和性能陷阱
Redis Cluster 要求同一个 slot 处理所有相关操作,所以 key 必须带 hash tag,比如 rate_limit:{user_123},否则 HMGET 和 HMSET 可能落在不同节点上失败。
性能影响明显:每秒几千次限流调用时,Lua 脚本本身耗时约 0.1–0.3ms,但网络 RTT 占大头。别在关键路径上额外加 pipeline 包裹这个脚本——它本身已是原子单位。
- key 名必须包含
{...}hash tag,且 tag 内容要足够分散(比如用 user_id 而非固定字符串) - 不要给每个接口单独建 key,按业务维度聚合(如
api:{/v1/pay}),否则 key 数量爆炸 - 监控
redis_script_error_total指标,Lua 报错通常意味着参数类型错或 key 结构损坏
真正难的是时间精度和分布式时钟偏差的平衡——TIME 返回秒级,对毫秒级限流不够,但自己算又容易出错。生产环境建议 window ≥ 1 秒,避免在这种边界反复折腾。










