首页
/ TiDB 整型分片索引(Integer Shard Index)设计与实现深度解析

TiDB 整型分片索引(Integer Shard Index)设计与实现深度解析

2026-09-08 11:45:08作者:昌雅子Ethen

本篇文章围绕 TiDB 设计文档 Integer shard index 展开:当业务对某一列做单调递增写入时,基于该列的唯一索引会退化为"热索引"(hot index),成为集群横向扩展的瓶颈;整型分片索引通过在唯一索引最前方嵌入 tidb_shard(a) 表达式前缀将写入打散到多个哈希分桶,同时由优化器自动补条件,使业务侧点查/点更新 SQL 完全无需改动。读完本文,你将掌握该特性的设计动机、tidb_shard() 函数原理、优化器改写链路,以及它的全部适用边界与已知限制。

问题的起点:单调递增写入产生的"热索引"

假设业务表定义如下,服务对字段 id2 单调递增地写入数据,同时通过 hotIndex 执行点查(point SELECT)与点更新(point UPDATE):

CREATE TABLE test(id1 INT PRIMARY, id2 INT, id3 INT, UNIQUE KEY hotIndex(id2));

INSERT INTO test values(val1,val2,val3);
UPDATE test SET id3 = val3 WHERE id2 = val2;
SELECT * FROM test WHERE id2 = val2;

在 TiDB 这类基于 KV 的分布式存储中,索引数据按 key 有序地分布在多个 TiKV Region 上。当写入值 id2 单调递增时,新写入的索引条目永远落在 key 空间的同一端,始终由少数(极端情况下是单个)Region/Leader 承接写入,形成"热索引"。热点无法随 TiKV 节点扩容被分摊,写入扩展性因此被锁死。

设计文档 Summary 章节 直接点明了问题与对策:热索引降低了写入数据单调递增场景下 TiDB 集群的写入可扩展性,提案采用"表达式索引"来打散热索引,这个新表达式索引被称为 shard index(分片索引)。

改造后的表定义如下——注意唯一索引 hotIndex 最前方多了一个 tidb_shard(id2) 表达式列:

CREATE TABLE test(id1 INT PRIMARY, id2 INT, id3 INT, UNIQUE KEY hotIndex((tidb_shard(id2)),id2));

改造后业务 SQL 需要改吗?不需要。优化器能识别出分片索引,并自动在 WHERE 子句中补上 tidb_shard(id2) = shardVal,改写后的最终形态是:

UPDATE test SET id3 = val3 WHERE tidb_shard(id2) = 8 AND id2 = 100;
SELECT * FROM test WHERE tidb_shard(id2) = 8 AND id2 = 100;

业务 SQL 原样可用,仍然走唯一索引 hotIndex,但索引数据本身已被哈希打散。

什么是分片索引:判定规则

一个索引要成为"分片索引",必须同时满足以下两条规则(见设计文档 Detailed design):

  • 它必须是一个唯一索引(unique index)
  • 它的第一个字段是 tidb_shard(a),其中列 a 是整型,并且是索引的第二个字段

换言之,合法形态形如 UNIQUE KEY uk((tidb_shard(a)), a, ...)。打散后 tidb_shard(a) 只可能取值 0~255 共 256 个分桶,配合紧随其后的原列 a(shard(a), a) 二元组与原唯一键 a 一一对应,唯一性约束依然成立——这是能把它做成"唯一索引前缀"的关键前提。

核心机制一:tidb_shard() 内置函数

语义与取值

设计文档规定 tidb_shard() 只支持一个整型参数,把输入的整数变换到 [0, 255] 区间,例如 tidb_shard(100) 的结果是 8

实现原理

实现上是先对输入参数计算哈希值,再对 256 取模。函数类与执行签名位于 pkg/expression/builtin_miscellaneous.go,分桶数常量定义为:

const (
	tidbShardBucketCount = 256
)

核心求值逻辑如下(源码与设计文档一致,仅上下文参数类型随版本演进由 chunk.Row 演化为 EvalContext):

