MVCC 底层实现与并发控制
校招考察频度:★ 高频(核心必背·高频生死线) 一句话速记:堆内存版本无回滚段,更新即插新元组;xmin 记生 xmax 记死,快照数组定边界;CLOG 位图判提交,Hint Bits 首读写脏页。
MVCC(Multi-Version Concurrency Control,多版本并发控制) 是关系型数据库支持高并发读写的核心引擎。其终极使命是:实现普通读不加锁,达成“读不阻塞写,写不阻塞读”的高吞吐并发状态。
PostgreSQL 采用了独特的**堆内多版本(In-heap Multi-Versioning)**架构。理解这一实现不仅是校招面试中的硬核高分点,也是排查线上表膨胀、慢查询与参数调优的理论根基。
一、PostgreSQL 堆内多版本架构本质
很多同学容易混淆不同数据库对旧版本的处理方式。面试官非常喜欢问:“PostgreSQL 的旧版本数据到底存放在哪里?它有类似回滚段(Undo Log)的结构吗?”
核心结论:PostgreSQL 没有 Undo Log(回滚日志)!旧版本数据与最新数据全部存放在数据堆表(Heap Table)的同一个数据页内!
1. 增删改的底层物理行为
INSERT:向堆表中写入一个新元组,并在元组头部记录当前事务 ID 为创建者(xmin = 当前事务 ID)。DELETE:绝不立刻物理抹去数据,而是将该元组头部的删除者字段(xmax)设置为当前事务 ID,标记该版本已“逻辑死亡”。UPDATE:等价于 “旧版本标记删除 + 新版本插入”:- 将旧元组的
xmax设置为当前事务 ID; - 在同一个数据页(或新页)中插入一条包含更新后数值的新元组,其
xmin设为当前事务 ID; - 旧元组的指针(
t_ctid)指向新元组的物理坐标,形成版本链。
- 将旧元组的
堆内多版本链:新旧版本共存,靠 xmin 与 xmax 标识生命周期
二、元组头部(HeapTupleHeader)的 4 大核心隐藏字段
每一行元组在磁盘物理存储时,除了用户定义的业务字段,都在元组头部分配了 23 字节以上的系统信息:
| 隐藏字段 | 物理长度 | 核心职责 | 面试考察细节 |
|---|---|---|---|
t_xmin | 4 字节(32 位整数) | 插入或创建该元组的事务 ID(XID)。 | 标志元组的“出生点”。 |
t_xmax | 4 字节(32 位整数) | 删除或更新该元组的事务 ID。 | 标志元组的“死亡点”;若元组有效且未被删除,此值为 0。此外,当行被 FOR UPDATE 加锁时,加锁事务 ID 也会暂存在这里。 |
t_cid | 4 字节 | Command ID(命令标识符)。 | 记录同一事务内部 SQL 语句的执行顺序(从 0 递增)。解决“同一个事务内部先 INSERT 一行再 SELECT 自身时能否看见”的可见性判断。 |
t_ctid | 6 字节 | 物理位置指针(ItemPointer)。 | 指向当前元组的最新版本物理位置(页号 + 槽号)。若未被更新,则指向元组自己;若被更新,则指向更新后的下一代元组。 |
t_infomask | 2 字节 | 状态掩码位图。 | 保存当前元组的提交状态与 Hint Bits(提示位),避免频繁查磁盘 CLOG。 |
三、快照(Snapshot)数据结构与可见性判断算法
版本链中记录了新旧不同的多个元组,那么当前查询执行时,究竟哪些元组可见?哪些元组不可见? 这由查询时刻创建的 快照(Snapshot) 决定!
1. 快照的数据结构:xmin:xmax:xip_list
在 PostgreSQL 内部,一个快照主要由三部分构成(例如快照 100:120:102,105):
snapshot.xmin(活跃最小事务 ID):- 快照生成瞬间,系统中所有活跃(未提交)事务中最小的那个 ID。
- 规则:所有
XID < xmin的事务,在快照生成时早已全部提交或回滚完毕。因此只要创建事务xmin_tuple < snapshot.xmin,且该事务已提交,则一定可见!
snapshot.xmax(预备新事务 ID):- 快照生成瞬间,系统分配给下一个新事务的事务 ID(注意:不是最大活跃 ID,而是最大已分配 ID + 1)。
- 规则:所有
XID >= xmax的事务都是在快照生成之后才开启的全新事务,对当前快照绝对不可见!
xip_list(活跃事务列表):- 快照生成瞬间,处于
snapshot.xmin与snapshot.xmax之间,当前正在并发执行但尚未提交的所有活跃事务 ID 集合。 - 规则:落在
xip_list数组里的事务,由于未提交,对当前快照不可见;不在数组里的事务,说明快照生成前早已提交,可见!
- 快照生成瞬间,处于
快照可见性判断区间切分
2. 完整可见性判定口诀(大厂面试高分推导)
当执行引擎扫描到一个元组时,按以下优先级逐层比对:
- 自己改的自己看:若
t_xmin == my_xid,说明是本事务创建的:- 若
t_xmax == my_xid,说明在本事务内部又被删除了,不可见; - 若
t_cid < my_cid,说明在当前查询之前创建,可见;否则不可见。
- 若
- 未来事务不可见:若
t_xmin >= snapshot.xmax,该元组创建于快照之后,不可见。 - 活跃事务不可见:若
t_xmin在xip_list活跃列表中,说明快照创建时还没提交,不可见。 - 已提交事务判断存活状态:若
t_xmin已经提交且在快照中可见:- 检查
t_xmax:若t_xmax == 0(未被删除),元组可见! - 若
t_xmax不为 0:检查删除它的事务状态:- 若
t_xmax对应的删除事务尚未提交,或者其删除动作在当前快照中不可见(处于未来或活跃),则元组依然有效且可见; - 若
t_xmax对应的删除事务已提交且早于当前快照,则说明行已被删除,不可见!
- 若
- 检查
四、CLOG(pg_xact)与 Hint Bits 黑科技(高分分水岭)
面试官极深追问:“判定可见性时,PostgreSQL 是怎么知道一个事务究竟是提交(COMMITTED)还是回滚(ABORTED)的?每次判断都要查日志吗?”
1. CLOG(Commit Log)
- 位于
pg_xact/目录下,在共享内存中开辟有缓存区; - 采用极致压缩的位图存储:每个事务仅占 2 个 bit:
00:IN_PROGRESS(正在进行中)01:COMMITTED(已提交)10:ABORTED(已回滚)11:SUB_COMMITTED(子事务已提交)
2. Hint Bits(提示位)机制与“只读查询写脏页”之谜
- 性能痛点:数据库每秒可能有数十万次元组读取。如果每次读取元组都要去访问 CLOG 共享内存判断状态,CLOG 锁争用与 CPU 缓存行失效将直接锁死吞吐!
- Hint Bits 的巧妙设计:
- 当第一个查询读到某元组时,发现元组头部的状态位尚未打标,它会去查一次 CLOG,得知创建者
xmin已经提交; - 该查询会在当前元组头部的
t_infomask中直接打上标志位:HEAP_XMIN_COMMITTED; - 后续所有其他查询读到该元组时,一看到这个 Hint Bit,再也不用去查 CLOG 了,直接确认已提交!
- 当第一个查询读到某元组时,发现元组头部的状态位尚未打标,它会去查一次 CLOG,得知创建者
- 经典面试题:“为什么在 PostgreSQL 中,执行纯 SELECT 只读查询,也会导致 Buffer Pool 中的数据页变脏(Dirty Buffer),并产生写盘操作?”
- 答案:正是因为首次读取未打标的元组时,查询进程就地修改了元组头部的 Hint Bits,导致包含该元组的数据页被标记为脏页,后续后台写进程(bgwriter/checkpointer)必须将其刷回磁盘!
五、PostgreSQL 堆内 MVCC 的利弊权衡(架构深度)
面试官:“对比拥有 Undo 回滚段的设计,PostgreSQL 这种堆内多版本有什么优势与劣势?”
1. 核心优势
- 回滚成本接近 0(Instant Rollback):事务
ROLLBACK时,根本不需要做反向数据修改,只需在 CLOG 里将当前事务对应的 2 个 bit 改为ABORTED,瞬间完成! - WAL 写入更轻量:不需要记录复杂庞大的 Undo 日志,极大地节约了崩溃恢复时的反向重放开销;
- 架构简单自洽:数据与历史版本统一由堆页组织管理。
2. 核心劣势与代价
- 表膨胀(Table Bloat):频繁的
UPDATE和DELETE会导致大量无人可见的“死元组(Dead Tuples)”残留在数据页中,导致表体积膨胀、全表扫描变慢; - 必须依赖后台清理:必须配备专有的 VACUUM / autovacuum 机制持续清理死元组(详见下一篇);
- 32 位事务 ID 回卷危机:由于 XID 仅有 4 字节(42 亿),必须通过定期 Freeze 冻结机制来防止历史事务回卷为未来事务。