TiDB 表分区(Table Partition)设计与实现解析
【免费下载链接】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
表分区是 MySQL 用户广泛使用的一项核心能力,本文基于 TiDB 仓库中的设计提案文档 docs/design/2018-10-19-table-partition.md,系统梳理 TiDB 分区表从设计取舍、兼容性策略到读写路径、DDL 操作与限制的完整脉络,并结合当前仓库源码(pkg/table/tables/partition.go、pkg/meta/model/table.go、pkg/planner/core/rule/rule_partition_processor.go 等)还原其底层实现原理。读完本文,你将理解 TiDB 分区表"如何存储、如何读写、如何裁剪、如何演进",并能直接对照源码验证每一个关键结论。
背景:TiDB 为什么要支持表分区
MySQL 提供成熟的表分区(Table Partition)能力。在 TiDB 尚未支持分区时,社区大量场景无法在 TiDB 上落地,设计提案中归纳了三类典型收益:
- 按范围删除旧数据:对于按时间增长的数据,可以通过
DROP PARTITION直接摘除过期分区,避免昂贵的全表DELETE; - 缓解热点、提升写入性能:通过
PARTITION BY HASH将写入打散到多个分区,避免单点写热点; - 加速查询:利用分区裁剪(partition pruning),查询只需扫描命中的分区,比全表扫描更快。
设计目标与取舍
提案明确了"分步实现、问题驱动"的路线:
- 先实现 Range 分区,再实现 Hash 分区(当前仓库中已进一步演进为 Range、Hash、Key、List 等多种类型,见下文演进现状);
- 暂不支持子分区(subpartition);
- 暂不支持涉及数据搬移的 REORGANIZE PARTITION;
- 新功能以解决问题为导向,初期不必覆盖 MySQL 的全部行为;
- 语法必须保持 MySQL 兼容——这是 TiDB 从诞生起的承诺。对于包含未实现特性的分区表,TiDB 解析 SQL 后忽略其分区属性,将其当作普通表处理。
这一"解析但降级为普通表"的策略,是 TiDB 分区功能能够渐进式落地、不阻塞用户建表的关键设计。
兼容性与升级策略:PartitionInfo.Enable标志
分区功能引入前后存在三阶段兼容问题:
- 新 TiDB 运行在旧集群数据上;
- 旧 TiDB 运行在新集群数据上;
- 功能部分实现期间(如只实现了 Range、尚未实现 Hash)的升级过程。
提案给出的方案是在TableInfo中引入并持久化一个PartitionInfo.Enable标志:
- 新 TiDB 运行在旧集群上时,检查到该标志为
false,仍按普通表处理,行为与旧版本一致; - 旧 TiDB 无法运行在包含分区表数据的集群上,因此升级本身不兼容(但若新 TiDB 从未创建过分区表,即功能未被使用,集群仍可降级回旧版本);
- 在 Range 已实现而 Hash 未实现的阶段:
CREATE TABLE ... PARTITION BY HASH ...不会把Enable置为true,而CREATE TABLE ... PARTITION BY RANGE ...会置为true。
结论:只有当持久化的PartitionInfo.Enable为true且代码能够处理分区表时,分区功能才真正生效。
这一标志在今天的源码中依然存在并发挥着同样的作用。在 pkg/meta/model/table.go 的PartitionInfo结构体中:
type PartitionInfo struct { Type ast.PartitionType `json:"type"` Expr string `json:"expr"` Columns []ast.CIStr `json:"columns"` // User may already create table with partition but table partition is not // yet supported back then. When Enable is true, write/read need use tid // rather than pid. Enable bool `json:"enable"` ... Definitions []PartitionDefinition `json:"definitions"` Num uint64 `json:"num"` }注释明确说明:Enable为true时,读写需使用分区 ID(pid)而非表 ID(tid);为false时则按老逻辑处理。该结构后续还演进出了AddingDefinitions、DroppingDefinitions(分区 ADD/DROP/TRUNCATE 中间态)、DDLAction、DDLState等字段,用于支撑在线 DDL 状态机,这也是后续版本支持 REORGANIZE、EXCHANGE 等复杂操作的基础。
实现原理:分区表如何存储
TiDB 存在两级映射:SQL 数据 → 逻辑 key 区间 → 物理存储(TiKV)。普通表在编码时以table id + row id作为 key、行数据作为 value;逻辑 key 区间再被切分为 Region 分布到 TiKV。
分区作用于第一级映射:分区 ID 被视作与表 ID 等价,分区表的一行数据使用partition id + row id作为编码后的 key,且分区 ID 在集群范围内唯一。表 ID 与分区 ID 的对应关系维护在TableInfo中。
插入分区表的新行若不属于任何分区,则写入操作失败。NULL值的行为参照 MySQL 文档(例如 Range 分区中NULL会被归入最小的分区等规则)。
源码中这一"分区 ≈ 表"的建模体现得十分直接。在 pkg/table/tables/partition.go:
// Both partition and partitionedTable implement the table.Table interface. var _ table.PhysicalTable = &partition{} var _ table.Table = &partitionedTable{} // partition is a feature from MySQL: ... // A partition table may contain many partitions, each partition has a unique partition // id. The underlying representation of a partition and a normal table (a table with no // partitions) is basically the same. type partition struct { TableCommon table *partitionedTable } type partitionedTable struct { TableCommon partitionExpr *PartitionExpr partitions map[int64]*partition ... }注释与设计文档一一对应:"每个分区拥有唯一的 partition id,分区与普通表在底层表示上基本一致"。partition内嵌了TableCommon(与普通表共享的行/索引编码逻辑),因此对 TiKV 而言,一个分区就是一张独立的"表"。测试辅助函数也印证了这一编码方式,pkg/table/tables/partition.go 中PartitionRecordKey(pid, handle)直接用分区 ID 生成记录前缀并编码记录 key。
读取路径:UnionAll 展开与分区裁剪
逻辑展开:DataSource → UnionAll
提案以 Range 分区表为例,说明读取等价关系:
CREATE TABLE t (id INT) PARTITION BY RANGE (id) (PARTITION p1 VALUES LESS THAN (10), PARTITION p2 VALUES LESS THAN (20), PARTITION p3 VALUES LESS THAN (30));查询SELECT * FROM t等价于:
SELECT * FROM (UNION ALL SELECT * FROM p1 WHERE id < 10 SELECT * FROM p2 WHERE id < 20 SELECT * FROM p3 WHERE id < 30);在逻辑优化阶段,DataSource算子被改写为UnionAll算子,每个分区在物理优化阶段生成各自的TableReader。这一实现存在两个已知缺点:
- 分区数量很多时会产生大量 reader,
EXPLAIN结果对用户不友好; UnionAll算子无法保持有序性,若下游算子需要有序结果(如IndexReader),需要额外引入Sort算子。
当前源码中的PartitionProcessor规则正是这一设计的直接继承。在 pkg/planner/core/rule/rule_partition_processor.go 中,其文件头注释完整保留了上述等价改写示例,并说明"PartitionProcessor 之所以存在,是因为在谓词下推之后更容易做分区裁剪",其Optimize通过rewriteDataSource完成改写。同时注释注明它服务于静态分区裁剪模式(static partition prune mode),说明后续 TiDB 又演进出了动态裁剪模式以缓解 reader 过多的问题。
若使用分区选择(partition selection)语法(如SELECT * FROM t PARTITION (p1)),优化器应将表 ID 转换为分区 ID,并关闭分区裁剪。
分区裁剪(Partition Pruning)
分区裁剪在谓词下推之后、逻辑优化阶段执行:
- Range 分区:基于分区列上的范围过滤条件进行裁剪;
- Hash 分区:当过滤条件形如
key = const时可以进行裁剪。
结合上文:裁剪先于DataSource改写执行(谓词下推 → 裁剪 → UnionAll 展开),只有被判定命中的分区才会进入最终的读取计划。对应测试可在 pkg/planner/core/casetest/partition/partition_pruner_test.go 中找到,覆盖各类分区类型的裁剪用例。
写入路径:locatePartition + AddRecord
所有写操作最终都会调用table.AddRecord之类的接口方法。因此分区表的写实现,就是在PartitionedTable结构体上实现该接口:
PartitionedTable实现table.Table接口,并重载AddRecord方法;- 同时提供一个
locatePartition方法,用于决定一行数据应插入到哪个分区; - 每个分区各自维护独立的索引数据,插入操作必须保证数据与索引的一致性。
当前源码完整继承了这一设计。在 pkg/table/tables/partition.go 中:
func (t *partitionedTable) AddRecord(ctx table.MutateContext, txn kv.Transaction, r []types.Datum, opts ...table.AddRecordOption) (recordID kv.Handle, err error) { return partitionedTableAddRecord(ctx, txn, t, r, nil, opts) } func partitionedTableAddRecord(ctx table.MutateContext, txn kv.Transaction, t *partitionedTable, r []types.Datum, partitionSelection map[int64]struct{}, opts []table.AddRecordOption) (recordID kv.Handle, err error) { opt := table.NewAddRecordOpt(opts...) pid, err := t.locatePartition(ctx.GetExprCtx().GetEvalCtx(), r) if err != nil { return nil, errors.Trace(err) } ... tbl := t.getPartition(pid) recordID, err = tbl.addRecord(ctx, txn, r, opt) ... }写入流程清晰可见:先locatePartition定位分区,再委派给该分区的addRecord写入。若指定了partitionSelection(对应 SQL 中的分区选择),还会校验定位到的分区是否在允许集合内,否则返回ErrRowDoesNotMatchGivenPartitionSet——这正是"插入的行不属于任何分区则操作失败"以及分区选择语义的落地。
locatePartition的底层分发逻辑在locatePartitionCommon中(pkg/table/tables/partition.go):按分区类型switch,Range 分区走locateRangePartition/locateRangeColumnPartition,Hash 走locateHashPartition,Key 走LocateKeyPartition,List 走locateListPartition。可以推断,随着分区类型从最初"Range、Hash"两类扩展到 Key、List,定位逻辑也被逐步泛化到同一个方法中,但"定位 → 委派写入"的整体架构与提案一致。而GetPartitionByRow(pkg/table/tables/partition.go)则是locatePartition的只读复用:读路径同样通过行数据定位物理分区。
DDL 操作与分区管理
DROP / TRUNCATE / ADD
提案阶段,DROP PARTITION、TRUNCATE PARTITION、ADD PARTITION三种操作作用于 Range 分区:
DROP PARTITION与DROP TABLE类似,区别在于使用分区 ID;操作完成后需更新TableInfo。特别提醒:若要删除表中最后一个分区,应使用DROP TABLE而非DROP PARTITION;TRUNCATE PARTITION清空分区内的全部数据和索引,但保留分区本身。
对应的 DDL 实现位于 pkg/ddl/partition.go。例如在 ADD PARTITION 时会对分区数量做上限检查(pkg/ddl/partition.go):
// The last loop still not reach the max value, return error. if i == mysql.PartitionCountLimit-1 { return errors.Trace(dbterror.ErrTooManyPartitions) } if len(tbInfo.Partition.Definitions)+len(partDefs) > mysql.PartitionCountLimit { return errors.Trace(dbterror.ErrTooManyPartitions) }而checkAddPartitionTooManyPartitions(pkg/ddl/partition.go)在物理阶段再次校验新增分区数,与上述逻辑构成双重防线。
分区管理语句
提案时期,下列分区管理语句可以解析但暂时忽略(即不报错也不执行实际语义),为后续能力逐步补齐预留语法入口:
ALTER TABLE ... REBUILD PARTITION ... ALTER TABLE ... OPTIMIZE PARTITION ... ALTER TABLE ... ANALYZE PARTITION ... ALTER TABLE ... REPAIR PARTITION ... ALTER TABLE ... CHECK PARTITION ... ALTER TABLE ... DROP/TRUNCATE PARTITION ALTER TABLE ... ADD PARTITION SHOW CREATE TABLE SHOW TABLE STATUS INFORMATION_SCHEMA.PARTITIONS 表需要说明的是,这是 2018 年提案的初始范围。当前仓库中部分语句已具备完整实现,例如SHOW CREATE TABLE会结合PartitionInfo.IsEmptyColumns等字段还原建表语句,ANALYZE TABLE t PARTITION a ...的分区级分析语法也已在 pkg/parser/parser_test.go 中有对应解析测试;分区 REORGANIZE、EXCHANGE 等涉及数据搬移的操作也已在后续演进中实现(可见 pkg/ddl/tests/partition/exchange_partition_test.go 与 pkg/ddl/tests/partition/reorg_partition_test.go)。提案规划的是最小可行路径,而非最终能力的边界。
限制(Limitations)
提案明确引用 MySQL 的分区限制文档,并列出 TiDB 当时的约束:
- 分区键不能是整型列之外的列,或最终解析为整型(或
NULL)的表达式; - 分区键不能是子查询,即使子查询解析为整型;
- 分区数量上限:MySQL 为 8192,TiDB 当时为 1024;
- 分区表达式中允许使用的函数需参考 MySQL 的分区函数限制列表。
关于分区数上限,当前仓库已对齐 MySQL 标准:在 pkg/parser/mysql/const.go 中:
// PartitionCountLimit is limit of the number of partitions in a table. // Reference linking https://dev.mysql.com/doc/refman/5.7/en/partitioning-limitations.html. PartitionCountLimit = 8192即当前版本 TiDB 的单表分区数上限为8192,与 MySQL 一致(作为对比,提案当时规划为 1024,属于早期保守取值)。超出该限制会抛出dbterror.ErrTooManyPartitions,相关测试用例(如 pkg/ddl/tests/partition/db_partition_test.go 中PARTITION BY HASH(store_id) PARTITIONS 102400000000的用例)验证了这一约束的生效。
演进现状:从提案到当前实现
作为 2018 年的设计提案,其核心架构经受住了时间的检验,并在当前仓库中持续演进:
| 维度 | 提案初始范围 | 当前仓库状态 |
|---|---|---|
| 分区类型 | 先 Range、后 Hash | Range / Hash / Key / List(locatePartitionCommon中四类分派齐全) |
| 分区数上限 | 1024 | 8192(pkg/parser/mysql/const.go) |
| 元数据 | PartitionInfo.Enable标志 | 增加AddingDefinitions/DroppingDefinitions、DDLAction/DDLState等在线 DDL 中间态字段(pkg/meta/model/table.go) |
| 分区裁剪 | 逻辑优化阶段执行 | 静态裁剪(PartitionProcessor)+ 动态裁剪并存 |
| DDL 操作 | DROP/TRUNCATE/ADD | 进一步支持 REORGANIZE、EXCHANGE、ADD/REMOVE PARTITIONING 等 |
无论功能如何扩展,"分区在存储层等价于独立表、在读写层通过定位函数分发到具体分区"这一从提案确立的两层模型始终未变——这正是该设计文档最具价值的核心结论,也使其成为理解 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),仅供参考