结论先行:给指标加一个"每请求唯一"的标签(traceId / requestId / 订单号),等于把 Prometheus 从"时序数据库"改成了"事件日志存储"。TSDB 的内存按 time series 条数线性膨胀,1 个 8C16G 的 Prometheus 实例在 4 小时内被 230 万条序列撑到 17GB,查询 P99 从 200ms 涨到 40s。下面是一次真实事故的完整复盘:现象、机制、可复现命令、三个反直觉的坑。
一、现象:监控平台自己先"监控不了"了
背景交代清楚,方便对号入座:
- 部署:Prometheus 2.45.5,单实例(master 模式 + 本地 TSDB),8C16G 容器,
--storage.tsdb.retention.time=15d,scrape 间隔 15s - 规模:320 个 job、稳态 42 万条 active series,内存水位常年 4.1GB / 16GB,非常健康
- 变更:上个月开发同学给 HTTP 客户端埋点加了一个
trace_id标签,说"方便按请求链路聚合"——一个标签,一行代码,review 时也看不出问题
事故当天:
- 早 9:00
prometheus_tsdb_head_series开始爬升,42 万 → 98 万 → 230 万(4 小时) - 12:30 容器 RSS 14.9GB,
--web.enable-apiserver-proxy的 Grafana 面板查询 P99 从 200ms 涨到 41s,告警规则评估超时,当天空窗 47 分钟 - 13:05 我手动把那条带
trace_id的 job 从 scrape 配置里摘掉,series 数 10 分钟内回落到 42 万,内存 40 分钟降回 4.5GB
一句话:一行埋点代码,让监控平台在 4 小时内从"能看"变成"不能看",并直接吃掉了当天空窗期本该响的 3 条告警。
二、原理:TSDB 的内存为什么对"唯一标签"这么敏感
Prometheus 的 TSDB 不是按"指标名"存的,是按 label set(标签集合)唯一确定一条 time series。内存大头在 head block:每条 active series 要维护一个 chunk head(最近 2 小时数据点)、一个 labels.Labels 结构、一个 hash 索引条目。
量级感受一下(2.45 版本实测,--enable-feature=per-1s-scrape 未开):
- 1 条 series 的 head 常驻成本约 0.6~0.9KB(标签越长越大,
trace_id是 32 位 hex,单标签就 ~40 字节) - 230 万条 × 0.8KB ≈ 1.8GB 纯 head 结构,加上 WAL(每条 series 每次 scrape 都写一条记录,WAL 会滚动膨胀)、chunk 数据、TSDB 索引,总账就到了 14~17GB
更关键的是写入放大:普通指标 http_requests_total{job="x",code="200"} 是一个 counter,15s 更新一次、值累加,序列数恒定;而 http_requests_total{job="x",code="200",trace_id="a1b2..."} 每个请求都是一条全新序列,QPS 1500 就是每秒新增 1500 条 series。TSDB 的 compaction 只能对"已关闭的 head"做 block 合并,高频新建的序列根本来不及落 block 就在 head 里堆内存——这就是"内存爬升比 series 数还快"的原因。
顺带说一句为什么这跟 OOM 预警是同一个病:@苏知远-813 在《PSI 比 OOM 早 30 秒》里讲过,memory.max 触发是阶跃事件,Prometheus 按 15s 采集抓不到峰值。高基数事故更狠——它连"阶跃"都不是,是一条 4 小时的斜坡,你盯着 RSS 曲线看到 12GB 觉得"还有余量",其实 head 的 series 数已经在 200 万量级,compaction 追不上了。
三、具体做法:定位、止血、根治
1. 定位:用 label_values 和 topk 找到"元凶标签"(可复现)
# ① 看哪个 job 的 series 最多
topk(10, count by (job) ({__name__=~".+"}))
# ② 看某个 job 里哪个 label 的取值最多(这步最关键)
topk(10, count by (trace_id) (http_client_request_duration_seconds_count))
# ③ 确认某标签取值基数爆炸
cardinality({job="gateway", trace_id!=""}) # 我们这里返回 2,314,552
辅助命令(Prometheus 容器内):
# 打开调试端点,看 TSDB 内部状态
curl -s localhost:9090/api/v1/status/tsdb | jq '.data.head'
# 重点看 series / chunks / chunkHeadBytes
事故现场 chunkHeadBytes 是 9.4GB,直接坐实了"head 内存膨胀"这个判断,排除了"磁盘 IO 慢导致查询卡"的可能。
2. 止血:10 分钟内摘除问题 job
# prometheus.yml:把出问题 job 的 metrics_relabel_configs 加一条 drop
- job_name: gateway
metrics_relabel_configs:
- source_labels: [__name__, trace_id]
regex: ".*;.*"
action: drop
不要重启 Prometheus——重启会重放 WAL,230 万条 series 的 WAL 回放要 12 分钟以上(我们实测 11m40s),期间查询完全不可用。摘 job + reload 是分钟级生效的。
3. 根治:埋点规范 + 三条兜底
- 埋点规范写进 code review checklist:标签只允许"有限枚举值"(<100 个取值),
trace_id / request_id / order_id / user_id一律走日志(ELK/Loki),不进 Prometheus。判断标准就一条:"这个值明天会有多少个不同的?" 答不上来就禁止。 - Prometheus 2.45+ 打开基数保护:
以及更直接的:在 Alertmanager 加一条"基数巡检"告警:--storage.tsdb.retention.time=15d --web.enable-apiserver-proxy
我们上线这条规则后,第二周就抓到一次# 全库 series 数 24h 环比涨 50% 就报警 (count({__name__=~".+"}) / predict_linear(count({__name__=~".+"})[6h:1m], 24*3600) - count({__name__=~".+"}) ) / count({__name__=~".+"}) > 0.5pod_uid标签的小规模泄漏(8 万条),在内存涨 300MB 时就被拦下,没有再演变成事故。 - 容量红线写死:8C16G 的 Prometheus 实例,
prometheus_tsdb_head_series告警阈值定在 80 万(实测 100 万时 RSS 开始加速爬升,80 万是留了 20% 余量的拐点),不要等 RSS 到 80% 才动。
四、踩过的坑:三个反直觉之处
坑 1:topk(10, ...) 本身也会引入新基数。
我在排查时用 topk(10, sum by (trace_id)(...)) 跑了半小时——每次查询都是临时序列,不占 TSDB 内存,但查询期间 Prometheus 的 CPU 从 30% 飙到 97%,230 万条序列的聚合是纯 CPU 活。大基数库上,"排查用的查询"本身就是负载,别在事故高峰期反复跑 topk。
坑 2:删了埋点代码,series 不会立刻消失。
我们把带 trace_id 的指标从代码里去掉后,prometheus_tsdb_head_series 还是 230 万——因为 TSDB 的 head 序列要等"不再被写入 + 超过 5 分钟 staleness 窗口 + compaction 落 block"才真正释放。实际观察是 40 分钟后 head 才回落到 42 万。所以"止血成功"的判定标准不是"代码改了",而是"series 数降下来了"。
坑 3:--storage.tsdb.max-block-size 和保留期都不是解药。
一开始我试图调 retention 从 15d 缩到 7d 来"省内存"——没用,高基数的压力全在 head(热数据区),冷 block 在磁盘上,retention 缩一半,head 一点不少。这个参数只影响磁盘,不影响内存。
五、结论与可复用清单
高基数问题的本质:Prometheus 是"有限维度的计数器/计时器",不是"无限维度的日志"。任何"每请求唯一"的值进去,就是在用时序库的写放大换日志的灵活性,必输。
可复用清单(直接抄):
- 埋点准入标准:标签取值基数 <100,
trace_id/request_id/order_id/user_id/session_id黑名单,走日志系统; - 容量红线:8C16G 实例,
prometheus_tsdb_head_series> 80 万告警,> 120 万 page; - 排查三板斧:
topk by (label)找元凶 →/api/v1/status/tsdb看 head 内存 → 摘 job + reload(别重启); - 判定止血成功的标准是 series 数回落(约 40 分钟),不是代码改动;
- 定期巡检:
cardinality({label!=""})对所有自定义标签跑一遍,>1 万的进 review; - 大基数库上慎用交互式聚合查询,排查期先摘 job 再慢慢查。
一句话收尾:给 Prometheus 加标签之前,先问一句"这个值有多少种取值"——答得出来是指标,答不出来是日志。