想象一座藏书过亿的图书馆。馆里每本书都不只有一册实体,而在“修订史架”上摆着一排修订版:旧版、新版、草稿按修订编号依次陈列。读者进门领一张盖了当场日期的“借阅凭证”,之后无论书架上被改了多少次,他凭这张凭证看到的永远是盖章那一刻的世界。有人在贵宾室赶写新版,外头读者照常翻旧版,互不打扰。这就是 MVCC——多版本并发控制(Multi-Version Concurrency Control)。 数据库里,一行数据就像一本书。每次 UPDATE 不直接抹掉旧值,而是盖一个“修订版”挂到版本链上;数据库为每个事务发一个单调递增的修订编号(事务 ID),为读者的快照生成一张“借阅凭证”(ReadView),按一套可见性规则告诉读者哪个版本能看、哪个不能看。旧版本没人再需要了,馆员定期下架清理(垃圾回收)。整套制度让“读写互不阻塞”,成为 MySQL InnoDB、TiDB、CockroachDB 等分布式存储引擎的并发基石。
理解 MVCC 是看透分布式数据库并发语义的钥匙。无论是排查“明明提交了却读不到”的诡异问题,还是为高并发读多写少系统做引擎选型,这套知识都会直接决定你的判断质量。
目录
可视化演示
演示数据库MVCC为每行数据维护多个版本快照实现读写互不阻塞,展示版本链结构、事务可见性判定与垃圾回收机制,并对比2PL的隔离级别差异。
本节配有交互动效(进入视口后自动加载,约 3KB)。
MVCC 并发控制 版本链:多版本快照 可见性:事务判定 垃圾回收:清理旧版 读旧写新 互不阻塞
并发控制概述:从“借书上锁”到“多版本并存”
图书馆旧制度:一本书只有一册,谁要借走抄写,馆员就把书锁进柜台。你借我就得等,你还了我才能看。数据库初期的并发控制也是这套“借书上锁”逻辑——读要锁、写要锁,读写一相遇,总有一方在排队。排队的代价不只是慢:锁等待可能引发死锁,死锁又要回滚重试,高并发下系统吞吐直线下滑。
数据库里的“读者”是 SELECT,“写者”是 UPDATE。早期的 2PL(两阶段锁)协议靠加锁保证一致性,但本质是拿互斥换安全。工程师们开始反问:为什么非要让读者和写者抢同一册书?能不能让书架上同时摆着新旧两册——读者看旧的,写者在后台改新的?
MVCC(Multi-Version Concurrency Control,多版本并发控制)给出了答案:每行数据保存多个历史版本,UPDATE 产生新版本而不是覆盖旧值,SELECT 按事务快照选定一个版本读取。读写因此彻底解耦:读永不等待,只有写写冲突时才需要锁。MySQL InnoDB、TiDB、CockroachDB 等主流存储引擎,都把 MVCC 作为并发控制的基石。
用时间换空间,还是用空间换时间?MVCC 选择了后者——用一小块额外存储(版本)+ 每次读的定位成本,换掉锁等待的全部代价。读多写少的场景下,这笔交易稳赚不赔。
就像图书馆允许“看旧改新”,MVCC 用一小块额外存储换来了并发度的大幅提升。理解了“为什么不直接覆盖、而要留着旧版本”,你就握住了整套多版本制度的第一把钥匙。
T_{\text{MVCC 读}} = T_{\text{快照定位}},\qquadT_{\text{2PL 读}} = T_{\text{锁等待}} + T_{\text{读取}}- 快照定位时间$T_{\text{快照定位}}$ 指读操作沿版本链找到目标版本的开销,通常为常数级
- 锁等待时间$T_{\text{锁等待}}$ 指 2PL 下读操作被写锁阻塞的排队时长,与持锁时间正相关
- 对比结论读写不互斥后,2PL 读路径中的等待项被整体消除
版本链结构:每本书背后的“修订史架”
修订史架上的每册书都盖着三个章:书的原始流水号、这次修订的编号、以及“上一版放在哪”的便签。顺着便签一路翻,就能从最新版回溯到初版。这个设计聪明在:新修订不用搬动旧书,只需要在旧册上贴一张指向自己的便签。
数据库行记录同样藏着三个隐藏字段:DB_ROW_ID(隐藏主键,未显式声明主键时的行标识)、DB_TRX_ID(最近修改本行的事务编号)、DB_ROLL_PTR(回滚指针,指向上一个版本)。它们对 SQL 不可见,却是 MVCC 运行的地基——就像修订章对读者不可见,但对馆员是账本的根本。
一次 UPDATE 的执行路径:先把当前行的旧值连同版本信息写入 undo log,让新行的 DB_ROLL_PTR 指向它,再把新值写到行上作为新的链头。旧版本并没有被删,而是“退居二线”挂到了链上。版本链因此越长,可回溯的历史越深,但也埋下了清理的伏笔。
修订史架上的每一册都放得下“上一册在哪”的便签,数据库的每个版本节点也都带着回滚指针。理解了版本链,你就明白“旧数据”并不是被删掉,而是被推到了链的更深处——这也为后面的垃圾回收埋下了伏笔。
\text{版本链}:\quadhead \to (trx\_id, data) \xrightarrow{DB\_ROLL\_PTR} (trx\_id', data') \xrightarrow{DB\_ROLL\_PTR} \cdots \xrightarrow{DB\_ROLL\_PTR} \varnothing- 链头 head$head$ 始终指向最新版本,新更新头插保证“读最新”的路径最短
- 版本节点$(trx\_id, data)$ 表示版本内容及其写入事务编号,编号是后面可见性判定的依据
- 链尾终止最古老的版本其回滚指针为空,链表到此结束
快照与可见性:馆员的“借阅凭证规则”
读者进门,馆员递上一张借阅凭证,上面记着:此刻正在贵宾室赶稿的名单、以及最早和最晚的修订编号。此后无论书架上怎么变,馆员只认凭证不认现场。你攥着这张纸,看到的就是盖章那一刻的世界。
这张凭证就是 ReadView。快照读(普通 SELECT)在事务内第一次执行时生成 ReadView,记录三样东西:m_ids(此刻活跃事务编号集合)、min_trx_id(m_ids 中的最小值,即最早活跃编号)、max_trx_id(下一个待分配的编号)。有了它,馆员沿版本链逐册检查,决定哪一册能看。
判定规则按优先级分四档:第一,事务编号小于 min_trx_id,快照前就提交了,直接可见;第二,编号在 m_ids 名单里,正在执行未提交,不可见;第三,编号大于等于 max_trx_id,快照之后才开局,不可见;第四,其余情况——晚于快照但已提交落定,可见。四档判据,覆盖了版本的一切可能处境。
快照读之外还有“当前读”(SELECT …… FOR UPDATE、UPDATE、DELETE):它读最新已提交数据并加锁,相当于跑到修订间门口现场核对。快照读看凭证,当前读看现场——理解了这对组合,你就理解了 MVCC 与 2PL 是如何在同一引擎里分工合作的。
- 最早边界$trx\_id < min\_trx\_id$,事务在快照前已提交,直接可见(绿色)
- 活跃名单$trx\_id \in m\_ids$,事务还在执行中,草稿版不可见(红色)
- 未来边界$trx\_id \geq max\_trx\_id$,事务在快照之后才开启,不可见(灰色)
- 其余情况快照生成期间提交的事务,历史已落定,可见(绿色)
垃圾回收:馆员的“定期清旧书”
修订版越积越多,书架早晚爆掉。馆员每天闭馆后推车清点:凡是没有读者凭证还可能引用的旧册,就下架回收入库。注意关键词——“没有凭证引用”:只要还有一张旧凭证没失效,旧书就得继续留在架上。
数据库的回收由后台 purge 线程负责。它维护一条“回收水位线”:取所有存活快照中最老的 min_trx_id。版本事务编号低于水位、并且不是链头的版本,说明任何快照都不再需要“越过”它去读更旧的值,可以安全物理删除。水位线往前推一寸,历史列表就短一寸。
长事务是回收的天敌。一个 40 分钟不提交的事务,会把水位线死死按在它开始的位置,期间每一次更新产生的旧版本都无法回收,undo log 越长、版本链越深、查询越慢。运维监控里 history list length 持续上涨,十有八九是有人攥着旧凭证不还。
回收调优的抓手:innodb_purge_threads 提高清理并行度,控制批量大小减少每次停顿;应用侧则要避免大事务、及时提交、拆分长事务。书架清理从来不只是馆员勤快的问题,更是读者自觉的问题。
长事务一多,回收小车就推不动,书架随之爆满。所以 MVCC 世界里有个铁律:快照持有时间越短,系统越健康。谁攥着旧凭证不撒手,谁就是在帮版本堆积“续命”。
- 水位的定义$\min_{t \in \text{活跃事务}}$ 取所有存活 ReadView 中最小的 min_trx_id,它决定了“历史线”卡在哪里
- 回收条件$v.trx\_id < \text{水位线}$ 表示该版本早于所有快照能引用的范围,旧到无人问津
- 保留链头$v \text{非链头}$,最新值永远保留,回收绝不碰当前有效数据
隔离级别对比:两种“阅览制度”
图书馆有两种阅览制度:普通阅览室每次进门盖新章、凭证当场作废;贵宾室一证到底,全程只看初进门那一刻的世界。数据库的两种常用隔离级别,恰好对应这两种制度。
READ COMMITTED(读已提交):每条快照读语句都新建 ReadView,凭证一直换新,所以能看到别的事务刚刚提交的新版本。REPEATABLE READ(可重复读):事务内首次快照读生成 ReadView 后反复复用,世界定格在首读瞬间,之后别人再怎么提交,这里都看到同一片风景——这就是“可重复读”名字的由来。
快照读解决了“读旧”,当前读怎么办?REPEATABLE READ 下,快照读管不住“新插入的行”,InnoDB 用 next-key lock(记录锁 + 间隙锁)锁住扫描范围来防止幻读。所以完整答案是:MVCC 负责快照读的一致性,2PL 负责当前写的互斥,两者拼在一起才是完整的隔离级别。
贵宾室的“一证到底”听着高级,代价却是事务拖得越久、回收水位卡得越久。选 RC 还是 RR,取决于业务要“每次都看最新”,还是要“一次看见、始终一致”——以及你受不受得了旧版本堆积的风险。
- RC 的凭证每语句新建一次 ReadView,$1\text{语句} \to 1\text{ReadView}$,能看到最新提交
- RR 的凭证每事务复用首次生成的 ReadView,$1\text{事务} \to 1\text{ReadView}$,世界定格在首读
- 语义差异前者保证“每次读到最新”,后者保证“多次读结果一致”,隔离强度不同
工程实践:图书馆的“事故复盘”
深夜,图书馆告警:修订史架爆满。值班馆员一查,3 号贵宾室有位读者凭证盖的还是 40 分钟前的章,推车的小车根本推不动。这是 MVCC 生产事故最常见的剧本——长事务卡死回收水位,版本链无限膨胀。
高并发热点更新的另一种剧本:秒杀场景下库存行每秒被改上千次,版本链疯狂加长,每条快照读都要沿链回溯,性能随之劣化。对策不外乎三板斧:缩短事务时长、拆分热点行(库存分散到多个子行)、控制批量更新粒度。监控指标盯住 version chain length 与 history list length,趋势比绝对值更有说服力。
排障流程同样有章法:先查 information_schema.innodb_trx 找出长时间未提交的事务;再看 SHOW ENGINE INNODB STATUS 中的 History list length 和 purge 状态;最后用 performance_schema 定位持锁语句与 undo 暴涨来源。设计决策上,读多写少、追求高并发读的系统优先选 MVCC 引擎;写冲突密集、强一致的关键路径,则要让 2PL 显式锁做兜底。
修订史架爆满的根源从来不只在书架本身——要么书改动太快,要么有人攥着旧凭证不肯还。理解了 MVCC 的全链条,排查类似故障时你就能一眼锁定“版本链太长”还是“水位被卡死”。
- 版本产生速度单位时间更新次数越高,链头插入越频繁,旧版本越多
- 回收卡顿窗口最长活跃事务时长越久,水位线被压住越久,回收越滞后
- 乘积放大效应两者相乘即堆积规模,任一因子归零,版本堆积就消失
AI 应用场景
场景一:大模型训练的 Checkpoint 读写分离
阶段:模型训练与推理部署(存储层)
解决的问题:训练节点周期性落盘 checkpoint,推理服务需要持续读取模型参数。传统 2PL 下,写 checkpoint 会阻塞推理读,造成推理延迟尖刺甚至超时。MVCC 让推理节点持稳定快照不停读旧版,训练节点在后台写新版,两者互不阻塞,_checkpoint 期间推理 P99 延迟不再抖动。
具体案例:某推荐模型平台每 5 分钟落一次 checkpoint,推理服务 QPS 3 万。将模型仓库迁移到支持 MVCC 的存储(如基于 TiKV 的特征存储)后,写 checkpoint 期间推理 P99 延迟从 280ms 降到 40ms,且模型版本可随时回溯核对。
场景二:推荐系统特征库的在线更新
阶段:特征工程与在线推理
解决的问题:离线特征回填任务批量更新特征表,线上推理服务并发读取。回填写入量大,若与读取互斥,推理服务会频繁超时或读到“改了一半”的脏数据。MVCC 保证每次推理请求读到一致的特征快照,回填任务全程不影响在线流量。
具体案例:短视频推荐的用户特征存储采用 MVCC 引擎,每日特征回填 2 亿行,在线读 P99 保持在 10ms 以内;特征版本可回溯,模型上线前可用指定版本的快照做离线-在线一致性校验。
场景三:AI 数据管道的时间旅行
阶段:数据湖与训练样本管理
解决的问题:训练样本的版本管理需要“回到过去某时刻的数据状态”。传统覆盖写无法追溯,而 MVCC/快照隔离(如 Delta Lake 的时间旅行)让分析师可以查询任意历史版本,用于审计、回放和定位数据漂移。
具体案例:风控模型训练样本每周重算,分析人员用时间旅行查询比对 T-1 与 T-2 版本样本的分布差异,快速定位特征漂移源头,避免了“样本错了全部重训”的灾难。
Python 演示
# -*- coding: utf-8 -*-# 迷你 MVCC 引擎: 用版本链和 ReadView 实现多版本并发控制class Version: def __init__(self, trx_id, value): self.trx_id = trx_id # 写出本版本的事务编号 self.value = value # 版本内容 self.roll_prev = None # 回滚指针, 指向上一个旧版本 class Row: def __init__(self, trx_id, value): self.head = Version(trx_id, value) # 链头永远是最新版本 def add_version(self, trx_id, value): v = Version(trx_id, value) v.roll_prev = self.head self.head = v class ReadView: def __init__(self, active_ids, max_trx_id): self.m_ids = set(active_ids) self.min_trx_id = min(self.m_ids) if self.m_ids else max_trx_id self.max_trx_id = max_trx_id def is_visible(self, trx_id): if trx_id < self.min_trx_id: return True if trx_id in self.m_ids: return False if trx_id >= self.max_trx_id: return False return True class MVCCStore: def __init__(self): self.rows = {} self.global_trx = 0 self.active = set() self.views = {} # trx_id -> ReadView, 实现可重复读 def begin(self): self.global_trx += 1 self.active.add(self.global_trx) return self.global_trx def commit(self, trx_id): self.active.discard(trx_id) self.views.pop(trx_id, None) def write(self, key, trx_id, value): if key not in self.rows: self.rows[key] = Row(trx_id, value) else: self.rows[key].add_version(trx_id, value) def read(self, key, trx_id): if trx_id not in self.views: # 首次快照读才生成 ReadView, 之后复用(可重复读) self.views[trx_id] = ReadView(set(self.active), self.global_trx + 1) rv = self.views[trx_id] cur = self.rows[key].head while cur: if cur.trx_id == trx_id or rv.is_visible(cur.trx_id): return cur.value cur = cur.roll_prev return None def purge(self): removed = 0 for row in self.rows.values(): cur = row.head while cur and cur.roll_prev: prev = cur.roll_prev needed = False for rv in self.views.values(): if rv.is_visible(prev.trx_id): needed = True break if not needed and prev.trx_id not in self.active: cur.roll_prev = prev.roll_prev removed += 1 else: cur = prev return removed if __name__ == "__main__": db = MVCCStore() t1 = db.begin() db.write("book", t1, "英文原版") db.commit(t1) t2 = db.begin() db.write("book", t2, "中文新版") t3 = db.begin() print("t3 首次读取:", db.read("book", t3)) # 看不到未提交的新版 db.commit(t2) print("t3 再次读取:", db.read("book", t3)) # 快照复用, 仍读旧版 print("t3 存续期回收:", db.purge()) # 旧版仍被快照引用 db.commit(t3) print("全部提交后回收:", db.purge()) # 旧版可被回收
self.views[trx_id] = ReadView(set(self.active), self.global_trx + 1)首次快照读才生成借阅凭证,活跃事务集合必须拷贝副本,防止后续提交影响旧凭证if cur.trx_id == trx_id or rv.is_visible(cur.trx_id):自己写的版本直接可见,否则按 ReadView 规则沿版本链判定if trx_id < self.min_trx_id:小于最早活跃编号的版本为快照前已提交旧版,直接可见(绿色路径)for rv in self.views.values():回收前遍历所有存活快照,确认没有凭证还在引用该版本cur.roll_prev = prev.roll_prev断开待回收版本,完成链表摘除,物理空间得以释放self.views.pop(trx_id, None)事务提交后删除其快照,释放引用,为后续回收让路self.m_ids = set(active_ids)活跃事务名单快照化,避免外部集合变化污染判定结果
常见误区
误区MVCC 这么好,读写都不阻塞,那数据库是不是完全不需要锁了?我在代码里裸奔 UPDATE 也没事。
正解MVCC 解决的是“读写互斥”,而“写写冲突”依然要靠行锁等 2PL 机制保证。InnoDB 的 UPDATE 会对记录加排他锁,当前读(FOR UPDATE)也要加锁。可以理解为:图书馆里看书互不打扰,但两个人同时抢同一支笔写批注,还是要排队。
为何常见:教材反复强调“MVCC 实现读写不互斥”,初学者容易把“读写不互斥”过度推广成“任何情况都不互斥”。
误区每更新一次就复制一整行甚至整张表,那数据库磁盘早就爆了,MVCC 肯定特别费空间。
正解版本之间不复制全量数据。InnoDB 的 undo log 记录的是“逻辑反操作”(更新前、删除前的影像),行主体只有一份最新数据,旧版本通过回滚指针按需构造。就像修订版图书不是重抄整本,只是在勘误页上贴便签。
为何常见:“多版本”的“多”字让人联想到多份完整拷贝,忽略了共享与增量设计的精妙。
误区我在事务 A 里提交了 UPDATE,事务 B 的 SELECT 应该马上读到新值,不然就是数据库有 bug。
正解是否立刻可见取决于隔离级别与 ReadView 生成时机。可重复读下,事务 B 复用第一次读时的快照,A 的提交发生在快照之后,B 就是看不到——这是“可重复读”的语义保证,不是故障。只有读已提交级别才会每条语句新建快照。
为何常见:把数据库想象成单一全局状态,忽略了每个事务各有一个“视角”,凭证盖了章就不认后来事。
误区旧版本没人用就该立刻回收,purge 线程怎么这么慢,肯定是性能有问题。
正解回收必须保证“没有任何存活快照还能引用该版本”。长事务会卡住回收水位,导致 history list 膨胀。清理慢常常是“有人攥着旧凭证不还”(未提交或未关闭的长事务),而不是 purge 线程偷懒。
为何常见:只看到回收动作,没看到回收的前置约束——可见性安全。回收的本质是“等所有旧凭证失效后再动手”。
面试题
Q1. MVCC 的核心思想是什么?
- A.加锁串行化所有读写操作
- B.多版本并存实现读写分离← 正确答案
- C.用缓存加速所有重复查询
- D.对写入事务进行二次确认
答案:B。MVCC 的核心是“保留多版本、读写各看各的”,写不覆盖旧值,读按快照选版本。2PL 才是加锁串行化;缓存是另一类优化;二次确认是两阶段提交的思路,均非 MVCC 本质。
Q2. 行记录隐藏字段 DB_ROLL_PTR 的作用是?
- A.存储行数据的物理地址
- B.表示事务写入的时间戳
- C.指向上一个旧版本节点← 正确答案
- D.标记当前行的锁定状态
答案:C。DB_ROLL_PTR 是回滚指针,把同一行的新旧版本串成链表;物理地址是主键索引的职责,事务编号是 DB_TRX_ID 的职责,锁定状态由锁系统维护。
Q3. 快照读生成 ReadView 后,事务ID 小于 min_trx_id 的版本是?
- A.正在活跃的未提交版本
- B.在快照前就已提交的版本← 正确答案
- C.快照之后才开启的版本
- D.当前事务自己写的版本
答案:B。min_trx_id 是生成快照瞬间最早活跃事务的编号,比它更小的版本在此之前已提交落定,任何快照都能直接看到,就像凭证盖章前就已出版的书。
Q4. 可重复读隔离级别下,事务内多次快照读的 ReadView 是?
- A.每次读取都重新生成一个
- B.首次快照读生成并复用← 正确答案
- C.事务提交时统一生成一次
- D.按 SQL 语句类型分别生成
答案:B。可重复读的语义是“首读定格”,复用首次生成的 ReadView 才能保证多次读结果一致;每次读都新建是读已提交的行为。提交时生成没有意义,语句类型不影响生成时机。
Q5. 以下哪项不是 MVCC 相比 2PL 的优势?
- A.读写之间可以互不阻塞
- B.读操作无需等待写锁
- C.写写并发也不需要加锁← 正确答案
- D.读多写少场景吞吐更高
答案:C。MVCC 解除的是“读写互斥”,写写冲突仍需行锁等 2PL 机制保证,C 说法错误。A、B、D 都是 MVCC 的真实优势,如同图书馆里看旧书与改新书互不打扰。
Q6. 关于 MVCC 垃圾回收,正确的说法有?
- A.事务未提交前版本不能回收← 正确答案
- B.长事务会阻塞回收水位推进← 正确答案
- C.回收过程会阻塞在线读写
- D.已提交的版本立即可回收
答案:AB。未提交事务的新版对快照不可见,但回收需保证无任何存活快照引用;长事务卡住水位是版本堆积主因。回收由后台 purge 线程异步执行,不阻塞在线读写;“已提交即可回收”忽略了存活快照的引用。
Q7. InnoDB 行记录的隐藏字段包含哪些?
- A.隐藏主键 DB_ROW_ID← 正确答案
- B.最近修改事务 DB_TRX_ID← 正确答案
- C.回滚指针 DB_ROLL_PTR← 正确答案
- D.数据页校验和 CHECKSUM
答案:ABC。DB_ROW_ID 是未显式主键时的隐藏主键,DB_TRX_ID 记录最近修改行的事务编号,DB_ROLL_PTR 串联版本链;CHECKSUM 属于页级完整性机制,与版本控制无关。
Q8. 读已提交与可重复读的 ReadView 差异是?
- A.前者每语句新建,后者复用← 正确答案
- B.后者每语句新建,前者复用
- C.两者都在事务开始处生成
- D.两者提交后立即销毁
答案:A。读已提交每次快照读都新建 ReadView,能看到最新已提交数据;可重复读复用首次读的 ReadView,保证事务内视角一致。两者都是首次读时生成,并不在事务开始时统一生成。
Q9. 一个快照读无法看到哪些事务的版本?
- A.快照生成时尚未提交的活跃事务← 正确答案
- B.快照生成之后才开始的新事务← 正确答案
- C.快照生成之前已提交的旧事务
- D.快照持有者自己刚写的数据
答案:AB。活跃事务版本在 m_ids 名单中直接不可见;快照之后开启的事务编号不小于 max_trx_id,也不可见。快照前已提交的版本可见,自己写的版本直接可见——这两个都是可见路径。
Q10. 版本链中最新版本位于哪里?
- A.链表最末尾的旧版本之后
- B.链表头部作为链首节点← 正确答案
- C.单独存放在独立索引页
- D.与旧版本平级并列存放
答案:B。每次 UPDATE 将新版本“头插”,链头即最新版本,读取路径最短;旧版本依次向链尾延伸。新版本不单独存放,也不与旧版本平级排列,而是作为链首统领整条链。