高级 #database#lock#concurrency#mvcc

锁协议与并发控制

数据库用锁和 MVCC(多版本并发控制)来保证并发事务的隔离性——锁防止写冲突,MVCC 让读不阻塞写

🔒 隔离性是怎么实现的?

上一节我们学了四种隔离级别——它们就像是数据库的”交通规则”,规定了事务之间能”看到”多少对方的数据。

这些规则是怎么执行的? 在数据库底层,有两个核心机制在发挥作用:

  1. 锁(Lock)——让事务之间互斥访问数据(写和写之间不能同时进行)
  2. MVCC(多版本并发控制)——让每个事务看到属于自己的”数据快照”(读不影响写)

🚦 类比:十字路口的交通管理

锁 = 红绿灯——红灯时你不能通过,等绿灯亮了才能走。这是”互斥访问”。

MVCC = 立交桥——东西方向的车走高架桥,南北方向的车走地面道路,互不干扰。这是”并发访问”。

没有红绿灯和立交桥 → 十字路口乱作一团(数据混乱) 只有红绿灯 → 安全但通行效率低(性能下降) 红绿灯 + 立交桥 → 既安全又高效(数据库的真实方案)


🔐 锁的类型

数据库的锁分为两大类:

共享锁(Shared Lock, S-Lock)——读锁

多个事务可以同时持有同一行的共享锁——因为读操作不会修改数据,互相不冲突。

-- 事务 A:读取数据(加共享锁)
SELECT * FROM accounts WHERE id = 1;  -- 加 S 锁

-- 事务 B:同时读取同一行(也可以加共享锁)✅
SELECT * FROM accounts WHERE id = 1;  -- 也加 S 锁,成功

排他锁(Exclusive Lock, X-Lock)——写锁

只有一个事务可以持有排他锁——因为写操作会修改数据,必须互斥。

-- 事务 A:修改数据(加排他锁)
UPDATE accounts SET balance = 500 WHERE id = 1;  -- 加 X 锁

-- 事务 B:尝试读取同一行
-- 在锁模式下:被阻塞(等事务 A 释放锁)
-- 在 MVCC 模式下:看到旧版本的数据,不加锁
SELECT * FROM accounts WHERE id = 1;

锁的兼容性矩阵

已持有锁 ↓请求共享锁请求排他锁
共享锁✅ 兼容❌ 等待
排他锁❌ 等待❌ 等待

共享锁和共享锁兼容(可以同时读),但排他锁和任何其他锁都不兼容(写必须独占)。

📖 类比:自习室的座位

共享锁 = 你看书——其他人也可以坐在同一张桌子看书(共享读)。

排他锁 = 你在桌子上做实验——必须清空桌面,一个人独占整张桌子(独占写)。

你在看书时,另一个人想在你桌上做实验——他必须等你离开(写锁等待读锁释放)。

但你在做实验时,任何人都不能在你桌上放东西看书(读锁等待写锁释放)。


📜 两阶段锁协议(2PL)

锁不是想什么时候加就什么时候加、想什么时候解就什么时候解的。两阶段锁协议(Two-Phase Locking, 2PL) 规定了加锁和解锁的次序:

阶段 1:扩张阶段(Growing Phase)
    只加锁,不解锁

    LOCK(A) → LOCK(B) → LOCK(C) → ...

阶段 2:收缩阶段(Shrinking Phase)
    只解锁,不加锁
    ... → UNLOCK(A) → UNLOCK(B) → UNLOCK(C)

为什么需要 2PL?

-- 违反 2PL 的例子:先解锁再锁
BEGIN;
LOCK(A);  -- 锁账户 A
READ(A);  -- 读 A 余额
UNLOCK(A); -- 释放 A 的锁
-- ⚡ 其他事务在这时可以修改 A!
LOCK(B);  -- 再锁账户 B
READ(B);
UPDATE A SET balance = A - 100;  -- A 可能已经被别的线程改了!
COMMIT;

如果事务先释放了某行的锁,又去锁其他行——中间的时间窗口内,其他事务可能修改了已经”解锁”的行,导致数据不一致。

2PL 保证:所有加锁操作都在解锁操作之前完成。 这保证了一个事务在”扩张阶段”锁住了所有需要的数据,不会有”遗漏”。

2PL 的问题:死锁(Deadlock)

当一个事务 A 锁住了行 1 等待行 2,而事务 B 锁住了行 2 等待行 1 时——两个事务互相等待,永远无法继续