// evalInt evals tidb_shard(int64).
func (b *builtinTidbShardSig) evalInt(ctx EvalContext, row chunk.Row) (int64, bool, error) {
	shardKeyInt, isNull, err := b.args[0].EvalInt(ctx, row)
	if isNull || err != nil {
		return 0, true, err
	}
	var hashed uint64
	if hashed, err = vitess.HashUint64(uint64(shardKeyInt)); err != nil {
		return 0, true, err
	}
	hashed = hashed % tidbShardBucketCount
	return int64(hashed), false, nil
}

几个值得注意的实现细节:

  • 哈希算法来自 vitess 的 HashUint64:把 64 位无符号整数做确定性哈希,输出均匀且可复现,保证同一输入永远得到同一分桶值;
  • 参数为 NULL 时返回 (0, true, err):即结果为 NULL,写入时不会为 NULL 值生成可匹配的前缀条件;
  • 结果类型为无符号 4 字节整型getFunction 中设置 bf.tp.SetFlen(4)AddFlag(mysql.UnsignedFlag),并注册了向量化执行码 tipb.ScalarFuncSig_TiDBShard,说明该函数可下推/向量化执行;
  • 函数注册在全局函数表中pkg/expression/builtin.go 中以 ast.TiDBShard 键绑定 &tidbShardFunctionClass{},SQL 层可直接书写 tidb_shard(...)

单元测试 pkg/expression/builtin_miscellaneous_test.go 固化了几个典型映射:tidb_shard(-1) == 81tidb_shard(0) == 167tidb_shard(1) == 214tidb_shard(9999999999999999) == 63;字符串参数(如 "abc")一律归一到同一个桶值 167(测试断言 tidb_shard("string") always return 167)。

为什么能打散热点

因为单调递增的 id2(如 1、2、3…100000)经哈希后均匀散落到 0~255 各分桶,索引 key 前缀不再单调,写入被摊开到 256 个不同 key 区间,热点随之被分散到不同 Region。这一点正是"热索引→分片索引"能提升扩展性的本质。

核心机制二:优化器自动补前缀条件

仅有打散还不够——原始查询 WHERE id2 = 100 无法直接匹配形如 (tidb_shard(id2), id2) 的索引(因为缺少最左前缀 tidb_shard(id2))。因此设计文档为 TiDB 优化器增加了一条规则:

当查询的访问条件已经包含分片索引中除首个 tidb_shard(a) 表达式之外的所有字段时,优化器自动把 tidb_shard(a) = xxx 拼接到访问条件中。

原设计在 DataSource.PredicatePushDown 阶段完成该改写。整体调用链自上而下为:

(DataSource) PredicatePushDown
        │  PropagateConstant / DeleteTrueExprs
        ▼
AddPrefix4ShardIndexes            只处理含分片索引(expr prefix uk)的数据源
        ▼
addExprPrefixCond                 取索引前缀列,构造 exprPrefixAdder
        ▼
addExprPrefix4ShardIndex          按 DNF(OR) / CNF(AND·IN) 分流
        ├── addExprPrefix4DNFCond    处理 LogicOr 表达式
        └── addExprPrefix4CNFCond    处理 AND/IN,转调 ranger 包
                 ▼
ranger.AddExpr4EqAndInCondition    核心:EQ 与 IN 补前缀
                 ├── NeedAddGcColumn4ShardIndex  是否值得补
                 └── AddGcColumnCond ── AddGcColumn4EqCond / AddGcColumn4InCond

入口:PredicatePushDown 阶段执行改写

设计文档说明:改写入口放在访问条件下推之前(理想位置是语法解析后的逻辑计划构建阶段,为简化实现放在此处,源码里留有 TODO 注释)。在最新仓库中该逻辑从 DataSource 方法迁入了规划器规则代码:

addPrefix4ShardIndexes 的逻辑骨架:数据源 ContainExprPrefixUk 为真时,遍历所有可能访问路径,跳过表路径与非分片唯一索引路径,对剩余路径逐个调用 addExprPrefixCond

