直接用 time.Now() 做计数会失效,因为 Gin 中间件并发执行时全局变量计数存在竞态条件,导致“漏流”;可靠方案需原子操作、锁或外部存储,如 sync.Map(需手动清理过期项)或 Redis(原子性+自动过期)。

为什么直接用 time.Now() 做计数会失效
因为 Gin 的中间件是并发执行的,多个请求同时进来时,如果只靠内存变量(比如一个全局 count)加减,会出现竞态:两个 goroutine 同时读到 10,各自加 1 再写回,结果还是 11。这不是“限流”,是“漏流”。
真正能用的方案必须带原子操作或锁,或者干脆交给外部存储。
用 sync.Map 实现单机每 IP 每分钟最多 10 次
适合开发/测试环境,不依赖 Redis,但要注意:sync.Map 不支持 TTL,得自己清理过期项,否则内存持续增长。
- 用
remoteIP + timestamp.Minute().Unix()当 key,比如"192.168.1.100:1715423600" - 每次请求先算当前分钟戳,查对应 key 是否存在;不存在则初始化为 1,存在则
atomic.AddInt64()计数 - 检查计数值是否 > 10,超了就写
c.AbortWithStatusJSON(429, gin.H{"error": "too many requests"}) - 另起 goroutine 每分钟遍历一次 map,删掉 timestamp 小于当前分钟的 key(注意:遍历
sync.Map要用Range(),不能用 range for)
redis.Incr + redis.Expire 是生产首选
Redis 原子性保证计数准确,EXPIRE 自动清理,省心。Gin 中推荐用 github.com/go-redis/redis/v8 客户端。
- key 设计建议:
"rate_limit:" + c.ClientIP() + ":" + strconv.FormatInt(time.Now().Unix()/60, 10) - 先
client.Incr(ctx, key).Val(),再client.Expire(ctx, key, time.Minute)(注意:Expire 必须紧跟 Incr,否则第一次请求可能没设过期时间) - 如果
val > 10,直接c.AbortWithStatusJSON(429, ...),别等响应结束再判断 - 别用
GET + INCR两步——中间可能被其他请求插队,必须用原子命令
为什么 gin-contrib/limiter 不推荐直接用
它默认用 memory 存储,底层是 sync.RWMutex + map,看起来简单,但有隐藏问题:
- 它把“窗口”按秒切分(比如
10 reqs/second),实际是滚动窗口,但实现里用了固定时间片,导致临界点(如 00:59 → 01:00)可能出现双倍放行 - 文档没说清楚
burst和rate的关系,设成rate=10, burst=5并不等于“每秒最多 10 次”,而是“令牌桶初始 5 个令牌,每秒补 10 个”,语义容易误解 - 如果真要用,务必替换掉默认的
memory存储,换成redis驱动,并确认你用的是 v2 版本(v1 已归档,不维护)
c.ClientIP() 默认信任 X-Forwarded-For,线上必须配置 engine.ForwardedByClientIP = true 并设置可信代理段,否则攻击者随便填 header 就能绕过限流。


















