图 1:本文封面,三条路线的差别在于冲突代价谁来付。
上一篇真题解析讲过隔离级别与 MVCC 快照读的实现,本文不再重复隔离级别细节,而是回答它的上游问题:数据库要管住并发事务,一共有几条技术路线,各自的适用负载是什么?看懂这个谱系,才能把 MVCC 放回它该在的位置。
路线一:两阶段锁(2PL),冲突在动手前变成排队。
这是最经典的悲观路线:事务要碰数据,先向锁管理器申请锁——读加共享锁,写加排他锁。所谓「两阶段」指扩展阶段只加锁、收缩阶段只放锁,这个纪律足以保证冲突可串行化。工程上普遍用的是更强的强严格 2PL:所有锁持有到事务结束才放,避免级联中止。它的代价结构很直白:冲突发生时,后到者排队等待;等待形成环就是死锁,数据库用等待图检测加牺牲者回滚来解。冲突的代价在「动手之前」付,形式是等待时间。
路线二:MVCC,用版本换读的免费。
多版本并发控制给每行数据保留多个版本:写事务不覆盖旧值,而是写一个新版本;读事务拿一个一致性快照,读到的是快照时刻的旧版本。于是读不阻塞写、写不阻塞读——这是 MVCC 相对纯锁方案最大的卖点,也是它成为主流关系库默认选择的原因。但两个边界要记清:写-写冲突 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 烧光;讨论并发控制时混用「隔离级别」与「控制机制」——前者是承诺给用户的语义,后者是兑现语义的手段,三条路线都能兑现同一组隔离级别,只是代价结构不同。判断任何并发控制方案,只问一个问题:冲突代价谁付、何时付。你的系统里并发冲突的代价,现在是谁在付?欢迎聊聊。