在上一篇文章中,我们通过 SQL 改写和索引设计把查询从秒级压到了毫秒级。但 SQL 优化解决的是"单个查询跑得快不快"的问题,而事务与并发解决的是"多个事务同时跑时,数据还对不对、系统会不会卡死"的问题。一个没有索引的查询只是慢;一个隔离级别用错的事务,可能在并发下读到不存在的数据、丢失更新、甚至让两个事务互相锁死。 本文将深入 PostgreSQL 的 MVCC 实现------每一行元组都携带 xmin 和 xmax,可见性由读者快照决定;拆解四种隔离级别在 PostgreSQL 中的真实行为(Read Uncommitted 被静默映射为 Read Committed);剖析 Serializable 的 SSI 实现;最后系统讲解表锁、行锁、咨询锁和死锁处理,帮你建立事务并发的完整心智模型。
一、MVCC 的核心:每一行都携带自己的"可见性时间戳"
1.1 xmin 与 xmax:元组的诞生与消亡
PostgreSQL 通过多版本并发控制(MVCC) 实现"读不阻塞写、写不阻塞读"。每一个堆元组(heap tuple)的头部都携带两个关键的 32 位事务 ID:
xmin :创建该元组的事务 ID。
xmax :删除或更新该元组的事务 ID(如果未被删除/更新,则为 0)。
一个关键认知:在 PostgreSQL 中,UPDATE 在物理上不是原地修改------它逻辑上等价于"将旧元组的 xmax 标记为当前事务,然后插入一个 xmin 为当前事务的新元组"。插入、更新、删除共用同一套机制,没有任何用户可见的数据是在不写入新元组的情况下被原地更新的。
1.2 快照与可见性判断
一个读事务在开始时获取一个快照------一个冻结的"哪些事务已提交、哪些还在运行"的视图。快照由三个部分组成:xmin(所有小于它的 XID 都已提交)、xmax(所有大于等于它的 XID 都未提交)、xip\[\](快照时刻仍在运行的事务 ID 列表)。
可见性判断是一个函数:给定元组的 (xmin, xmax) 和读者的快照,判断该元组对当前事务是否可见。元组本身在事务提交时不会发生任何变化------同一个元组对 A 读者可见、对 B 读者不可见,完全取决于各自快照的差异。
一个实用的推论:SELECT count(*) 在 PostgreSQL 中不是免费的------每一行的可见性都需要根据快照计算,而不是简单读取一个预先维护的行数计数器。
1.3 事务 ID 回卷:32 位计数器的"定时炸弹"
xmin 和 xmax 是裸的 32 位 XID,总空间约 20 亿。当事务 ID 接近上限时,会发生回卷(wraparound) ------新事务的 ID 看起来比旧事务还"老",导致 MVCC 的可见性比较彻底失效。
VACUUM 的冻结(freeze)机制是唯一的防线:VACUUM 将足够旧的元组 xmin 标记为特殊的 FrozenTransactionId,表示"比所有正常 XID 都老,永远可见"。每张表、每个数据库至少每 20 亿个事务必须被 VACUUM 一次,否则 PostgreSQL 会拒绝分配新事务 ID,只允许只读查询和 VACUUM 继续运行。
二、四种隔离级别在 PostgreSQL 中的真实行为
SQL 标准定义了四种隔离级别,但 PostgreSQL 内部只实现了三种:

2.1 Read Committed:语句级快照
这是 PostgreSQL 的默认隔离级别。每条 SQL 语句在开始时获取一个新快照------同一个事务中的两条相同查询,如果中间有其他事务提交了修改,两条查询可能看到不同的结果。这允许"不可重复读",但不会出现脏读。
2.2 Repeatable Read:事务级快照 + 写冲突检测
Repeatable Read 在事务中第一次非事务控制语句执行时获取快照,整个事务复用这个快照。事务内的所有查询看到一致的数据视图。
一个关键的额外保护:PostgreSQL 的 Repeatable Read 在更新或删除时如果发现目标行已被并发事务修改,会主动报错 ERROR: could not serialize access due to concurrent update(错误码 40001),而不是像某些数据库那样静默覆盖。这是 PostgreSQL 防止丢失更新的重要机制。
2.3 Serializable:SSI,不加锁的"真串行化"
PostgreSQL 的 Serializable 不是通过锁读来实现的。它使用 Serializable Snapshot Isolation(SSI) ------基于快照隔离,额外跟踪事务之间的读写依赖关系,在提交时检测是否存在不可串行化的依赖环。
SSI 的核心工作方式:
事务基于快照执行,读不阻塞写,写不阻塞读。
系统被动地记录 SIReadLock(谓词锁),不阻塞任何操作。
提交时检查是否存在危险结构(如两个事务之间的读写依赖形成环),如果存在,中止其中一个事务并返回 40001 serialization_failure。
SSI 的实用价值:对于需要严格串行化语义的业务(如金融对账),Serializable 提供了比显式锁更轻量的一致性保证。代价是应用必须实现 40001 错误的捕获和重试逻辑------这不是可选项,而是使用 Serializable 的必要契约。
DEFERRABLE READ ONLY 优化:对于只读的长时间分析查询,可以声明 SET TRANSACTION ISOLATION LEVEL SERIALIZABLE, READ ONLY, DEFERRABLE。这种事务不参与 SSI 的危险结构检测,可以在不产生序列化失败的情况下获得一致的快照,非常适合报表场景。
三、锁机制:MVCC 之外的必要补充
MVCC 解决了大部分并发问题,但DDL 操作、显式锁定、外键约束等场景仍然需要锁。
3.1 表级锁:八种模式
PostgreSQL 有八种表级锁模式,从最弱的 ACCESS SHARE 到最强的 ACCESS EXCLUSIVE。大多数 DML 操作自动获取合适的锁:
SELECT → ACCESS SHARE
INSERT/UPDATE/DELETE → ROW EXCLUSIVE
ALTER TABLE → ACCESS EXCLUSIVE------会阻塞所有其他访问,业务高峰期执行 DDL 是典型的"人为锁表事故"。
3.2 行级锁:四种强度
行级锁只在行被显式锁定或更新时获取。SELECT FOR UPDATE 是最强的行级锁,会阻塞其他事务对同一行的 UPDATE、DELETE 和 SELECT FOR UPDATE。

