time.Now().UnixNano() 是毫秒级限流的关键起点,因为它提供纳秒级精度的绝对时间戳,除以 1e6 后可得稳定、无浮点误差、无系统调度依赖的毫秒整数时间轴,确保令牌填充计算严格线性、边界精准、零漂移。
为什么 time.Now().UnixNano() 是毫秒级限流的关键起点
go 标准库的 time.ticker 最小精度受系统调度影响,通常达不到稳定毫秒级;而限流器必须精确知道“此刻距上次请求过了多少毫秒”,否则令牌填充逻辑会漂移。直接用 time.now().unixnano() 获取纳秒时间戳,再除以 1e6 转为毫秒整数,是唯一可控、无依赖、零额外开销的方式。
注意:不能用 time.Since() 或 time.Sub() 后四舍五入——它们返回 time.Duration,底层仍是纳秒,但做毫秒对齐时若用浮点或非截断转换(如 int64(d / time.Millisecond)),在边界时刻可能多算或少算 1 毫秒,导致令牌数突变。
- 正确做法:
ms := time.Now().UnixNano() / 1e6 - 错误示例:
int64(time.Since(last).Seconds()*1000)(浮点误差 + 时区/闰秒干扰) - 所有时间比较和填充计算,必须基于同一套毫秒整数时间轴,而非
time.Time对象本身
atomic.LoadInt64 和 atomic.CompareAndSwapInt64 必须成对用于令牌计数
令牌桶的核心状态只有一个:tokens 当前剩余令牌数。它被多个 goroutine 并发读写,但绝不能用 sync.Mutex——锁会成为性能瓶颈,尤其在高 QPS 场景下,简单加锁会使 P99 延迟飙升数毫秒。
用原子操作实现无锁更新:每次 Allow() 先 atomic.LoadInt64(&t.tokens) 读当前值,再根据毫秒差计算应填充量,得到期望新值;最后用 atomic.CompareAndSwapInt64(&t.tokens, old, new) 尝试提交。失败则重试(通常 1–2 次即成功)。
- 填充公式:
newTokens = min(capacity, oldTokens + (nowMs - lastMs) * ratePerMs),其中ratePerMs是每毫秒新增令牌数(如 100 QPS →0.1,需用定点乘法避免浮点) - 务必在 CAS 前完成全部计算,且不修改任何共享状态,否则重试时逻辑错乱
- 不要用
atomic.AddInt64直接加——它无法判断是否超出容量上限,会导致令牌溢出
如何处理 ratePerMs 小于 1 的情况(比如 50 QPS)
50 QPS 意味着每 20 毫秒才允许一个请求,即 ratePerMs = 0.05。Go 没有原生定点数,用 float64 会累积误差,几万次运算后偏差可达 ±1 令牌。可靠解法是把时间单位放大:不再按“每毫秒”,而是按“每 baseMs 毫秒”计算。
例如设 baseMs = 20,则 50 QPS 变成“每 20ms 补 1 令牌”,此时所有计算全为整数:fillCount = (nowMs - lastMs) / baseMs,再乘以每周期令牌数(=1)。这样既无浮点误差,又保持毫秒级响应(因为 nowMs 仍是实时毫秒)。
- 通用规则:选
baseMs = 1000 / gcd(1000, qps),比如 75 QPS → gcd(1000,75)=25 →baseMs = 40,即每 40ms 补 3 令牌 - 实际存储两个字段:
baseMs int64和tokensPerBase int64,填充时用整数除法 - 注意:
(nowMs - lastMs) / baseMs是向下取整,天然符合“未满一个周期不补”的语义
为什么不能把 lastMs 和 tokens 合并进一次 CAS
有人试图用一个 struct{ lastMs, tokens int64 } 加 atomic.CompareAndSwapPointer 来“原子更新两个值”,这看似简洁,实则引入严重竞争漏洞:当 A goroutine 读到 lastMs=1000, tokens=5,B 同时将 lastMs 更新为 1005 并填充了令牌,A 在计算时仍用 1000 做差值,导致多算了 5ms 的令牌,造成漏放行。
正确做法是只对 tokens 做 CAS,而 lastMs 用 atomic.LoadInt64 单独读——因为 lastMs 仅用于计算填充量,即使读到稍旧的值,最多少填几个令牌(安全降级),绝不会多放。
- 关键原则:状态变量中,只有
tokens是“决策依据”,必须强一致;lastMs是“辅助参数”,可容忍短暂陈旧 - 每次
Allow()开头读一次lastMs,结尾用新时间覆盖(同样用atomic.StoreInt64),无需同步 - 不要为了“看起来原子”而牺牲正确性——限流器宁可严苛,不可宽松
毫秒级精度真正难的不是计算,而是所有时间基准必须统一、所有状态更新必须可线性化、所有边界条件(如启动瞬间、长时间空闲后突增)都要落在同一套整数时间推演里。稍有松动,漏放就藏在那 0.3 毫秒的浮点余数里。


















