土法炼钢兴趣小组的算法知识备份

【WiredTiger】History Store 与 Durable History:文档库里的第三种 MVCC

文章导航

分类入口
databasestorage
标签入口
#wiredtiger#mongodb#mvcc#history-store#durable-history#reconciliation#lookaside#snapshot

目录

站内 数据库 MVCC 已经把快照隔离的语义钉在 PostgreSQL 堆版本上,并用 InnoDB undo 对照;InnoDB Undo Log 则把「旧版本在专用回滚段里」写到了源码路径。文档库默认引擎 WiredTiger 走第三条路:用户表 B-Tree 上只持久化最新已提交版本,旧版本进独立的 History Store(磁盘文件 WiredTigerHS.wt)。问题因此变成——

脏页被 reconcile / evict 之后,旧读者凭什么还能读到正确快照?checkpoint 之后这些历史是否仍然可查?

本文以 WiredTiger Architecture Guide 的 History Store 章与 mongodb-8.0 分支源码为主线,回答写路径、读路径与 Durable History 相对 Lookaside 改了什么;不写复制集、分片、Change Stream,也不做未实测的跨引擎性能排名。

版本锚定:机制叙述以 WiredTiger Architecture Guide(文档树标注 Version 12.0.0 / developHistory Store)为准;源码路径锚定 GitHub wiredtiger/wiredtigermongodb-8.0 分支(src/history/)。MongoDB 侧参数与文件名以官方 WiredTiger Storage Engine 手册为准。Architecture Guide 自身声明不与每次发版锁步——具体函数签名以标注分支源码为准。


一、三种 MVCC 落点

Berenson 等人在 SIGMOD 1995 把 ANSI SQL 隔离级别拆成具体异常(脏写、脏读、不可重复读、幻读、Write Skew 等)。生产引擎要在不阻塞读的前提下兑现其中一部分语义,就必须决定:旧版本物理上放哪

落点 代表系统 主表上留下什么 旧版本去哪
堆内多版本 PostgreSQL 新旧元组都在 heap 同表版本链 + VACUUM
Undo 旁路 InnoDB 聚簇索引「当前」行 Undo log / history list
History 旁路 WiredTiger(MongoDB 4.4+) 用户表页上最新已提交值 全局 History Store 表

第三条路的工程动机很具体:用户表页要能被 eviction 清出 cache,又不能把「仍可能被旧快照读到的版本」一并扔掉。把历史从用户 B-Tree 上拆出去,用户页 reconcile 时可以只写最新值,旧值进旁路表——这正是 Architecture Guide 对 History Store 的定义。

站内相关阅读:持久化数据结构 讨论的是 COW / 不可变结构另一条谱系;本文只钉 WiredTiger 这条旁路历史路径。


二、WiredTiger 在 MongoDB 里的位置

MongoDB 默认存储引擎是 WiredTiger。文档级写并发、optimistic concurrency、checkpoint 与 journal 的分工,官方手册已有说明;与本文直接相关的是两句:

  1. MVCC 快照:操作开始时 WiredTiger 提供 point-in-time 快照,读看到的是一致的内存视图。
  2. 落盘形态:checkpoint 把某一快照以跨文件一致的方式写到数据文件;journal 覆盖 checkpoint 之间的修改。

History Store 不在应用 API 里暴露。所有用户表共享一张内部 history 表,文件名固定为 WiredTigerHS.wt,落在 dbPath 下。MongoDB 5.0 起可用 minSnapshotHistoryWindowInSeconds 控制快照历史保留窗口——窗口越大,磁盘上要多留多久的旧修改值,空间随更新负载上升。

内存里,更新首先挂在页的 update chain 上;未提交更新不进磁盘镜像(与「提交后才进入可持久化历史」一致)。真正把「最新值进用户表、旧值进 History Store」钉死的步骤,是 reconciliation(把脏页收成 on-disk page image),常由 eviction 或 checkpoint 触发。

flowchart LR
  upd["Update chain in cache"] --> rec["Reconciliation"]
  rec --> mainDisk["User table: newest committed"]
  rec --> hsDisk["History Store: older versions"]
  readOp["Snapshot read"] --> chain["Search update chain"]
  chain --> ondisk["Inspect on-disk value"]
  ondisk --> hsRead["Search History Store"]

三、History Store 表结构

History Store 在磁盘上是一张普通 row-store B-Tree 表。Architecture Guide 给出的键值组成如下。

键(便于按用户键 + 读时间戳定位):

字段 含义
btree ID 所属用户表的内部 btree 编号
record key 行存为字节串,列存为记录号;统一打成 byte string 以免两套 HS 文件
start timestamp 该版本开始可见的时间戳
counter 同一 commit timestamp 上多条更新时的单调计数,保证键唯一

值:

字段 含义
stop timestamp 该版本不再可见的时间点(被更新或删除)
durable timestamp 与持久化 / 稳定点相关的时间戳
update type 更新类型
value 历史载荷

