用 time.tick + 原子计数器实现秒级qps统计:每秒tick重置计数器,请求入口仅atomic.add,轻量无锁;暴露qps应独立路由避免伪共享;rate.limiter不适用于观测,高精度需分桶滑动窗口。

用 time.Tick + 原子计数器做秒级 QPS 统计
实时 QPS 统计不需要高精度时间戳或复杂滑动窗口,多数服务场景下“最近 1 秒内请求数”就足够指导限流和告警。直接用 time.Tick 配合 sync/atomic 是最轻量、无锁、低开销的做法。
常见错误是用 time.Now() 手动比对时间边界,导致每请求都触发条件判断和变量读写,压测时原子操作反而成了瓶颈;或者误用 time.AfterFunc 每次新建 goroutine,积压大量待执行函数。
- 每秒 tick 触发一次重置:用
atomic.SwapUint64(&qps, 0)清零并拿到上一秒的值 - 请求入口只做一次
atomic.AddUint64(&qps, 1),无分支、无锁、无内存分配 - tick 频率必须严格为
time.Second,不能用time.Millisecond * 1000(Go 1.20+ 有细微差异) - 注意:该方案统计的是“完成时间落在该秒内的请求数”,不是“发起时间”,但 HTTP server 处理耗时通常远小于 1s,偏差可接受
HTTP 中间件里怎么安全暴露 QPS 值
QPS 是瞬时指标,直接从变量读取即可,不需要加锁或 channel 传递。但暴露方式决定是否线程安全和是否拖慢主流程。
典型翻车点是把 qps 变量放进 http.ServeMux 的 handler 里每次调用 atomic.LoadUint64 —— 这本身没问题,但若同时注册了 Prometheus 的 /metrics handler 并用 expvar 或自定义 collector,容易因采集频率高而引发缓存行伪共享(false sharing),尤其在多核机器上 QPS 波动异常。
- 暴露端点建议独立路由,比如
/debug/qps,响应体只写纯数字,不带 JSON 包装 - 避免在中间件里对每个请求都调用
atomic.LoadUint64做日志或埋点,高频日志会放大原子操作开销 - 如果要用 Prometheus,写一个
prometheus.Gauge,在 tick 回调里用Set(float64(atomic.LoadUint64(&qps)))更新,不要在Collect()里现场读 - 别用
expvar.NewInt("qps"),它底层是 mutex + map 查找,比裸 atomic 慢一个数量级
为什么不用 golang.org/x/time/rate 做 QPS 统计
rate.Limiter 是为限流设计的,不是为观测设计的。它内部维护的是 token bucket 状态,没有提供“过去 N 秒平均请求数”的只读接口。
有人试图通过反复调用 limiter.AllowN(time.Now(), 1) 并计数来反推 QPS,这会导致两个严重问题:一是破坏限流逻辑(允许成功即消耗 token),二是高并发下 AllowN 的 CAS 操作成为性能热点,实测在 50k QPS 下 CPU 占用比裸 atomic 高 3–4 倍。
-
rate.Limiter的limit参数是“最大允许速率”,不是“当前观测速率” - 它的
Burst影响突发容忍度,但和实际 QPS 统计无关 - 若你已经在用
rate.Limiter做限流,可以额外起一个 goroutine 用time.Tick单独统计,不要复用 limiter 实例
高精度需求下怎么避开 1 秒整点漂移
当业务要求“任意连续 1 秒窗口”而非“日历秒”,比如风控策略需检测“最近 1000ms 内是否超过 100 次请求”,就得用滑动窗口。但全量存请求时间戳成本太高,推荐用分桶法(circular buffer of counters)。
容易被忽略的是:Go 的 time.Now().UnixNano() 在某些虚拟化环境或旧内核上有微秒级抖动,直接按纳秒切分桶可能让相邻桶边界错位,导致统计跳变。
- 用固定长度 slice 存 10 个桶(每桶 100ms),用
time.Since(start).Milliseconds() / 100 % 10算桶索引,start 是程序启动时的time.Now() - 每次请求更新对应桶的原子计数器,查询时 sum 所有桶值 —— 这是近似 1 秒窗口,误差 ≤100ms
- 不要用
time.Now().Second()或Minute()做桶索引,系统时间可能被 NTP 调整,造成桶切换异常 - 如果必须精确到毫秒级连续窗口,改用
github.com/beefsack/go-rate这类专注滑动窗口的库,它用单调时钟 + ring buffer 实现
真正难的不是算出数字,而是确保这个数字在 10 万 QPS 下仍稳定、不漂移、不拖慢主流程。很多人卡在把统计逻辑塞进 middleware 的第一行,却忘了 atomic 操作虽快,频繁调用仍会争抢 CPU 缓存行 —— 把它挪到 tick 回调里,才是让 QPS 统计“隐形”的关键。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











