TiDB 聚簇索引(Clustered Index)设计与实现:从 Handle 抽象到全链路改造
2026/9/10 21:10:24 网站建设 项目流程

TiDB 聚簇索引(Clustered Index)设计与实现:从 Handle 抽象到全链路改造

【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb

聚簇索引是 TiDB 将主键与行数据存储位置绑定、省去二次回表的关键能力。本文以 TiDB 官方设计文档 docs/design/2020-05-08-cluster-index.md 为骨架,结合当前仓库源码,系统讲解聚簇索引的存储编码、读写路径、Planner/Coprocessor/Executor 改造、兼容性策略与落地路线。读完本文,你将理解 TiDB 如何从"仅支持单整数列主键聚簇"演进到"支持任意类型主键聚簇",并能定位到对应的源码实现。

背景:为什么聚簇索引只能覆盖单整数列主键

在设计文档提出的时间点(2020-05-08),TiDB 只支持单整数列主键的聚簇索引。其存储模型是:表中每行有一个 row-key 条目存储整行数据,所有二级索引条目指向该 row-key。如果主键是单整数列,就直接把该列的值当作行 handle(row-key 的一部分),实现"主键即存储位置";否则由系统内部为行分配一个自增 handle,并额外建立一条指向 row-key 的索引。

当时的 row key 格式

t | {table_id} | _r | {handle} // component 1 | 8 | 2 | 8 // byte size

table_idhandle均为 int64,因此 row key 长度恒为 19 字节。

非唯一索引 key 格式

t | {table_id} | _i | {index_id} | {index_column_values} | encoded_handle // component 1 | 8 | 2 | 8 | size of the values | 9 // byte size

handle 作为索引 key 的后缀,保证同一索引值下不同行按 handle 有序排列。

唯一索引 key 格式

t | {table_id} | _i | {index_id} | {index_column_values} // component 1 | 8 | 2 | 8 | size of the values // byte size

唯一索引的 handle 存放在索引条目的value中,而不是 key 中。

三种典型操作路径的差异

  • On Write:主键为单整数列时,直接用主键列值作为 handle;否则内部分配 handle 并额外写入一条指向 row key 的索引条目。
  • On Point Select:单整数列主键时,直接用主键值构造 row key 做一次 TiKV 查找;否则要先构造索引 key 读 handle,再构造 row key 做第二次查找(回表)。
  • On Range Scan:单整数列主键时,直接构造 row key 范围做一次扫描;否则要扫描索引收集 handle 集合,再对每行做 point lookup。

可以看到,非单整数列主键的表在写入和查询时都多了一次索引往返,这正是聚簇索引要解决的问题。

核心抽象:Handle 接口与两种具体实现

设计文档提出将 handle 抽象为接口,从而把"行的 ID"从 int64 推广到任意主键列组合。当前源码中的定义位于 pkg/kv/key.go,接口方法包括:

package kv // Handle is the ID of a row. type Handle interface { // IsInt returns if the handle type is int64. IsInt() bool // IntValue returns the int64 value if IsInt is true, it panics if IsInt returns false. IntValue() int64 // Next returns the minimum handle that is greater than this handle. Next() Handle // Equal returns if the handle equals to another handle, it panics if the types are different. Equal(h Handle) bool // Compare returns the comparison result of the two handles, it panics if the types are different. Compare(h Handle) int // Encoded returns the encoded bytes. Encoded() []byte // Len returns the length of the encoded bytes. Len() int // NumCols returns the number of columns of the handle. NumCols() int // EncodedCol returns the encoded column value at the given column index. EncodedCol(idx int) []byte // String implements the fmt.Stringer interface. String() string }

接口提供两个具体类型:

  • IntHandletype IntHandle int64,pkg/kv/key.go):对应单整数列主键,编码格式与旧实现完全一致,零额外开销。
  • CommonHandle(pkg/kv/key.go):对应其余所有主键类型,内部保存encoded字节序列和colEndOffsets []uint16(每个主键列在编码后的结束偏移),借助EncodedCol(idx)可以切出第 idx 列主键的编码值。

从源码看,接口在演进中还补充了CopyDataMemUsageExtraMemSize等方法(用于内存追踪与深拷贝),说明该抽象经受住了后续内存控制等特性的复用。

一个容易被忽略的工程细节:handle 在很多地方被当作 map key 使用。如果直接用编码字节作 key,IntHandle 每次都会产生内存分配。设计文档给出的方案是自定义HandleMap类型,内部用两个 map 分别存放 int64 与字符串 key,从而避免 IntHandle 的分配开销。

