2.3 快照与可见性判断:一行数据的生死判定


2.3 快照与可见性判断:一行数据的生死判定

本节摘要:事务能看到哪些元组版本,由它的快照决定。快照记录三个关键事务号:xmin(最早仍活跃的事务)、xmax(下一个未分配的事务号)加上活跃事务列表。每个元组对着这张名单核对 xmin 与 xmax,即可判定可见与否。理解这套判定,第 6 章的隔离级别就不再是背诵题。

快照里装了什么

事务开始(或每条语句开始,取决于隔离级别)时拍下快照,本质是三样东西:

  • 快照 xmin:当前仍活跃的事务里最早的那个的编号——比它更早的事务都已提交
  • 快照 xmax:快照时刻下一个将要分配的事务号——大于等于它的事务一律"还没发生"
  • 活跃列表:介于两者之间、尚未提交的事务编号集合

判定规则用一句话概括:元组的创建者已提交且早于我的视野,它的终结者要么不存在、要么不在我的视野内提交,它就可见

四个典型场景推演

设快照为 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(某版本的出生事务)与快照 xmin(视野下界)重名不同义,前者刻在数据上,后者握在读事务手里。快照 xmax 同理——它是"视野上界",与元组的死亡事务号无关。第三对是事务自己的 txid_current 与快照的关系:只有执行过写操作的事务才真正分配事务号,纯读事务拿到的是虚拟事务号,不消耗 2.5 节那三十二位的宝贵配额——这也是"只读事务不吃事务号"这条优化的来源。辨析清楚这三对名字,读源码资料与日志时就不会在术语里迷路。

本节要点回顾

  • 快照三要素:视野下界、上界、活跃列表,决定事务的世界线
  • 判定两问:创建者是否在视野内提交、终结者是否在视野内提交
  • 可重复读:事务级快照,世界在 BEGIN 那一刻定格
  • 长事务是全局税:它不死,全库的死元组都不能回收

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U