背景

PolarDB-X 分布式数据库,采用集中式和分布式一体化的架构,为了能够灵活应对混合负载业务,作为数据存储的 Data Node 节点采用了多种数据结构,其中使用行存的结构来提供在线事务处理能力,作为 100% 兼容 MySQL 生态的数据库,DN 在 InnoDB 的存储结构基础上,进行了深度优化,大幅提高了数据访问的效率。

在 OLTP 业务中,唯一键索引(Unique Key Index, UK)是最常见的约束手段之一。业务表上通过 UK 来保证订单号、手机号、身份证号、交易流水号等关键字段的唯一性,是数据完整性的核心保障。然而,InnoDB 当前对 UK 索引的 MVCC 实现方式,在高并发场景下会引发两类让业务头疼的问题:

  1. 锁等待与死锁:在有 UK 的表上做高并发的插入和更新,业务经常遇到理论上并无关联的两个事务出现锁等待甚至死锁。

  2. 覆盖索引失效:对 UK 索引做覆盖索引查询时仍然出现回表动作,查询速度不及预期。

这两个问题的根源,都指向 InnoDB UK 索引在多版本管理上的设计局限。针对这一问题,PolarDB-X DN 存储引擎设计了 Panda Index——一种赋予 UK 索引原生多版本能力的新一代唯一键索引结构,从根本上解决了 UK 的锁范围扩大化与可见性判断依赖回表的问题。

InnoDB UK 索引的 MVCC 困境

数据库 MVCC 的两种历史版本存储方式

要理解 UK 索引的问题,首先需要了解 MVCC 中历史版本的存储方式。主流 TP 数据库对主表数据的历史版本存储大致分为两种范式:

PG: 追加写存储 (Append-only Storage)

InnoDB/Oracle: 回滚段存储(Rollback Segment Storage)

特点

多版本物理平铺:历史版本不动,新版本插入新位置

原地更新(In-place Update): 最新版本直接覆盖历史版本

历史版本位置

数据表空间

独立的 UNDO 表空间

历史版本记录形态

完整记录:每个版本都是完整 Tuple,可以自解释

增量补丁 (Delta) :只记录被修改记录的差异

历史版本空间管理

空间易膨胀:依赖 Vacuum 机制后台扫描数据表空间进行清理

空间更紧凑:Undo 段可循环利用,Purge 可以做精准清理,效率高得多

历史版本读取

读取极快:直接读取完整 Tuple,且不需要额外 IO

读取较慢:需要额外的 UNDO Page IO,且需应用 Delta 构建完整 Tuple

更新对索引的影响

写放大显著:最新版本物理位置变化,所有索引必须修改(在未触发 HOT 更新时)

写稳定性好:主表数据原地更新,索引逻辑指针不变

InnoDB 在聚簇索引上采用的是回滚段方式:每条记录包含 TRX_ID(事务 ID)和 ROLL_PTR(指向 Undo 链的指针),修改时先写 Undo 日志,再原地更新记录本身。历史版本通过 Undo 链追溯,B+tree 上始终只保留当前最新版本,结构简洁高效。

InnoDB 聚簇索引版本链示意图

UK 索引的"多版本物理平铺"问题

然而,InnoDB 的二级索引(包括 UK)并没有采用与聚簇索引相同的版本管理策略。二级索引上不包含 TRX_ID 和 ROLL_PTR,没有版本信息,也没有 Undo 链。当一条 UK 记录需要被更新时,InnoDB 采用的是 Delete-mark + Insert 的方式:先在旧记录上打一个删除标记,再插入一条新记录。

这意味着在 UK 索引的 B+tree 上,相同唯一键值可能同时存在多条物理记录——一条被标记删除的旧版本和一条新版本。在 Purge 线程清理之前,这些历史版本会一直残留在索引结构上。

InnoDB 二级索引多版本存储示意图

这种"多版本物理平铺"的方式带来了三个层面的问题:

GAP 锁范围扩大化

在 InnoDB 的加锁协议中,为了在多版本平铺的 UK 索引上保证唯一性约束(Insert 冲突检查),系统不得不在相同 Key 值的多个物理版本记录上施加 GAP 锁。

