跳转至

Prometheus TSDB 深度运维:WAL/Head/Block 结构与 5 个真实故障

Prometheus 的存储层叫 TSDB(Time Series Database),是社区用 Go 写的、专门为 Prometheus 定制的时序数据库,不复用 InfluxDB、也不复用 Cassandra。它跟 Prometheus 是同进程、同二进制文件的一部分,没有单独的服务端口。所有数据都写在 Prometheus 的 --storage.tsdb.path 目录里(默认 /prometheus/data),目录结构非常规整,运维起来其实不难——前提是你得知道每个文件在干嘛。

目录结构解剖

我生产上的一台 Prometheus(1 年保留、单机、非托管)目录长这样:

$ ls -lah /var/lib/prometheus/
total 342G
drwxr-xr-x   5 prom prom    12M Oct 26 02:11 .
drwxr-x---.  10 root root  4.0K Oct 26 02:11 ..
drwxr-xr-x   2 prom prom  220K Oct 26 02:11 chunks_head
drwxr-xr-x   2 prom prom 1.4G  Oct 26 02:11 wal
drwxr-xr-x   2 prom prom  3.2G Oct 24 15:40 01H7XV4X0K3P5Y6K2JQ8W9Y8ZT
drwxr-xr-x   2 prom prom  3.3G Oct 23 08:12 01H7X9ZQ9P4R7XV8K8N9M3J5Y6
drwxr-xr-x   2 prom prom  3.4G Oct 22 04:33 01H7XJ2H3L6B5T6R3H8KJ0K2W9
...
-rw-r--r--   1 prom prom  472M Oct 26 02:11 chunks_head.meta.json

三个目录 + 一堆 block 文件夹:

wal/:Write-Ahead Log,前向日志。所有新样本先落这里,再异步刷到 head chunk。里面是若干个 000001、000002、000003... 编号的 WAL 段文件,段文件满了切新的。

chunks_head/:Head chunk 持久化文件,是 WAL 的一种"更结构化"的形态。Prometheus 会把 head 里的部分 chunk 提前 flush 到这里,方便重启时快速恢复。同目录还有 chunks_head.meta.json,记录 head 的元数据。

01H7XV4X0K3P5Y6K2JQ8W9Y8ZT/:一个已 compaction 完成的 block,目录名是 ULID(通用唯一标识,含时间戳,排序天然按时间)。打开一个 block 看:

$ ls -lah /var/lib/prometheus/01H7XV4X0K3P5Y6K2JQ8W9Y8ZT/
total 3.2G
-rw-r--r-- 1 prom prom  284M Oct 24 15:40 chunks_000000
-rw-r--r-- 1 prom prom  198M Oct 24 15:40 chunks_000001
-rw-r--r-- 1 prom prom  256M Oct 24 15:40 chunks_000002
-rw-r--r-- 1 prom prom 2.5G  Oct 24 15:40 index
-rw-r--r-- 1 prom prom  2.1M Oct 24 15:40 meta.json
-rw-r--r-- 1 prom prom     0 Oct 24 15:40 tombstones

chunks_* 存原始样本(每 48 小时一段),index 存标签到 chunk 引用位置的映射(查询走这个),meta.json 是块的元信息(时间范围、segment 列表),tombstones 是被 soft-delete 的区间记录。

Block 之间的时间范围可能重叠(尤其刚写完的 head 切 block 时),因此 -storage.tsdb.allow-overlapping-blocks=true 这个参数在生产强烈建议打开,否则重启后看到重叠 block 会直接拒启动。

写入流程:WAL → Head → Block

一图流:

Scrape
  │
  ▼
+----------+   每条样本    +------------------+   chunk 满    +-----------------+
|  WAL     | ------------> | Head (内存)       | ------------> | Head Chunk 上锁  |
| (磁盘)   |               | series → chunks  |               | flush 到磁盘    |
+----------+               +------------------+               +--------+--------+
                                                                     │
                                                     每 2h 触发一次 head cut
                                                                     ▼
                                                            +-------------------+
                                                            | Chunk 目录        |
                                                            | chunks_head/      |
                                                            +--------+----------+
                                                                     │
                                                     compaction:把多个 2h chunk 合成 block
                                                                     ▼
                                                            +-------------------+
                                                            | Block 01H7XXX/    |
                                                            +-------------------+

