4. PostgreSQL 中的读已提交:默认隔离级别的工作原理、快照机制与脏读防护

在 PostgreSQL 的四种事务隔离级别中,读已提交(Read Committed) 是默认且最常用的级别。对于支付订单系统而言,理解其工作原理、快照机制以及如何防止脏读,是构建安全、一致数据访问的基石。本章将深入剖析这些核心机制。

4.1 读已提交的核心原则与脏读防护

读已提交隔离级别保证一个事务只能读取到其他事务已经提交的数据。这意味着,如果一个事务正在修改某行数据但尚未提交,那么其他并发事务将无法看到这些未提交的修改,从而彻底杜绝了脏读现象。

脏读防护要点:
  • 写操作互斥:事务在修改某行时,会持有该行的行级锁(通常是排他锁),阻止其他事务读取未提交的修改。
  • 读操作不阻塞:读操作不会等待写锁释放,而是读取该行数据的上一个已提交版本(通过多版本并发控制 MVCC 实现)。
  • 支付场景价值:确保订单状态、账户余额等关键数据不会被“脏”数据污染,避免基于未确认的支付结果做出错误决策。

4.2 快照机制:MVCC 与事务快照

PostgreSQL 通过 多版本并发控制(MVCC) 实现读已提交。每个事务在开始执行第一条 SQL 语句时,会获取一个事务快照。这个快照记录了当前系统中所有活跃(正在进行中且未提交)事务的 ID 列表。

在读已提交级别下,事务快照的获取时机是每一条 SQL 语句的开始,而不是整个事务的开始。这意味着,同一个事务内的不同查询,可能会看到不同的数据版本。

快照 vs. 可重复读: 与可重复读(Repeatable Read)级别不同,读已提交的快照是语句级别的。可重复读使用事务开始时的快照,而读已提交每次查询都重新获取快照。这导致了不可重复读和幻读的可能性,但换来了更高的并发性能。

4.3 读已提交下的数据可见性规则

当一个事务执行 SELECT 查询时,PostgreSQL 根据当前语句的快照,按以下规则判断每行数据的可见性:

数据行状态 事务快照判断 可见性 说明
由已提交事务创建 创建事务 ID 不在快照的活跃列表中 可见 正常读取已提交的数据。
由当前事务自身创建 创建事务 ID 等于当前事务 ID 可见 事务可以看到自己所做的修改。
由未提交事务创建 创建事务 ID 在快照的活跃列表中 不可见 防止脏读,读取该行的上一个已提交版本。
由已提交事务删除 删除事务 ID 不在快照的活跃列表中 不可见 该行已被删除,查询结果中不包含此行。
由未提交事务删除 删除事务 ID 在快照的活跃列表中 可见 删除操作尚未提交,该行数据仍然有效。

4.4 读已提交工作流程:语句级快照与脏读防护

下图展示了读已提交隔离级别下,两个并发事务(T1 和 T2)对同一行数据进行操作时,快照机制如何工作并防止脏读。

读已提交:语句级快照与脏读防护流程 读已提交:语句级快照与脏读防护流程 事务 T1 BEGIN UPDATE 未提交 COMMIT 事务 T2 BEGIN SELECT 快照1: 活跃事务列表 = {T1} T1 未提交,T2 看不到 T1 的修改 SELECT 快照2: 活跃事务列表 = {} T1 已提交,T2 可以看到 T1 的修改 关键点:读已提交下,T2 的两次 SELECT 获取了不同的快照。 第一次 T1 未提交,T2 读取旧版本(脏读防护);第二次 T1 已提交,T2 读取新版本。

4.5 支付订单场景下的行为分析

在支付订单系统中,读已提交的行为直接影响业务逻辑的正确性。以下是一个典型的“检查余额并扣款”场景:

  1. 事务 A(扣款): 读取用户余额(假设为 100 元),准备扣款 80 元。
  2. 事务 B(查询余额): 在事务 A 提交之前,查询同一用户的余额。
  3. 读已提交行为: 事务 B 的查询会获取一个新的快照,由于事务 A 尚未提交,事务 B 看到的余额仍然是 100 元(旧版本),而不是 20 元(未提交的新版本)。
  4. 结果: 事务 B 不会看到“脏”的 20 元余额,避免了基于未确认数据的错误判断。
注意不可重复读问题: 如果事务 B 在事务 A 提交后再次查询同一用户的余额,由于新的快照不再包含事务 A 的 ID,事务 B 将看到 20 元。这导致了不可重复读。在支付对账、报表统计等需要一致性视图的场景中,应考虑使用可重复读或可序列化隔离级别。

4.6 读已提交与写偏序(Write Skew)

读已提交虽然防止了脏读,但无法防止写偏序问题。写偏序发生在两个并发事务分别读取一组数据的不同部分,然后基于各自读取到的旧值进行更新,导致整体约束被违反。

支付场景示例:

  • 规则:一个账户的总可用额度不能超过 1000 元。
  • 事务 A:读取额度 A(500 元)和额度 B(500 元),总和 1000 元。事务 A 将额度 A 增加 100 元。
  • 事务 B:读取额度 A(500 元)和额度 B(500 元),总和 1000 元。事务 B 将额度 B 增加 100 元。
  • 结果:两个事务都提交后,额度 A 变为 600 元,额度 B 变为 600 元,总和 1200 元,违反了约束。

在读已提交下,这种问题无法被自动检测。解决写偏序通常需要引入显式锁(如 SELECT ... FOR UPDATE)或使用更严格的隔离级别。

4.7 实践建议:在支付系统中使用读已提交

场景 推荐做法 原因
订单状态查询 使用读已提交(默认) 高并发,允许看到最新已提交状态,脏读防护足够。
余额扣减 使用 UPDATE ... RETURNING 或 SELECT ... FOR UPDATE 防止并发扣款导致余额为负,需要行级锁保证原子性。
对账报表生成 使用可重复读或可序列化 需要整个事务期间看到一致的数据快照,避免不可重复读。
唯一约束检查 使用唯一索引或可序列化 读已提交无法防止幻读,唯一索引是更可靠的保障。

4.8 读已提交下的快照获取时机对比

为了更清晰地理解读已提交与可重复读的区别,下表对比了两种隔离级别下快照的获取时机:

特性 读已提交(Read Committed) 可重复读(Repeatable Read)
快照获取时机 每条 SQL 语句开始执行时 事务中第一条 SQL 语句开始执行时
同一事务内多次查询结果 可能不同(不可重复读) 完全相同(可重复读)
脏读防护 是 是
幻读防护 否 是(通过快照实现)
并发性能 高 中等(快照冲突可能导致序列化失败)
适用支付场景 高并发查询、状态更新 对账、报表、需要一致性视图的复杂业务逻辑

理解读已提交的快照机制和脏读防护原理,是掌握 PostgreSQL 事务隔离的基础。在支付订单系统中,合理利用其特性,并结合适当的锁机制,可以构建出既高效又安全的数据处理流程。