锁协议与并发控制
数据库用锁和 MVCC(多版本并发控制)来保证并发事务的隔离性——锁防止写冲突,MVCC 让读不阻塞写
🔒 隔离性是怎么实现的?
上一节我们学了四种隔离级别——它们就像是数据库的”交通规则”,规定了事务之间能”看到”多少对方的数据。
但这些规则是怎么执行的? 在数据库底层,有两个核心机制在发挥作用:
- 锁(Lock)——让事务之间互斥访问数据(写和写之间不能同时进行)
- 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) ...
数据库怎么处理死锁? 数据库的死锁检测器会定期检查是否有”等待环路”:
- 发现死锁
- 选择一个”牺牲品”事务(通常是代价最小的那个)
- 强制回滚这个事务(释放它持有的所有锁)
- 另一个事务继续执行
🚗 类比:十字路口的”四辆车顶牛”
四个方向的车同时到达十字路口——每辆车都想直行,但谁也不让谁,全部堵死。
数据库的做法 = 交警强制让其中一辆车倒回去(回滚一个事务),让其他车先过。
📸 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)。