MVCC 实现变体:版本存储、快照可见性与写偏斜

💡 原文中文,约31100字,阅读约需74分钟。
📝

内容提要

本文横向对比主流数据库的 MVCC 实现:PostgreSQL 采用堆表追加式、由旧到新的版本链,InnoDB 采用 undo 增量、由新到旧的版本链,TiKV 将版本编码进键。三者可见性规则等价,但快照隔离会放过写偏斜;PostgreSQL 用 SSI 在线检测危险结构实现可串行化,代价是误杀。长事务会拖住回收水位,导致版本膨胀。

🔎

延伸解读

隔离级别名称不可跨库移植

文章指出,不同数据库对隔离级别的命名存在严重错位:Oracle 的 SERIALIZABLE 实际是快照隔离,TiDB 的 REPEATABLE-READ 也是快照隔离且不支持可串行化,InnoDB 的可重复读在锁定读和更新时又偏离快照隔离。这意味着迁移数据库时,不能假设同名隔离级别行为一致,必须用 Hermitage 式交错脚本逐条验证关键事务。

长事务是版本膨胀的共同根源

所有 MVCC 系统都依赖最老活跃快照作为回收水位线,长事务会拖住水位,导致旧版本无法回收。PostgreSQL 表现为死元组和索引膨胀,InnoDB 表现为 undo 历史链表增长,TiDB 则可能因安全点停滞而堆积旧版本。监控最老快照的年龄比监控版本数量更能提前发现问题。

SSI 的误杀率随负载形态剧烈变化

文章模拟显示,在冲突稀疏的负载中,基本 SSI 的误杀历史数可能超过 SI 本身真正出错的历史数;而在冲突密集时,误杀几乎消失。PostgreSQL 采用的提交顺序条件能将误杀压到基本规则的四成左右。因此,是否值得为 SSI 付出误杀代价,取决于具体负载的冲突密度,没有通用答案。

版本存储决定读写代价的分配

追加式存储(如 PostgreSQL)更新时复制整行,读最新版本需沿旧到新的链行走;增量存储(如 InnoDB)原地更新,读旧版本需逆向应用 undo。前者写放大但扫描延迟低,后者写代价小但扫描需重建版本。没有同时赢两边的方案,选择取决于负载更偏重写吞吐还是扫描延迟。

❓

Q&A

PostgreSQL、InnoDB 和 TiKV 的 MVCC 版本存储方式有什么不同?

PostgreSQL 采用追加式存储,旧版本留在堆表中,版本链从旧到新;InnoDB 采用增量存储,旧版本以 undo 记录形式保存,版本链从新到旧;TiKV 将版本编码进键中,按提交时间戳降序排列。

为什么快照隔离(SI)不能实现可串行化?

快照隔离允许写偏斜(write skew),即两个事务各读一项、各写另一项,写的项互不相同,先提交者胜不起作用,导致违反约束。例如值班医生例子中,两个事务同时提交后可能没人在岗。

PostgreSQL 的 SSI 如何实现可串行化?代价是什么?

SSI 在线检测危险结构:读操作留下 SIREAD 标记,写操作发现冲突时记录 rw 反依赖边,当事务同时有入边和出边时中止其中一个。代价是可能误杀(false positive),即中止本可串行的事务。

长事务为什么会导致 MVCC 版本膨胀?

旧版本可回收的条件是比最老的活跃快照还老。长事务持有老快照,使回收水位无法推进,导致旧版本无法回收,从而引起版本膨胀,如 PostgreSQL 死元组堆积、InnoDB 历史链表增长。

PostgreSQL 和 InnoDB 的可见性判断规则有何异同?

两者规则等价:PostgreSQL 使用 xmin、xmax 和 xip[] 数组,InnoDB 使用 ReadView 的 m_up_limit_id、m_low_limit_id 和 m_ids。区别在于 InnoDB 不需要检查提交状态,因为回滚会撤销修改;PostgreSQL 需要查 pg_xact。

TiDB 的隔离级别是什么?支持可串行化吗?

TiDB 实现的是快照隔离(SI),为兼容 MySQL 对外称作 REPEATABLE-READ,允许写偏斜;SERIALIZABLE 不受支持,只有打开 tidb_skip_isolation_level_check 才能设置而不报错,但语义不会变成可串行化。

🏷️

标签

➡️

继续阅读