直接用 time.ticker 做令牌桶填充会出问题,因为其固定周期触发无法按时间流逝比例精确补令牌,导致突发流量积压、空闲时溢出浪费及并发漏判;正确做法是每次 take() 时基于时间差动态计算并原子更新。

为什么直接用 time.Ticker 做令牌桶填充会出问题?
因为 time.Ticker 是固定周期触发,而令牌桶需要按「时间流逝比例」精确补充令牌——比如每秒 100 个,那 50ms 就该补 5 个,不是等到下一个 tick 才补 100 个。用 time.Ticker 硬填会导致:突发流量下令牌“攒着不发”,或空闲时令牌“溢出浪费”,还可能和并发请求竞争导致漏判。
真正要的是:每次 Take() 时,按距离上次填充的时间差动态计算应新增令牌数,再原子更新。
如何用 sync/atomic + 时间戳实现线程安全的动态填充?
核心是把「当前令牌数」和「上次更新时间」都存为 int64,用 atomic.Load/Store 读写,避免锁开销。每次 Take(n int) 时:
- 先
atomic.LoadInt64拿到当前令牌数和上次时间戳 - 用
time.Since()算出已过去纳秒数,除以「单个令牌所需纳秒」得到应补数量 - 用
atomic.CompareAndSwapInt64原子更新:只在「当前值未被其他 goroutine 修改过」的前提下,才把新令牌数和新时间戳写入 - 失败就重试(CAS 循环),直到成功或判定令牌不足
示例关键逻辑片段:
// rate = 100 tokens/sec → interval = 1e9 / 100 = 10_000_000 ns/token now := time.Now().UnixNano() elapsed := now - lastTime add := int64(elapsed / interval) newTokens := min(maxTokens, curTokens+add) // CAS 更新 tokens 和 lastTime(两个字段需打包进一个 int64 或用 struct+unsafe)
time.Ticker 在什么场景下可以“凑合用”?
仅当满足全部以下条件时,才可考虑用 time.Ticker 启动后台 goroutine 填充:
- 限流精度要求低(比如允许 ±100ms 误差)
- QPS 不高(
- 能接受“空闲时不归零、满后不丢弃”的行为(即填充是“推”而非“拉”)
- 你愿意为它额外起一个 goroutine,并处理
Stop()和 channel 关闭
此时典型写法是:for range ticker.C { atomic.AddInt64(&tokens, 1) },但必须配合 atomic.MaxInt64 截断,否则整数溢出。
为什么不要把 time.Now() 放进 sync.Mutex 里?
很多人想“加锁保护所有状态”,于是把 time.Now() 也塞进临界区——这会让高并发下大量 goroutine 阻塞在锁上,time.Now() 本身虽快,但锁争用会让吞吐暴跌。动态填充的本质是无锁设计,time.Now() 必须在锁外调用,然后靠 CAS 的「乐观更新」来协调竞争。
另外注意:time.Now() 在容器环境或虚拟机里可能有漂移,若对精度要求极高(如金融级限流),应改用 runtime.nanotime()(返回单调递增纳秒数,不受系统时间调整影响)。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











