不能直接用 mutex 做请求计数,因其锁竞争在高并发下成瓶颈;应使用 atomic.AddUint64 等无锁操作实现高效累加,注意类型选 uint64、读写均须原子函数、滑动窗口需环形缓冲+Swap/Load 配合。

为什么不能直接用 mutex 做请求计数
高并发场景下,sync.Mutex 的锁竞争会迅速成为瓶颈。哪怕只是递增一个计数器,每毫秒几万次请求时,Lock()/Unlock() 的系统调用开销和调度延迟会让吞吐量掉 30% 以上。atomic 提供无锁的硬件级操作,适合这类高频、低粒度的累加场景。
但要注意:atomic 只保证单个操作的原子性,不提供复合逻辑(比如“先读再条件更新”需用 atomic.CompareAndSwap*),也不能直接替代需要临界区保护的结构体字段更新。
用 atomic.AddUint64 实现 QPS 累加器
最常见需求是统计总请求数,然后按周期算 QPS。这里必须用 uint64 类型配 atomic.AddUint64 —— int 在 32 位环境可能非原子,atomic.AddInt64 虽可用,但 Go 官方文档明确推荐对计数器使用无符号类型,避免溢出行为歧义。
- 声明计数器必须是全局变量或结构体导出字段,且初始化为 0:
var reqTotal uint64 - 每次请求入口处调用:
atomic.AddUint64(&reqTotal, 1) - 切勿用
reqTotal++或reqTotal += 1,它们不是原子操作 - 读取当前值用:
atomic.LoadUint64(&reqTotal),而不是直接读变量(可能看到脏值)
如何安全地做滑动窗口 QPS 统计
单纯总数不够,真实监控需要最近 1 秒/10 秒的速率。这时不能只靠一个原子变量,得组合环形缓冲 + 原子操作。核心思路是:每个时间槽用独立的 uint64 计数器,用 atomic.SwapUint64 重置旧槽,再用 atomic.LoadUint64 汇总当前窗口内所有槽。
关键陷阱:
- 时间槽数组必须用指针或固定大小 slice,避免逃逸导致 GC 压力;推荐
[10]uint64这样的数组类型 - 重置槽时不能用
atomic.StoreUint64(&slot[i], 0),因为其他 goroutine 可能正在写入;应改用atomic.SwapUint64(&slot[i], 0),它返回旧值并设为 0,天然线程安全 - 读取窗口总和时,要遍历全部槽并用
atomic.LoadUint64,不能缓存中间结果——否则可能漏掉刚发生的写入
示例片段(10 槽代表 10 秒):
type QPSSampler struct {
slots [10]uint64
idx uint32 // 原子读写,用 atomic.LoadUint32/StoreUint32
}
atomic 值跨 goroutine 传递时的坑
把原子变量地址传给新 goroutine 没问题,但若把它作为结构体字段嵌入,又在多个 goroutine 中修改该结构体本身(比如重新赋值整个结构体),会导致原子字段被覆盖,丢失之前所有变更。
更隐蔽的问题是:用 fmt.Printf("%d", reqTotal) 直接打印原子变量,Go 会复制该值再格式化——这本身安全,但如果你在打印前没用 atomic.Load*,而是依赖未同步的变量读取,就可能输出过期值。
所以务必遵守:所有读写都走 atomic 函数族,不裸露变量名参与运算;结构体中存放原子变量时,确保该结构体不被整体赋值或作为 map value 频繁更新。
真正的难点不在怎么写,而在于确认哪些路径真正高频、哪些统计维度必须实时——多数服务其实只需要总量+最近 1 秒 QPS,过度设计滑动窗口反而增加 cache line 争用。