func addPrefix4ShardIndexes(lp base.LogicalPlan, sc base.PlanContext, conds []expression.Expression) []expression.Expression {
	ds := lp.(*logicalop.DataSource)
	if !ds.ContainExprPrefixUk {
		return conds
	}
	newConds := conds
	for _, path := range ds.AllPossibleAccessPaths {
		if !path.IsUkShardIndexPath {
			continue
		}
		newConds, err = addExprPrefixCond(ds, sc, path, newConds)
		// ...
	}
	return newConds
}

DNF 与 CNF 的分流处理

exprPrefixAdder.addExprPrefix4ShardIndex() 负责判断原始条件是"逻辑或"还是"逻辑与"形态,据此调用不同的处理函数(rule_predicate_push_down.go):

  • 若条件为单个 LogicOr(如 a = 1 OR a = 10),走 addExprPrefix4DNFCond
  • 其余情况(a = 1a = 1 AND b = 10a IN (1,2,3) 等)一律走 addExprPrefix4CNFCond

DNF(OR 表达式)处理流程:先从 LogicOr 中拆出每个子项 dnfItems;遍历每个子项——若子项自身是 LogicAnd,继续拆出 cnfItems 交给 addExprPrefix4CNFCond 处理;若子项是 ast.EQast.In,则用 addExprPrefix4CNFCond([]Expression{item}) 处理;最后把所有处理结果用 LogicOr 重新拼接。例如 WHERE a = 1 OR a = 10 会被改写为:

WHERE (tidb_shard(a) = 214 AND a = 1) OR (tidb_shard(a) = 142 AND a = 10)

CNF(AND 表达式处理流程)addExprPrefix4CNFCond 直接调用 ranger 包的核心函数 ranger.AddExpr4EqAndInCondition(见 pkg/util/ranger/detacher.go)。

核心改写函数 AddExpr4EqAndInCondition

该函数负责真正往访问条件里补 tidb_shard(x) = xxx,处理流程分为三步:

  1. 遍历每个 cond,按索引定义的字段顺序归位到 accesses 切片(例如索引是 uk(tidb_shard(a), a, b)WHERE b = 1 AND a = 2 AND c = 3 只把 a = 2b = 1 归入该索引的访问条件);
  2. 遍历 accesses,仅当 condast.EQast.In 时提取列值存入 columnValues
  3. 调用 NeedAddGcColumn4ShardIndex 判断是否真的需要补前缀,需要时再调用 AddGcColumnCond 完成改写。

以单条件 WHERE a = 1 为例,产物为 WHERE tidb_shard(a) = 214 AND a = 1;以 WHERE a IN (1, 2, 3) 为例,产物为:

WHERE (tidb_shard(a) = 214 AND a = 1)
   OR (tidb_shard(a) = 143 AND a = 2)
   OR (tidb_shard(a) = 156 AND a = 3)

前置校验与条件补全

NeedAddGcColumn4ShardIndexdetacher.go)用于把关是否值得补前缀,判定三要素:

  • 分片索引的列数必须多于 2,形如 (tidb_shard(a), a, ...)
  • 索引第一列必须是生成列(generated column),且其表达式是 tidb_shard 内置函数——对应表达式层辅助函数 GcColumnExprIsTidbShard(见 pkg/expression/column.go);
  • tidb_shard 的参数必须是列(不能是复杂表达式),且该列必须是索引第二列。

AddGcColumnConddetacher.go)是分发函数:根据第二列访问条件 accessesCond[1] 的具体类型分发:

  • ast.EQAddGcColumn4EqConddetacher.go):遍历 columnValues,逐一算出 tidb_shard 取值存入 evaluated,再把新表达式 tidb_shard(columnName) = evaluated 写入 accessesCond[0]
  • ast.InAddGcColumn4InConddetacher.go):遍历 IN 的每个参数,逐个构造 tidb_shard(a) = value AND a = argLogicAnd,多个子项再以 LogicOr 串接。

