背景
PolarDB-X 分布式数据库,采用集中式和分布式一体化的架构,为了能够灵活应对混合负载业务,作为数据存储的 Data Node 节点采用了多种数据结构,其中使用行存的结构来提供在线事务处理能力,作为 100% 兼容 MySQL 生态的数据库,DN 在 InnoDB 的存储结构基础上,进行了深度优化,大幅提高了数据访问的效率。
在真实的 OLTP 业务中,单张业务大表挂十几条二级索引是相当常见的场景,SaaS 多租户、电商商品/订单、交易流水、链路 trace、归档库等场景尤为典型。二级索引的总占用达到单实例总存储的 50% 以上的线上实例比比皆是,有些业务表二级索引占用甚至显著超过主表本身。
二级索引空间的膨胀,不仅直接推高存储成本,还会进一步影响 Buffer Pool 热数据覆盖率、放大 IOPS 压力、加剧 B+tree 分裂与 SX 锁争抢。在云原生数据库追求极致性价比的当下,如何针对二级索引的结构特征做专门的压缩,是 PolarDB-X 存储引擎在存储效率方向上重点要回答的问题。
针对这一问题,PolarDB-X DN 存储引擎设计了 Secondary Prefix Compression(SPC) 方案:一种只作用于二级索引、页内局部字典、页分裂触发编码的轻量级前缀压缩能力。在典型业务上,SPC 可让二级索引占用下降 30%~70%,读写路径有效控制额外 CPU 开销,在 IO Bound 场景下还能带来显著的性能提升。
InnoDB 二级索引存储结构
二级索引的 B+tree 布局
在 InnoDB 存储引擎中,一个用户定义的表实际上由多个 B+tree 索引来实现,主键对应的聚簇索引 (Clustered Index) 的叶子节点上保存着全量的数据行信息,普通列上的二级索引 (Secondary Index) 的叶子节点上仅存储相应的二级索引键和主键。二级索引的物理记录排列如下图所示:
InnoDB 当前的二级索引记录组织方式,本质上是将每条记录按 (SK, PK) 形式顺序存放在 B+tree 叶子页中。这样的设计简洁直接,也保证了良好的查找与范围扫描能力;但从页内存储视角看,它并没有进一步利用二级索引数据本身的一些典型分布特征。
这些特征主要体现在三点:
- 相同 SK 的记录连续出现,但不会共享存储。
对于 non-unique index,多条 SK 相同的记录会连续排列,但每条记录仍然各自保存一份完整的 SK 内容,只有后缀 PK 不同。 - 业务键通常具有较长公共前缀。
在线业务中的订单号、SKU、URL、文件路径、trace id、邮箱域名、业务编码等字段,往往前缀高度相似,真正的区分信息集中在后半段。 - 有序排列使相邻记录天然高度相似。
二级索引按 SK 有序存放,同一叶子页中相邻记录通常共享较长的公共前缀;但在 InnoDB 当前的记录格式下,这些重复前缀仍然会在每条记录中被分别保存。
从功能正确性上看,这种组织方式没有问题;但从空间利用率角度看,它意味着二级索引页内存在大量可被进一步利用的重复信息。这也正是 PolarDB-X DN 设计 SPC 的出发点:不改变二级索引的逻辑结构,而是在页内局部利用这些重复前缀,提升存储效率。
MySQL 原生压缩方案的局限
MySQL InnoDB 提供了两种原生压缩方案,均采用通用压缩算法对块级别进行压缩,存在压缩计算成本高昂等问题,目前在实际的生产应用中往往难以同时兼顾压缩收益与访问效率,主要对空间资源比较敏感的业务场景中推广使用。
压缩对比项 |
表压缩(Table Compress) |
页压缩(Page Compress) |
使用语法 |
CREATE TABLE t1 (c1 INT PRIMARY KEY)ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8; |
CREATE TABLE t1 (c1 INT) COMPRESSION="zlib"; |
压缩原理 |
Page在内存中保留压缩和非压缩两个版本,行级修改需要持续维护两个page,并在压缩页进行压缩,磁盘文件只写入压缩页。 |
Page写入磁盘时进行压缩,少于OS 文件系统unit size的可以通过 File Hole Punch方式释放空间,达到空间压缩效果。 |
使用依赖 |
逻辑页压缩,不依赖OS。 |
物理页压缩,强依赖文件系统打洞功能。 |
使用成本 |
内存:Buffer Pool 保留了压缩和未压缩两份数据page,利用率减半。 CPU:数据修改需同时对压缩和非压缩页进行修改,5%-20%的消耗。 |
内存:Buffer Pool只保留一份数据页。 CPU:在进行系统IO过程中进行压缩和解压计算,5%-20%的消耗。 |
归根结底,InnoDB 当前二级索引的空间代价,主要来自页内大量重复保存的 SK 信息:一方面,相邻记录之间往往共享很长的公共前缀;另一方面,相同 SK 的多条记录会连续出现,但每条记录仍各自保存一份完整 SK。这样的存储方式会直接降低叶子页的空间利用率,进而稀释 Buffer Pool 对热数据的覆盖能力,带来更多的索引页访问、更多的 B+tree 分裂以及更高的并发争用。从这个意义上说,二级索引压缩的关键,不在于再引入一种更重的通用压缩,而在于如何在不破坏现有索引结构与访问路径的前提下,更有效地利用这些天然存在的重复前缀与相似性。
SPC 索引结构
针对 InnoDB 二级索引的存储代价问题,PolarDB-X DN 存储引擎设计了 SPC 索引结构,通过页内前缀字典显著降低二级索引的空间占用,并提升 Buffer Pool 与 IO 的有效利用率。
SPC 整体架构
SPC 的核心思想是:
在每个二级索引页内部,专门开辟一小块元数据区(Page Dict)保存本页的若干条公共前缀;页内的每条记录只保留"用哪条前缀 + 前缀多长 + 自己的尾巴"。
SPC 页的整体布局如下图所示:
Page Dict:页级共享的前缀字典
前缀压缩的一个关键选型是:前缀信息存在哪里、允许在什么粒度上变动?
- 按记录维护差分前缀:每条记录都以前一条记录为 base 存储差分。理论压缩率最高,但记录之间形成强依赖,单条记录的解析需要先还原前一条;一旦中间记录发生插入、删除或更新,后续记录都可能被连带影响,乐观写容易退化为悲观重写,工程复杂度极高。
- 跨页共享字典:在页外维护一份更大范围可复用的前缀字典,例如按索引、按分区,甚至按一段 page range 共享。这样可以让多个页复用同一批高频前缀,在某些前缀高度稳定的场景下有机会获得更高的总体压缩率。但它的问题也很明显:字典本身变成了新的全局元数据,带来版本管理、并发控制、崩溃恢复、页分裂迁移、冷热前缀演化等一系列复杂性;同时单页读写不再完全自洽,访问路径和回收机制都会变重。
- 页级共享字典:每页维护一份局部字典,页内所有记录共享。单条 DML 通常只改记录本身,不触发字典变动;只有在页重组、页分裂或整页重编码时,才重新计算字典。记录可以自然拆成“字典引用 + 自身尾部”,页内访问路径简单,局部性好,也更容易与现有 B+tree 页面生命周期对齐。
综合来看,三种方案分别对应三种不同的 trade-off:按记录差分把压缩率推到最高,但写放大和实现复杂度也最高;全局共享字典扩大了复用范围,但引入了额外的全局一致性与元数据管理成本;页级共享字典虽然在理论压缩率上不是最激进的方案,但它把压缩作用域严格限制在单页内部,在压缩收益、读写代价和工程可控性之间取得了更均衡的折中。SPC 选择了 页级共享字典,它主要由三部分组成:
- 字典项数量。
- 每个前缀在字典区中的偏移目录。
- 真正的前缀字节串数据。
这样,多个索引记录就可以共享同一段页内前缀内容,而不需要各自重复保存。
从结构上看,SPC 把“记录里重复出现的前缀”提升成了一个页级的一等公民对象。
SPC 记录格式
SPC 记录在普通 COMPACT 记录头之外,额外增加了一段 SPC Header。最核心的两个字段是:
Pack ID:表示该记录引用页内哪一个前缀。Pack Len:表示该记录实际复用了这个前缀的多少字节。
这样,一条 SPC 记录在物理布局上就从“完整索引键”变成了“页内共享前缀 + 本记录剩余内容”。记录的逻辑值没有发生变化,索引排序语义也没有发生变化,但页内重复前缀只需要保存一次。
SPC 数据编码
Split-triggered encoding:只在页面即将分裂时触发
编码时机是 SPC 在工程上需要审慎考虑的一件事。每条 DML 都做一次整页编码,CPU 代价是硬成本,会直接拖累写路径;完全不做又失去压缩意义。
SPC 采用 Split-triggered 编码 策略:新写入的记录先以非压缩形态写入页;直到页内空间即将耗尽、即将分裂时,才整页做一次 SPC 编码。
具体的触发点设置在 InnoDB 的乐观插入路径上:
- 页内剩余空间不足以容纳新记录;
- 且 reorganize(清理删除标记、整理碎片)之后依然腾不出空间;
- 此时原本的逻辑会放锁进入 SMO 流程(B+Tree 结构变更);SPC 插在这里,先尝试一次整页 SPC 编码;
- 编码后能塞下新记录,走"乐观 + 压缩"路径,避免 SMO;
- 编码后依然塞不下,回退到正常的 page split 流程。
这样做的收益是系统性的:
- 冷页、稀疏页永远不会被编码,写路径无任何额外 CPU 开销;
- 压缩代价被均摊到一页内的 N 次 DML 上;
- 能避免的 SMO 就避免掉——压缩省下来的空间,很多时候刚好让本来要分裂的插入就地完成,压缩本身就在帮 SMO 减压。
页内编码算法
SPC 编码的目标是在字典条数上限(128)的约束下,为页内有序记录选取一组不相交的连续区间,每个区间共享一条公共前缀,使总字节净收益最大。这是相邻记录公共前缀长度序列上的最优分段问题,精确最优解需要 O(n²) 的动态规划,不适合放在页分裂的热路径上。
SPC 用线性时间的三阶段贪心做近似。第一阶段顺序扫描该序列,按升降转折切分为若干极大单调区间作为候选,每个候选的字典内容取自公共前缀最长处的记录。第二阶段从左至右逐对比较相邻候选独立保留与合并的字节收益,择优执行;合并后共享前缀退化为衔接处的最小值。第三阶段将保留的候选落入页级字典,为每条记录回填字典引用与压缩长度;前缀过短或未覆盖的记录保留原始形态,与压缩记录共存于同一页。
二级索引页内记录按键值有序,相同前缀天然连续成簇,这一贪心策略在实际负载下与精确最优的收益差距很小。
SPC 数据解码
前缀压缩的工程难点不在算法本身,而在解压要覆盖读路径每一个角落。InnoDB 里访问二级索引记录的场景非常多,每一条路径上都涉及rec_get_offsets、基于 offsets的字段访问、rec_t 与 dtuple_t 的比较,都必须能正确处理 SPC 记录。
Slice:统一的逻辑视图
原生 InnoDB 的上层代码直接基于 rec 指针 + offsets 访问记录。若让每一处上层代码都去区分"这是连续的 COMPACT 记录,还是分裂的 SPC 记录",维护成本极高,也容易引入隐蔽 bug。
为此,SPC 在 InnoDB 的 rem 层引入了一层新的抽象——Slice。所有上层读取路径统一基于 Slice 接口访问记录:
- 对连续的 COMPACT 记录,Slice 退化为直接访问 rec 指针,与社区原本方式保持一致;
- 对 SPC 记录,Slice 在需要时按需把"字典前缀 + 记录尾巴"拼成一段连续内存再返回;
- 对 DDL 外部排序的 merge record,Slice 也提供了对应的适配形态。
Slice 抽象的关键在于:它不改变上层代码的语义,只改变实现。上层代码看到的仍然是"逻辑上完整"的字段,物理差异被完全封装在 Slice 内部。
在这层抽象之上,SPC 对 MVCC / Undo / Purge / 锁 / Change Buffer / 复制 / 备份恢复 都是 语义等价 的——外部看起来,SPC 只是一个纯粹的页内物理存储优化,逻辑行为和普通二级索引完全一致。
在性能敏感的读路径上,Slice 还支持"不完全解压"的访问:
- 比较一条 SPC 记录和一个 dtuple 时,可以直接基于字典 + 尾巴 + 扩展 offsets 在压缩形态下做逐列比较,不需要先构造出一条完整的非压缩 rec;
- 这种"压缩态直接比较"在热路径上省掉一次 memcpy 和一次临时分配,是 SPC 在 CPU Bound 场景下性能的重要保障。
业界对比
MySQL 原生压缩
如前文所述,表压缩 和 透明页压缩 都是通用块压缩,不利用 B+tree 叶子页"相邻即相似"的结构特征;压缩/解压链路使用 zlib/lz4,CPU 代价大;并且表压缩在 Buffer Pool 保留压缩+解压双份副本,对内存利用率不利。
PostgreSQL B-tree Deduplication
PostgreSQL 13+ 在 B-tree 索引上支持 "deduplication":对相同键值的多条 TID 做聚合存储。其本质是针对重复键值的聚合,而不是针对前缀的字典压缩,对长前缀、前缀不完全相同的场景(如 URL、trace id)收益有限。
Oracle Key Compression
Oracle 的基础索引压缩是 prefix/key compression(COMPRESS n):它将索引键拆成 prefix 与 suffix,并在索引块内共享前导列前缀,因此本质上是按列数指定前缀长度的列级前缀共享。相比之下 SPC 的字典是字节级的,能更精细地利用真实数据分布。
在此基础上,Oracle 又提供了 Advanced Index Compression(COMPRESS ADVANCED LOW/HIGH)。与固定前缀方式不同,Advanced Index Compression 采用按块、结合数据分布的自适应压缩:数据库会为每个块选择更合适的压缩方式,公开文档提到其内部会使用 intra-column level prefixes、duplicate key elimination 与 rowid compression 等技术。
SQL Server Page Compression
SQL Server 的 Page Compression 在现有主流数据库中,与 SPC 在“页内利用重复模式降低存储开销”这一方向上最为接近。其实现并非单一算法,而是由 Row Compression、Prefix Compression 和 Dictionary Compression 三层机制顺序组成:先对行格式做紧缩,再在页内提取列前缀,最后对整页范围内的重复值建立字典。其进行压缩的时机也与 SPC 相似:当页写满并需要容纳新行时,数据库会对整页重新评估,决定是否应用 Prefix 和 Dictionary Compression。
测试数据
性能表现
为了验证 SPC 在真实 OLTP 负载下的实际效果,我们基于 sysbench 设计了一组针对二级索引前缀重复特征的测试负载,在同一套硬件和数据集上,分别对比开启与关闭 SPC 时的空间占用与吞吐表现。
测试环境与数据集
压测使用128 并发线程,表结构模拟典型前缀重复特征:每张表包含 3 个 INT 类型的枚举列(模拟 status、category 等低基数字段,每列 5 种取值)和 6 个 VARCHAR(64) 的业务键列(模拟订单号、编码等具有公共前缀特征的字段,前缀长度 40 字节,1000 种前缀)。在此基础上建立 12 个二级索引:3 个枚举列单列索引、6 个业务键单列索引、3 个(枚举列, 业务键)联合索引。共 16 张表,每表 100 万行。关闭 SPC 的单表大小为 1.8GB,二级索引占 1.16G,开启 SPC 的单表大小为 1.1GB,二级索引占 0.52B,二级索引压缩率约 55%,整体压缩率约 38%。
测试负载包括三种:
- read_only:二级索引点查 + 联合索引点查 + 联合索引范围扫描,模拟典型的在线查询路径。
- read_write:在 read_only 基础上叠加二级索引列更新与 delete/insert 操作,模拟混合事务负载。
- write_only:仅包含二级索引列更新与 delete/insert,每次写操作涉及全部 12 个二级索引的维护,模拟写密集场景。
IO Bound 场景
通过调整 Buffer Pool 大小,使索引工作集无法全部驻留内存,制造磁盘随机读成为瓶颈的 IO Bound 条件。
Buffer Pool = 4 GB
负载 |
关闭 SPC (QPS) |
开启 SPC (QPS) |
提升 |
read_only |
82,401 |
108,384 |
+31.5% |
read_write |
33,292 |
34,060 |
+2.3% |
write_only |
20,259 |
20,615 |
+1.8% |
Buffer Pool = 8 GB
负载 |
关闭 SPC (QPS) |
开启 SPC (QPS) |
提升 |
read_only |
105,069 |
154,215 |
+46.8% |
read_write |
47,904 |
70,655 |
+47.5% |
write_only |
27,369 |
27,818 |
+1.6% |
在 IO Bound 条件下,SPC 的性能收益主要来自两条路径:其一,压缩后每个 16 KB 页容纳更多记录,Buffer Pool 的有效覆盖率显著提升,缓存命中率的差距直接转化为更少的磁盘随机读;其二,压缩减少了 B+tree 的叶子页总数,降低了页分裂频率,间接缓解了 SMO 带来的 SX 锁争抢。8 GB Buffer Pool 是本数据集下的最佳对比区间:关闭 SPC 时 BP 仅覆盖约三成数据,大量索引访问需要读盘;开启 SPC 后覆盖率提升至近半,缓存命中率的差距被最大化放大,读和读写负载均获得近 47% 的吞吐提升。
write_only 负载在两种 BP 配置下均几乎无差异(+1.6%~1.8%),这是符合预期的:该负载的读路径通过主键定位行,不经过二级索引的 B+tree 查找,因此 SPC 对二级索引页的压缩不影响读侧缓存命中率;写路径虽然涉及全部 12 个二级索引的维护,但 Split-triggered 编码策略确保了常规 DML 不触发额外的压缩开销,写吞吐基本持平。
CPU Bound 场景
将 Buffer Pool 调至 32 GB,使全部数据驻留内存,消除磁盘 IO 因素,观察 SPC 在纯 CPU 路径上的表现。
负载 |
关闭 SPC (QPS) |
开启 SPC (QPS) |
差异 |
read_only |
426,476 |
416,194 |
-2.4% |
read_write |
294,917 |
291,175 |
-1.3% |
write_only |
138,673 |
149,890 |
+8.1% |
数据全部驻留内存后,SPC 的 IO 收益消失,三种负载的吞吐差异均在测试波动范围内, Split-triggered 编码策略和压缩态直接比较优化有效控制了解压开销。