主键类型与 handle 类型的对应关系:单列整数主键 → IntHandle;其他任意主键 → CommonHandle。整数 handle 的 row key 与索引 key 格式保持不变,改造只需关注 CommonHandle 路径。

编码方案:CommonHandle 的三类存储格式

行 key 格式(CommonHandle row key)

t | {table_id} | _r | {common_handle} // component 1 | 8 | 2 | len(common_handle) // byte size

common_handle与索引 key 中的index_column_values编码完全一致,因此 row key 长度不再固定为 19 字节,而是随主键内容变化。

非唯一索引 key 格式

t | {table_id} | _i | {index_id} | {idx_col_vals} | {common_handle} // component 1 | 8 | 2 | 8 | len(idx_col_vals) | len(common_handle) // byte size

非唯一索引的 value 保持原样,common_handle 作为 key 后缀,延续了"同一索引值下按 handle 排序"的性质。

唯一索引 value 格式

{tailLen} | {common_handle_flag} | {common_handle_len} | {common_handle} // component 1 | 1 | 2 | len(common_handle) // byte size

唯一索引的 key 保持不变,common_handle 被编码进 value。tailLen用于描述 handle 之后是否还有附加数据(如 TTL 等),common_handle_flag用于区分 handle 类型,使旧版本 TiKV 也能安全跳过未知 handle。

设计文档强调:编码的关键原则是让 common_handle 与索引列值使用同一套列值编码(codec),这样 Coprocessor 解码索引列时可以复用同一逻辑,也保证了行 key 与索引 key 之间的字典序一致性。

读写路径的收益:为什么能省掉一次往返

聚簇索引的价值集中体现在三类操作上:

操作非聚簇(旧)聚簇(CommonHandle)
写入分配内部 handle + 额外索引条目直接用主键值做 handle,主键不再冗余编码进行值
点查索引查 handle → 回表查行用主键值直接构造 row key,一次点查
范围扫描扫索引收 handle → 多次 point lookup直接扫 row key 范围,单次扫描

设计文档给出的量化动机是:TPC-C 负载下,把多列主键压缩成单整数主键后性能提升约 33%——这 33% 正是聚簇索引期望在"多列主键表"上追回的开销。需要说明的是,这是设计阶段的实验数据,不同负载下的实际收益取决于主键长度与回表频率。

另外值得注意的是:聚簇索引表在写入时不需要把主键列编码进行 value(见下文 Insert 改造),进一步压缩了行存储。

Planner:把主键访问当作表路径而非索引路径

设计文档对优化器提出了两点关键改造:

  1. 主键索引路径视为表路径:对 CommonHandle 表,主键索引的访问路径应走 TableScan 计划而非 IndexLookUp 计划——因为主键本身就是行位置,不需要"索引 → 回表"两步。同时要仔细审查所有隐式假设 TableScan 使用 int64 handle 的逻辑,并可能针对聚簇表调整代价模型。

  2. IndexScan 输出 Schema 按需包含主键列

    • 若查询用到任一主键列,或执行器处于 IndexLookUp 中,索引扫描的 schema 为{index columns},{primary key columns},且 CommonHandle 表不再额外输出 handle 列
    • 若只用索引列,schema 仅为{index columns}

文档示例:

create table t (a int, b int, c int, d int, e int, primary key (a, b), index c_d (c, d));

c_d索引扫描的 schema 应为c, d, a, bc, d,取决于查询是否引用主键列。若查询不引用列e,则c_d成为覆盖索引,可直接构建 IndexScan 计划而无需 IndexLookUp。

Coprocessor:解码与采样改造

TiKV Coprocessor 侧需要配合的改动(对应 docs/design/2020-05-08-cluster-index.md 的 Coprocessor 一节):

  • IndexScan:若扫描 schema 不含主键列,逻辑不变;若含主键列,则需把 common handle 解码为主键列值,编码后的列值可通过 Handle 的EncodedCol方法直接切出,无需重新编码。
  • Analyze Column:多列主键时,CMSketch 需要插入主键的前缀列值(这决定了旧版SHOW STATS_BUCKETS/ 多列统计的收集口径)。
  • Fast Analyze:handle 不再是整数,无法按给定位置随机生成采样 key,因此需要新增一种 Coprocessor 请求类型:在扫描过程中直接采样 key/value 对并返回统计结果。

Executor:受影响的四个主要组件

