跳到正文

事务隔离与 MVCC:快照、可见性与膨胀 ​

一句话:PostgreSQL 把每个行版本都放在堆表里,用 xmin/xmax 和快照判断可见性;换来读不阻塞写,代价是 Vacuum 和表膨胀。

适用 PostgreSQL 16/17。隔离级别行为以官方文档为准;示例用两个独立连接观察。

为什么需要 ​

并发事务会遇到四类问题:脏读、不可重复读、幻读、序列化异常。数据库用隔离级别和并发控制来限制它们。

PostgreSQL 的选择是 MVCC(多版本并发控制):读不阻塞写,写不阻塞读。它不在原地覆盖行,而是保留多个版本,让每个事务按自己的快照判断「哪一版可见」。这解决了读写冲突,但留下一个问题:旧版本不会自己消失,必须有 Vacuum 清理。

核心机制 ​

隔离级别与默认值 ​

隔离级别脏读不可重复读幻读序列化异常
Read Uncommitted不会可能可能可能
Read Committed(默认)不会可能可能可能
Repeatable Read不会不会不会*可能
Serializable不会不会不会不会

PostgreSQL 不实现真正的 Read Uncommitted,它等同 Read Committed。注意默认值:PostgreSQL 默认 Read Committed,MySQL InnoDB 默认 Repeatable Read,迁移或对比时不要写反。

* PostgreSQL 的 RR 用事务级快照,重复执行同一查询不会看到新插入的行,所以标准定义的幻读不出现;但这不等于可串行化,写偏斜(write skew)仍可能发生,需要 Serializable。

行头与可见性 ​

每个堆元组(行版本)头部有:

  • xmin:创建该版本的事务 ID。
  • xmax:删除/锁定该版本的事务 ID;0 表示未删除,行锁也可能让它指向多事务 ID(MultiXact)。
  • cid / ctid:事务内命令序号 / 物理位置 (block, offset);ctid 不是稳定的业务主键。
  • t_infomask:记录提交状态、是否冻结等标志。

简化后的可见条件是「xmin 对当前快照可见,且 xmax=0 或 xmax 对当前快照不可见」。快照包含 xmin、xmax 和活跃事务列表 xip;一个事务能看到「在快照建立前已提交」且「不在活跃列表里」的版本。

版本链与索引指针(常见误区) ​

UPDATE 不是原地修改,而是插入一个新版本,并把旧版本的 xmax 标记为当前事务。旧版本留在堆表里,等待 Vacuum。

关键点:索引项指向具体的元组版本(ctid),不是永远指向最新版本。

  • 普通 UPDATE:如果更新了索引列,PostgreSQL 会为新版本插入新的索引项;旧索引项仍指向旧版本。
  • HOT UPDATE:如果更新的列没有被任何索引引用,且新版本能放在同一页,PostgreSQL 不新增索引项;旧元组的 t_ctid 指向新版本,索引扫描沿 HOT 链找到新版本。
  • 索引扫描拿到 ctid 后,仍要回堆检查可见性;不可见就沿版本链继续找。不是「索引永远指向最新版,再回溯」。ctid 会随 UPDATE 改变,VACUUM FULL/pg_repack 也会改变物理位置。

Read Committed 与 Repeatable Read 的快照时机 ​

维度Read CommittedRepeatable Read
快照获取每条语句开始时事务第一条查询时
同一事务多次查询可能看到不同结果看到同一快照
并发更新同一行等待后基于最新版本更新可能报 could not serialize access
幻读可能标准意义下不会
写偏斜可能可能

RR 下,如果目标行在你的事务开始后被其他事务修改并提交,UPDATE/DELETE 会收到 ERROR: could not serialize access due to concurrent update;应用必须捕获 40001 并重试整个事务,不能只重试这条 SQL。

Serializable 与 SSI ​

Serializable 建立在快照隔离之上,用 SSI(Serializable Snapshot Isolation)跟踪读写冲突:

  • 用 SIREAD 谓词锁标记「这个事务读过这个范围」,它不阻塞写。
  • 检测 rw-conflict(读-写)和 wr-conflict(写-读),在提交时检查依赖环。
  • 发现环就中止一个事务,返回 40001。

代价是冲突率上升,应用要能重试。适合冲突少、需要强一致的业务,不适合高竞争写入。

Vacuum 与事务 ID 回卷 ​

Vacuum 做三件事:回收死元组、清理索引中的死条目、冻结老事务 ID。autovacuum 还会顺带 ANALYZE 更新统计信息。

触发阈值是 autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * n_live_tup,默认 50 + 0.2 * 行数(autovacuum_vacuum_scale_factor=0.2,PostgreSQL 16/17)。大表按比例触发太晚,常按表调小到 0.01。