补充说明:因为分片索引第一列是 tidb_shard(a) 这种"虚拟生成列"形态,代码中对它的识别、克隆、是否可分享等处理在 pkg/expression/column_test.go 有对应断言(如 tidb_shard(a) 能被 GcColumnExprIsTidbShard 识别、普通 a = 1 不能)。

实践:如何定义与验证分片索引

综合上述设计与源码,落地使用分片索引时建议遵循如下步骤:

1. 判定场景。确认业务满足:对某整型列单调递增/递减写入(如自增 id、时间戳换算的整数、流水号),并且该列承载高频等值点查与点更新

2. 改写 DDL。把原唯一索引 uk(a) 改写为 uk((tidb_shard(a)), a),即保持 a 仍作为唯一键主体(保证唯一性不受影响),在最前方插入 tidb_shard(a) 前缀:

CREATE TABLE test(id int primary key clustered,
                  a int,
                  b int,
                  unique key idx_a((tidb_shard(a)), a));

或者对存量表使用 ALTER TABLE ... ADD UNIQUE KEY 追加此类索引。

3. 业务 SQL 保持原样。继续写 WHERE a = 100WHERE a IN (100, 200),优化器会按前文链路自动改写;也支持在查询里显式书写 tidb_shard(a) = 值

4. 用 EXPLAIN 验证走索引。观察执行计划:应当看到访问条件里出现 tidb_shard(a) = ... AND a = ...,并命中该唯一索引(PointGet/IndexRangeScan 取决于改写后形态),而不是全表扫描。

5. 留意限制。该列相关的非等值条件、GROUP BY/ORDER BYJOIN ON、子查询等场景下无法复用此索引,详见下文"边界与限制"。

效果量化:热索引 vs 分片索引的扩展性

设计文档给出了同一套基准(800 线程,TiDB×3,从 3 个 TiKV 扩容到 4 个 TiKV)下的对比数据,扩展性计算公式为:以扩容前 QPS qps1、TiKV 节点数 tikv1,扩容后 QPS qps2、节点数 tikv2 计(公式图示见设计文档 .\imgs\scalability-formula.png)。

热索引(hot index)扩展性

threads tidb tikv tidb CPU tikv CPU duration(95) QPS(K) scalability remark
800 3 3 4700 3350 insert: 60.5 update:110 49.8
800 3 3 4400 3000 insert: 61 update:111 49
800 3 4 5000 3500 insert: 57 update:103 53 19.30% round1
800 3 4 5100 3600 insert: 56 update:100 54 30.60% round2

分片索引(shard index)扩展性

threads tidb tikv tidb CPU tikv CPU duration QPS scalability remark
800 3 3 5200 3100 insert: 60 update:110 47.5
800 3 3 5200 3000 insert: 60 update:110 47.2
800 3 4 6200 3800 insert: 56.5 update:88 57.5 63.20% round1
800 3 4 6250 3800 insert: 56.5 update:88 58.5 71.40% round2

(以上数据为设计文档撰写时期该提案的基准测量结果,作为了解扩展性提升量级的参考。)可以看到:扩容 1 个 TiKV(3→4)时,热索引方案的 QPS 提升约 19%~31%,而分片索引方案提升约 63%~71%,接近线性扩展;代价是基线上 CPU 略高(分片引入哈希与多 Region 写放大)。

设计取舍:Drawbacks 与 Alternatives

代价(Drawbacks):当集群规模小、负载压力不高时,用分片索引替换热索引会带来少量性能下降;但它能显著提升可扩展性。本质上是"单点最优 → 全局可扩展"的权衡:哈希打散让任何一次写入都只落在 1/256 的分桶,牺牲了局部顺序性,换来写入压力的均匀分布。

备选方案(Alternatives):另一种更简单的思路是直接用表达式索引整体替换普通索引,例如把 index idx(a) 换成 index idx(expr(a))。设计文档明确否定了该方案,理由很关键:

  • expr(a)(a) 并非一一对应(同一 a 可能命中多个 expr(a) 取值、不同值可能坍缩到同一表达式值),唯一性语义会被破坏;
  • PointGet 计划会退化为 IndexRangeScanWHERE a = xxx 的过滤必须上提到 TiDB-server 或 TiKV 执行,性能损失明显。

