欢迎光临
我们一直在努力

把 user_id 打进指标 label 之后:本机实测 series 2,400→100,000,一次查询 p50 从 28µs 涨到 2.0ms

摘要

「把 user_id 打进指标 label,就能按用户看 QPS」这句话在很多评审会上都能过。本文在本机(Windows 10 / Python 3.11.5 / 32 逻辑核 / numpy 2.4.6)复刻了时序索引的核心结构(label-set → series_id + 倒排链),用同一份 100,000 样本的写入负载跑了 3 轮,只多加一个 label 做对照。实测:series 从 2,400 涨到 100,000(41.7 倍),索引元数据从 1.30 MB 涨到 76.29 MB,一次三选择器查询的 p50 从 28.2~33.3µs 涨到 1,808~2,069µs,一个抓取 tick 的 p99 从 0.63~1.37ms 涨到 40.4~49.4ms。而倒排链条目总数一条没多——慢的不是数据量,是链长。

一、要钉死的是三句常被当作常识的话

指标打标签(label)时,团队里通常有三句话没人反驳:

  • 「多一个 label 只是多一列,成本可以忽略」——那成本到底长在哪:样本点、series 元数据,还是倒排链?
  • 「查询慢是因为数据多了」——如果倒排链的条目总数压根没变,查询慢 60 多倍该怪谁?
  • 「实在不行把 user_id 分个桶就好了」——分桶之后规模真的变小了,还是只是"有界"了?
  • 这三句都能用一台笔记本量出来。本文不引用任何外部基准,不写理论上限,所有数字来自本机 stdout,3 轮取区间。

    二、实验台:把 Prometheus 的索引骨架自己写一遍

    不需要真起 Prometheus,高基数代价发生在索引层,所以只要复刻三件事就能读数:

    #mermaid-svg-zOripxlNCHWiCOeZ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zOripxlNCHWiCOeZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zOripxlNCHWiCOeZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zOripxlNCHWiCOeZ .error-icon{fill:#552222;}#mermaid-svg-zOripxlNCHWiCOeZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zOripxlNCHWiCOeZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zOripxlNCHWiCOeZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zOripxlNCHWiCOeZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zOripxlNCHWiCOeZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zOripxlNCHWiCOeZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zOripxlNCHWiCOeZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zOripxlNCHWiCOeZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zOripxlNCHWiCOeZ .marker.cross{stroke:#333333;}#mermaid-svg-zOripxlNCHWiCOeZ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zOripxlNCHWiCOeZ p{margin:0;}#mermaid-svg-zOripxlNCHWiCOeZ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-zOripxlNCHWiCOeZ .cluster-label text{fill:#333;}#mermaid-svg-zOripxlNCHWiCOeZ .cluster-label span{color:#333;}#mermaid-svg-zOripxlNCHWiCOeZ .cluster-label span p{background-color:transparent;}#mermaid-svg-zOripxlNCHWiCOeZ .label text,#mermaid-svg-zOripxlNCHWiCOeZ span{fill:#333;color:#333;}#mermaid-svg-zOripxlNCHWiCOeZ .node rect,#mermaid-svg-zOripxlNCHWiCOeZ .node circle,#mermaid-svg-zOripxlNCHWiCOeZ .node ellipse,#mermaid-svg-zOripxlNCHWiCOeZ .node polygon,#mermaid-svg-zOripxlNCHWiCOeZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zOripxlNCHWiCOeZ .rough-node .label text,#mermaid-svg-zOripxlNCHWiCOeZ .node .label text,#mermaid-svg-zOripxlNCHWiCOeZ .image-shape .label,#mermaid-svg-zOripxlNCHWiCOeZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-zOripxlNCHWiCOeZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zOripxlNCHWiCOeZ .rough-node .label,#mermaid-svg-zOripxlNCHWiCOeZ .node .label,#mermaid-svg-zOripxlNCHWiCOeZ .image-shape .label,#mermaid-svg-zOripxlNCHWiCOeZ .icon-shape .label{text-align:center;}#mermaid-svg-zOripxlNCHWiCOeZ .node.clickable{cursor:pointer;}#mermaid-svg-zOripxlNCHWiCOeZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zOripxlNCHWiCOeZ .arrowheadPath{fill:#333333;}#mermaid-svg-zOripxlNCHWiCOeZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zOripxlNCHWiCOeZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zOripxlNCHWiCOeZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zOripxlNCHWiCOeZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zOripxlNCHWiCOeZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zOripxlNCHWiCOeZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zOripxlNCHWiCOeZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zOripxlNCHWiCOeZ .cluster text{fill:#333;}#mermaid-svg-zOripxlNCHWiCOeZ .cluster span{color:#333;}#mermaid-svg-zOripxlNCHWiCOeZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zOripxlNCHWiCOeZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zOripxlNCHWiCOeZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-zOripxlNCHWiCOeZ .icon-shape,#mermaid-svg-zOripxlNCHWiCOeZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zOripxlNCHWiCOeZ .icon-shape p,#mermaid-svg-zOripxlNCHWiCOeZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zOripxlNCHWiCOeZ .icon-shape .label rect,#mermaid-svg-zOripxlNCHWiCOeZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zOripxlNCHWiCOeZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zOripxlNCHWiCOeZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zOripxlNCHWiCOeZ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    样本 ingest

    series_ids 字典label-set 排序元组 -> sid

    postings 倒排label 键值对 -> sid 列表

    points 样本存储sid -> 时间序列

    match 查询多条倒排链求交

    (Mermaid 若平台不渲染,等价文字:写入时先查 series 字典,新 series 才往每个 label 键值对的倒排链里追加一个 id;查询就是把几个倒排链求交集。)

    核心代码(节选自 demos/metric_cardinality_lab.py,逐字可运行):

    class SeriesIndex:
    """最小可用的时序索引:label-set -> series_id,外加 (k,v) 倒排链。"""

    def __init__(self) –> None:
    self.series_ids: dict[tuple[str, ...], int] = {}
    self.postings: dict[tuple[str, str], list[int]] = defaultdict(list)
    self.points: dict[int, list[tuple[float, float]]] = defaultdict(list)

    def ingest(self, metric, labels, ts, value):
    self.ingest_calls += 1
    pairs = tuple(sorted(labels.items())) # label 顺序无关,排序后做 key
    sid = self.series_ids.get(key := (metric, pairs))
    if sid is None: # 只有新 series 才写倒排链
    sid = len(self.series_ids)
    self.series_ids[key] = sid
    for k, v in pairs:
    self.postings[(k, v)].append(sid)
    self.points[sid].append((ts, value))

    def match(self, selectors):
    """label-match 查询:把各选择器的倒排链按长度升序求交。"""
    chains = [self.postings.get((k, v), []) for k, v in selectors.items()]
    chains.sort(key=len) # 先用最短的链,交集塌得快
    acc = set(chains[0]) if chains else set()
    for ch in chains[1:]:
    acc.intersection_update(ch)
    if not acc:
    break
    return sorted(acc)

    负载是同一个订单服务的 QPS 指标,固定 5 个 label(job / instance(8) / method(3) / uri(20) / status(5)),然后按模式只加一个 label:

    模式多加的那个 label基数性质
    baseline 不加 2,400 series 封顶
    client_ver 客户端版本 v{1-12}.{0-9} 有界,但组合多
    user_bucket 用户分桶 b0…b15 有界(治理后)
    user_id 随机 u1…u50000 名义有界、实际够大
    trace_id 每请求 12 位随机串 无界增长

    每种模式写 100,000 个样本,3 轮换种子。计时通道与内存通道严格分开:tracemalloc 开着会拖慢写入,实测同一模式在 tracemalloc 下耗时从 608~787ms 变成 3,545~5,441ms(见第六节的表),所以任何"高基数让写入慢多少"的结论都不能用带内存追踪的那一遍。

    三、结果一:内存涨在元数据上,不是涨在样本点上

    把索引里的样本点 points 清空后再读 tracemalloc,剩下的就是"纯 series 元数据 + 倒排链"占用。三轮完全稳定:

    模式series总内存 MB元数据 MB每 series 元数据 KB
    baseline 2,400 9.87 1.30 0.55
    client_ver 84,423~84,523 64.71~64.78 47.61~47.67 0.58
    user_bucket 35,542~35,617 32.18~32.22 20.28~20.32 0.58
    user_id 99,957~99,965 85.71~85.73 64.80~64.83 0.66
    trace_id 100,000 97.20 76.29 0.78

    同样 100,000 个样本,baseline 的元数据只有 1.30 MB,trace_id 是 76.29 MB,差 58.7 倍,而样本点数量一模一样。每 series 元数据 0.55~0.78 KB,多出来的部分来自那个唯一的 label 值本身(12 位随机串要存进字典 key 和倒排链的键里)。

    写入吞吐也跟着塌:baseline 103.4~118.2ms 写完 10 万样本(84.61~96.70 万样本/秒),trace_id 要 754.8786.6ms(12.7113.25 万样本/秒),慢 6.4~7.6 倍。原因不在哈希计算,而在每个样本几乎都是新 series,都要走一遍"插字典 + 追加 6 条倒排链 + 分配一个新 list"。

    四、结果二:倒排链条目一条没多,查询却慢 60 多倍

    这是本篇最值得记的一个读数。

    模式serieslabel 键值对种类postings 条目总数每 series 平均
    baseline 2,400 37 12,000 5.00
    client_ver 84,423~84,523 157 506,538~507,138 6.00
    user_bucket 35,542~35,617 53 213,252~213,702 6.00
    user_id 99,957~99,965 43,242~43,372 599,742~599,790 6.00
    trace_id 100,000 100,037 600,000 6.00

    postings 条目总数严格等于 series 数 × label 个数,也就是说"索引里存了多少条倒排记录"这件事,只由 series 数决定。那查询慢在哪?慢在被查的那几条链变长了。同一句 job=X, uri=/api/v1/orders/4, status=200:

    模式命中 series查询 p50 µs查询 p99 µs查询 max µs
    baseline 120 28.2~33.3 47.1~166.3 85.0~392.5
    client_ver 4,167~4,287 1,493.4~1,717.4 2,701.0~3,359.6 2,753.7~3,625.1
    user_bucket 1,756~1,790 542.3~582.4 959.7~3,856.5 1,017.9~5,836.8
    user_id 4,880~4,987 1,704.6~1,950.7 3,176.9~3,354.1 3,618.9~3,864.7
    trace_id 4,983~5,069 1,808.2~2,069.2 3,565.8~4,340.7 4,494.8~5,376.9

    baseline 命中 120 条、p50 约 30µs;trace_id 命中约 5,000 条、p50 约 1.9ms。命中集要装进 set、排序、再返回,成本跟着"这条链有多长"走,而不是跟着"总数据量"走。同一个 job 下多打一个高基数 label,等于把所有常用选择器的倒排链同时拉长,那些和 user_id 毫无关系的查询也一起付费。

    五、结果三:抓取还赶得上,但清理和内存先出问题

    按 15s 抓取周期模拟 1 小时(240 个 tick,每 tick 给全部 series 各写一个点):

    模式series每 tick p50 ms每 tick p99 ms240 tick 总耗时 sp99 占 15s 预算
    baseline 2,400 0.48~0.59 0.63~1.37 0.12~0.15 0.0042%~0.0091%
    client_ver 84,423~84,629 17.29~23.25 34.19~43.65 4.25~6.00 0.2279%~0.2910%
    user_bucket 35,471~35,617 7.59~9.88 9.73~17.02 1.86~2.49 0.0649%~0.1134%
    user_id 99,956~99,965 19.52~24.75 42.09~49.27 4.90~6.16 0.2694%~0.3285%
    trace_id 100,000 18.78~23.06 40.41~49.38 4.70~5.82 0.2694%~0.3292%

    抓取周期这一环不会成为瓶颈——最坏情形也只吃掉 15s 预算的 0.33%,Python 单机跑 10 万条链都追得上。真正炸的是第四节(查询)和下面这项:

    TTL 全量清理(把 series 字典、倒排链、样本存储一起清空):baseline 3.2~3.8ms,user_id 25.3~35.1ms,trace_id 25.4~26.3ms。折算成"每万 series"反而是小索引更贵(baseline 13.18~16.03ms/万 vs 大索引 2.53~2.63ms/万),因为清空操作有固定开销,规模一大就摊薄了。看绝对值就行:高基数把清理拉贵 6.7~11.0 倍(trace_id 25.4~26.3ms 对 baseline 3.2~3.8ms),而且它是纯 CPU+GC,会撞在同一个抓取线程上。

    六、治理手段实测:分桶只是封顶,真正省的是不打进去

    做法series元数据 MB查询 p50 µs写入 ms(带内存追踪,只作同表横向比较)
    user_id 原样入 label 99,954~99,960 64.80~64.81 1,536.8~1,654.6 3,544.8~3,843.0
    分桶 bucket(16) 35,478~35,572 20.25~20.30 390.3~450.7 1,835.3~1,987.9
    trace_id 原样入 label 100,000 76.29 1,515.8~1,631.0 3,850.1~5,440.9
    trace_id 不入 label 2,400 1.29 23.4~24.7 90.3~93.0

    读法有三条:

    • 分桶把无界变成有界,但没把它变小。16 个桶 × 原有组合,实测 series 仍到 35,478~35,572,是 baseline 的 14.8~14.9 倍;相对 user_id 只降到 35.5%。收益是真实的(元数据 20.25~20.30 MB 对 64.80 MB,查询 p50 约 1/3.9),但别指望"分桶 = 回到基线"。
    • 不入 label 才是回到基线:series 回到 2,400,元数据 1.29 MB,查询 p50 23.4~24.7µs,比 trace_id 原样快 64~66 倍(同一轮对比)。需要按 trace 查的时候,用日志/trace 系统做,指标里只留一个 has_error=true 这类有界维度。
    • 这一列的"写入 ms"开着 tracemalloc,绝对值比第三节的干净计时大 5~7 倍,只能用于同表内横向比较,不能当吞吐结论。

    七、避坑指南

  • 先量 series 数,再谈内存。样本点数量由流量决定,series 元数据由 label 组合决定;本节实测后者能差 58.7 倍而前者一模一样。任何"存储成本"估算都要分开报这两个数。
  • 高基数的代价是"链长",会污染无关查询。加了 user_id 之后,job+uri+status 这种和它毫无关系的查询也从 30µs 涨到 1.9ms。别只对"按用户查"做容量评估。
  • 不要用抓取周期是否超时来判断能不能加 label。实测 10 万 series 也只占 15s 预算的 0.33%,看起来毫无问题;查询 p99 已经贵了 21~76 倍。
  • uri、path、sql 这类 label 必须先做模板归一化。/api/v1/orders/4 是 20 个值,/api/v1/orders/{id} 才是 1 个值。没做归一化的路径标签,其效果等价于本文的 trace_id。
  • 分桶要报"桶数 × 现有机数",不是报"桶数"。16 桶听起来很小,实测 35,500 个 series。要控规模得同时降别的维度。
  • 内存和耗时不能在同一遍测。本文实测同一模式(user_id)在 tracemalloc 下写入耗时从 608.1~713.7ms 涨到 3,544.8~3,843.0ms,同一段代码换种测法结论就反了。
  • TTL 清理成本落在抓取线程上。绝对值 25~35ms 不算大,但它是 GC 密集操作,和查询、写入抢同一时间片;高基数场景下的抖动常出现在清理时刻而不是抓取时刻。
  • 八、复现方法与读数口径

    # 三轮,不同种子;脚本会把每轮完整 stdout 打出来
    set PYTHONIOENCODING=utf-8
    python metric_cardinality_lab.py 101
    python metric_cardinality_lab.py 102
    python metric_cardinality_lab.py 103

    • 实验脚本:demos/metric_cardinality_lab.py(Python 3.11.5,Windows 10 x64,32 逻辑核,只用标准库 + tracemalloc,无第三方依赖)。
    • 逐轮留档:demos/_mc_r101.txt / _mc_r102.txt / _mc_r103.txt;合并留档:demos/metric_cardinality_lab.transcript.txt。
    • 每轮固定 6 个实验,唯一变量是随机种子;series 数、内存、postings 条目数是确定性的(轮间差异只来自抽样到的标签组合数),耗时与分位数给 min~max 区间。
    • 口径声明:内存是 tracemalloc 统计的 Python 层分配字节数,不是 RSS,更不是 Prometheus TSDB 的真实磁盘/内存占用;倒排链用 Python list 代替了真实索引的 roaring bitmap。因此本文的数字只能读成"倍数关系",不能读成绝对容量。方向性结论(元数据主导内存、链长决定查询耗时、分桶只封顶)不依赖这些实现细节。

    九、结论

  • 多打一个 label 的成本从来不是"多一列",而是"series 数乘以 label 个数"。同一个 100,000 样本的负载,元数据从 1.30 MB 涨到 76.29 MB(58.7 倍),写入吞吐从 84.61~96.70 万样本/秒掉到 12.71~13.25 万。
  • 查询慢的根因是倒排链长度,不是索引总大小——postings 条目总数严格等于 series × label 数,而同一句查询的 p50 从 28.2~33.3µs 涨到 1,808~2,069µs(同轮对比 60.5~65.7 倍),因为命中集从 120 涨到约 5,000。
  • 抓取周期不是判据。10 万 series 的 tick p99 只占 15s 预算的 0.27%~0.33%,看着毫无风险,同期查询 p99 已经贵了 21~76 倍(同一轮对比)。
  • 治理顺序要明确:能不打进 label 就不打(回到基线,同一轮对比快 64~66 倍)> 必须按维度看就用分桶(只封顶,实测仍是基线的 14.8~14.9 倍)。user_id、trace_id、order_id、未归一化的 uri,是四个最常被顺手加进去的无界维度。
  • 赞(0)
    未经允许不得转载:171主机测评 » 把 user_id 打进指标 label 之后:本机实测 series 2,400→100,000,一次查询 p50 从 28µs 涨到 2.0ms
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址