考虑这样的场景:事务 A 删除了 uk_col = 10 的记录,此时 B+tree 上该记录变为 delete-marked 状态;事务 B 尝试插入 uk_col = 10,需要检查是否存在冲突。由于索引上可能存在多条相同 UK 值的物理记录(当前版本、已删除但未 purge 的版本),插入冲突检测必须扫描所有这些记录并在相应的区间上加 GAP 锁。

结果就是:原本不冲突的事务因锁范围重叠而陷入等待甚至死锁。这不仅降低了系统的并发吞吐量,也给业务侧的 SQL 逻辑设计带来了极大的不确定性与困扰。这在交易系统、余额扣减、限时抢购等高并发 UK 更新场景中尤为突出。

可见性判断完全依赖回表

由于 InnoDB 二级索引不包含版本信息,纯索引扫描(Index-only Scan)在需要判断可见性时是无法独立完成的。即使查询的所有列都在 UK 索引中(理论上可以走覆盖索引),MVCC 查询仍然必须回到聚簇索引的记录和 Undo 链中检查版本信息,才能判断记录是否对当前事务可见。

此外,在隐式锁判断(Implicit Lock Detection)和 Purge 路径上,系统同样需要触发回表动作来获取事务信息。这些额外的回表操作对 Buffer Pool 和 IO 资源造成不必要的压力。

Purge 延迟导致的性能退化

Delete-marked 的 UK 记录需要等 Purge 线程异步清理。在写入密集的业务中,Purge 可能跟不上删除速度,导致 UK 索引上积累大量标记删除的记录。这些残留记录不仅浪费空间,还会增加后续插入时冲突检测的扫描范围,进一步加剧锁争抢和延迟。

Panda Index 设计

核心思想

Panda Index 的核心思想是:把聚簇索引独有的多版本管理能力下沉到 UK 索引。

具体来说,Panda Index 在传统 UK 索引记录的基础上,融合了聚簇索引的事务信息列和版本链,使 UK 索引拥有了原生的多版本能力。

这一设计带来了两个根本性的变化:

  1. 更新方式的转变:传统的 Delete-mark + Insert 替换为原地更新 + 历史版本记录在 Undo,消除了 B+tree 上同一唯一键多个物理记录共存的根源。

  2. 可见性判断的自主化:UK 索引记录上直接携带了事务信息,不再需要回表到聚簇索引去获取版本信息。

Panda Index 与普通 UK 索引的对比图

记录格式

Panda Index 在传统 UK 记录的基础上增加了 4 个系统列:

系统列

含义

TRX_ID

最近修改该记录的事务 ID,用于判断事务状态

ROLL_PTR

指向 Panda Index 自己独立的 Undo 记录,用于引导出历史版本

SCN

Lizard 事务系统的提交序列号,用于可见性判断

UBA

Undo Block Address,事务槽地址,用于可见性判断

其中,TRX_ID / SCN / UBA 的语义与聚簇索引记录中的一致,使得 Panda Index 可以复用 Lizard 事务系统既有的可见性判断框架(Vision)。而 ROLL_PTR 指向的是 Panda Index 自己独立的 Undo 记录——这与聚簇索引的 Undo 链是完全独立的两条链,可以据此找到当前记录对应的所有历史版本。

Panda Index 记录格式

原地更新:消除多版本平铺

Panda Index 记录拥有 ROLL_PTR 的基础上,记录的更新不再需要通过 Delete-mark + Insert 的方式将历史版本保留在数据页上。更新动作向聚簇索引看齐:

  1. 记录 Undo:先为 Panda Index 写一条独立的 Undo 记录,保存记录的旧版本信息。

  2. 原地修改:直接在原记录上修改数据内容,同时将 Undo 地址保存在 ROLL_PTR 字段。

这种设计将历史版本和当前版本在空间上彻底分离:

  • B+tree 索引结构上只保留当前版本,没有大量残留的标记删除记录给记录扫描和加锁带来额外代价。

  • 历史版本全部放在独立的 Undo 表空间,交给 Purge 系统管理,并可以通过 ROLL_PTR 轻松引导出任意历史版本,无需回表。

