四:MVCC 深度解析:三事务并发全流程
PostgreSQL MVCC 深度解析三事务并发全流程本文以三个并发事务为主线逐步骤、逐字段拆解 PostgreSQL MVCC多版本并发控制的完整工作过程。涵盖 INSERT/UPDATE/SELECT 时页面字段的逐一变化、快照的精确比较规则、提示位写回机制、两种隔离级别的行为差异以及与 MySQL MVCC 的本质对比。文章目录PostgreSQL MVCC 深度解析三事务并发全流程1. MVCC 的核心思想2. 场景设定三个事务3. 关键数据结构tuple 头部字段4. 步骤一T1 执行 INSERT5. 步骤二T1 COMMIT6. 步骤三T2 执行 UPDATE7. 步骤四T2 COMMIT8. 步骤五T3 BEGIN获取快照9. 步骤六T3 执行 SELECT可见性逐 tuple 判断9.1 判断 Tuple (0,1)xmin100xmax1019.2 判断 Tuple (0,2)xmin101xmax09.3 可见性判断完整伪代码10. 提示位Hint Bits写回机制10.1 为什么需要提示位10.2 写回时机10.3 提示位写回与 WAL 的关系10.4 三种访问路径速度对比11. 两种隔离级别下 T3 的行为差异11.1 Read Committed默认隔离级别11.2 Repeatable Read11.3 快照获取时机对比12. ROLLBACK 场景T2 回滚后 T3 看到什么13. 与 MySQL InnoDB MVCC 的本质对比14. 总结MVCC 的代价与收益收益代价运维建议1. MVCC 的核心思想MVCC 要解决的核心问题是读写操作如何互不阻塞。传统锁方案读加共享锁写加排他锁读和写之间互相等待。MVCC 的方案写操作产生数据的新版本读操作读旧版本读和写看的是不同时间点的数据互不阻塞。PostgreSQL 实现 MVCC 的方式叫Append-Only追加写INSERT在页面追加一个新 tupleUPDATE不修改旧 tuple在旧 tuple 头部打删除标记再追加新 tupleDELETE不删除数据只在 tuple 头部打删除标记SELECT根据事务的快照Snapshot决定看哪个版本这与 MySQL InnoDB 不同。InnoDB 是原地修改 Undo LogUPDATE 直接覆盖数据页旧版本存入独立的 Undo Log 文件。PostgreSQL 的旧版本就地保留在堆表页面直到 VACUUM 清理。2. 场景设定三个事务-- 建表CREATETABLEaccounts(idinteger,nametext,balancenumeric);-- T1XID100INSERT 一行然后提交BEGIN;-- T1INSERTINTOaccountsVALUES(1,Alice,500);COMMIT;-- T2XID101UPDATE 这行然后提交BEGIN;-- T2UPDATEaccountsSETbalance800WHEREid1;COMMIT;-- T3XID102长事务执行 SELECTBEGIN;-- T3Repeatable Read 隔离级别SELECT*FROMaccountsWHEREid1;-- ... T3 仍在运行 ...三个事务的 XID 分别是 100、101、102依次递增这是 PostgreSQL 全局单调递增的事务 ID 分配机制保证的。3. 关键数据结构tuple 头部字段每个 HeapTuple堆元组的头部HeapTupleHeader包含以下 MVCC 相关字段HeapTupleHeader 布局最小 23 字节 偏移 0 4 8 14 18 19 23 -------------------------------------------- |t_xmin|t_xmax|t_ctid|t_infom|t_off|nullbm| 用户数据| -------------------------------------------- 4字节 4字节 6字节 4字节 1字节 可变字段大小含义t_xmin4 字节创建此版本的事务 XIDINSERT/UPDATE 新版本时设置t_xmax4 字节删除此版本的事务 XIDDELETE/UPDATE 旧版本时设置0 未删除t_ctid6 字节(页号, 槽号)格式对最新版本指向自身UPDATE 后旧版本指向新版本t_infomask22 字节信息位含提示位hint bits缓存事务状态避免重复查 CLOGt_hoff1 字节头部总长度用户数据从此偏移开始null bitmap可变每列一 bit标记 NULL 列仅当 HEAP_HASNULL 设置时存在infomask 中最重要的四个提示位bit 掩码名称含义0x0100XMIN_COMMITTEDxmin 事务已提交缓存结论避免查 CLOG0x0200XMIN_INVALIDxmin 事务已中止或为虚拟 ID0x0400XMAX_COMMITTEDxmax 事务已提交此版本已被成功删除0x0800XMAX_INVALIDxmax 无效值为 0 或事务已中止表示行未被删除4. 步骤一T1 执行 INSERTBEGIN;INSERTINTOaccountsVALUES(1,Alice,500);-- XID 100PostgreSQL 执行 INSERT 时内部发生了什么第一步分配事务 XID执行第一条写操作时从全局 XID 计数器位于共享内存分配一个新 XID 100写入当前 backend 进程的 ProcArray 条目表示XID100 的事务正在运行。第二步查询 FSM 找目标页FSM空闲空间映射文件以近似精度记录了每个页面的可用空间。查 FSM 得到 Page 0 有足够空间将 Page 0 载入shared_buffers。第三步在页面内分配空间pd_upper向低地址移动tuple_size字节pd_lower向高地址移动 4 字节一个 ItemId。分配槽位 1lp_flags 1normallp_off指向 tuple 的起始偏移。第四步写入 tuple 字段t_xmin 100 // 我是 T1 创建的 t_xmax 0 // 尚未被删除 t_ctid (0, 1) // 指向自身(页号0, 槽号1) infomask XMAX_INVALID 1 // xmax0 无效表示行活跃 XMIN_COMMITTED 0 // 提示位未设T1 尚未提交 XMIN_INVALID 0 HEAP_HASVARWIDTH 1 // 有变长列text, numeric t_hoff 24 // 头部 24 字节无 null bitmap数据对齐后第五步写用户数据从t_hoff偏移处开始按列顺序写入id 1int44 字节4 字节对齐name Alicetextvarlena 头1 字节 5 字节数据 6 字节balance 500numeric变长约 8~16 字节第六步写 WAL将上述页面修改新 tuple 的内容写入 WALWrite-Ahead Log确保在 COMMIT 之前日志已持久化。这保证了即使在 COMMIT 后立即断电重启时也能通过重放 WAL 恢复。5. 步骤二T1 COMMITCOMMIT;-- T1 提交COMMIT 的物理实现极其轻量只做一件事在 CLOG 文件PGDATA/pg_xact/目录即提交日志中将 XID100 对应的 2 bit 从00进行中改为01已提交。CLOG 中 XID100 的 2 bit00 → 01已提交COMMIT 不修改任何数据页面。页面中 tuple 的XMIN_COMMITTED提示位此时仍为 0。这是 PostgreSQL 设计上的权衡若 COMMIT 时遍历所有被修改的页面写回提示位代价太高可能已不在 buffer cache 中。从 ProcArray 中移除 XID100其他事务此后不再能在 ProcArray 中找到它。6. 步骤三T2 执行 UPDATEBEGIN;UPDATEaccountsSETbalance800WHEREid1;-- XID 101T2 的 UPDATE 等价于标记删除旧版本 插入新版本在页面内产生两个 tuple。第一步找到目标 tuple通过全表扫描或索引找到 tuple(0,1)发现t_xmax 0且XMAX_INVALID 1确认此行未被删除是当前有效版本。同时还要确认t_xmin 100对应的 T1 已提交检查 XMIN_COMMITTED 提示位为 0未设则查 CLOG发现 XID100 已提交将XMIN_COMMITTED写回 infomask这就是提示位的写回时机。第二步修改旧 tuple(0,1)t_xmax 101 // T2 标记删除此版本 infomask XMAX_INVALID 0 // 清除 invalid 标记xmax 现在有意义 XMAX_COMMITTED 0 // T2 尚未提交先不设置 XMIN_COMMITTED 1 // 刚才写回的提示位 t_ctid (0, 2) // 改写指向即将写入的新版本注意t_ctid从(0,1)指向自身改写为(0,2)指向新版本这是版本链的建立时刻。第三步追加新 tuple(0,2)在同一页面若有空间或新页面追加新 tuplet_xmin 101 // T2 创建此版本 t_xmax 0 // 未删除 t_ctid (0, 2) // 指向自身 infomask XMIN_COMMITTED 0 // T2 尚未提交 XMAX_INVALID 1 // xmax 无效未删除 HEAP_UPDATED 1 // 标记此 tuple 由 UPDATE 产生非首次插入 用户数据 id 1, name Alice, balance 800 // balance 已更新第四步更新索引若balance列有索引则在索引中新增条目(800, TID(0,2))旧条目(500, TID(0,1))暂时保留VACUUM 清理时再删。若id列有索引balance 没变化而只是修改了 balance且满足 HOT 条件同页且被修改列无索引则不需要修改任何索引走 HOT 路径。7. 步骤四T2 COMMITCOMMIT;-- T2 提交同 T1 COMMIT在 CLOG 中将 XID101 的 2 bit 置为01已提交从 ProcArray 移除数据页面不变。此时页面状态如下页面内完整状态T2 COMMIT 后Page 0 页头lower160 upper7980 special8192 槽1normal offset8116 len44 槽2normal offset8072 len44 Tuple (0,1) Tuple (0,2) t_xmin 100 t_xmin 101 t_xmax 101 t_xmax 0 t_ctid (0, 2) ←版本链 t_ctid (0, 2) ←自身 infomask infomask XMIN_COMMITTED 1 XMIN_COMMITTED 0 ←待写回 XMAX_COMMITTED 0 ←待写回 XMAX_INVALID 1 data: balance500旧 data: balance800新两个 tuple 物理共存。旧 tuple 并没有被标记 “废弃” 的特殊位它的死活完全由 xmin/xmax 的事务状态决定这正是 MVCC 的精髓。8. 步骤五T3 BEGIN获取快照BEGINISOLATIONLEVELREPEATABLEREAD;-- T3 第一条语句执行时获取快照SELECTpg_current_snapshot();-- 输出102:103:102快照的三个字段xmin 102 最老活跃事务 XID。 所有 XID 102 的事务要么已提交要么已中止均已完成。 T1(100) 和 T2(101) 均 102所以它们的结果都有机会可见。 xmax 103 当前已分配的最大 XID 1即下一个将被分配的 XID。 所有 XID 103 的事务尚未开始或刚开始对当前快照不可见。 xip_list [102] 快照创建时所有活跃事务 XID 的列表排除虚拟事务。 102 是 T3 自己虽然在 [xmin,xmax) 范围内但在 xip_list 中 所以 T3 自己的修改遵循特殊规则由 cmin/cmax 控制。快照是怎么获取的PostgreSQL 加一把短暂的 ProcArray 锁读取当前所有活跃后端进程的 XID 列表构造上述三元组存储在当前进程的内存中。整个过程在共享内存中完成极快。9. 步骤六T3 执行 SELECT可见性逐 tuple 判断SELECT*FROMaccountsWHEREid1;执行引擎对 Page 0 发起全表扫描无索引时逐个 tuple 判断可见性9.1 判断 Tuple (0,1)xmin100xmax101第一步判断 xmin100 是否可见检查 infomaskXMIN_COMMITTEDbit为 1之前 T2 读取时写回的提示位直接结论xmin100 已提交跳过 CLOG 查询第二步将 xmin100 与快照比较100 xmin(102)是说明 T1 在快照获取之前就已经完成结论T1 的修改在快照范围内第三步判断 xmax101 是否可见检查 infomaskXMAX_COMMITTEDbit为 0T2 提交了但提示位尚未写回XID101 在 xip_list [102] 中不在查 CLOGXID101 状态为已提交将XMAX_COMMITTED 1写回 tuple 的 infomask提示位写回产生脏页第四步判断 xmax101 对快照是否可见101 xmax(103)是101在 xip_list [102] 中不在结论xmax101T2 的删除操作在快照范围内即这行的删除对 T3 可见最终结论xmin 可见 xmax 可见 该 tuple 已被删除T3 不可见跳过 Tuple(0,1)不返回。9.2 判断 Tuple (0,2)xmin101xmax0第一步判断 xmin101 是否可见检查 infomaskXMIN_COMMITTEDbit为 0提示位未设XID101 在 xip_list [102] 中不在101 xmax(103)是XID101 在快照范围内查 CLOGXID101 已提交将XMIN_COMMITTED 1写回 infomask第二步判断 xmax0 是否有效检查 infomaskXMAX_INVALIDbit为 1表示 xmax 无效未被删除。最终结论xmin 可见 xmax 无效未删除 Tuple(0,2) 对 T3 可见返回数据id1, name‘Alice’, balance8009.3 可见性判断完整伪代码defis_tuple_visible(tuple,snapshot):# 步骤 1判断 xminiftuple.infomask.XMIN_COMMITTED:xmin_visibleTrue# 提示位已设直接用eliftuple.infomask.XMIN_INVALID:returnFalse# xmin 已中止此版本从未存在eliftuple.xmininProcArray.active_xids:returnFalse# xmin 仍活跃未提交else:statusCLOG.get(tuple.xmin)ifstatusCOMMITTED:tuple.infomask.XMIN_COMMITTED1# 写回提示位xmin_visibleTrueelse:tuple.infomask.XMIN_INVALID1# 写回提示位returnFalse# xmin 可见继续判断 xmax# 步骤 2判断 xmaxiftuple.xmax0ortuple.infomask.XMAX_INVALID:returnTrue# 未删除可见iftuple.infomask.XMAX_COMMITTED:xmax_in_snapshotis_xid_visible_in_snapshot(tuple.xmax,snapshot)returnnotxmax_in_snapshot# xmax 已提交且在快照范围内 已删除else:iftuple.xmaxinProcArray.active_xids:returnTrue# xmax 仍活跃删除未提交行仍可见statusCLOG.get(tuple.xmax)ifstatusCOMMITTED:tuple.infomask.XMAX_COMMITTED1returnnotis_xid_visible_in_snapshot(tuple.xmax,snapshot)else:tuple.infomask.XMAX_INVALID1returnTrue# xmax 已中止删除失败行仍可见defis_xid_visible_in_snapshot(xid,snapshot):# xid 是否在快照的可见范围内ifxidsnapshot.xmin:returnTrue# 快照创建前已完成ifxidsnapshot.xmax:returnFalse# 快照创建后才开始ifxidinsnapshot.xip_list:returnFalse# 快照创建时仍活跃returnTrue# 在范围内且已完成10. 提示位Hint Bits写回机制10.1 为什么需要提示位判断 tuple 可见性时若每次都去查 CLOG磁盘上的文件虽然有缓存代价较高。提示位是将 CLOG 查询结果缓存在 tuple 头部的机制一旦某进程确认了某 XID 的最终状态就将结论写回 tuple 的 infomask后续所有进程直接读 bit无需再查 CLOG。10.2 写回时机提示位不在 COMMIT 时写而在任意进程首次发现某事务已完成时写可以是另一个事务的 SELECT可以是 autovacuum 进程可以是 VACUUM这意味着只读 SELECT 可能产生脏页只改了 infomask页面内容变了这是 PostgreSQL 特有的行为。10.3 提示位写回与 WAL 的关系修改提示位的页面被标记为脏页但这个修改不保证立刻写 WAL。若此时系统崩溃重启后页面被恢复到提示位未设置的状态但这没有任何问题——下次访问时重新查 CLOG 即可结果一致。若开启了data_checksums提示位的改变会触发 checksum 重算否则会检测到损坏因为 checksum 对应的是旧数据。这是 PostgreSQL 需要full_page_writes的原因之一。10.4 三种访问路径速度对比提示位已设 → 直接读 infomask O(1)最快 提示位未设 → 查 ProcArray共享内存 O(n_active_backends) 提示位未设 → 查 CLOG文件有缓存 磁盘 or 内存缓存较慢11. 两种隔离级别下 T3 的行为差异11.1 Read Committed默认隔离级别BEGIN;-- 默认 Read Committed-- 此时 T2 尚未提交balance 还是 500SELECTbalanceFROMaccountsWHEREid1;-- 获取快照 S1: xmin101, xmax102, xip[101]-- T2(101) 在 xip_list 中 → 其修改不可见-- 结果balance 500-- T2 此时 COMMIT-- 下一条 SQL 重新获取快照SELECTbalanceFROMaccountsWHEREid1;-- 获取快照 S2: xmin102, xmax103, xip[102]-- T2(101) xmax(103) 且不在 xip_list → 可见-- 结果balance 800同一事务内两次读到不同结果——不可重复读。这是 Read Committed 允许的现象也是它默认快照策略每条 SQL 重新获取的直接结果。11.2 Repeatable ReadBEGINISOLATIONLEVELREPEATABLEREAD;-- T3 开始执行第一条语句时获取一次快照-- 假设此时 T2 已提交SELECTbalanceFROMaccountsWHEREid1;-- 获取快照: xmin102, xmax103, xip[102]-- T2(101) xmax(103) 且不在 xip_list → 可见-- 结果balance 800-- 即使此后有其他事务 UPDATE balance1000 并提交SELECTbalanceFROMaccountsWHEREid1;-- 同一快照不变新 XID xmax(103) → 不可见-- 结果仍是balance 800可重复读保证整个事务共用一个快照快照之后的任何提交对当前事务均不可见保证了可重复读。11.3 快照获取时机对比隔离级别快照获取时机快照数量每事务Read Committed每条 SQL 语句执行前多个每条语句一个Repeatable Read事务内第一条语句执行前1 个整个事务共用Serializable同 Repeatable Read额外有冲突检测1 个 序列化图追踪12. ROLLBACK 场景T2 回滚后 T3 看到什么假设 T2 执行 UPDATE 后回滚而不是提交BEGIN;-- T2, XID101UPDATEaccountsSETbalance800WHEREid1;ROLLBACK;-- 回滚ROLLBACK 的物理实现与 COMMIT 类似同样只写 CLOG将 XID101 的 2 bit 从00改为10已中止。不回滚任何数据页面。此时页面状态Tuple (0,1): t_xmin100 t_xmax101 t_ctid(0,2) Tuple (0,2): t_xmin101 t_xmax0 t_ctid(0,2)页面数据与 T2 提交时完全相同区别只在 CLOG。T3 此时执行 SELECT判断 Tuple(0,1)xmin100 已提交XMIN_COMMITTED1✓xmax101查 CLOGXID101 状态为aborted已中止写回提示位XMAX_INVALID 1中止的删除等同于没删xmax 无效 → Tuple(0,1) 对 T3可见返回 balance500判断 Tuple(0,2)xmin101查 CLOGXID101 已中止写回提示位XMIN_INVALID 1xmin 已中止 → Tuple(0,2) 对 T3不可见T3 看到balance500T2 的修改如同从未发生13. 与 MySQL InnoDB MVCC 的本质对比维度PostgreSQLMySQL InnoDB旧版本存放位置堆表页面与新版本共存独立的 Undo Log 文件UPDATE 操作Append-Only追加新 tuple旧 tuple 打 xmax 标记原地修改数据页旧值写入 Undo LogROLLBACK 操作只改 CLOG2 bit数据页不变从 Undo Log 还原数据页旧版本清理VACUUM 定期扫描并清理Purge 线程从 Undo Log 头部异步清理表膨胀风险存在UPDATE/DELETE 频繁时旧版本堆积不存在旧版本在 Undo Log不占表空间COMMIT 代价极小只写 CLOG 2 bit略大需刷 Undo Log、redo log 等可见性判断xmin/xmax 嵌在 tuple 头结合快照三元组read view 结合 Undo Log 版本链回溯版本链方向ctid 从旧→新正向链DB_ROLL_PTR 从新→旧反向链需回溯 Undo长事务影响阻塞数据库视界VACUUM 无法清理相关页面持有 read viewUndo Log 无法被 Purge 清理提示位机制有hint bits读操作可写脏页无可见性信息在 Undo Log 版本链中核心差异一句话总结PostgreSQL“数据留原地靠快照过滤靠 VACUUM 清扫”MySQL InnoDB“数据原地改旧版本外置靠 Undo 回溯靠 Purge 清扫”14. 总结MVCC 的代价与收益收益读不阻塞写写不阻塞读SELECT 不需要任何锁直接读取对应版本写操作不影响正在进行中的 SELECT。这是高并发 OLTP 场景的基础。COMMIT/ROLLBACK 极快无论修改多少行COMMIT 只写 CLOG 的几个 bitROLLBACK 同样只写 CLOG数据页面不变。崩溃恢复简单通过 WAL 重放就能恢复一致性不需要回滚未提交事务的数据页因为数据页中未提交事务的修改可以通过 CLOG 识别后忽略。代价表膨胀Table BloatUPDATE/DELETE 频繁的表旧版本 tuple 会不断堆积若 autovacuum 来不及清理表文件会持续增大影响顺序扫描性能。长事务阻塞清理长事务持有老快照数据库视界oldest xmin无法前进VACUUM 无法清理该视界范围内的任何死 tuple可能引发严重膨胀。写放大每次 UPDATE 都产生新 tuple 和新索引条目比原地修改消耗更多 I/OHOT 更新可缓解部分场景。只读 SELECT 也可能写脏页提示位写回机制导致 SELECT 操作修改 buffer对某些场景如只读副本或存储层优化需要特别考虑。运维建议-- 监控死元组数量判断膨胀风险SELECTrelname,n_dead_tup,n_live_tup,round(n_dead_tup*100.0/nullif(n_live_tupn_dead_tup,0),2)ASdead_ratioFROMpg_stat_user_tablesORDERBYn_dead_tupDESC;-- 监控长事务阻塞 VACUUM 的主要原因SELECTpid,now()-pg_stat_activity.query_startASduration,query,stateFROMpg_stat_activityWHEREstate!idleAND(now()-pg_stat_activity.query_start)interval5 minutes;-- 查看数据库视界horizon视界越老说明 VACUUM 越受阻SELECTdatname,age(datfrozenxid)ASxid_ageFROMpg_databaseORDERBYxid_ageDESC;-- 手动触发 VACUUM 分析特定表VACUUMANALYZEaccounts;