三个关键点:

  1. WAL 是异步刷盘的,写入 WAL 后立即返回给 scraper,不等 head 处理完成。
  2. Head 是内存结构,series 按 labels → hashmap 索引,chunk 用 128 字节定长块(4k 样本/chunk)。
  3. Block 是磁盘只读,一旦写完不再修改,删除 block 就是彻底清理。

这条流水线上,任何一段出问题都会导致"数据丢"或"数据堆积"。下面 5 个事故全部对应这三段。

compaction 触发机制

-storage.tsdb.max-block-size-bytes 控制 block 目标大小,默认 512MB(1.x 之前是 512MB,2.x 起默认 2.5GB 分片写入)。compaction 触发条件:

  • 定时触发:每 5 分钟检查一次,head 中"未落盘"的 chunk 数 > min_head_chunks(默认 9)时触发。
  • 大小触发:待压缩的 chunk 总大小超过阈值。
  • 手动触发:curl -X POST http://localhost:9090/api/v1/admin/tsdb/snapshot 或 snapshot 子命令,把当前 head 强制切成 block。

关键指标:

# 待压缩的 pending block 数
prometheus_tsdb_retention_limit_bytes - prometheus_tsdb_disk_free_bytes  # 不是这个,看下面
prometheus_tsdb_compaction_pending

如果 prometheus_tsdb_compaction_pending 长时间 > 0,说明 compaction 追不上写入速度。

retention 策略深度

三个参数:

  • --storage.tsdb.retention.time=15d:时间保留,到点自动删 block。
  • --storage.tsdb.retention.size=50GB:大小保留,磁盘占用超阈值删老 block。
  • --storage.tsdb.retention.time=0:关闭时间保留。

为什么 size 优先容易出问题:size 触发时 Prometheus 会挑最老的 block 删,但因为 block 是稀疏写入的(一个 2h block 可能只有 500MB),触发阈值后可能一次性删掉好几个 block,用户观察到的现象就是"数据莫名其妙缺了一段"。生产强烈建议只配 retention.time,磁盘空间问题用外部监控兜底。

事故 1:compaction 卡死

现象:磁盘占用持续增长,Grafana 上 prometheus_tsdb_compaction_running_seconds_total 长时间不变,prometheus_tsdb_head_chunks 从 50000 涨到 400000。

日志关键字(grep 就能定位):

level=info ts=2026-10-20T14:22:03Z caller=db.go:1465 msg="Compaction completed" ...
level=info ts=2026-10-20T14:22:03Z caller=db.go:1442 msg="Compaction failed" err="context canceled"
level=error ts=2026-10-20T14:23:15Z caller=db.go:1478 msg="Failed to complete compaction" err="write /var/lib/prometheus/01H7...: no space left on device"

根因:99% 是基数爆炸导致 head chunks 数暴涨。compaction 需要把 head 中 40 万个 series 的 chunk 逐条搬到新 block,一旦磁盘 IO 跟不上,就卡死。

修复步骤:

# 1. 立即停止抓取,减少写入压力
curl -X POST http://localhost:9090/api/v1/admin/tsdb/snapshot   # 手动切一次 block

# 2. 查高基数 series
curl -G 'http://localhost:9090/api/v1/status/tsdb' | jq '.data.seriesCountByMetricName | to_entries | sort_by(-.value) | .[0:20]'

# 3. 定位到 pod_ip、container_name 这类维度被 label 化,改 alert rule 或 scrape job
# 4. 磁盘扩容或清理老 block 后重启
systemctl restart prometheus

事故 2:head chunk 内存泄漏

现象:Prometheus 进程内存缓慢上涨,一天涨 200MB,重启后恢复。prometheus_tsdb_head_active_series 稳定在 12 万,但 RSS 从 2GB 涨到 5GB。

排查思路:

# 1. 对比 active series 和内存
prometheus_tsdb_head_active_series     # 120000
process_resident_memory_bytes          # 5.2e9

# 2. 按 series 分组看内存(官方 debug 接口)
curl http://localhost:9090/debug/fgprof
# 生成火焰图 → 看内存分配热点

# 3. pprof 内存分配剖析
go tool pprof -top http://localhost:9090/debug/pprof/heap

如果火焰图上看到 tsdb.Head.appendSeries 或 labeled.LabeledValues 占比异常,就是 leaky bucket 类的内存回收问题。这类问题基本都在 patch 版本里修过,升级到最新 patch 即可。我们生产上一台 Prometheus 2.37.1 有这个问题,升到 2.38.3 后消失。

事故 3:retention 到期数据"消失"