而本方案把表达式放在索引前缀、同时保留原列 a 紧随其后,既维持了 (tidb_shard(a), a) 与原唯一键的一一映射,又让优化器能精确补出 tidb_shard(a) = 确定值,查询仍可退化到最短路径。

边界与限制(设计文档列出的未决问题)

分片索引并非万能,设计文档在 Unresolved questions 中逐条给出了适用边界:

1. 仅支持唯一二级索引

分片索引只对唯一二级索引有效,不适用于主键或非唯一二级索引。合法形态必须满足前文两条规则。

2. 非等值条件无法使用索引

优化器只对 WHERE 中的等值条件tidb_shard(?) = ?。例如唯一索引 uk(a) 原本可服务 SELECT * FROM A WHERE a > 100,改成 uk(tidb_shard(a), a) 后,该范围查询不再选用此索引。

3. AND 与 OR 混用无法使用索引

只对"全 AND"或"全 OR"形态补前缀。若 WHERE 中 AND 与 OR 混用(如 WHERE ((a=100 AND b=100) OR a=200) AND b=300),改写后无法再使用该索引。

4. GROUP BY 无法使用索引

前缀只在 WHERE 等值条件上补充,对 GROUP BY 不做处理。如 SELECT SUM(a) FROM A GROUP BY a 原可走 uk(a),改造后无法再使用索引,分组需在 TiDB-server 端完成。

5. ORDER BY 无法使用索引

同理,SELECT a FROM A ORDER BY a 原可利用 uk(a) 的有序性,改造后索引前缀被哈希打乱、不再有序,排序需回到 TiDB-server 端执行。

6. JOIN 的 ON 条件无法使用索引

前缀补充只针对 WHERE 子句,对 JOIN ... ON 条件无效。SELECT * FROM B JOIN A ON B.a = A.a 改造后不再能使用该索引做连接访问。

7. WHERE 子查询无法使用索引

WHERE A.a IN (SELECT B.a FROM B WHERE B.a > 100) 这类带子查询的条件同样不补前缀,无法使用分片索引。

8. 联合索引部分列命中可能失效

如果查询只使用联合索引的一部分前缀列作为等值条件,改造后可能导致原本可用的索引访问路径失效。

9. FastPlan 失效

FastPlan 快速规划路径不执行 tidb_shard(?) = ? 前缀补充,因此 FastPlan 无法识别分片索引。原本走 SELECT * FROM A WHERE A.a = 100 直接产出 PointGet 的 FastPlan 场景,在索引改造后不再适用。

10. 无法使用 Prepare Plan Cache

分片索引首列 tidb_shard(a) 本质是一个隐藏的生成列;TiDB 中凡表上存在生成列,相关查询便无法使用 Prepare Plan Cache。因此带分片索引的表无法受益于 Prepare Plan Cache。

小结

整型分片索引是针对"单调写 + 等值读/改"负载的一种精准优化:tidb_shard() 把整型列哈希并取模到 256 个分桶,作为唯一索引最左前缀打散热点写入;优化器在谓词下推前自动为等值条件补上 tidb_shard(a) = val,从而在不改动业务 SQL 的前提下继续命中原唯一索引。从源码可以确认,它的完整链路贯穿表达式层(builtin_miscellaneous.go)、谓词下推规则层(rule_predicate_push_down.go)与 ranger 访问路径构建层(detacher.go),并配有 builtin_miscellaneous_test.go 等单元测试验证映射行为。

选择该特性的合理姿势是:确认写入列单调且承载等值访问 → 按 uk((tidb_shard(a)), a) 形态建唯一二级索引 → 用 EXPLAIN 验证自动补条件与索引命中。同时必须清醒认识其边界:它牺牲了点查极致的 PointGet/FastPlan 与缓存/排序类优化,换取的是横向扩容时写入吞吐接近线性增长——是否采用,取决于业务对"单机峰值性能"与"集群规模扩展性"的取舍。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.79 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
390