PostgreSQL事务隔离级别与案例

上一篇我们从 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 下"同一事务两次查询结果不同"引发的问题?如果你的业务涉及多行约束(如"至少保留一条""总和不超限"),你是靠数据库隔离级别保证,还是靠应用层锁或约束?欢迎在评论区聊聊。

相关推荐
派小心.1 小时前
App数据统计平台怎么选?从SDK接入到日活留存看板的技术实现
数据库·人工智能·小程序
m0_639906941 小时前
MySQL 实现分布式锁:悲观锁、乐观锁、唯一索引锁
数据库·分布式·mysql
RuoyiOffice1 小时前
达梦 DM8 + openGauss:RuoYi Office 的 MySQL 数据转换与导入实践
数据库·spring boot·python·sql·信创
代码不停1 小时前
MyBatis操作数据库
数据库·mybatis
程序猿乐锅1 小时前
面向对象练习:图书借阅系统里的封装、抽象类与多态
开发语言·前端·数据库·后端·python·学习·1024程序员节
严同学正在努力1 小时前
PostgreSQL 高可用教程(流复制 + Patroni 生产级实战)
运维·数据库·ai·postgresql·架构
牧瀬クリスだ1 小时前
Redis String深度解析:从底层到五大实战场景
数据库·redis·缓存
wno7042 小时前
Spring Boot中JdbcTemplate与Mybatis使用PostgreSQL
spring boot·postgresql·mybatis
Random Glow2 小时前
聚簇索引:为什么主键顺序决定插入性能
数据库·mysql·性能优化·innodb·索引