事务 ID 是 32 位,约 40 亿个;可见性比较按 20 亿的安全余量设计。老元组必须被冻结(FrozenTransactionId=2),否则回卷后可见性会混乱,数据库会进入保护性只读。监控:

sql
SELECT datname, age(datfrozenxid) AS freeze_age
FROM pg_database
ORDER BY freeze_age DESC;

autovacuum_freeze_max_age=200000000(2 亿)是强制 autovacuum 的阈值;vacuum_freeze_min_age=50000000、vacuum_freeze_table_age=150000000 控制冻结节奏(版本差异以官方文档为准)。

最小上手 ​

用两个独立连接(psql 两个窗口):

sql
-- 会话 A:准备数据
DROP TABLE IF EXISTS account;
CREATE TABLE account (id int PRIMARY KEY, balance numeric NOT NULL);
INSERT INTO account VALUES (1, 1000);

-- 会话 A:RR 快照
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM account WHERE id = 1;   -- 1000

-- 会话 B:另一个连接,先改并提交
UPDATE account SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- 会话 A:仍是 1000;再更新会报序列化失败
SELECT * FROM account WHERE id = 1;
UPDATE account SET balance = balance + 1 WHERE id = 1;  -- 40001
ROLLBACK;

观察与清理:SELECT xmin, xmax, ctid, * FROM account WHERE id = 1;、SELECT pg_current_snapshot();、VACUUM (VERBOSE, ANALYZE) account;;死元组看 pg_stat_user_tables.n_dead_tup,冻结年龄看 age(datfrozenxid)。

生产动作 ​

  • 长事务是 Vacuum 的天敌:pg_stat_activity 里 xact_start 很早、state='idle in transaction' 的会话会阻止死元组回收。设置 idle_in_transaction_session_timeout(默认 0,建议 30–60 秒,按业务调整),并监控 pg_prepared_xacts。
  • 膨胀监控:n_dead_tup / (n_live_tup + n_dead_tup) 超过 20% 关注;大表按表设置 autovacuum_vacuum_scale_factor=0.01,必要时 pg_repack。VACUUM FULL 拿 ACCESS EXCLUSIVE,不要在高峰做。
  • 回卷监控:age(datfrozenxid) 接近 2 亿就安排 VACUUM FREEZE,不要等数据库进入只读保护。
  • 重试逻辑:RR/Serializable 下捕获 40001(序列化失败)和 40P01(死锁),整个事务重试;重试要有次数上限和退避。
  • 隔离级别按需选:默认 RC 适合大多数 OLTP;需要事务内一致读用 RR;需要防写偏斜用 Serializable。不要全局改成 RR 来「更安全」,它会带来序列化失败和更多重试。
  • 排障路径:现象「表只涨不降、DELETE 后空间不回收」→ pg_stat_user_tables 看 n_dead_tup 和 last_autovacuum → pg_stat_activity 看长事务 → 先结束长事务,再调 autovacuum,最后才考虑 pg_repack。

边界 ​

  • RR 不是 Serializable:它防标准幻读,但不防写偏斜;需要可串行化时用 Serializable 并接受重试。
  • 没有间隙锁:PostgreSQL 不用 next-key lock 防幻读,靠快照;并发插入不会被间隙锁阻塞,但写冲突可能以序列化失败的形式出现。
  • 快照不是「实时最新」:RR 下看到的是事务开始时的数据;需要最新值用 RC 或 READ COMMITTED 的单语句。
  • Vacuum 不是可选项:它同时负责空间回收和事务 ID 冻结;关闭 autovacuum 只是把故障推迟。
  • ctid 不是主键:UPDATE、VACUUM FULL、pg_repack 都会改变物理位置;用它做游标要重新取。
  • 索引不是「永远指向最新版」:普通更新会新增索引项,HOT 更新才复用索引项并沿 t_ctid 链找新版本;诊断时要看实际执行计划和行头,不要凭印象。

讲清楚 ​

PostgreSQL 的 MVCC 是「堆内多版本」:UPDATE 插入新行版本,旧版本留在堆表里,行头的 xmin/xmax 记录创建和删除事务,快照决定哪一版可见。读不阻塞写,代价是旧版本要靠 Vacuum 回收,长事务会让表膨胀,事务 ID 还要定期冻结防回卷。RC 每条语句取新快照,RR 整个事务一个快照,所以 RR 下重复查询看不到新插入的行,标准幻读不出现;但 RR 不是 Serializable,写偏斜仍可能发生,并发更新同一行还可能直接报序列化失败,应用要重试。另一个常见误区是「索引永远指向最新版本」:普通更新会新增索引项,只有 HOT 更新才复用索引项并沿链找新版本。生产上最该盯的是长事务、死元组比例和 age(datfrozenxid),而不是把隔离级别一律调高。

把一门技术讲透到既懂也会用