
Go 限流选型:令牌桶比计数器更稳,但别直接用 golang.org/x/time/rate 做全局限流
因为 rate.Limiter 默认是 per-Goroutine 安全的单实例,不是跨请求共享的——你把它塞进 HTTP handler 里,每个请求拿的都是独立桶,根本起不到限流作用。真正要限的是“同一接口每秒最多 N 次”,得靠外部状态协调。
- 本地限流(单机、低并发)可用
rate.NewLimiter+http.Handler包裹,但必须复用同一个*rate.Limiter实例,不能每次 new - 多实例部署时,
rate.Limiter失效,必须上 Redis + Lua(如INCR + EXPIRE原子组合)或专用服务(如 Sentinel) -
rate.Every(1 * time.Second)控制填充速率,但burst参数容易设错:设太小导致突发请求全拒,太大则失去限流意义;建议从2–3 × QPS起调
手写令牌桶中间件:绕过 rate.Limiter 的 goroutine 绑定陷阱
想在 Gin 或 Echo 中统一限流?别把 rate.Limiter 当中间件变量声明在函数里。正确做法是定义包级变量或依赖注入,确保所有请求共享同一桶。
- 错误写法:
func RateLimit() gin.HandlerFunc {<br> limiter := rate.NewLimiter(rate.Every(time.Second), 10)<br> return func(c *gin.Context) { ... }<br>}——每次调用RateLimit()都新建一个桶 - 正确写法:
var globalLimiter = rate.NewLimiter(rate.Every(time.Second), 10)<br>func RateLimit() gin.HandlerFunc {<br> return func(c *gin.Context) {<br> if !globalLimiter.Allow() {<br> c.AbortWithStatus(429)<br> return<br> }<br> }<br>} - 注意
Allow()是非阻塞的,适合 Web;要用阻塞式请改用WaitN(ctx, n),但需处理超时和取消
Redis 实现分布式令牌桶:别用 SETEX 存 token 数,用 Lua 保证原子性
单纯用 INCR key + EXPIRE key 1 有竞态:两个请求同时 INCR 后再 EXPIRE,后者可能覆盖前者的过期时间,导致 key 永不淘汰。
- 必须用 Lua 脚本一次性完成“读、判、增、设过期”:
if redis.call("INCR", KEYS[1]) == 1 then<br> redis.call("EXPIRE", KEYS[1], ARGV[1])<br>end<br>return tonumber(redis.call("GET", KEYS[1])) - KEYS[1] 建议带业务前缀+路径哈希,比如
rate:api:/user/profile:192.168.1.100,避免单 key 热点 - 客户端拿到返回值后,要跟阈值比对——Lua 只管操作,不替你做判断;别漏掉这步,否则限流逻辑就断了
性能敏感场景:内存令牌桶 + 定期采样同步,比纯 Redis 快 3–5 倍
高 QPS 接口(如 >5k/s)直接走 Redis 会成瓶颈。折中方案是:本地内存存桶,每 100ms 异步同步一次总量到 Redis,容忍短时超发但大幅降延迟。
立即学习“go语言免费学习笔记(深入)”;
- 用
sync.Map存 key →*tokenBucket,key 可以是userID或IP,避免锁争用 - 同步时机别用定时器硬 tick,改用「每次取令牌后检查距上次同步是否超 100ms」,更平滑
- Redis 里只存“已消耗量”,本地桶负责计算剩余,重启后从 Redis 拉一次初始值即可,不用持久化全状态
实际部署时,最易被忽略的是 burst 和 refill 速率的物理意义混淆:比如设 Every(200ms) + burst=5,不代表“每秒 5 次”,而是“每 200ms 最多放 1 个令牌,桶最多存 5 个”,等效 QPS 是 5,但突发能力取决于 burst,不是 Every。