Insert 冲突检测优化

原地更新能力带来了一个重要的衍生收益:相同唯一键在索引上只能存在一条物理记录。

这使得 Insert 冲突检测的逻辑大幅简化:

  • 传统 UK:需要扫描索引上所有可能插入相同唯一键的记录(包括 delete-marked 的),并在整个区间上加 GAP 锁,以防止并发事务插入重复键。

  • Panda Index:如果索引中不存在与待插入键相同的键值,则直接插入成功;如果存在,则只对那一条记录加记录锁(Record Lock),并进一步根据其是否为 Delete-marked 来判断是否真正冲突。

整个过程无需 GAP 锁,缩小了加锁范围,显著降低了高并发场景下 UK 操作的死锁概率。

独立的 Undo 系统

Panda Index 拥有完全独立的 Undo 日志体系,不与聚簇索引共享 Undo 链。这意味着在以下关键路径上,Panda Index 都有自驱动的独立处理逻辑:

  • 正向 DML:每次修改 Panda Index 记录时,独立记录 Undo 日志,与聚簇索引的 Undo 写入是分开进行的。

  • Rollback:事务回滚时,Panda Index 的 Undo 记录被独立应用,回滚逻辑不依赖聚簇索引。

  • Purge:后台 Purge 线程对 Panda Index 的 delete-marked 记录有独立的清理路径,通过 ROLL_PTR 验证记录版本后执行物理删除。

独立 Undo 系统的设计避免了 UK 索引与聚簇索引在版本管理上的耦合,使得 Panda Index 的版本生命周期管理完全自洽。

可见性判断

Panda Index 记录上直接携带了 TRX_ID、SCN、UBA 等事务信息,使得可见性判断可以在索引本地完成,不再依赖回表。

与 Lizard 事务系统的聚簇索引路径类似,Panda Index 的一致性读路径为:

  1. 从记录上读取事务信息(TRX_ID、SCN、UBA)。

  2. 通过 Lizard Vision 框架判断记录对当前事务是否可见。

  3. 若当前版本不可见,通过 ROLL_PTR 追溯 Undo 链,构建历史版本。

整个过程完全在 Panda Index 的 B+tree 内部完成,无需任何回表操作。这使得 Panda Index 上的覆盖索引查询能够真正实现 Index-only Scan,同时隐式锁判断等路径也不再需要访问聚簇索引。

验证效果

通过一个简单的并发场景即可直观感受 Panda Index 消除 GAP 锁的效果。

数据准备:

CREATE TABLE t1(
  id int,
  c1 int,
  PRIMARY KEY(id),
  UNIQUE KEY uk1(c1)
);
INSERT INTO t1 VALUES (1, 1);
INSERT INTO t1 VALUES (100, 100);

并发测试:

Session 1 执行一笔"先删后插"操作(事务未提交):

BEGIN;
DELETE FROM t1 WHERE id = 1;
INSERT INTO t1 VALUES (2, 1);

Session 2 插入一条不冲突但位于间隙内的数据:

INSERT INTO t1 VALUES (3, 2);

普通 UK 的表现: Session 2 的插入操作会因等待 Session 1 持有的 GAP 锁而超时失败。查看锁信息可以观察到 UK 上存在多个 GAP 锁:

SELECT lock_data, lock_mode FROM performance_schema.data_locks WHERE index_name = 'uk1';
+-----------+---------------+
| lock_data | lock_mode     |
+-----------+---------------+
| 1, 1      | X,REC_NOT_GAP |
| 1, 1      | S,GAP         |
| 100, 100  | S,GAP         |
| 1, 2      | S,GAP         |
+-----------+---------------+

Panda Index 的表现: Session 2 的插入操作立即成功,因为 Panda Index 不会产生不必要的 GAP 锁:

SELECT lock_data, lock_mode FROM performance_schema.data_locks WHERE index_name = 'uk1';
+-----------+---------------+
| lock_data | lock_mode     |
+-----------+---------------+
| 1, 2      | X,REC_NOT_GAP |
+-----------+---------------+

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