本节摘要:事务能看到哪些元组版本,由它的快照决定。快照记录三个关键事务号:xmin(最早仍活跃的事务)、xmax(下一个未分配的事务号)加上活跃事务列表。每个元组对着这张名单核对 xmin 与 xmax,即可判定可见与否。理解这套判定,第 6 章的隔离级别就不再是背诵题。
事务开始(或每条语句开始,取决于隔离级别)时拍下快照,本质是三样东西:
判定规则用一句话概括:元组的创建者已提交且早于我的视野,它的终结者要么不存在、要么不在我的视野内提交,它就可见。
设快照为 xmin=100、xmax=200、活跃列表含事务 150:
| 元组情况 | 判定 | 原因 |
|---|---|---|
| xmin=80 已提交,xmax=0 | 可见 | 出生于视野之前,无人终结 |
| xmin=150 未提交 | 不可见 | 创建者还在活跃列表里 |
| xmin=120 已提交,xmax=170 已提交 | 不可见 | 快照前已被终结 |
| xmin=210 | 不可见 | 出生于快照之后 |
-- 眼见为实:可重复读下,后续提交对当前事务不存在 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT txid_current_snapshot(); -- 看快照三要素 SELECT count(*) FROM account; -- 另一会话插入并提交新行后,再查 count,数字不变 COMMIT;
快照要保住"我开始时的世界",办法只有一个:不许 VACUUM 回收任何可能对活跃快照可见的死元组。一个跑了几小时的报表查询或忘提交的交互式会话,会让整个数据库的清理工作停摆,死元组堆积、表膨胀、事务 ID 消耗加快(2.5 节)连锁反应全来了。
-- 揪出长事务 SELECT pid, state, xact_start, now() - xact_start AS duration FROM pg_stat_activity WHERE state <> 'idle' AND backend_xid IS NOT NULL ORDER BY xact_start LIMIT 5;
idle in transaction 状态的会话同样持有快照,比跑着的查询更隐蔽——连接池里"借了不还"的会话常处于这个状态。
💡 关键直觉:MVCC 的代价不是锁,而是"旧版本不能删"。任何让旧事务活得更久的习惯,都在给全库的存储与清理埋账单。
读已提交与可重复读的差异,本质只是"快照多久换一张"。两个会话可以完整复现:
-- 会话 A:可重复读,事务级快照 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT count(*) FROM customer;
count ------- 50000
-- 会话 B:插入新行并提交 INSERT INTO customer (name, balance) VALUES ('intruder', 1); -- 回到会话 A 再数: SELECT count(*) FROM customer; -- 仍是 50000 COMMIT; SELECT count(*) FROM customer; -- 提交后新快照,50001
同一条 SELECT 在同一个事务里前后数字不同,不是错觉:第一张快照把插入事务挡在视野外,COMMIT 后拍了新快照世界才更新。把隔离级别去掉重跑,第二次查询立刻是 50001——语句级快照每条语句重拍。
快照三要素可以直接打印出来对照:
SELECT txid_current_snapshot();
txid_current_snapshot ----------------------- 1090:1093:1090,1092
读法:视野下界 1090、上界 1093、活跃列表含 1090 与 1092。任何 xmin 小于 1090 且已提交的元组可见;xmin 等于 1091 的元组若已提交则可见(它不在活跃列表里);xmin 为 1090 或 1092 的一律不可见(创建者还在跑);xmin 大于等于 1093 的是"未来",不存在。这张三元组就是每个事务的世界线。
读已提交加"先 SELECT 再 UPDATE"的组合在并发下会丢数据:两个事务同时读到余额 100,各自加 50 后写回,最终 150 而非 200——后写者覆盖前写者。MVCC 不阻止你写入,它只是让你看不到对方未提交的版本。
两种正统解法。悲观式用行锁把"读与写"绑成原子:
BEGIN; SELECT balance FROM acct WHERE id = 1 FOR UPDATE; -- 锁住这行 -- 应用层计算新值 UPDATE acct SET balance = 150 WHERE id = 1; COMMIT;
乐观式靠单条原子语句,让引擎在最新版本上求值:
UPDATE acct SET balance = balance + 50 WHERE id = 1;
注意第二条在读已提交下会等待对方提交后在新版本上重新求值——这正是语句内 EvalPlanCheck 行为的好处一面。而在可重复读下,单条 UPDATE 若目标行已被并发事务改过并提交,会直接报 could not serialize 异常,应用必须捕获重试。选哪条路取决于冲突频率:低冲突用乐观式省锁开销,高冲突用悲观式省重试成本。
元组 xmin(某版本的出生事务)与快照 xmin(视野下界)重名不同义,前者刻在数据上,后者握在读事务手里。快照 xmax 同理——它是"视野上界",与元组的死亡事务号无关。第三对是事务自己的 txid_current 与快照的关系:只有执行过写操作的事务才真正分配事务号,纯读事务拿到的是虚拟事务号,不消耗 2.5 节那三十二位的宝贵配额——这也是"只读事务不吃事务号"这条优化的来源。辨析清楚这三对名字,读源码资料与日志时就不会在术语里迷路。