站内 数据库 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 /
develop的 History Store)为准;源码路径锚定 GitHubwiredtiger/wiredtiger的mongodb-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 的分工,官方手册已有说明;与本文直接相关的是两句:
- MVCC 快照:操作开始时 WiredTiger 提供 point-in-time 快照,读看到的是一致的内存视图。
- 落盘形态: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_ALL、WT_CURSTD_HS_READ_COMMITTED
等标志调节——持有较低隔离、没有有效 snapshot
的调用方必须显式选一种策略。
在 MongoDB 部署里,该表的 format 字符串可见
key_format=IuQQ、value_format=QQQu(与键值字段宽度一致)。本文不复述第三方
dump 输出;格式以引擎元数据与 Architecture Guide 为准。
四、写路径:reconciliation 如何灌 History Store
Architecture Guide 的核心不变量:
用户文件 btree 上 reconcile 脏页时,检查 update chain,选出最新已提交更新作为 on-disk 值;尚未过时的更旧更新写入 History Store。
示意(btree id = 1000,用户键
"AAA",已提交更新 U1@70 → U2@80 → U3@90 → 最新 U4@100):
| 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 给出的查找顺序:
- 在内存 update chain 上找对当前事务可见的更新;
- 若没有,看 on-disk 版本是否可见;
- 若仍不可见,再进 History
Store:从该键在 HS
中的最新条目向更旧方向迭代,直到满足读时间戳与事务可见性;若无可见记录,返回
WT_NOTFOUND。
因此「主表只有最新值」并不否定 MVCC:旧快照的代价是可能多一次旁路查找与 I/O,而不是在用户页上保留完整版本链。代价模型落在:
- History Store 命中率与页缓存是否仍热;
- 同一文档频繁更新时 HS 条目数量(文档模型下一次字段更新仍可能保留整份旧 BSON 作为历史载荷——这是存储形态后果,不是「比 undo 一定更好」);
- oldest timestamp /
minSnapshotHistoryWindowInSeconds决定历史何时可删。
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 |
对照结论可以收成三句:
- 语义目标相近(读不阻塞写、快照读),物理落点不同,因此故障模式不同:PG
表膨胀、InnoDB undo history 过长、WT 的
WiredTigerHS.wt与窗口参数。 - WT 把「当前」与「历史」拆开,有利于用户页 eviction,但把读放大与历史 I/O 显式化。
- 文档更新常带整份旧值进 HS 时,历史空间与「只改一个字段」的用户直觉不对齐——选型时要按更新大小与频率估磁盘,而不是只看 cacheSizeGB。
八、争论、工程间隙与开放问题
谱系(短): Berenson et al., SIGMOD 1995 给出隔离异常词汇 → 各引擎用多版本兑现其中子集 → WiredTiger 在 4.4 用 Durable History Store 替换 Lookaside,把「可驱逐的用户页」与「可持久查询的旧版本」拆开。这不是新的隔离理论,而是存储布局与缓存回收的工程分叉。
争论(有文档/运维依据):
- 旁路历史 vs 堆内版本:堆内版本让「表即历史」局部性好,但膨胀直接打在主表;旁路历史让主表页更干净,却引入第二棵 B-Tree 的缓存竞争与运维对象(LAS 时代的无限增长、HS 时代的窗口与磁盘)。没有无工作负载的普适赢家。
- LAS 膨胀责任:社区与升级说明把 LAS 定位为 cache overflow;长游标、延迟从库等会放大溢出。升级到 HS 后,问题形态变为「历史保留策略是否匹配业务读模型」,而不是「swap 文件能不能删」。
工程间隙:
- Architecture Guide 与发版源码可能短暂不一致;排障应以对应 MongoDB 所带 WiredTiger 标签为准。
- 官方手册强调不要手工碰
WiredTigerHS.wt;空间问题应调窗口、查长事务/慢会话与复制延迟,而不是删文件。 - 本环境未跑 MongoDB /
wt dump实验;文中机制以 Architecture Guide 与mongodb-8.0源码为准,不引用未复核的第三方耗时数字。
开放问题(可检验):
- 局部更新与全量历史载荷:在高频
$set小字段场景下,HS 体积增长是否可用列式/增量历史编码压到可接受曲线?(入口:WT History Store 章 + MongoDB 存储指标/WiredTigerHS.wt增长观测。) - 窗口参数与复制延迟的耦合:
minSnapshotHistoryWindowInSeconds与从库追赶、prepared 事务并存时,oldest 推进卡在哪一层?(入口:MongoDB 手册 Snapshot History Retention + WT rollback-to-stable / prepared 节。) - 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,本文给出的是它存在的原因与读写边界——不是调参清单,也不是跨引擎排名。
参考资料
规范 / 官方文档
- WiredTiger Architecture Guide, History
Store(文档树 Version 12.0.0 /
develop):https://source.wiredtiger.com/develop/arch-hs.html - WiredTiger Architecture Guide, Eviction(reconciliation 与 HS 写入关系,mongodb-4.4 文档树):http://source.wiredtiger.com/mongodb-4.4/eviction.html
- WiredTiger, Upgrading(LAS → HS 文件切换说明,mongodb-4.4):http://source.wiredtiger.com/mongodb-4.4/upgrading.html
- MongoDB Manual, WiredTiger Storage
Engine(MVCC、checkpoint、
WiredTigerHS.wt、minSnapshotHistoryWindowInSeconds)
源码
wiredtiger/wiredtigermongodb-8.0:src/history/hs_rec.c(__wt_hs_insert_updates)、hs_cursor.c(__wt_curhs_open等)、hs_conn.c、hs_verify.c
论文 / 书
- Berenson, H., Bernstein, P., Gray, J., Melton, J., O’Neil, E., & O’Neil, P. A Critique of ANSI SQL Isolation Levels. SIGMOD, 1995.(隔离异常词汇;与站内 MVCC 文同一锚点)
站内对照
- 数据库 MVCC:快照隔离到底隔离了什么
- 【MySQL InnoDB 内核】Undo Log 与事务回滚
- 【MySQL InnoDB 内核】MVCC 与 Read View
- 持久化数据结构在数据库中的应用
实验 / 工具
- 本文未做本地
mongod/wt dump实测。若复现 History Store 落盘,需自行准备 MongoDB 实例,在更新后等待 checkpoint 或触发 eviction,再观察dbPath下WiredTigerHS.wt;输出须来自真实执行,勿把预期体积写进结论。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【SQLite 内核】事务与隔离:DEFERRED/IMMEDIATE/EXCLUSIVE 与单写者快照
拆解 BEGIN DEFERRED/IMMEDIATE/EXCLUSIVE 三种模式的取锁时机差异,对照 Berenson et al. SIGMOD 1995 的隔离词汇与官方 Isolation In SQLite 文档;说明单写者约束下 WAL 快照为何天然回避 write skew,并用本机 3.53.2 实测三种 BEGIN 均可提交。
【RocksDB 内核机制】Get 与 Snapshot:SuperVersion 与 sequence number
从 DBImpl::GetImpl 层级查找路径出发,拆解 LookupKey、sequence number 编码、SuperVersion 引用与 Snapshot 可见性边界;对照 PostgreSQL MVCC 的 txn id 语义差异。
数据库内核实验索引
汇总本站数据库内核文章:PostgreSQL / MySQL InnoDB / 列存、湖仓、流处理、查询引擎、RocksDB、向量、Redis、全文检索、TiKV/HTAP、FoundationDB 与 SQLite 内核,以及 LSM-Tree 实验与 WiredTiger History Store 单篇。
【FoundationDB 内核】Client API 与事务模型:冲突范围、自动重试与 5 秒窗口
钉住 FoundationDB 客户端事务契约:读写缓冲、冲突范围、@transactional 自动重试,以及 5 秒上限如何约束应用设计——机制证明留给第 8–9 篇。