FOR UPDATE 的核心用途:在"读-判断-写"的业务逻辑中,防止其他事务在你判断之后、写入之前修改同一行。典型场景是库存扣减:先 SELECT ... FOR UPDATE 锁定库存行,判断充足后再 UPDATE,防止超卖。
3.3 咨询锁(Advisory Lock):应用层的互斥
咨询锁不绑定任何数据库对象,是应用自定义的整数或双整数锁。它的生命周期完全由应用控制,适合分布式环境下的业务互斥,例如"同一时刻只允许一个任务执行数据迁移"。
sql
-- 尝试获取 key=100 的排他咨询锁(非阻塞)
SELECT pg_try_advisory_lock(100);
-- 释放
SELECT pg_advisory_unlock(100);
3.4 死锁:检测与处理
死锁发生在两个事务互相等待对方持有的锁时。PostgreSQL 自动检测死锁,在约 1 秒的超时后中止其中一个事务,报错 40P01 deadlock_detected,另一个事务继续执行。
死锁的根因和根治:死锁几乎总是因为不同事务以不同的顺序访问相同的资源。例如事务 A 先更新 id=1 再更新 id=2,事务 B 先更新 id=2 再更新 id=1。根治手段是统一资源访问顺序------所有事务都按 id 升序(或降序)更新,从结构上消除循环等待的条件。
应用侧的处理:捕获 40P01 和 40001 错误,实现幂等重试。重试逻辑必须保证幂等性------因为第一次事务可能已经部分执行,重试时要避免重复副作用。
四、长事务:MVCC 体系下最隐蔽的"杀手"
4.1 长事务如何阻止 VACUUM
VACUUM 只能回收所有快照都不再需要的死元组。一个长事务持有旧的快照,意味着在该快照可见的范围内产生的所有死元组都不能被回收------即使它们在物理上已经是垃圾。**长事务的 xmin 会"钉住"全局的 xmin horizon,让 VACUUM 无法推进。
4.2 危害:表膨胀 + 事务 ID 回卷风险
长事务的后果是双重的:
表膨胀:死元组无法回收,表和索引持续膨胀。
XID 回卷风险:VACUUM 无法冻结足够旧的元组,datfrozenxid 年龄持续增长,最终逼近 20 亿的上限。
防御措施:
sql
-- 设置空闲事务超时,自动断开长时间空闲的事务
idle_in_transaction_session_timeout = 60000 -- 60 秒
监控指标:
sql
-- 检查数据库的事务 ID 年龄
SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY xid_age DESC;
-- 检查长时间运行的事务
SELECT pid, state, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'idle in transaction' OR xact_start < now() - INTERVAL '5 minutes';
五、PostgreSQL 19 的并发与运维改进
PostgreSQL 19 在事务和运维方面带来了几项直接相关的改进:
REPACK CONCURRENTLY :此前 REPACK 需要 AccessExclusiveLock,锁表期间业务中断,DBA 只能凌晨操作。PostgreSQL 19 的 CONCURRENTLY 选项让索引重建和表重组在业务正常访问的情况下完成,配合 MVCC 快照和逻辑解码实现零停机。
并行 Autovacuum:Autovacuum 终于可以使用并行 worker 了。此前只有手动 VACUUM 支持并行,大表的自动清理经常"跟不上"膨胀速度。PostgreSQL 19 解除了这个限制,SET autovacuum_max_parallel_workers = 4 即可启用。
Autovacuum 优先级机制:不再按"发现顺序"处理表,而是引入优先级评分,频繁更新的核心表优先清理。pg_stat_autovacuum_scores 视图可以直接查看每张表的清理优先级。
六、小结
MVCC 核心:每个元组携带 xmin(创建者)和 xmax(删除/更新者);读事务通过快照判断可见性;读不阻塞写、写不阻塞读,代价是死元组需要 VACUUM 回收。
事务 ID 回卷:32 位 XID 有限(约 20 亿),VACUUM 的冻结机制是唯一防线;datfrozenxid 年龄必须持续监控。
四种隔离级别,三种实现:Read Uncommitted 被映射为 Read Committed;Repeatable Read 在写冲突时主动报 40001;Serializable 通过 SSI 实现,不加锁读,提交时检测危险结构,应用必须实现 40001 重试。
锁机制:表级锁八种模式(DDL 的 AccessExclusiveLock 会阻塞所有访问);行级锁四种强度(FOR UPDATE 最强);咨询锁用于应用层互斥;死锁由 PostgreSQL 自动检测(40P01),统一资源访问顺序是根治手段。
长事务是最隐蔽的杀手:钉住 xmin horizon,阻止 VACUUM 回收死元组,导致表膨胀和 XID 回卷风险。idle_in_transaction_session_timeout 是必需的防御配置。
PostgreSQL 19 改进:REPACK CONCURRENTLY 实现零停机重组,并行 Autovacuum 解除了大表清理瓶颈,优先级机制让核心表优先清理。