带有效 stop 时间的记录也称为 tombstone。除「紧挨在 prepared update 之前」等特例(stop 可先写成 WT_TS_MAX)外,History Store 里的更新都带 stop 信息。当 stop timestamp 小于 oldest timestamp、且 stop 事务对当前并发事务全局可见时,tombstone 变为 globally visible;checkpoint 可以删掉只含这类记录的页,从而收缩 WiredTigerHS.wt

打开与检索走内部 cursor:__wt_curhs_open();因键含 counter / timestamp,精确 search 不适用,统一用 search_near,并提供 __wt_curhs_search_near_before() / __wt_curhs_search_near_after()。可见性可用 WT_CURSTD_HS_READ_ALLWT_CURSTD_HS_READ_COMMITTED 等标志调节——持有较低隔离、没有有效 snapshot 的调用方必须显式选一种策略。

在 MongoDB 部署里,该表的 format 字符串可见 key_format=IuQQvalue_format=QQQu(与键值字段宽度一致)。本文不复述第三方 dump 输出;格式以引擎元数据与 Architecture Guide 为准。


四、写路径:reconciliation 如何灌 History Store

Architecture Guide 的核心不变量:

用户文件 btree 上 reconcile 脏页时,检查 update chain,选出最新已提交更新作为 on-disk 值;尚未过时的更旧更新写入 History Store。

示意(btree id = 1000,用户键 "AAA",已提交更新 → 最新 ):

Btree ID User Key Start ts Counter Stop ts Durable ts Type Value
1000 AAA 70 0 80 70 STANDARD U1
1000 AAA 80 0 90 80 STANDARD U2
1000 AAA 90 0 100 90 STANDARD U3

U1 的 stop 等于 U2 的 start;用户表页上留下 U4。无时间戳的更新也会按规则落到磁盘镜像一侧。

源码侧,把「边界上保存的更新列表」拷进 History Store 的入口是:

/* wiredtiger mongodb-8.0: src/history/hs_rec.c */
int
__wt_hs_insert_updates(WT_SESSION_IMPL *session, WT_RECONCILE *r, WT_MULTI *multi);

同目录还有 hs_cursor.c(cursor)、hs_conn.c(连接 / 表初始化)、hs_verify.c(校验)。注释写明职责:Copy one set of saved updates into the database’s history store table if they haven’t been inserted yet。若没有选出 on-page 更新,则不必写 History Store;已插入过的更新会跳过。实现里还会处理 reverse modify、乱序时间戳与 checkpoint 并发等边界——这些是正确性补丁,不是「旁路历史」范式本身。

Prepared 事务(MongoDB 事务 prepare)有单独契约:页被 evict 时,prepared 更新写在用户表 on-disk 页上,更旧版本进 History Store;History Store 不应含 prepared 更新。Prepare 提交时,History Store 中最新历史条目的 stop 从 WT_TS_MAX 改成 commit timestamp;回滚时则把 History Store 最新条目恢复为 on-disk 值并删掉该 HS 条目。

History Store 自己的页被 reconcile 时,会生成去掉 globally visible tombstone 后的新镜像——只保留服务 oldest reader / application oldest timestamp 仍需要的历史。


五、读路径:链 → 磁盘页 → History Store

Architecture Guide 给出的查找顺序:

  1. 在内存 update chain 上找对当前事务可见的更新;
  2. 若没有,看 on-disk 版本是否可见;
  3. 若仍不可见,再进 History Store:从该键在 HS 中的最新条目向更旧方向迭代,直到满足读时间戳与事务可见性;若无可见记录,返回 WT_NOTFOUND

因此「主表只有最新值」并不否定 MVCC:旧快照的代价是可能多一次旁路查找与 I/O,而不是在用户页上保留完整版本链。代价模型落在:

Rollback to stable 也会读 History Store:按 stable timestamp 选出仍稳定的版本写回用户表 on-disk,并删掉不稳定的 HS 条目。复制追赶、崩溃恢复相关路径依赖 durable / stable 时间戳语义;细节超出本文,但说明 History Store 不只是「给长查询用的只读缓存」。


六、Durable History:相对 Lookaside 改了什么

MongoDB 4.4 / WiredTiger 升级说明写得很直白:WiredTigerLAS.wt 不再由 cache overflow 机制生成;改为生成 WiredTigerHS.wt 作为 updates 的 history store。与其它引擎文件一样,禁止手工改删该文件。

这不是换文件名:

Lookaside(LAS,4.2 及更早常见) History Store(4.4+ Durable History)
文件 WiredTigerLAS.wt WiredTigerHS.wt
角色 cache overflow / swap:cache 撑不住时把内容溢到磁盘 持久化历史版本表:用户表只留最新,旧版本可跨 eviction/checkpoint 服务读
与重启 社区说明里 LAS 更接近「swap」,重启后行为与「可查询历史契约」不同 HS 是数据库目录里的正式表,随 oldest/窗口回收
运维痛点 LAS 无限膨胀曾是已知事故模式(长游标、延迟从库等) 痛点转为 HS 体积与 minSnapshotHistoryWindowInSeconds、更新速率的乘积

