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