『为什么 MySQL 里一个事务查着的报表,另一个事务同时改数据,两边互不干扰?』答案就是 MVCC(Multi-Version Concurrency Control):写不阻塞读、读不阻塞写,每个事务看到自己那份一致性快照。本文用一个 140 行的 Python 模拟器完整复现版本链、快照读、RC 与 RR 两个隔离级别的语义差异、写写冲突检测和长事务阻塞版本回收的全过程,每一行输出都来自真实运行。

一、MVCC 解决的核心矛盾

没有 MVCC 的世界,一致性靠锁:读者加读锁,写者被堵在门外;写者改数据,所有读者排队。吞吐在高并发下直线崩塌。MVCC 的思路釜底抽薪:更新不覆盖旧值,而是生成一个新版本;每个事务按自己的时间戳读『对它可见的那个版本』。读写从此各走各的路,只有写-写才需要排队。PostgreSQL、MySQL InnoDB、Oracle、TiDB 全部建立在这套机制上。

二、版本链与快照读

每一行数据不再是一个值,而是一条版本链。每个版本带两个时间戳(用事务 ID 充当):

事务读数据时拿到一个快照(本质是自己的事务 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]

六、理论分析

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 的活跃事务数组——都是这套规则的数据结构落地。

七、工程应用

八、常见误区

九、参考文献

  1. P. Bernstein, V. Hadzilacos, N. Goodman (1987). Concurrency Control and Recovery in Database Systems. Addison-Wesley.(多版本并发控制的理论源头,开放电子版)
  2. H. Berenson et al. (1995). A Critique of ANSI SQL Isolation Levels. ACM SIGMOD.(写偏斜等异常的经典定义)
  3. D. Ports, K. Grittner (2012). Serializable Snapshot Isolation in PostgreSQL. VLDB.(SSI 实现论文)
  4. MySQL Documentation: InnoDB Multi-Versioning 与 Consistent Nonlocking Reads 章节。
  5. PostgreSQL Documentation: Chapter 13 Concurrency Control 与 routine vacuuming 章节。
← 返回文章列表