← 返回筆記列表

固定内存算分位数:手写一个对数线性直方图

發布于 來自 Kite

  • 工程笔记
  • Y-HTTP-Bench
  • Go
  • 性能测试

为什么不能留全量样本

算 P99 最省事的办法是把所有延迟记下来排序。压测跑 10 分钟、每秒几万请求, 那就是千万级样本 —— 光是 []time.Duration 就要几十 MB, 而且内存随压测时长线性增长,长跑必爆。

直方图是标准答案,但「固定 1ms 一桶」也不行: 1ms 分辨率下要覆盖 30 秒延迟就是 3 万个桶,而绝大多数样本挤在前几个桶里。

对数线性分桶

HdrHistogram 的做法是按数量级分段,段内再等分:延迟落在哪个 2 的幂区间, 就在那个区间里再分成 1024 份。于是分辨率是相对的(约 0.1%),而不是绝对的:

const (
    subBucketBits  = 10                     // 每个量级 1024 桶
    subBucketCount = 1 << subBucketBits
    maxMagnitudes  = 32                     // 量级层数
    maxLatencyNS   = (int64(1) << (subBucketBits + maxMagnitudes - 1)) - 1
)

// v < 2^10 时落在量级 0(一值一桶),否则按最高有效位定出量级与桶内偏移
mag := 0
if v >= subBucketCount {
    mag = bits.Len64(uint64(v)) - subBucketBits
}
h.counts[mag][v>>uint(mag)]++

算下来:counts [32][1024]int64 = 256 KB,无论跑一百万次还是一亿次都不变。 可表示的上限 maxLatencyNS 约 36 分钟,相对误差上限 1/1024 ≈ 0.1%。

桶的代表值取中点

分位数要从桶还原成具体数值,取桶的下界会让结果系统性偏低:

// bucketValue 返回 (mag, sub) 桶的代表值,即桶中点
func bucketValue(mag, sub int) int64 {
    if mag == 0 {
        return int64(sub) // 量级 0 是一值一桶,没有中点问题
    }
    return int64(sub)<<uint(mag) + int64(1)<<uint(mag-1)
}

取完还要 clamp 到 [min, max],否则会出现「P99.9 比 Max 还大」这种一看就是假的报告。

三条让内存不随请求量增长的设计

光有直方图还不够,采集路径本身也可能攒内存:

  1. Collector 只留聚合量:直方图 + 状态码 / 错误 / 字节计数器,不留样本;
  2. 每个 worker 本地攒,满 4096 条才合并一次 —— 否则几万个 goroutine 高频抢同一把锁:
if len(buf) >= 4096 { // 定期回吐,控制单 worker 内存
    col.merge(buf)
    buf = buf[:0]
}
  1. 原始延迟导出是流式的:-dump-latency 在 merge 时顺手写进 bufio.Writer(64 KB 缓冲), 不在内存里攒到结束。这样即使导出几千万行 CSV,内存依然是常数。

顺带:报告要按「显示宽度」对齐

fmt 的 %-8s 按字符个数补空格,而中文在终端占 2 个显示列。 中英混排的表头会比纯数字的数据行更宽,整张表就歪了。 所以自己算显示宽度(汉字、假名、谚文、全角、emoji 都算 2 列):

func runeWidth(r rune) int {
    switch {
    case r < 32:
        return 0
    case r < 0x1100:
        return 1
    case r <= 0x115F, // 谚文字母
        r >= 0x2E80 && r <= 0xA4CF && r != 0x303F, // 中日韩部首、汉字、假名
        r >= 0xAC00 && r <= 0xD7A3,                // 谚文音节
        r >= 0xF900 && r <= 0xFAFF,                // 中日韩兼容表意文字
        r >= 0xFF00 && r <= 0xFF60,                // 全角字符
        r >= 0x1F300 && r <= 0x1F9FF:               // emoji
        return 2
    }
    return 1
}

这个判断在另一个项目里也用到了:程序化改 YAML 配置时要按显示宽度重新对齐行尾注释, 否则中英混排的注释会被挤成参差的一列。