Head 与 WAL · 内存里的数据怎么不丢

新数据先进内存的 Head,靠 WAL 防丢。这一章读 head.go 和 WAL 格式规范:为什么 WAL 的页式编码借鉴了 LevelDB、m-map 怎么让「在内存里」变成一件不那么贵的事,以及 Head 切块与截断的时机。

作者 David更新于 第 20 篇(共 52 篇)

Head 与 WAL · 内存里的数据怎么不丢

上一章说数据先进 Head(内存),定期切成 block。但内存是易失的——进程崩了怎么办? 答案是 WAL(Write-Ahead Log,预写日志)。这一章看这对搭档。

WAL:先写日志,再改内存

WAL 的契约极简:任何要进内存的改动,先顺序追加到磁盘日志里,再改内存。这样进程崩溃后,重放日志就能重建内存状态。

为什么这招管用?因为它把“随机的内存改动”变成了“顺序的磁盘追加”:

  • 顺序写极快 —— 即使在机械盘上,顺序写也能到几百 MB/s;SSD 上更是接近带宽上限。它不是瓶颈。
  • 内存改动可以随便折腾 —— 反正日志已经落地了,内存里怎么组织(哈希表、跳表、压缩的 chunk)都不影响持久性。

这是数据库领域最经典的模式之一(所有正经数据库都有 WAL),Prometheus 也不例外。它让 TSDB 能同时拥有内存的速度和磁盘的持久性。

WAL 格式:借鉴 LevelDB 的页式编码

tsdb/docs/format/wal.md 描述了真实格式,有几个值得注意的决定:

The write ahead log operates in segments that are numbered and sequential, and are limited to 128MB by default. […] A segment is written to pages of 32KB. […] A WAL record is an opaque byte slice that gets split up into sub-records should it exceed the remaining space of the current page. Records are never split across segment boundaries.

The encoding of pages is largely borrowed from LevelDB's/RocksDB's write ahead log.

三个设计点,每个都有讲究:

  • 分段(segment),每段 128MB —— 为什么不写一个大文件?因为要能删。数据被切成 block 落盘后,对应的 WAL 就没用了——分段让“删掉旧日志”变成“删掉整个文件”,而不是在一个巨大文件里做空洞回收。
  • 分页(page),每页 32KB —— 页是原子写入的单位。崩溃时最多损坏最后一页,前面的页都是完整的。而且记录可以跨页拆分成子记录(但绝不跨段)——这保证了“一个段自成一体”,恢复时不用跨文件拼接。
  • 借鉴 LevelDB/RocksDB —— 这是个务实的选择:LSM 数据库的 WAL 格式已经被千锤百炼,页式布局 + CRC 校验 + 记录分片这套组合久经考验。没必要重新发明。

注意“Records are never split across segment boundaries”这条:如果一条记录大到超过整个段,TSDB 会专门开一个更大的段装它。这种“宁可破例也要保持段自足”的取舍,再次体现了崩溃恢复的简单性优先。

m-map:让“在内存里”没那么贵

Head 里有个精妙的优化,藏在 head.go 的这几个字段里(mmappedChunks、headChunks):

Head 里的数据并非全在堆内存里。一旦一个 chunk 写满(默认 120 个采样点或达到 chunkRange 时长),它会被写到磁盘的 chunks_head/ 目录,然后用 m-map(内存映射)映射回来**。**

Head 里一条 series 的 chunk 们:
[已满 chunk] [已满 chunk] [已满 chunk] [正在写的 chunk]
 ↑ m-map 到磁盘,不占堆内存      ↑ 真正在堆里
 (mmappedChunks)                (headChunks)

好处:

  • 堆内存大幅下降 —— 只有“正在写的那个 chunk”在堆里,历史的都 m-map 出去了。这直接决定了 Prometheus 能扛多少 series。
  • 由操作系统管理缓存 —— m-map 的页由内核的 page cache 管:热的留在内存,冷的自动换出。你不用自己写缓存淘汰算法,内核比你写得好。
  • 进程重启快 —— 重启时不用重放全部 WAL,已 m-map 的 chunk 直接映射回来就行。

