不能直接用 atomic.AddInt64 记录每秒请求数,因其仅保证单次操作原子性,无法安全实现“按秒归零”,高并发下易漏计或重复;需用双槽位+时间戳轮转机制规避重置竞争。

为什么不能直接用 atomic.AddInt64 记录每秒请求数
因为 atomic.AddInt64 只保证单次操作原子性,不解决“按秒归零”问题。你无法在整点瞬间把计数器设为 0 而不丢失并发写入——atomic.StoreInt64 和 atomic.LoadInt64 之间存在竞态窗口,尤其在高 QPS 场景下,1 秒内可能有数万次递增,强行重置大概率漏计或重复计。
用双槽位 + 时间戳轮转避免重置竞争
核心思路是维护两个 int64 槽:slot0 和 slot1,配合一个原子读写的 currentSlot(0 或 1)和 lastSwitchAt(上一次切换时间戳)。每次 Inc() 先检查是否该切槽(当前秒 ≠ lastSwitchAt),若是,则 CAS 切换 currentSlot 并更新 lastSwitchAt,再往新槽累加。这样每秒只做最多一次 CAS 切换,无锁写入始终落在当前活跃槽。
示例关键逻辑:
func (m *SecondCounter) Inc() {
now := time.Now().Unix()
if atomic.LoadInt64(&m.lastSwitchAt) != now {
// 尝试抢占切换权:只有第一个成功 CAS 的 goroutine 执行切换
if atomic.CompareAndSwapInt64(&m.lastSwitchAt, atomic.LoadInt64(&m.lastSwitchAt), now) {
slot := 1 - int(atomic.LoadInt64(&m.currentSlot))
atomic.StoreInt64(&m.currentSlot, int64(slot))
}
}
slot := int(atomic.LoadInt64(&m.currentSlot))
if slot == 0 {
atomic.AddInt64(&m.slot0, 1)
} else {
atomic.AddInt64(&m.slot1, 1)
}
}
读取当前秒流量时要注意时间偏移
调用 Current() 时不能直接返回当前槽的值,因为系统时钟可能回跳、或 goroutine 被调度延迟,导致你读到的是“上一秒”的槽。稳妥做法是:先读 lastSwitchAt,再根据当前秒与它的差值决定读哪个槽——若差值为 0,读当前槽;若为 1,读上一槽;若 ≥2,说明已错过至少一秒,应清空两槽并重置(防累积误差)。
-
lastSwitchAt == now→ 读当前槽 -
lastSwitchAt == now - 1→ 读上一槽(即1 - currentSlot对应的槽) -
now - lastSwitchAt >= 2→ 安全起见,用atomic.SwapInt64清空两槽,并重置lastSwitchAt和currentSlot
注意 time.Now() 在高频场景下的开销和精度
每毫秒调用一次 time.Now() 会成为瓶颈。实际部署中建议用 runtime.nanotime() 做低成本秒级判断(除以 1e9 后截断),但要注意它不随系统时钟调整——如果业务对 NTP 调时敏感(如日志时间对齐),就得接受 time.Now() 的微小开销。另外,Linux 上 CLOCK_MONOTONIC 是默认源,基本不会倒退,比 time.Now() 更适合做滑动窗口判据。
真正难处理的不是原子操作本身,而是「秒边界」这个逻辑概念在分布式、多核、时钟漂移环境下的模糊性——哪怕你代码完全正确,不同机器上报的“第 N 秒”也可能相差几十毫秒。监控聚合层必须容忍这种抖动,而不是在采集端硬做对齐。











