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

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

- 作者：David（道雾轩）
- 专栏：指标采集与观测（https://daiw.org/manual/observability.md）
- 最后更新：2026-07-15
- 原文：https://daiw.org/manual/observability/head-and-wal
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

[上一章](https://daiw.org/manual/observability/tsdb-overview)说数据先进 **Head**（内存），定期切成 [block](https://daiw.org/manual/observability/blocks-compaction)。但内存是易失的——**进程崩了怎么办？** 答案是 **WAL**（Write-Ahead Log，预写日志）。这一章看这对搭档。

## WAL：先写日志，再改内存

<Callout type="info">
  **WAL 的契约极简：**任何要进内存的改动，先顺序追加到磁盘日志里，再改内存**。这样进程崩溃后，重放日志就能重建内存状态。**

  为什么这招管用？因为它把“**随机的内存改动**”变成了“**顺序的磁盘追加**”：
  - **顺序写极快** —— 即使在机械盘上，顺序写也能到几百 MB/s；SSD 上更是接近带宽上限。**它不是瓶颈**。
  - **内存改动可以随便折腾** —— 反正日志已经落地了，内存里怎么组织（哈希表、跳表、[压缩的 chunk](https://daiw.org/manual/observability/chunk-encoding)）都不影响持久性。

  **这是数据库领域最经典的模式之一**（所有正经数据库都有 WAL），Prometheus 也不例外。它让 TSDB 能**同时拥有内存的速度和磁盘的持久性**。
</Callout>

## 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**.

<Callout type="info">
  **三个设计点，每个都有讲究：**

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

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

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

Head 里有个精妙的优化，藏在 `head.go` 的这几个字段里（`mmappedChunks`、`headChunks`）：

<Callout type="info">
  **Head 里的数据并非全在堆内存里。一旦一个 [chunk](https://daiw.org/manual/observability/chunk-encoding) 写满（默认 120 个采样点或达到 [chunkRange](https://daiw.org/manual/observability/head-and-wal) 时长），它会被**写到磁盘的 `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](https://daiw.org/manual/observability/head-and-wal)，已 m-map 的 chunk 直接映射回来就行。

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

## Head 的两个时机：切块与截断

<Callout type="info">
  **Head 有两个关键动作：**

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

    **为什么要等到 1.5 倍而不是刚好 2 小时？** 因为要**留出余量给迟到的数据**——[抓取有抖动](https://daiw.org/manual/observability/scrape-loop)、有的目标慢、[远程写](https://daiw.org/manual/observability/query-path)可能延迟。如果卡在整 2 小时就切，那些迟到 1 分钟的点就会因为“block 已经封了”而被拒收。**多等一小时，换取对乱序和迟到的容忍。**

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

## 实战：重启慢与 WAL 重放

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

  **series 越多、WAL 越大，重放越慢。** 这是[基数问题](https://daiw.org/manual/observability/defining-metrics)的又一个代价：百万级 series 的实例，重启重放几分钟很常见。

  几个缓解手段：
  - **m-map 的 chunk 不用重放** —— [上面说过](https://daiw.org/manual/observability/head-and-wal)，这已经省掉了大头。
  - **memory snapshot**(`tsdb/docs/format/memory_snapshot.md`)—— 关闭时把内存状态**快照**下来，重启时直接加载快照 + 只重放快照之后的 WAL。**这能把重启时间从分钟级降到秒级**，是个值得开的特性。
  - **减少基数** —— [永远是根治手段](https://daiw.org/manual/observability/defining-metrics)。

  **运维提示：重放期间 Prometheus 不响应查询，但[它也没在抓数据](https://daiw.org/manual/observability/scrape-loop)——这段时间的数据是真的丢了（pull 模型下，没抓就是没有）。** 所以重启慢不只是“启动慢”，而是**监控数据的空洞**。这也是为什么生产环境要跑[多个冗余实例](https://daiw.org/manual/observability/prometheus-architecture)、错开重启。
</Callout>

## 小结与下一章

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

数据在 chunk 里到底怎么被压到 1~2 字节？下一章看那个漂亮的位级魔法。👉 [chunk 编码与 Gorilla 压缩](https://daiw.org/manual/observability/chunk-encoding)。

## 参考资料

- [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 总览](https://daiw.org/manual/observability/tsdb-overview)、[基数代价](https://daiw.org/manual/observability/defining-metrics)。