现象:业务方问"上周三到上周四的 pod_restart 指标怎么没了?"运维一看 retention 是 30d,今天还没到 30 天。

根因:不是 retention 触发,是 compaction 时把 head 中一段数据误判为过期。常见于 retention 配了 size(见上节)。也可能是某个中间态的 block 被删除了。

验证:

# 列所有 block 的时间范围
for d in /var/lib/prometheus/01H*; do
  echo -n "$d: "
  jq -r '"\(.timeRange.mint) → \(.timeRange.maxt)"' $d/meta.json
done

看时间线是否连续。发现空洞就是 block 被提前删了。

教训:retention 只配 time,不要配 size。size 触发是不可预测的。

事故 4:WAL 崩溃恢复

现象:Prometheus 重启后卡在"Loading storage",日志报:

level=error ts=2026-10-22T03:14:22Z caller=wal.go:2001 msg="WAL corrupted, trying to recover" err="unexpected EOF"
level=warn  ts=2026-10-22T03:14:23Z caller=wal.go:2015 msg="Recovered WAL" from="000042" to="000047" dropped="3"

WAL 段文件损坏时,Prometheus 会尝试修复,从最后一个完好段截断,把损坏段丢弃。代价是这几个段的 head 数据丢(一般 5-15 分钟)。

预防:

  • 启用 WAL 压缩:--storage.tsdb.wal-compression=true(Prometheus 2.32+)。压缩后 WAL 大小减少 60-70%,写入量下降自然减少损坏概率。
  • 挂 tmpfs 或 NVMe 到 --storage.tsdb.wal-directory(Prometheus 2.26+),把 WAL 和 block 分开。tmpfs 断电丢数据无所谓,反正重启后会从 block + 重新 scrape 恢复。
# docker-compose / kube-state 参数
command:
  - --storage.tsdb.wal-directory=/tmp/prom-wal
  - --storage.tsdb.path=/var/lib/prometheus
  - --storage.tsdb.wal-compression=true

事故 5:block 目录膨胀

现象:磁盘占用从 100GB 涨到 400GB,ls 一堆 block,retention 明明配了 30d。

排查:

# 1. 查 block 数量和时间跨度
ls -d /var/lib/prometheus/01H* | wc -l    # 应该 ≈ 30d / 2h = 360
ls -la /var/lib/prometheus/ | grep 01H | awk '{print $6, $7, $8, $9}'

# 2. 看是否有孤儿 block(meta.json 时间远超 retention)
for d in /var/lib/prometheus/01H*; do
  maxt=$(jq -r '.timeRange.maxt' $d/meta.json)
  now=$(date +%s%3B)
  age=$(( (now - maxt) / 86400000 ))
  [ $age -gt 30 ] && echo "OLD: $d age=${age}d"
done

根因:compaction 没跑完,或者某个 block 的 meta.json 损坏导致 retention 扫描跳过它。

修复:

# 温和方案:改 retention 短一点,等 compaction 自然清理
systemctl stop prometheus
sed -i 's/retention.time=30d/retention.time=7d/' /etc/prometheus/prometheus.yml
systemctl start prometheus

# 暴力方案(需权衡数据风险):手动删老 block
systemctl stop prometheus
find /var/lib/prometheus -maxdepth 1 -name '01H*' -type d -mtime +30 -exec rm -rf {} +
systemctl start prometheus

生产环境我们更倾向温和方案,暴力删 block 有可能破坏 index 一致性。

TSDB 监控:10 个必看指标

一行一个,直接抄:

# 1. 磁盘使用率
sum by (instance) (prometheus_tsdb_storage_blocks_size_bytes)

# 2. head chunk 数(异常增长说明基数爆炸)
prometheus_tsdb_head_chunks{job="prometheus"}

# 3. head 中活跃 series 数
prometheus_tsdb_head_active_series

# 4. 已压缩完成的 block 数
prometheus_tsdb_storage_blocks

# 5. compaction 是否在运行
deriv(prometheus_tsdb_compaction_duration_seconds_count[5m]) > 0

# 6. compaction 耗时(超过 10 分钟就慢了)
prometheus_tsdb_compaction_duration_seconds

# 7. WAL 大小
prometheus_tsdb_wal_storage_size_bytes

# 8. WAL 段文件数(越多说明 checkpoint 越慢)
prometheus_tsdb_wal_segments

# 9. 查询命中 head 的比例(比例高说明数据还热在内存)
rate(prometheus_tsdb_queries_total[5m]) > 0
# 配合 prometheus_tsdb_head_chunks 看查询热度

