time.Ticker 不适合接口限流,因其固定窗口机制会漏判突发流量;应使用 rate.Limiter(令牌桶)或滑动窗口方案,配合动态 key 管理与 Redis+Lua 原子操作实现高精度、并发安全的限流。

为什么 time.Ticker 不适合做接口限流
直接用 time.Ticker 配合计数器实现“每秒最多 N 次”看似简单,但实际会漏判突发流量。它只在固定时间点检查累计请求数,两次 tick 之间涌入的请求全被算进下一个窗口,导致瞬时超限。真正要的是滑动窗口或令牌桶这类能连续计量的模型。
实操建议:
- 别自己基于
time.AfterFunc或time.Tick手写重置逻辑,容易出竞态 - 优先选用成熟限流库(如
golang.org/x/time/rate),它的rate.Limiter底层用原子操作+单调时钟,精度和并发安全都有保障 - 若需滑动窗口(比如“最近 60 秒内最多 100 次”),
rate.Limiter默认不支持,得换uber-go/ratelimit或用 Redis + Lua 实现
用 rate.Limiter 做 HTTP 中间件的正确姿势
rate.Limiter 的核心是 Allow() 和 Wait() —— 前者非阻塞判断,后者会阻塞直到有配额。Web 接口通常该用 Allow() 快速失败,避免协程堆积。
常见错误现象:在中间件里对每个请求都调用 limiter.Wait(ctx),结果高并发下大量 goroutine 卡住,内存暴涨甚至 OOM。
立即学习“go语言免费学习笔记(深入)”;
实操建议:
- 初始化时按需创建
rate.Limiter,例如rate.NewLimiter(10, 5)表示“平均 10 QPS,最多允许 5 个请求瞬时突增” - 中间件中用
if !limiter.Allow() { http.Error(w, "too many requests", http.StatusTooManyRequests); return } - 别把同一个
rate.Limiter实例全局共享给所有路由——不同接口应有独立配额,按路径或用户 ID 构建 key 做 map 分片
如何按用户 ID 或 IP 做差异化限流
硬编码一个 limiter 只能做全局限流。真实场景需要“每个用户每分钟最多 30 次”,这就得动态管理 limiter 实例,并控制内存增长。
性能影响:用 sync.Map 存 map[string]*rate.Limiter 能避免锁争用,但长期不用的 key 会泄漏。没清理机制的话,爬虫扫一遍接口就能撑爆内存。
实操建议:
- 用
user_id或ip(注意 X-Forwarded-For 头可信度)做 key,查sync.Map获取对应 limiter - limiter 创建后加 TTL 控制,例如用
time.AfterFunc在 10 分钟后尝试删除;删除前先检查limiter.Reserve().OK()是否为 false(说明已空闲) - 更稳妥的做法是引入 LRU 缓存(如
github.com/hashicorp/golang-lru),限制最大缓存数量,淘汰冷 key
Redis + Lua 实现分布式滑动窗口的坑
单机 rate.Limiter 无法跨进程同步状态。上 Redis 是常见解法,但直接用 INCR + EXPIRE 会有竞态:两个请求同时发现 key 不存在,都去设 expire,导致过期时间被覆盖。
错误示例:先 GET 再 INCR 再 EXPIRE —— 这三步不是原子的。
实操建议:
- 必须用 Lua 脚本保证原子性,例如用
redis.Eval执行一段脚本,完成“读当前值→判断是否超限→+1→设置过期”整套逻辑 - 滑动窗口需要存储多个时间片,推荐用 Redis Sorted Set:score 存时间戳,member 存请求 ID,每次用
ZCOUNT统计指定时间范围内的 member 数量 - 注意 Lua 脚本中不能用
os.time(),得传入当前毫秒时间戳作为参数,否则集群时钟不同步会导致窗口错乱
复杂点在于本地限流和分布式限流的 fallback 策略——Redis 故障时,是降级为宽松限流,还是直接拒绝?这个决策点往往被忽略,但线上出问题时它决定服务是否雪崩。










