上一篇我们从 ACID 的高度俯瞰了 PostgreSQL 的承诺与代价。这一篇,我们把镜头对准 ACID 中最复杂的那一项:隔离性 。Read Committed、Repeatable Read、Serializable------这三个名字看起来是"越来越强"的线性递进,但在 PostgreSQL 中,它们的实现方式和行为边界,远比字面复杂。尤其是:为什么 PG 的 Repeatable Read 能避免幻读?Serializable 又为此付出了什么代价?

一、先纠正一个常见误解:PG 内部只有三种隔离级别
SQL 标准定义了四种隔离级别,但 PostgreSQL 只实现了三种不同的行为:
- Read Uncommitted 被映射为 Read Committed。在 PG 的 MVCC 架构下,"读未提交"没有独立的实现方式,也没有业务场景真正需要脏读;
- 实际可用的是 Read Committed(默认) 、Repeatable Read 、Serializable。
更重要的是,PostgreSQL 对后两者的保证强于 SQL 标准的最低要求 。SQL 标准允许 Repeatable Read 出现幻读,但 PostgreSQL 的 Repeatable Read 不允许幻读;标准允许该级别出现序列化异常,PostgreSQL 的 Repeatable Read 仍然可能遇到,而 Serializable 则将其完全消除。
二、Read Committed:语句级快照,同一事务两次查询可能不同
Read Committed 是 PostgreSQL 的默认隔离级别。它的核心机制是:每条语句在执行开始时获取一个新的快照。
这意味着:一条 SELECT 只看到"查询开始之前已提交"的数据,不会看到未提交的数据,也不会看到查询执行期间其他事务提交的变更。但同一事务内的两条连续 SELECT,如果中间有其他事务提交,它们可能看到不同的数据------因为第二条语句获取了新快照。
用两个会话演示这个"经典陷阱":
sql
-- 会话 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 返回 1000
-- 会话 B(在 A 的两条查询之间)
BEGIN;
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
-- 会话 A(仍在同一事务中)
SELECT balance FROM accounts WHERE id = 1; -- 返回 500,而不是 1000
Read Committed 的代价最低,适合大多数 OLTP 场景,但它提供的是一致性"语句级"的视图,而不是"事务级"的视图。如果你的业务逻辑要求"同一事务内多次读取同一行必须得到相同结果",Read Committed 不够。
三、Repeatable Read:事务级快照,这是 PG 能避免幻读的关键
Repeatable Read 的核心改变只有一句话:快照在事务中第一条非事务控制语句执行时冻结,整个事务共用这一个快照。
sql
-- 会话 A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 1000,快照在此刻冻结
-- 会话 B
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;
-- 会话 A
SELECT balance FROM accounts WHERE id = 1; -- 仍然是 1000
在同一个事务内,无论其他事务提交了什么,A 看到的都是"快照冻结那一刻"的数据。不可重复读消失了。
但幻读呢? 幻读的经典定义是:事务内两次执行同一个范围查询,第二次看到了第一次没有的新行 。在基于锁的数据库中(如 MySQL 的 InnoDB),这通常通过间隙锁来防止------锁住范围之间的"空隙",让其他事务无法在那个范围内插入。PostgreSQL 不这么做。
PostgreSQL 的 Repeatable Read 能避免幻读,不是因为"锁住了范围",而是因为快照本身就是一个固定的历史视图 。事务 A 的快照冻结之后,事务 B 插入的新行,其 xmin 是 B 的事务 ID;A 的快照中,所有在快照获取时尚未开始或尚未提交的事务 ID,都被视为不可见。因此,B 插入的行对 A 根本不存在,无论 A 执行多少次范围查询,都不会"幻影般"地看到它。
关键认知:PG 的 Repeatable Read 不是"更强的 Read Committed",而是"快照隔离(Snapshot Isolation)"。 它防幻读的方式是"让新行在你的世界里不存在",而不是"阻止别人插入"。这也意味着,它没有间隙锁带来的并发代价------写操作不会被读操作阻塞。
四、但 Repeatable Read 不是终点:写倾斜与序列化异常
快照隔离解决了幻读,却没有解决所有并发异常。最典型的漏网之鱼是写倾斜(Write Skew)。
考虑一个经典的"医生值班"约束:至少有一名医生在岗。两个医生 A 和 B 同时在岗,各自执行:
sql
-- 事务 1(医生 A)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true; -- 看到 2,满足"至少 1 人"的条件
UPDATE doctors SET on_call = false WHERE name = 'A';
COMMIT;
-- 事务 2(医生 B),几乎同时
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT count(*) FROM doctors WHERE on_call = true; -- 也看到 2,同样满足条件
UPDATE doctors SET on_call = false WHERE name = 'B';
COMMIT;
两个事务各自基于"有两个人值班"的快照做出了"可以离开"的决定,各自修改了不同的行,没有行级写冲突。最终两个人都离开了,约束被违反,但 Repeatable Read 不会阻止其中任何一个。
这就是序列化异常:并发执行的结果,不符合任何串行执行顺序。Repeatable Read 保证了"每一行"的一致性,但没有保证"行与行之间的业务约束"在并发下不被破坏。
五、Serializable:在快照隔离之上,加一层"危险结构"检测
PostgreSQL 从 9.1 开始,Serializable 通过 SSI(Serializable Snapshot Isolation,可串行化快照隔离) 实现。官方文档的表述非常精确:Serializable 事务本质上就是 Repeatable Read 事务,额外增加了对"危险读写冲突模式"的非阻塞监控。
SSI 检测什么?
SSI 不锁任何数据,它跟踪事务之间的读写依赖 。当它发现一个"危险结构"时------官方描述是两条相邻的 rw-conflict(读写冲突)边形成的结构------就意味着一组并发事务的最终结果,可能无法对应到任何串行顺序。
在"医生值班"的例子中:
- 事务 1 读了 B 的行(
on_call = true),然后写了 A 的行; - 事务 2 读了 A 的行,然后写了 B 的行。
两者之间形成了相互的读写依赖。SSI 检测到这个环,就会回滚其中一个事务,打破环,从而保证最终结果等价于某个串行顺序。
为什么回滚不是"立即"发生的?
一个容易困惑的问题是:SSI 已经知道存在冲突了,为什么不立刻中止一个事务,非要等到冲突集合中至少有一个事务提交才动手?
PostgreSQL 核心开发者 Tom Lane 在邮件列表中的解释很关键:SSI 的目标不是"尽早失败",而是"让至少一个事务成功" 。如果一检测到冲突就立即杀掉某个事务,可能导致"所有相关事务最终都失败"------被杀的可能是本来可以成功的那个,而其他事务随后也因为别的原因失败。推迟到有事务成功提交之后再中止,是为了保证"不止零个工作被完成",并且让被选中中止的事务在立即重试时,不会因为完全相同的冲突再次失败。
Serializable 的代价是什么?
第一,序列化失败与重试。 使用 Serializable 的应用必须准备好处理序列化失败并重试整个事务。这不是可选的优化,而是使用该级别的必要前提。没有自动重试机制的应用,会在并发冲突下频繁报错。
第二,长事务放大冲突窗口。 事务运行时间越长,它与其他事务产生读写依赖的机会越大,被回滚的概率越高。Serializable 与长事务天然不兼容,它会迫使你把事务切短。
第三,只读事务的监控开销。 默认情况下,Serializable 的只读事务也会参与 SSI 跟踪,以避免"只读事务看到不一致快照"的异常。对于以只读为主的负载,可以通过 DEFERRABLE 让只读事务在获得安全快照后才开始,但代价是可能需要等待。
第四,复制环境的边界。 PostgreSQL 文档明确警告:Serializable 的完整性保护尚未扩展到热备(Hot Standby)和逻辑复制。在备库上,最高只能使用 Repeatable Read。如果你的架构依赖只读备库做一致性校验,Serializable 无法覆盖。
六、实战选型:不要默认"越强越好"
| 场景 | 推荐级别 | 理由 |
|---|---|---|
| 绝大多数 OLTP 读写 | Read Committed | 默认、低开销,语句级一致性对多数业务足够 |
| 同一事务内需要稳定读取(报表、多步校验) | Repeatable Read | 事务级快照,防不可重复读和幻读,且无间隙锁 |
| 多行之间的业务约束在并发下必须成立(写倾斜、跨表校验) | Serializable + 自动重试 | SSI 消除序列化异常,但必须配套重试框架 |
| 只读长事务、需要安全快照 | Repeatable Read,或 Serializable + DEFERRABLE | 避免只读事务被 SSI 不必要地中止 |
一个重要的实践建议:如果启用 Serializable,应该通过框架统一配置和自动重试,而不是让每个开发者手动处理。官方文档也建议,可以通过触发器检查事务隔离级别,确保没有其他代码"偷偷"降级到更弱的级别。
七、总结
| 级别 | 快照粒度 | 幻读 | 序列化异常 | 核心代价 |
|---|---|---|---|---|
| Read Committed | 每条语句 | 可能 | 可能 | 同一事务两次查询可能不同 |
| Repeatable Read | 整个事务 | 不会(快照隔离) | 可能(写倾斜) | 写-写冲突可能导致序列化失败并需要重试 |
| Serializable | 整个事务 + SSI 监控 | 不会 | 不会 | 冲突回滚、必须重试、长事务不友好 |
两句话回答核心问题:
PG 的 Repeatable Read 能避免幻读,不是因为锁住了范围,而是因为事务级快照让"其他事务新插入的行"对当前事务根本不可见。 这是一种"视图隔离",而不是"锁隔离",所以它没有间隙锁的并发代价。
Serializable 的代价,本质上是"正确性税" :它用 SSI 检测读写依赖环,用事务回滚打破环,换取"并发结果等价于某种串行顺序"的保证。这个保证要求应用必须接受重试,并且天然惩罚长事务。在绝大多数业务中,Read Committed 已经够用;只有当多行约束在并发下确实可能被破坏时,Serializable 才是值得付出的代价。
下一篇,我们进入锁机制:表级锁、行级锁、轻量级锁------UPDATE 到底锁住了什么?为什么"锁等待"和"死锁"在生产环境中如此常见?
思考题:
你现在的业务用的是哪个隔离级别?有没有遇到过 Read Committed 下"同一事务两次查询结果不同"引发的问题?如果你的业务涉及多行约束(如"至少保留一条""总和不超限"),你是靠数据库隔离级别保证,还是靠应用层锁或约束?欢迎在评论区聊聊。