前面我们了解了事务与隔离级别,以及 MVCC,理解数据库如何通过多个数据版本,让不同事务读取各自应该看到的数据。
但 MVCC 并不能解决所有并发问题。假设两个事务同时修改同一条记录,数据库必须决定哪个事务先修改、后来的事务是否等待,以及互相等待时如何处理。这就是锁(Lock)机制要解决的问题。
一、为什么数据库需要锁?
假设一条记录的余额为 1000,事务 A 扣除 200,事务 B 扣除 300。如果两个事务都先读取 1000,再分别写入 800 和 700,后写入的值可能覆盖前一个事务的修改,造成丢失更新(Lost Update)。
锁通过协调对冲突资源的访问,避免不受控制的并发修改。典型过程是:
- 事务获取锁;
- 执行操作;
- 在适当的事务结束点释放锁;
- 其他事务请求冲突锁时,需要等待或按数据库规则中止。
二、共享锁与排他锁
Shared Lock(S Lock,共享锁)
共享锁通常用于锁定读取。多个事务可以同时持有同一资源的兼容共享锁。
Exclusive Lock(X Lock,排他锁)
排他锁通常用于修改数据。它与同一资源上的共享锁和排他锁一般不兼容,因此其他事务请求冲突锁时需要等待。
| 已持有锁 | 请求 S Lock | 请求 X Lock |
|---|---|---|
| S Lock | 兼容 | 冲突 |
| X Lock | 冲突 | 冲突 |
这是基础兼容矩阵。真实数据库还会有意向锁、记录锁、范围锁等类型,具体规则也取决于数据库实现。
三、锁粒度
锁粒度(Lock Granularity) 指锁保护的资源范围。
| 粒度 | 保护范围 | 典型权衡 |
|---|---|---|
| 表锁 | 整张表 | 锁管理相对简单,但可能造成较多不必要阻塞 |
| 页锁 | 一个数据页 | 介于表锁与行锁之间 |
| 行锁 | 特定记录或索引记录 | 并发更灵活,但锁管理成本可能更高 |
锁越粗,管理通常越简单,但并发灵活性越低;锁越细,冲突范围通常越小,但系统需要管理更多锁状态。不同数据库支持的锁粒度和内部实现并不完全相同。例如,InnoDB 的常规数据锁主要围绕索引记录及范围工作。
四、两阶段锁(2PL)
两阶段锁(Two-Phase Locking,2PL) 约束事务获取和释放锁的顺序,分为两个阶段:
- 增长阶段(Growing Phase):事务可以获取新锁,但不能释放已有锁。
- 收缩阶段(Shrinking Phase):事务可以释放锁,但不能再获取新锁。
2PL 能保证冲突可串行化(Conflict Serializability) 。严格两阶段锁(Strict 2PL) 进一步要求排他锁等关键锁保持到事务提交或回滚,从而避免其他事务过早依赖尚未最终确定的修改。
锁保持时间越长,隔离与恢复语义通常越容易管理,但事务等待也可能增加。
五、死锁(Deadlock)
假设事务 A 持有 X,事务 B 持有 Y;随后 A 请求 Y,B 请求 X。A 等待 B,B 又等待 A,双方都无法继续,这就是死锁。
等待关系可以表示为等待图(Wait-for Graph):
text
T1 → T2
↑ ↓
└─────┘
在常见的锁等待模型中,等待图出现环是死锁的重要判据。
死锁处理方式
- 检测(Detection) :数据库检查等待关系,发现循环等待后选择一个事务作为牺牲者(Victim),中止或回滚它,释放锁。
- 超时(Timeout):等待超过配置阈值后取消等待或中止事务。超时不一定代表死锁,也可能只是事务执行时间较长。
- 预防(Prevention):例如规定事务按统一顺序获取资源,减少循环等待形成的机会,但不能覆盖所有死锁场景。
六、锁管理器在数据库内核中的职责
锁管理器不负责查找记录本身,而是管理资源锁状态和请求协调。概念上,它需要维护:
- 锁定资源,例如表、记录或索引范围;
- 锁模式与兼容关系;
- 当前持有锁的事务;
- 等待队列;
- 事务状态、取消与唤醒流程。
简化的请求流程如下:
text
事务请求锁
↓
查找资源锁状态
↓
检查兼容性
├── 兼容 → 授予锁,事务继续
└── 冲突 → 加入等待队列
↓
冲突锁释放
↓
重新检查并授予
事务被唤醒并不意味着一定立即获得锁;数据库仍需重新检查兼容性和队列调度规则。并发实现还必须妥善处理线程同步、事务取消、回滚和异常路径。
七、MVCC 与锁如何配合?
- MVCC 主要处理版本可见性:事务应读取哪个版本。
- 锁 主要处理冲突协调:哪些访问需要互斥、哪些修改需要等待。
例如,MVCC 一致性读可能让读事务读取旧版本,而不必等待另一个事务完成更新;但两个事务同时修改同一记录时,数据库仍然需要处理写冲突。显式锁定读取、唯一性约束、外键检查和某些范围保护场景,也可能需要锁或其他并发控制机制。
因此不能把 MVCC 理解为"不需要锁"。实际数据库通常组合使用 MVCC、锁、事务状态和日志机制。
八、PostgreSQL 与 InnoDB 的典型差异
| 维度 | PostgreSQL | InnoDB |
|---|---|---|
| 普通一致性读 | 通常通过 MVCC Snapshot | 通常通过 Read View |
| 行级并发控制 | 行级锁与 Tuple 版本机制协作 | 锁通常围绕索引记录及范围 |
| 范围保护 | Serializable 下使用 SSI 等机制 | 适用场景下使用 Gap Lock、Next-Key Lock |
| 死锁处理 | 检测后中止事务 | 检测后选择事务回滚 |
| 历史版本回收 | VACUUM 等 | Purge 等 |
PostgreSQL 支持多种表锁、行锁及咨询锁。普通 MVCC 一致性读取通常不需要对每一行获取传统共享行锁;SELECT ... FOR UPDATE 等锁定读取则会请求相应锁。在 Serializable 隔离级别下,PostgreSQL 使用 Serializable Snapshot Isolation(SSI) 跟踪可能导致不可串行化结果的依赖,必要时中止事务。
InnoDB 的记录锁和范围锁与索引密切相关。Gap Lock 保护索引记录之间的间隙;Next-Key Lock 组合记录锁与间隙锁,在适用场景下防止范围内的冲突插入或修改。实际锁定范围取决于索引、查询计划、隔离级别和具体数据库版本。
九、锁的成本与设计原则
锁带来的成本主要包括:
- 管理开销:维护锁对象、状态、等待队列和兼容性检查;
- 等待开销:冲突事务可能阻塞,降低吞吐量;
- 死锁处理成本:检测、选择牺牲者和回滚;
- 并发度损失:锁范围过大时,互不冲突的操作也可能被阻塞。
常见的优化方向包括:
- 缩短事务持续时间;
- 减少不必要的锁范围;
- 合理设计索引;
- 避免事务以不一致的顺序访问资源;
- 在适合的场景利用 MVCC 一致性读。
目标不是消灭锁,而是以合理成本保护真正存在冲突的操作。
十、把锁放回数据库内核全景
text
SQL / Query Executor
↓
Transaction Manager
↓
Lock Manager + MVCC
↓
Index / Page / Buffer Pool
↓
WAL
↓
Commit / Recovery
这是概念上的职责关系,不代表真实数据库严格按单一线性顺序执行。执行器、事务管理、锁与版本控制、数据页修改和 WAL 记录会在事务过程中相互协作。
需要区分三件事:
- 索引与存储层定位数据;
- 锁管理器与 MVCC协调并发访问和版本可见性;
- WAL 与恢复机制支持崩溃后的持久性恢复。
小结
- S Lock 与 X Lock 描述常见的锁模式及兼容关系。
- 锁粒度决定锁保护资源的范围,是并发灵活性与管理成本之间的权衡。
- 2PL 约束锁获取和释放顺序;Strict 2PL 会将关键锁保持到事务结束。
- 死锁是循环等待,可通过检测、超时或预防策略处理。
- MVCC 负责版本可见性,锁负责冲突协调,两者通常协同工作。
- PostgreSQL 与 InnoDB 都支持 MVCC 和锁,但锁对象、范围保护与内部实现存在差异。