深蓝黑背景封面:左侧大标题「并发冲突,三条路各管一段」,副标题「两阶段锁、MVCC 与乐观并发控制的负载适配」;右侧原创示意图——三条横向泳道分别画着挂锁、叠放的数据版本快照与一枚对勾徽章,代表悲观锁、多版本与乐观验证三条路线;左上标注「知识专栏 · 并发控制」,右上标注「Day 06 · 10-24」 图 1:本文封面,三条路线的差别在于冲突代价谁来付。

上一篇真题解析讲过隔离级别与 MVCC 快照读的实现,本文不再重复隔离级别细节,而是回答它的上游问题:数据库要管住并发事务,一共有几条技术路线,各自的适用负载是什么?看懂这个谱系,才能把 MVCC 放回它该在的位置。

路线一:两阶段锁(2PL),冲突在动手前变成排队。

这是最经典的悲观路线:事务要碰数据,先向锁管理器申请锁——读加共享锁,写加排他锁。所谓「两阶段」指扩展阶段只加锁、收缩阶段只放锁,这个纪律足以保证冲突可串行化。工程上普遍用的是更强的强严格 2PL:所有锁持有到事务结束才放,避免级联中止。它的代价结构很直白:冲突发生时,后到者排队等待;等待形成环就是死锁,数据库用等待图检测加牺牲者回滚来解。冲突的代价在「动手之前」付,形式是等待时间。

路线二:MVCC,用版本换读的免费。

多版本并发控制给每行数据保留多个版本:写事务不覆盖旧值,而是写一个新版本;读事务拿一个一致性快照,读到的是快照时刻的旧版本。于是读不阻塞写、写不阻塞读——这是 MVCC 相对纯锁方案最大的卖点,也是它成为主流关系库默认选择的原因。但两个边界要记清:写-写冲突 MVCC 管不了,同一行的两个写事务仍然要互斥或回滚;多版本不是白来的,旧版本要存储、要清理,长事务会拖住版本回收。

路线三:OCC,先干活,提交时再算总账。

乐观并发控制假设冲突极少:事务分三个阶段——读阶段把读写都放进私有工作区,校验阶段在提交前检查与其他事务的冲突(写集与别人的读写集是否相交),写阶段校验通过才把改动落到库里,失败就整个事务作废重来。无冲突时它零等待、零锁开销;冲突一多,作废重做的代价会平方级放大。

三栏对照加选型图:上方三张卡片分别是三条路线的工作方式——两阶段锁把读写冲突变成排队等待,扩展阶段加锁、收缩阶段释放;MVCC 给每行数据保留多个版本,读事务拿一致性快照、写事务写新版本,读不阻塞写;OCC 分读、校验、写三个阶段,提交前校验期间无别人改过才算数。下方是按读写负载选型的决策路径:写多读少选两阶段锁,读多写少选 MVCC,冲突极少且重试代价低选 OCC,并提示三种路线都要回答「冲突代价谁付、何时付」 图 2:三条路线的工作方式与负载适配。

用一个模拟实验看三条路线的代价形态。

下面这份代码模拟 n 个事务同时改同一行——极端冲突场景下三条路线各付什么代价。用 Node.js 22.15.0 直接运行(node cc.js)即可:

'use strict';

// 并发控制三条路线的冲突代价对比实验(模拟)
// 场景:n 个事务同一时刻到达,都要对同一行做「读出 -> 处理 -> 写回」

function simulate2PL(n) {
  // 悲观锁:先抢锁再动手,冲突在动手前变成排队等待
  let value = 0, waits = 0, held = false;
  for (let i = 0; i < n; i++) {
    if (held) waits++;   // 锁在别人手里,排队
    held = true;         // 抢到锁,处理并写回
    value++;
  }
  return { value, waits, retries: 0 };
}

function simulateMVCC(n) {
  // 多版本:读一致性快照永不排队;写-写冲突时后到者拿新快照重做一遍
  let value = 0, waits = 0, retries = 0, held = false;
  for (let i = 0; i < n; i++) {
    if (held) retries++; // 写锁冲突:换个新快照重试
    held = true;
    value++;
  }
  return { value, waits, retries };
}

function simulateOCC(n) {
  // 乐观:读写都在私有工作区,提交前校验版本号,失败整个事务作废重来
  let value = 0, version = 0, retries = 0;
  let pending = Array.from({ length: n }, (_, i) => i);
  while (pending.length > 0) {
    const readVer = version;             // 本轮大家都读到同一版本
    const survivors = [];
    for (const i of pending) {
      if (version !== readVer) { retries++; survivors.push(i); continue; } // 校验失败
      value++; version++;                // 校验通过:提交,版本号 +1
    }
    pending = survivors;
  }
  return { value, waits: 0, retries };
}

for (const n of [4, 64]) {
  const a = simulate2PL(n), b = simulateMVCC(n), c = simulateOCC(n);
  console.log(`${n} 个事务同时改同一行(三种路线结果都应=${n}):`);
  console.log(`  2PL:等待 ${a.waits} 次,重试 ${a.retries} 次(代价在动手前付:排队)`);
  console.log(`  MVCC:读等待 ${b.waits} 次,写冲突重试 ${b.retries} 次(读免费,写-写仍互斥)`);
  console.log(`  OCC:等待 ${c.waits} 次,校验失败重试 ${c.retries} 次(代价在提交时付:作废重做)`);
}

实测输出:

4 个事务同时改同一行(三种路线结果都应=4):
  2PL:等待 3 次,重试 0 次(代价在动手前付:排队)
  MVCC:读等待 0 次,写冲突重试 3 次(读免费,写-写仍互斥)
  OCC:等待 0 次,校验失败重试 6 次(代价在提交时付:作废重做)
64 个事务同时改同一行(三种路线结果都应=64):
  2PL:等待 63 次,重试 0 次(代价在动手前付:排队)
  MVCC:读等待 0 次,写冲突重试 63 次(读免费,写-写仍互斥)
  OCC:等待 0 次,校验失败重试 2016 次(代价在提交时付:作废重做)

三种路线结果都对,差别全在代价形态:2PL 的等待随冲突线性增长,MVCC 把读的代价清零但写写冲突仍要重试,OCC 在极端冲突下重试次数平方爆炸——64 个事务撞一行,2016 次作废重做。

选型坐标与常见误区。

把负载画像对上去:写多读少、热点行竞争激烈,选 2PL 类的悲观路线,等待比重做便宜;读多写少,选 MVCC,读的零阻塞是最大红利;冲突极少、事务体小、重试代价低,OCC 最优——CAS、版本号更新这些应用层乐观锁就是 OCC 思想的简化版。常见误区三个:以为 MVCC 消灭了锁,其实写写冲突还在;把 OCC 用在热点行上,重试风暴把 CPU 烧光;讨论并发控制时混用「隔离级别」与「控制机制」——前者是承诺给用户的语义,后者是兑现语义的手段,三条路线都能兑现同一组隔离级别,只是代价结构不同。判断任何并发控制方案,只问一个问题:冲突代价谁付、何时付。你的系统里并发冲突的代价,现在是谁在付?欢迎聊聊。