『为什么 MySQL 里一个事务查着的报表,另一个事务同时改数据,两边互不干扰?』答案就是 MVCC(Multi-Version Concurrency Control):写不阻塞读、读不阻塞写,每个事务看到自己那份一致性快照。本文用一个 140 行的 Python 模拟器完整复现版本链、快照读、RC 与 RR 两个隔离级别的语义差异、写写冲突检测和长事务阻塞版本回收的全过程,每一行输出都来自真实运行。
一、MVCC 解决的核心矛盾
没有 MVCC 的世界,一致性靠锁:读者加读锁,写者被堵在门外;写者改数据,所有读者排队。吞吐在高并发下直线崩塌。MVCC 的思路釜底抽薪:更新不覆盖旧值,而是生成一个新版本;每个事务按自己的时间戳读『对它可见的那个版本』。读写从此各走各的路,只有写-写才需要排队。PostgreSQL、MySQL InnoDB、Oracle、TiDB 全部建立在这套机制上。
二、版本链与快照读
每一行数据不再是一个值,而是一条版本链。每个版本带两个时间戳(用事务 ID 充当):
begin_ts:创建该版本的事务 ID;end_ts:使该版本失效的事务 ID(被更新或删除时填上;为 None 表示当前版本)。
事务读数据时拿到一个快照(本质是自己的事务 ID + 当时的活跃事务列表),然后沿版本链从新到旧找到第一个满足可见性规则的版本:begin_ts ≤ 快照点 且创建者已提交,且 end_ts 为空或晚于快照点。读永远不需要加锁——这就是『快照读』。
三、RC 与 RR 的分水岭:快照何时取
SQL 标准最常用的两个隔离级别,在 MVCC 下的全部差异归结为一句话:读已提交(RC)每条语句取一次新快照;可重复读(RR)在事务第一条语句时取一次快照并复用到事务结束。RR 因此保证同一事务内多次读结果一致;RC 则能读到别的事务刚提交的新值。下面的模拟器会让这个差异变成可观测的数字。
四、完整可运行模拟器(Python 3,约 140 行)
#!/usr/bin/env python3
"""MVCC 模拟:版本链、快照读、RC vs RR 差异、写写冲突、版本回收"""
class Version:
def __init__(self, val, begin_ts, end_ts=None):
self.val = val
self.begin_ts = begin_ts # 创建该版本的事务 ID
self.end_ts = end_ts # 终结该版本的事务 ID(None=当前版本)
class Row:
def __init__(self, val, ts):
self.versions = [Version(val, ts)] # 版本链,最新在前
def read(self, snap_ts, active):
"""快照读:找对该快照可见的最新版本"""
for v in self.versions:
if v.begin_ts <= snap_ts and v.begin_ts not in active:
if (v.end_ts is None or v.end_ts > snap_ts
or v.end_ts in active):
return v.val
return None
class MVCC:
def __init__(self):
self.rows = {}
self.next_ts = 1
self.active = set() # 活跃事务集合
self.committed = [] # 已提交事务
def begin(self):
ts = self.next_ts
self.next_ts += 1
self.active.add(ts)
return ts
def commit(self, ts):
self.active.discard(ts)
self.committed.append(ts)
def snapshot_for(self, level, tx_ts):
"""RC: 每条语句取最新已提交快照; RR: 事务开始时的快照复用"""
if level == 'RC':
return max(self.committed, default=0)
return tx_ts
def write(self, tx_ts, key, val):
row = self.rows.setdefault(key, Row(None, 0))
head = row.versions[0]
# 写写冲突:最新版本属于未提交的他事务 -> 阻塞
if head.begin_ts in self.active and head.begin_ts != tx_ts:
return False
if head.begin_ts != tx_ts:
head.end_ts = tx_ts # 终结旧版本
row.versions.insert(0, Version(val, tx_ts)) # 链头插新版本
else:
head.val = val # 本事务重复写:原地改
return True
def read(self, level, tx_ts, key):
snap = self.snapshot_for(level, tx_ts)
if key not in self.rows:
return None
return self.rows[key].read(snap, self.active)
def gc(self):
"""回收『终结时间早于最老活跃快照』的版本"""
horizon = min(self.active, default=self.next_ts)
reclaimed = 0
for row in self.rows.values():
keep = []
for v in row.versions:
if v.end_ts is not None and v.end_ts < horizon:
reclaimed += 1 # 所有读者都开始于它之后,安全回收
else:
keep.append(v)
row.versions = keep
return reclaimed
print('=== MVCC 多版本并发控制模拟 ===')
db = MVCC()
t1 = db.begin()
db.write(t1, 'balance', 100)
db.commit(t1)
print('初始化: balance=100 (事务 t%d 提交)' % t1)
print()
print('--- 场景 1: 可重复读(RR)---')
tA = db.begin()
print('事务 A(t%d) 首次读: %s' % (tA, db.read('RR', tA, 'balance')))
tB = db.begin()
db.write(tB, 'balance', 200)
db.commit(tB)
print('事务 B(t%d) 把 balance 改为 200 并提交' % tB)
print('事务 A 再次读(RR 复用首次快照): %s <-- 可重复读成立'
% db.read('RR', tA, 'balance'))
db.commit(tA)
print()
print('--- 场景 2: 读已提交(RC)---')
tC = db.begin()
print('事务 C(t%d) 首次读: %s' % (tC, db.read('RC', tC, 'balance')))
tD = db.begin()
db.write(tD, 'balance', 300)
db.commit(tD)
print('事务 D(t%d) 把 balance 改为 300 并提交' % tD)
print('事务 C 再次读(RC 每语句取新快照): %s <-- 读到新提交值'
% db.read('RC', tC, 'balance'))
db.commit(tC)
print()
print('--- 场景 3: 写写冲突 ---')
tE = db.begin()
tF = db.begin()
ok1 = db.write(tE, 'counter', 1)
ok2 = db.write(tF, 'counter', 2)
print('事务 E(t%d) 写 counter: %s' % (tE, '成功' if ok1 else '失败'))
print('事务 F(t%d) 写 counter: %s <-- 先写者未提交,后写者被阻塞'
% (tF, '成功' if ok2 else '被阻塞'))
db.commit(tE)
db.commit(tF)
print()
print('--- 场景 4: 长事务与版本回收 ---')
tOld = db.begin()
_ = db.read('RR', tOld, 'balance')
print('长事务 t%d 启动并读取(快照钉在 balance=300 时代)' % tOld)
for i in range(3):
tX = db.begin()
db.write(tX, 'balance', 400 + i)
db.commit(tX)
print('3 次后续更新后,balance 版本链长度: %d'
% len(db.rows['balance'].versions))
reclaimed = db.gc()
print('长事务仍活跃时 GC: 回收 %d 个版本'
'(300 等旧版本仍可能被长事务看到,不能回收)' % reclaimed)
db.commit(tOld)
reclaimed = db.gc()
print('长事务提交后再次 GC: 回收 %d 个旧版本,版本链长度剩 %d'
% (reclaimed, len(db.rows['balance'].versions)))
print('最终版本链(新->旧):',
[v.val for v in db.rows['balance'].versions])
五、真实运行输出与解读
以上代码在 Python 3 下原样运行(零依赖),输出如下:
=== MVCC 多版本并发控制模拟 ===
初始化: balance=100 (事务 t1 提交)
--- 场景 1: 可重复读(RR)---
事务 A(t2) 首次读: 100
事务 B(t3) 把 balance 改为 200 并提交
事务 A 再次读(RR 复用首次快照): 100 <-- 可重复读成立
--- 场景 2: 读已提交(RC)---
事务 C(t4) 首次读: 200
事务 D(t5) 把 balance 改为 300 并提交
事务 C 再次读(RC 每语句取新快照): 300 <-- 读到新提交值
--- 场景 3: 写写冲突 ---
事务 E(t6) 写 counter: 成功
事务 F(t7) 写 counter: 被阻塞 <-- 先写者未提交,后写者被阻塞
--- 场景 4: 长事务与版本回收 ---
长事务 t8 启动并读取(快照钉在 balance=300 时代)
3 次后续更新后,balance 版本链长度: 7
长事务仍活跃时 GC: 回收 4 个版本(300 等旧版本仍可能被长事务看到,不能回收)
长事务提交后再次 GC: 回收 3 个旧版本,版本链长度剩 1
最终版本链(新->旧): [402]
- RR 与 RC 的差异被钉死在输出里:同样的『首次读 → 他人提交 → 再读』剧本,RR 下事务 A 两次都读到 100(快照复用),RC 下事务 C 第二次读到 300(快照每语句更新)。真实数据库里,MySQL 默认 RR、PostgreSQL 和 Oracle 默认 RC——这不是谁对谁错,是『一致性视角稳定性』与『数据新鲜度』的取舍。
- 写写冲突是唯一的阻塞点:F 写 counter 时发现最新版本由未提交的 E 创建,只能等 E 出结果。MVCC 消灭了读写互斥,但两个事务更新同一行依然要排队——热点行更新(秒杀库存扣减)的瓶颈正源于此。
- 版本回收的精确边界:长事务 t8 活跃时,GC 只回收了 4 个『终结于 t8 快照点之前』的版本(balance 链上的 100/200 和初始版本,加 counter 的旧版本);而 300 这个 t8 可能正在看的版本被牢牢钉住。等 t8 提交,300/400/401 立刻被回收,版本链收敛到只剩最新值。回收水位线 = 最老活跃快照点,这就是全部规则。
- 长事务是版本链膨胀的元凶:只要 t8 不结束,300 之后每次更新产生的新版本旧副本都无法回收。真实系统里一个挂着的备份事务或没关的连接,能让 undo log(InnoDB)或死元组(PostgreSQL)几个小时涨到几十 GB。
六、理论分析
6.1 InnoDB 与 PostgreSQL 的两种实现
同样的 MVCC 语义,两种主流实现路线截然不同。InnoDB 用回滚段:聚簇索引页里永远存最新版本,旧版本以 undo log 形式另存,读旧版本要沿 undo 链回放出历史值——旧版本读取有额外开销,但主表无膨胀。PostgreSQL 用追加式多版本:UPDATE 等于在表内直接插入新元组并把旧元组标记 xmax,读旧版本零回放成本,但死元组留在表里,需要 VACUUM 定期清理。前者更新快读旧值慢,后者读任意版本都快但要忍受表膨胀与 vacuum 调优——理解这一层,两大数据库的一半运维现象都能自己解释。
6.2 快照隔离 ≠ 可串行化
本文模拟器实现的是快照隔离(SI):读不阻塞写、写写冲突检测。SI 下著名的写偏斜(write skew)异常仍然存在——两个事务各自读了重叠数据集、写了不相交的行,各自无冲突地提交,却破坏了跨行约束(经典案例:值班表要求至少一人在岗,两个事务分别把不同人调休,结果无人值班)。PostgreSQL 的可串行化隔离用 SSI(Serializable Snapshot Isolation)在 SI 之上追踪读-写依赖环来解决;InnoDB 的可串行化则退化回两段锁。
6.3 可见性判定的边界细节
真实实现还有两个本文简化掉的判定:本事务的修改对自己立即可见(write 里 head.begin_ts == tx_ts 时原地改);创建者正在活跃中的版本不可见(read 里 begin_ts not in active 的过滤,防脏读)。PostgreSQL 里后者依赖 CLOG(提交日志)查询事务状态,InnoDB 里依赖 ReadView 的活跃事务数组——都是这套规则的数据结构落地。
七、工程应用
- 一致性备份的实现原理:mysqldump --single-transaction、pg_dump 的一致性快照,本质都是开一个 RR 事务长读——理解了场景 4 就明白为什么备份期间 undo/膨胀会涨,以及为什么要盯着备份时长。
- 监控长事务:MySQL 查 information_schema.innodb_trx 按 trx_started 排序;PostgreSQL 查 pg_stat_activity 的 backend_xmin/xact_start。任何超过分钟级的都要报警。
- PostgreSQL 膨胀治理:pg_stat_user_tables 的 n_dead_tup 比例高时手工 VACUUM;频繁更新大表的 hot update 优化(索引列不更新时新元组可同页存放)能显著减缓膨胀。
- 当前读与快照读混用:InnoDB 里 SELECT 是快照读,SELECT ... FOR UPDATE / UPDATE 是当前读(读最新已提交版本并加锁)。库存扣减必须用当前读,用快照读会基于过期值计算——这是 MVCC 语义在业务代码里最常见的踩坑点。
八、常见误区
- 误区 1:『MVCC 下没有锁了』。读写的锁没了,写写之间照样要行锁(场景 3),DDL 还要表级元数据锁。MVCC 消灭的是读写互斥,不是一切并发控制。
- 误区 2:『RR 级别就不会读到幻行』。快照读在 RR 下确实防幻读,但当前读(UPDATE/DELETE/SELECT FOR UPDATE)走最新版本仍会遇到幻行——InnoDB 的间隙锁(next-key lock)就是为当前读补的这块短板,它属于锁机制而非 MVCC 本身。
- 误区 3:『事务 ID 随便发,回卷无所谓』。PostgreSQL 的事务 ID 是 32 位,约 20 亿次后回卷,不处理会把已提交数据判成『未来事务创建』而不可见——这就是著名的 XID wraparound 事故,autovacuum_freeze_max_age 存在的意义就是提前冻结老元组防御它。
- 误区 4:『版本链越长查得越慢是优化器问题』。版本链长意味着每次快照读要跳过更多不可见版本(InnoDB 要沿 undo 链回放),根因几乎总是长事务或高频更新同一行——先查长事务,别先怀疑索引。
- 误区 5:『可重复读 = 可串行化』。写偏斜在 RR/SI 下合法存在。涉及跨行不变式的业务(配额、值班、余额对冲)要么用可串行化级别,要么显式 SELECT FOR UPDATE 把读升级为冲突——默认 RR 保不了你。
九、参考文献
- P. Bernstein, V. Hadzilacos, N. Goodman (1987). Concurrency Control and Recovery in Database Systems. Addison-Wesley.(多版本并发控制的理论源头,开放电子版)
- H. Berenson et al. (1995). A Critique of ANSI SQL Isolation Levels. ACM SIGMOD.(写偏斜等异常的经典定义)
- D. Ports, K. Grittner (2012). Serializable Snapshot Isolation in PostgreSQL. VLDB.(SSI 实现论文)
- MySQL Documentation: InnoDB Multi-Versioning 与 Consistent Nonlocking Reads 章节。
- PostgreSQL Documentation: Chapter 13 Concurrency Control 与 routine vacuuming 章节。