Skip to content

MVCC 底层实现与并发控制

堆内多版本、元组隐藏列(xmin/xmax/t_cid)、快照数据结构与可见性算法、CLOG 与 Hint Bits 机制。

Updated View as Markdown
For humans

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:等价于 “旧版本标记删除 + 新版本插入”:
    1. 将旧元组的 xmax 设置为当前事务 ID;
    2. 在同一个数据页(或新页)中插入一条包含更新后数值的新元组,其 xmin 设为当前事务 ID;
    3. 旧元组的指针(t_ctid)指向新元组的物理坐标,形成版本链。

堆内多版本链:新旧版本共存,靠 xmin 与 xmax 标识生命周期


二、元组头部(HeapTupleHeader)的 4 大核心隐藏字段

每一行元组在磁盘物理存储时,除了用户定义的业务字段,都在元组头部分配了 23 字节以上的系统信息:

隐藏字段物理长度核心职责面试考察细节
t_xmin4 字节(32 位整数)插入或创建该元组的事务 ID(XID)。标志元组的“出生点”。
t_xmax4 字节(32 位整数)删除或更新该元组的事务 ID。标志元组的“死亡点”;若元组有效且未被删除,此值为 0。此外,当行被 FOR UPDATE 加锁时,加锁事务 ID 也会暂存在这里。
t_cid4 字节Command ID(命令标识符)。记录同一事务内部 SQL 语句的执行顺序(从 0 递增)。解决“同一个事务内部先 INSERT 一行再 SELECT 自身时能否看见”的可见性判断。
t_ctid6 字节物理位置指针(ItemPointer)。指向当前元组的最新版本物理位置(页号 + 槽号)。若未被更新,则指向元组自己;若被更新,则指向更新后的下一代元组。
t_infomask2 字节状态掩码位图。保存当前元组的提交状态与 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. 完整可见性判定口诀(大厂面试高分推导)

当执行引擎扫描到一个元组时,按以下优先级逐层比对:

  1. 自己改的自己看:若 t_xmin == my_xid,说明是本事务创建的:
    • 若 t_xmax == my_xid,说明在本事务内部又被删除了,不可见;
    • 若 t_cid < my_cid,说明在当前查询之前创建,可见;否则不可见。
  2. 未来事务不可见:若 t_xmin >= snapshot.xmax,该元组创建于快照之后,不可见。
  3. 活跃事务不可见:若 t_xmin 在 xip_list 活跃列表中,说明快照创建时还没提交,不可见。
  4. 已提交事务判断存活状态:若 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 的巧妙设计:
    1. 当第一个查询读到某元组时,发现元组头部的状态位尚未打标,它会去查一次 CLOG,得知创建者 xmin 已经提交;
    2. 该查询会在当前元组头部的 t_infomask 中直接打上标志位:HEAP_XMIN_COMMITTED;
    3. 后续所有其他查询读到该元组时,一看到这个 Hint Bit,再也不用去查 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 冻结机制来防止历史事务回卷为未来事务。
Navigation

Type to search…

↑↓ navigate↵ selectEsc close