事务 A:LOCK(1) → 等待 LOCK(2) ...
事务 B:LOCK(2) → 等待 LOCK(1) ...

数据库怎么处理死锁? 数据库的死锁检测器会定期检查是否有”等待环路”:

  1. 发现死锁
  2. 选择一个”牺牲品”事务(通常是代价最小的那个)
  3. 强制回滚这个事务(释放它持有的所有锁)
  4. 另一个事务继续执行

🚗 类比:十字路口的”四辆车顶牛”

四个方向的车同时到达十字路口——每辆车都想直行,但谁也不让谁,全部堵死。

数据库的做法 = 交警强制让其中一辆车倒回去(回滚一个事务),让其他车先过。


📸 MVCC(多版本并发控制)

MVCC 是最重要的并发控制技术之一——它解决了”读不阻塞写,写不阻塞读”的问题。

没有 MVCC 时

-- 事务 A 正在写
UPDATE accounts SET balance = 500 WHERE id = 1;

-- 任何其他事务的读操作必须等待——因为数据被加了排他锁
SELECT balance FROM accounts WHERE id = 1;  -- 阻塞!

有 MVCC 时

每个事务在开始时,数据库会创建一个”快照”——事务内的读操作都基于这个快照,而不是当前最新的数据。

-- 事务 A 开始(创建快照 V1)
-- 快照中:id=1, balance=1000

-- 事务 B 修改并提交
UPDATE accounts SET balance = 500 WHERE id = 1;  -- 创建了新版本 V2
COMMIT;

-- 事务 A 再次读取(还在同一个事务中)
SELECT balance FROM accounts WHERE id = 1;  
-- 返回 1000(快照 V1 中的数据),而不是 500(V2)

MVCC 的核心:数据行不是原地覆盖更新,而是”追加版本”——旧版本和新版本同时存在。

数据行的版本链(以 balance 为例):

V1: balance=1000  ← 事务 A 看到这个版本
V2: balance=500   ← 事务 B 创建的(当前最新版本)

每个版本都记录了"该版本对哪些事务可见"。

MVCC 的关键优势

场景没有 MVCC有 MVCC
读操作遇到写操作读阻塞(等写释放锁)读立刻返回旧版本
写操作遇到读操作写阻塞(等读完才能写)写立刻创建新版本
并发性能串行化,但并发差读写并行,性能好

📝 实际数据库的使用情况

  • MySQL InnoDB:读操作默认不加锁(使用 MVCC),写操作加排他锁
  • PostgreSQL:所有读操作都不加锁(完全依赖 MVCC)
  • Oracle:同 PostgreSQL,MVCC 是核心机制

这也是为什么在 SELECT ... FOR UPDATE 语句中,FOR UPDATE 会显式给读操作加排他锁——因为你明确表示”我要读最新的值,准备后续更新它”。


🔄 锁和 MVCC 的配合

在实际数据库系统中,锁和 MVCC 不是二选一,而是配合使用

事务 A 读数据 → 不加锁或少加锁(MVCC 提供快照)
事务 A 写数据 → 加排他锁、创建新版本
事务 B 读数据 → 从 MVCC 快照读(即使 A 在写也不阻塞)
事务 B 写数据 → 如果和 A 写同一行 → 排他锁导致等待
隔离级别读实现写实现
READ UNCOMMITTED直接读最新版本(不加 MVCC)排他锁
READ COMMITTED每条语句创建新快照排他锁
REPEATABLE READ事务开始时创建快照排他锁 + 间隙锁(防幻读)
SERIALIZABLE读也加锁(或使用 SSI)排他锁

📝 小结

概念一句话
共享锁(S-Lock)读锁——可多个事务同时持有
排他锁(X-Lock)写锁——只能一个事务持有
2PL(两阶段锁)先全部加锁再全部解锁——保证可串行化
死锁事务互相等待——数据库自动回滚一个来解决
MVCC多版本并发控制——读不阻塞写,写不阻塞读
快照事务开始时数据的一份”冻结视图”

🎯 思考题:MVCC 这么好,为什么不所有场景都用 MVCC + READ COMMITTED?提示:考虑”先查后写”的场景(比如先查库存再减库存)——MVCC 的”快照”在这里会带来什么问题?

为什么先学这个? 理解了并发控制,最后来看数据库如何保证”即使系统崩溃数据也不丢”——日志与恢复(Undo/Redo)