设计文档明确列出需要改造的执行器:

  • Admin Executors(admin check/recover table/index:严重依赖 handle 是 int64 的假设,需要大改以支持 CommonHandle。
  • PointGet / BatchPointGet:需要把 common handle 解码到 chunk 中。
  • Insert:不再需要把主键列编码进行 value(主键信息已经蕴含在 row key 中)。
  • Update:若任一主键列发生变化,需要"删旧行 + 插新行"(因为主键即存储位置,主键变更等于行搬家)。
  • SplitTableRegion:需要重新设计——旧逻辑根据 region 的 start/end key 取中间 key,而 common handle 无法像整数那样简单取中值。

这些改动在当前仓库中已有大量落点,例如 pkg/ddl/executor.go、pkg/ddl/index_cop.go 中随处可见IsCommonHandle分支判断。

兼容性与系统变量:如何安全上线

设计文档给出的兼容策略是"向后兼容、按表启用、升级需显式开启",具体由两处机制承载:

  1. 表结构标记:在model.TableInfo中新增字段。当前定义见 pkg/meta/model/table.go:
// PKIsHandle is true when PK is clustered and a single integer column. PKIsHandle bool `json:"pk_is_handle"` // IsCommonHandle is true when PK is clustered and not a single integer column. IsCommonHandle bool `json:"is_common_handle"` // CommonHandleVersion is the version of the clustered index. // 0 for the clustered index created == 5.0.0 RC. // 1 for the clustered index created > 5.0.0 RC. CommonHandleVersion uint16 `json:"common_handle_version"`

配套提供了HasClusteredIndex()方法(pkg/meta/model/table.go)统一判断两种聚簇形态。所有 CommonHandle 相关逻辑都必须先检查IsCommonHandle再进入对应代码块。

  1. 全局系统变量tidb_enable_clustered_index:定义于 pkg/sessionctx/variable/sysvar.go,支持OFF/ON/INT_ONLY三个取值(INT_ONLY仅为兼容旧版本保留,会输出弃用警告),会话默认值见 pkg/sessionctx/variable/session.go。

启用语义:

  • 新集群:bootstrap 阶段直接写入tidb_enable_clustered_index = 1,默认开启聚簇索引;
  • 旧集群升级:需要用户显式执行SET GLOBAL tidb_enable_clustered_index = 1后才生效(避免升级瞬间改变既有表的存储布局);
  • 建表时机CREATE TABLE时若全局变量为1,则在TableInfo上设置IsCommonHandle = true(当前 DDL 建表路径见 pkg/ddl/create_table.go 中相关分支)。

实施路线与后续工作

设计文档给出的落地顺序如下:

implement the Handle interface for IntHandle and CommonHandle. | + refactor int64 handle to the Handle interface in all tidb packages. | +-+ implement the codec. | |-+ support codec in tikv. | | | +- support coprocessor indexScan | | | +- support coprocessor fastAnalyze | | | +- support coprocessor analyzeColumn | +-+ support common handle in planner. | |-+ support common handle in executors. | |-+ support common handle in ddl. | +-+ support common handle in other packages thats need minor change.

即:先落地 Handle 接口抽象 → 全量重构 int64 handle 为接口 → 实现 codec 与 TiKV 侧支持 → 再逐层改造 planner / executors / ddl。从当前仓库看,该路线已基本完成,Handle 接口还演进出了CopyDataMemUsage等额外能力,CommonHandleVersion字段记录了 5.0.0 RC 前后两代聚簇索引编码的差异。

设计文档最后列出的 Open Issues 是:TiFlash 与 CDC(TiCDC)也需要同步更新以支持聚簇索引——即列存副本与增量日志同步必须理解 CommonHandle 编码,否则主键变更(delete+insert)在复制链路上会产生错误。

小结

聚簇索引的本质是"主键值 = 行存储位置"。TiDB 通过把 handle 抽象为Handle接口,用IntHandle保住单整数列主键的零开销路径,用CommonHandle覆盖所有其他主键类型,并以"common_handle 与索引列共用同一套列值编码"为支点,撬动了从 TiKV codec、Coprocessor、Planner 到 Executor 的全链路改造;再通过IsCommonHandle表标记与tidb_enable_clustered_index全局变量实现了向后兼容的灰度上线。对于以多列主键为主业务模型的用户,这一特性直接消除了索引回表带来的读写放大,是理解 TiDB 行存储与主键语义的关键一课。

【免费下载链接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.项目地址: https://gitcode.com/GitHub_Trending/ti/tidb

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询