# 10. 启动加载耗时(> 60s 说明数据量到临界)
prometheus_tsdb_load_total - (prometheus_tsdb_load_total offset 1d)

前 5 个必须告警,后 5 个做仪表盘观察即可。

与 Thanos / VictoriaMetrics / Cortex 对比

不深入,只谈选型:

  • 纯 Prometheus:series < 35 万、单实例、保留 ≤ 30 天。运维最简,TSDB 结构清晰。
  • Thanos:Prometheus + Sidecar 上传到对象存储(S3/OSS),跨实例查询走 query 组件。TSDB 层完全复用 Prometheus 原样,运维心智负担低。适合"想保留 Prometheus 但保留 1 年+"的场景。
  • VictoriaMetrics:自己写的存储引擎,非兼容 TSDB 目录结构。单机可扛 100 万 series,多租户能力强。运维上要重新学一套概念。
  • Cortex:Mimir 前身,全托管分布式,块状存储类似 Thanos。企业级方案,学习曲线最陡。

我们生产上单机 Prometheus 保留 15d + Thanos 上传到 OSS 保留 1 年,两者叠加。TSDB 层面的坑踩过一遍之后,运维基本只关注 Thanos sidecar 是否正常上传。

常见误解澄清

  • "Prometheus 应该装到 SSD 上":不一定。TSDB 的写入是顺序的(WAL + block 都是追加),普通 SATA 机械盘配 15d 保留也能扛住。但 head chunk 读取走内存,查询性能跟磁盘关系不大。
  • "head chunk 内存是固定的":不是。跟活跃 series 数线性相关,每个 series 平均 50-200KB(取决于写入频率)。
  • "compaction 会锁查询":不会。compaction 走后台 goroutine,查询走 read-only 的 block 和当前 head 的 snapshot。
  • "TSDB 数据不能备份":可以。停 Prometheus → cp -a /var/lib/prometheus/ backup/,恢复时反过来。或者用 admin/tsdb/snapshot API 生成快照。

FAQ

Q: Prometheus 重启后数据丢了? 先看 wal/ 目录是否还在,且 -storage.tsdb.allow-overlapping-blocks 参数是否配置。重启会读 WAL 重建 Head,如果 WAL 损坏会跳过。生产必须配 --storage.tsdb.retention.time 而不是靠磁盘空间兜底。如果 wal/ 目录丢了,只丢 WAL 中的那 5-15 分钟数据,其余都还在 block 里。

Q: compaction 一直跑不停怎么办? 99% 是 pending 太多(head chunk 数 > 9 触发 compaction)。查 metrics:prometheus_tsdb_head_series 如果突增,说明基数爆炸,先治基数问题再谈 compaction。用 /api/v1/status/tsdb 定位高基数 metric。

Q: TSDB 占了 500GB 还能缩吗? 不能直接删 block(会破坏 index)。改 -storage.tsdb.retention.time 到更短值 + 等下一次 compaction 会自然清理。想立即缩,停 Prometheus,手动删老 block(find -mtime +N),重启会重建 index(几分钟到十几分钟)。

Q: head chunk 内存泄漏怎么判断? 看 process_resident_memory_bytes 与 prometheus_tsdb_head_active_series 的比例。head_active_series 是万级但内存 GB 级 → 大概率是 leaky bucket bug 或基数爆炸。升级到最新 patch 版可修。定位工具:/debug/pprof/heap。

Q: Prometheus 单实例能扛多少 series? 官方经验值 35 万活跃 series。超过就该分片(federation)或换 Thanos/VictoriaMetrics。35 万不是硬阈值,超过只是 head chunk 内存线性增长,compaction 变慢,重启加载时间变长。我们生产一台 Prometheus 峰值 42 万 series,运行稳定,但加载时间从 30s 涨到 90s。

Q: -storage.tsdb.allow-overlapping-blocks 一定要开吗? 推荐开。开了只影响重启加载时的 block 合并扫描,开销很小。不开的话如果因为某些原因出现了时间重叠的 block(比如快照恢复、手工拷贝),Prometheus 会直接拒启动。

Q: WAL 和 head chunk 都会丢数据吗? WAL 丢了丢 5-15 分钟。Head chunk 丢了(chunks_head/)也丢 5-15 分钟。两者都是"最近数据"。Block 丢了就是永久丢失。所以备份优先备份 block,WAL 和 chunks_head 可以不管(重启后会重建)。