代价:内存统计变得“不直观”——RSS 里包含了 m-map 的页,看起来内存很高,但那些页是可回收的(内核内存紧张时会自动丢弃干净页)。这经常让人误判 Prometheus 的真实内存需求——看到 RSS 20GB 就慌,其实其中一大半是可随时回收的 page cache。要看真实压力,该看的是换页和 OOM,而不是 RSS 绝对值。

Head 的两个时机:切块与截断

Head 有两个关键动作:

  • 切块(compact) —— 当 Head 覆盖的时间跨度超过 1.5 × chunkRange(源码 head.go:1866:h.MaxTime()-h.MinTime() > h.chunkRange.Load()/2*3)时,就把最老的那部分切出去写成一个 block。默认 chunkRange = 2 小时,所以大约每 2 小时切一次,但会等到跨度 3 小时才动手。

    为什么要等到 1.5 倍而不是刚好 2 小时? 因为要留出余量给迟到的数据——抓取有抖动、有的目标慢、远程写可能延迟。如果卡在整 2 小时就切,那些迟到 1 分钟的点就会因为“block 已经封了”而被拒收。多等一小时,换取对乱序和迟到的容忍。

  • 截断(truncate) —— Head.Truncate(mint)(head.go:1204)把 mint 之前的数据从 Head 里扔掉(它们已经安全落进 block 了),同时删掉对应的 WAL 段。这是 Head 内存能保持稳定的原因:它只保留“最近这一段”,而不是无限增长。

实战:重启慢与 WAL 重放

一个常见的运维痛点:Prometheus 重启要好几分钟,期间不可用。 原因就是 WAL 重放——它必须把 WAL 里的记录一条条读出来、重建内存里的 series 和 chunk。

series 越多、WAL 越大,重放越慢。 这是基数问题的又一个代价:百万级 series 的实例,重启重放几分钟很常见。

几个缓解手段:

  • m-map 的 chunk 不用重放 —— 上面说过,这已经省掉了大头。
  • memory snapshot(tsdb/docs/format/memory_snapshot.md)—— 关闭时把内存状态快照下来,重启时直接加载快照 + 只重放快照之后的 WAL。这能把重启时间从分钟级降到秒级,是个值得开的特性。
  • 减少基数 —— 永远是根治手段。

运维提示:重放期间 Prometheus 不响应查询,但它也没在抓数据——这段时间的数据是真的丢了(pull 模型下,没抓就是没有)。 所以重启慢不只是“启动慢”,而是监控数据的空洞。这也是为什么生产环境要跑多个冗余实例、错开重启。

小结与下一章

WAL 的契约是“先顺序写日志,再改内存”——把随机内存改动变成顺序磁盘追加(顺序写不是瓶颈),让 TSDB 同时拥有内存速度和磁盘持久性。格式(docs/format/wal.md):段 128MB(为了能整个删掉)、页 32KB(原子写入单位,记录可跨页拆分但绝不跨段——段自足优先)、编码借鉴 LevelDB/RocksDB(久经考验,不重新发明)。m-map 是 Head 的关键优化:写满的 chunk 落到 chunks_head/ 再映射回来,只有正在写的在堆里——由内核 page cache 管理冷热,代价是 RSS 看起来虚高、常被误判。Head 两个时机:切块(跨度 > 1.5×chunkRange 才切,多等一小时换取对迟到 / 乱序的容忍)和截断(Truncate,扔掉已落盘的部分 + 删对应 WAL,让 Head 内存稳定)。实战痛点是重启要重放 WAL(series 越多越慢,期间还是监控空洞)——用 memory snapshot 可降到秒级。

数据在 chunk 里到底怎么被压到 1~2 字节?下一章看那个漂亮的位级魔法。👉 chunk 编码与 Gorilla 压缩。

参考资料

  • [1] Prometheus 源码 tsdb/docs/format/wal.md、head_chunks.md、memory_snapshot.md;tsdb/head.go(Head:71、Truncate:1204、切块条件 :1866)。
  • [2] LevelDB/RocksDB WAL 格式(Prometheus WAL 编码的来源)。
  • [3] 关联TSDB 总览、基数代价。

本页目录