「Durable」强调的是:历史在 reconcile 进 WiredTigerHS.wt 之后,不再依赖「旧版本必须还挂在用户页 update chain 上」。checkpoint 之后旧读者仍可能从 HS 读到版本——前提是该版本尚未被 oldest / 窗口判定为可回收。这与「只把 cache 溢出去、契约含糊」的 LAS 不同。

MongoDB 5.0 的 minSnapshotHistoryWindowInSeconds 把「留多久历史」做成显式旋钮:加大窗口换更长的可回溯快照,代价是磁盘。这不是学术新模型,而是把 oldest-reader / 时间窗策略交到运维面。


七、与 PG / InnoDB 对照

口径:机制与代价,不是延迟排名。

维度 PostgreSQL 堆版本 InnoDB undo WiredTiger History Store
旧版本位置 同表 heap Undo tablespace / rollback segment 全局 WiredTigerHS.wt
主路径「当前行」 新元组插入,旧元组打 xmax 聚簇索引更新为新值,roll pointer 指 undo 用户表页写最新已提交值
读旧版本 沿 ctid / 可见性规则在堆上找 沿 undo 链回溯 update chain → on-disk → HS search_near
回收 VACUUM / 冻结 Purge 线程 HS reconcile 删 globally visible tombstone;受 oldest timestamp / 窗口约束
与缓存驱逐 旧元组占堆页,影响表膨胀 undo 膨胀拖住 purge 与长事务 用户页可 evict;历史是否仍可读取决于是否已进 HS 且未回收
学术/工程锚点 堆元组 + SSI 讨论见站内 MVCC Undo / Read View WT Architecture Guide History Store__wt_hs_insert_updates

对照结论可以收成三句:

  1. 语义目标相近(读不阻塞写、快照读),物理落点不同,因此故障模式不同:PG 表膨胀、InnoDB undo history 过长、WT 的 WiredTigerHS.wt 与窗口参数。
  2. WT 把「当前」与「历史」拆开,有利于用户页 eviction,但把读放大与历史 I/O 显式化。
  3. 文档更新常带整份旧值进 HS 时,历史空间与「只改一个字段」的用户直觉不对齐——选型时要按更新大小与频率估磁盘,而不是只看 cacheSizeGB。

八、争论、工程间隙与开放问题

谱系(短): Berenson et al., SIGMOD 1995 给出隔离异常词汇 → 各引擎用多版本兑现其中子集 → WiredTiger 在 4.4 用 Durable History Store 替换 Lookaside,把「可驱逐的用户页」与「可持久查询的旧版本」拆开。这不是新的隔离理论,而是存储布局与缓存回收的工程分叉。

争论(有文档/运维依据):

工程间隙:

开放问题(可检验):

  1. 局部更新与全量历史载荷:在高频 $set 小字段场景下,HS 体积增长是否可用列式/增量历史编码压到可接受曲线?(入口:WT History Store 章 + MongoDB 存储指标/WiredTigerHS.wt 增长观测。)
  2. 窗口参数与复制延迟的耦合minSnapshotHistoryWindowInSeconds 与从库追赶、prepared 事务并存时,oldest 推进卡在哪一层?(入口:MongoDB 手册 Snapshot History Retention + WT rollback-to-stable / prepared 节。)
  3. HS 与用户表缓存的公平性:同一 cache 池内,历史页与用户热页抢占是否导致「主路径读变慢」的可复现阈值?(入口:WT eviction 文档中 history store eviction 与用户表 eviction 的分工说明。)

九、收束

WiredTiger 的第三种 MVCC 落点可以记成一句:

用户表 reconcile 时只钉最新已提交版本;旧版本进全局 History Store;读按 update chain → on-disk → HS 的顺序追;Durable History 相对 Lookaside,把「可查询历史」做成跨 eviction/checkpoint 的一等契约,并用时间窗与 oldest 回收。

若你的问题是快照隔离语义与 Write Skew,先读 数据库 MVCC;若是 undo 链与 purge,读 InnoDB Undo;若是 MongoDB 数据目录里那个不断涨的 WiredTigerHS.wt,本文给出的是它存在的原因与读写边界——不是调参清单,也不是跨引擎排名。


参考资料

规范 / 官方文档

源码

论文 / 书

站内对照

实验 / 工具

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-07-19 · db / storage

数据库内核实验索引

汇总本站数据库内核文章:PostgreSQL / MySQL InnoDB / 列存、湖仓、流处理、查询引擎、RocksDB、向量、Redis、全文检索、TiKV/HTAP、FoundationDB 与 SQLite 内核,以及 LSM-Tree 实验与 WiredTiger History Store 单篇。


By .