PostgreSQL 事务与并发系列 · 第四期
事务隔离级别深度测试与序列化异常
前三期我们构建了从 MVCC 到锁机制的完整知识体系。本期将聚焦三种隔离级别的实际行为边界,通过可复现的案例演示“不可重复读”“幻读”以及最隐蔽的“写偏斜(Write Skew)”,同时揭开 PostgreSQL 可串行化快照隔离(SSI)的神秘面纱。
一、隔离级别速览与默认行为回顾
PostgreSQL 支持三种隔离级别(READ UNCOMMITTED 实际等同于 READ COMMITTED):
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 序列化异常(写偏斜) |
|---|---|---|---|---|
| READ COMMITTED | ❌ | ✅ 可能 | ✅ 可能 | ✅ 可能 |
| REPEATABLE READ | ❌ | ❌ | ❌(PostgreSQL 实现彻底阻止) | ✅ 可能 |
| SERIALIZABLE | ❌ | ❌ | ❌ | ❌(检测冲突并终止事务) |
关键事实:PostgreSQL 的 REPEATABLE READ 级别不仅阻止了不可重复读,还彻底阻止了幻读(通过快照隔离实现了比 SQL 标准更严格的语义)。但即便如此,仍无法防止写偏斜这种序列化异常。
二、实战环境准备
创建一个简单的医生值班表,用于演示所有异常场景:
DROP TABLE IF EXISTS doctors;
CREATE TABLE doctors (
id INT PRIMARY KEY,
name TEXT,
on_call BOOLEAN -- 是否值班
);
INSERT INTO doctors VALUES (1, 'Alice', true), (2, 'Bob', true);
规则:至少有一名医生值班(业务约束)。两个事务同时试图交接班,可能会导致违反约束。
三、READ COMMITTED:不可重复读与幻读
3.1 不可重复读
| 时间 | 事务 A (READ COMMITTED) | 事务 B (READ COMMITTED) |
|---|---|---|
| T1 | BEGIN; |
|
| T2 | SELECT on_call FROM doctors WHERE id=1; → true |
|
| T3 | UPDATE doctors SET on_call=false WHERE id=1; COMMIT; |
|
| T4 | SELECT on_call FROM doctors WHERE id=1; → false |
|
| T5 | COMMIT; |
同一个事务内读取到不同的值 → 不可重复读。
3.2 幻读
在 PostgreSQL 中,幻读通常指“范围内新增/删除的行导致两次查询结果集不同”。虽然 REPEATABLE READ 能阻止,但 READ COMMITTED 仍会出现:
| 时间 | 事务 A | 事务 B |
|---|---|---|
| T1 | BEGIN; |
|
| T2 | SELECT COUNT(*) FROM doctors WHERE on_call=true; → 2 |
|
| T3 | INSERT INTO doctors VALUES (3,'Carol',true); COMMIT; |
|
| T4 | SELECT COUNT(*) FROM doctors WHERE on_call=true; → 3 |
出现了之前未见的行 → 幻读。
四、REPEATABLE READ:快照隔离如何阻止幻读
在 REPEATABLE READ 隔离级别下,事务在第一条语句执行时获取一个固定的快照,整个事务期间复用。因此 T4 查询仍然看到的是 T2 时刻的快照结果,不会受 T3 提交的影响。
验证脚本:
-- 会话 1
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM doctors WHERE on_call=true; -- 假设为 2
-- 会话 2
BEGIN;
INSERT INTO doctors VALUES (3,'Carol',true);
COMMIT;
-- 会话 1 再次执行
SELECT COUNT(*) FROM doctors WHERE on_call=true; -- 仍然为 2(快照未变)
COMMIT;
但请注意:REPEATABLE READ 并不会阻止序列化异常中的写偏斜,下面来看。
五、写偏斜(Write Skew)——最隐蔽的并发异常
5.1 什么是写偏斜?
两个并发事务都读取一组数据,然后各自基于读取的结果修改互不相交的数据子集,最终导致整体违反某个业务约束。它不会产生冲突的行锁,因此 MVCC 快照隔离无法自动防止。
5.2 经典医生值班示例
业务约束:至少有一名医生需要值班(on_call = true 的医生数量 ≥ 1)。
初始状态:Alice 和 Bob 均为值班(true)。
现在两个事务同时试图将 Alice 和 Bob 分别设为不值班:
| 时间 | 事务 1 (REPEATABLE READ) | 事务 2 (REPEATABLE READ) |
|---|---|---|
| T1 | BEGIN; |
BEGIN; |
| T2 | SELECT COUNT(*) FROM doctors WHERE on_call=true; → 2 |
SELECT COUNT(*) FROM doctors WHERE on_call=true; → 2 |
| T3 | 判断 2>=1,允许交接班 | 判断 2>=1,允许交接班 |
| T4 | UPDATE doctors SET on_call=false WHERE id=1; |
UPDATE doctors SET on_call=false WHERE id=2; |
| T5 | COMMIT; |
COMMIT; |
最终结果:两名医生都不值班 → 违反约束。
为什么 PostgreSQL 的快照隔离没有阻止?因为:
- 事务 1 和事务 2 锁定的行分别是 id=1 和 id=2,没有交集。
- 每个事务的写操作没有与对方的写操作冲突,因此可以并发提交。
- 只在提交后检查约束才发现违反(但 PostgreSQL 默认不自动检测这种逻辑上的违反)。
5.3 如何在 REPEATABLE READ 中强制避免写偏斜?
方法 1:主动加锁保护整个约束集(例如使用 FOR UPDATE 锁定所有相关行)
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 锁定所有可能被修改的相关行(这里是所有医生行)
SELECT * FROM doctors WHERE on_call = true FOR UPDATE;
-- 现在业务逻辑判断,然后更新
UPDATE doctors SET on_call=false WHERE id=1;
COMMIT;
因为两个事务都会尝试锁定所有 on_call=true 的行,第二个事务会被第一个阻塞,串行执行,从而避免写偏斜。
方法 2:使用 SERIALIZABLE 隔离级别(见下一节)。
六、SERIALIZABLE:可串行化快照隔离(SSI)
6.1 SSI 原理简介
PostgreSQL 的 SERIALIZABLE 隔离级别基于 可串行化快照隔离(Serializable Snapshot Isolation,SSI) 算法。它在后台跟踪事务之间的读写依赖(rw-conflict),如果检测到依赖图中出现了循环(可能导致类似写偏斜的异常),就会主动终止其中一个事务,强制回滚。
优点:用户无需手动加锁,只要设置 SERIALIZABLE,数据库自动检测序列化冲突。
缺点:事务可能因序列化失败而被回滚,应用需要重试。
6.2 写偏斜的 SERIALIZABLE 行为
将上面的医生值班示例隔离级别改为 SERIALIZABLE:
| 时间 | 事务 1 (SERIALIZABLE) | 事务 2 (SERIALIZABLE) |
|---|---|---|
| T1 | BEGIN ISOLATION LEVEL SERIALIZABLE; |
BEGIN ISOLATION LEVEL SERIALIZABLE; |
| T2 | SELECT COUNT(*) FROM doctors WHERE on_call=true; → 2 |
SELECT COUNT(*) FROM doctors WHERE on_call=true; → 2 |
| T3 | 判断通过 | 判断通过 |
| T4 | UPDATE doctors SET on_call=false WHERE id=1; |
UPDATE doctors SET on_call=false WHERE id=2; |
| T5 | COMMIT; |
COMMIT; → 其中一个会收到错误:
ERROR: could not serialize access due to read/write dependencies among transactions |
PostgreSQL 检测到两个事务的写集合交集为空但读集合重叠(都读取了 on_call=true 的行),并且存在读写依赖循环,于是终止其中一个。应用捕获异常后重试即可。
6.3 SERIALIZABLE 与性能权衡
- 优点:最强的数据一致性保证,防止所有序列化异常(包括写偏斜)。
- 缺点:高冲突负载下,事务回滚率上升,需要重试逻辑;SSI 的检测本身也有一定开销。
建议:只有当业务约束确实要求“可串行化”时(例如金融系统中防止余额超扣等),才使用 SERIALIZABLE。对于大多数 Web 应用,REPEATABLE READ + 适当的乐观锁/悲观锁(如版本号或 FOR UPDATE)足够。
七、如何选择隔离级别?
| 场景 | 推荐隔离级别 | 理由 |
|---|---|---|
| 普通报表、简单查询 | READ COMMITTED | 性能好,无需额外开销 |
| 同一事务内多次查询需保持一致(例如生成报告期间表不变) | REPEATABLE READ | 快照一致,无幻读 |
| 任务队列、行级竞争极低 | READ COMMITTED | 足够 |
| 库存扣减、转账(两个账户余额总和不变) | REPEATABLE READ + FOR UPDATE |
显式行锁避免写偏斜 |
| 复杂的多表多行业务约束(如排班、预订系统等) | SERIALIZABLE + 重试 | 让数据库帮你检测冲突,简化代码 |
| 高并发下对性能要求极高,且允许极低概率的写偏斜 | REPEATABLE READ | 但需要充分理解风险 |
八、可串行化应用的重试模式
在使用 SERIALIZABLE 时,必须准备重试逻辑(伪代码):
def transfer_money():
while True:
try:
with db.transaction(isolation='SERIALIZABLE'):
# 读取余额,检查约束
# 执行更新
db.commit()
break
except SerializableError:
# 自动重试
continue
PostgreSQL 返回的序列化失败错误码为 40001(SQLSTATE),应用应据此重试。
九、总结与下期预告
本期我们完成了对三种隔离级别的深度剖析:
- READ COMMITTED:每条语句新快照,允许不可重复读和幻读。
- REPEATABLE READ:事务级固定快照,阻止不可重复读与幻读,但不能防止写偏斜。
- SERIALIZABLE:基于 SSI,自动检测读写依赖,防止所有序列化异常,但需重试。
- 通过医生值班案例,复现了写偏斜并演示了 SERIALIZABLE 如何自动回滚。
第五期预告:实战调优——事务的常见陷阱与最佳实践
- 长事务与表膨胀的治理方案
idle in transaction的危害与自动清理- 高并发下如何设置
deadlock_timeout与lock_timeout - 如何利用
pg_stat_activity建立实时告警 - 事务与连接池的配合(避免事务悬挂)
第五期将帮助你把这些理论知识落地到生产环境的日常监控与优化中,敬请期待!
有任何关于隔离级别、序列化异常的疑问或实战踩坑经历,欢迎在评论区讨论,下期见!