摘要
「把 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)时,团队里通常有三句话没人反驳:
这三句都能用一台笔记本量出来。本文不引用任何外部基准,不写理论上限,所有数字来自本机 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:
| 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 元数据 + 倒排链"占用。三轮完全稳定:
| 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 多倍
这是本篇最值得记的一个读数。
| 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:
| 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 各写一个点):
| 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,会撞在同一个抓取线程上。
六、治理手段实测:分桶只是封顶,真正省的是不打进去
| 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 倍,只能用于同表内横向比较,不能当吞吐结论。
七、避坑指南
八、复现方法与读数口径
# 三轮,不同种子;脚本会把每轮完整 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。因此本文的数字只能读成"倍数关系",不能读成绝对容量。方向性结论(元数据主导内存、链长决定查询耗时、分桶只封顶)不依赖这些实现细节。



