直接用 time.since + 切片分桶会因并发写冲突和硬编码边界导致统计错乱、难以复用;应使用 prometheus.histogramvec 或满足无锁、预分配、累积反推三底线的手写方案。

为什么直接用 time.Since + 切片做分桶会出问题
很多同学一上来就用 time.Since 记下每次请求耗时,再手动往预设的响应时间区间(比如 0–10ms、10–50ms)里计数。这看似简单,但实际在高并发下会遇到两个硬伤:一是多个 goroutine 同时写同一个 map 或 slice 时没加锁,导致统计结果错乱;二是分桶边界写死(如 []int{10, 50, 200}),后续想查 P99 或动态调整精度时得重写逻辑,没法复用。
用 prometheus.HistogramVec 是最省心的生产方案
Prometheus 官方 client_golang 提供了线程安全、带标签聚合能力的直方图实现,它底层自动维护累积计数和分位数估算(用的类似 CKMS 算法),无需自己算 P95/P99。
- 必须提前定义好分桶边界:
prometheus.ExponentialBuckets(0.001, 2, 12)表示从 1ms 开始,每档翻倍,共 12 档(覆盖到约 4s) - 标签要精简:只保留真正影响分析维度的字段,比如
method和status_code,别把user_id这种高基数字段塞进去,否则指标暴涨、内存吃紧 - 观测时用
Observe,不是Inc:传入的是 float64 秒为单位的耗时,比如hist.WithLabelValues("GET", "200").Observe(float64(d.Microseconds()) / 1e6)
不用 Prometheus?手写轻量级 Histogram 要守住三个底线
如果项目完全不暴露 metrics、也不依赖 Prometheus,可以自己实现一个无锁、支持并发写、能查 P95 的结构,但必须满足:
- 底层用
sync/atomic操作计数器,避免 mutex 在高频场景下成为瓶颈 - 分桶数组长度固定且边界预分配(比如 100 档),插入时用二分查找定位下标,不能每次遍历
- Pxx 查询必须基于累积计数反推:先算总请求数 × 百分位比例,再在线性累积数组里找第一个 ≥ 该值的桶索引 —— 别用排序后取第 n 个这种 O(n log n) 方式
示例关键逻辑:idx := sort.Search(len(h.buckets), func(i int) bool { return h.cumsum[i] >= target })
注意 Observe 的单位陷阱和采样偏差
Go 的 time.Duration 默认是纳秒,但 prometheus.Histogram.Observe 接收的是秒。直接传 d.Seconds() 看似合理,但如果原始值来自 time.Now().Sub(start),而 start 是从 HTTP middleware 外部传入的,要注意是否被 GC 延迟或调度器抢占拉长——尤其在短耗时(
- 推荐统一用
time.Now()打点,避免跨函数传time.Time值 - 若需极高精度(微秒级),可改用
runtime.nanotime()获取单调时钟,再转成秒(注意除以1e9,不是1e6) - 不要对日志或 debug 场景的请求打点:它们本身就会拖慢响应,污染真实分布
分位数不准往往不是算法问题,而是数据源混进了不该测的路径。











