TiDB 整型分片索引(Integer Shard Index)设计与实现深度解析
本篇文章围绕 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) == 81、tidb_shard(0) == 167、tidb_shard(1) == 214、tidb_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 方法迁入了规划器规则代码:
- pkg/planner/core/operator/logicalop/logical_datasource.go 的
PredicatePushDown中调用utilfuncp.AddPrefix4ShardIndexes(ds, ds.SCtx(), predicates); - 具体的
exprPrefixAdder结构与addPrefix4ShardIndexes函数位于 pkg/planner/core/rule_predicate_push_down.go; - 访问路径层面,
AccessPath携带IsUkShardIndexPath标志(见 pkg/planner/util/path.go),只有唯一分片索引对应的路径才会触发前缀补充。
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 = 1、a = 1 AND b = 10、a IN (1,2,3)等)一律走addExprPrefix4CNFCond。
DNF(OR 表达式)处理流程:先从 LogicOr 中拆出每个子项 dnfItems;遍历每个子项——若子项自身是 LogicAnd,继续拆出 cnfItems 交给 addExprPrefix4CNFCond 处理;若子项是 ast.EQ 或 ast.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,处理流程分为三步:
- 遍历每个
cond,按索引定义的字段顺序归位到accesses切片(例如索引是uk(tidb_shard(a), a, b),WHERE b = 1 AND a = 2 AND c = 3只把a = 2、b = 1归入该索引的访问条件); - 遍历
accesses,仅当cond是ast.EQ或ast.In时提取列值存入columnValues; - 调用
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)
前置校验与条件补全
NeedAddGcColumn4ShardIndex(detacher.go)用于把关是否值得补前缀,判定三要素:
- 分片索引的列数必须多于 2,形如
(tidb_shard(a), a, ...); - 索引第一列必须是生成列(generated column),且其表达式是
tidb_shard内置函数——对应表达式层辅助函数GcColumnExprIsTidbShard(见 pkg/expression/column.go); tidb_shard的参数必须是列(不能是复杂表达式),且该列必须是索引第二列。
AddGcColumnCond(detacher.go)是分发函数:根据第二列访问条件 accessesCond[1] 的具体类型分发:
ast.EQ→AddGcColumn4EqCond(detacher.go):遍历columnValues,逐一算出tidb_shard取值存入evaluated,再把新表达式tidb_shard(columnName) = evaluated写入accessesCond[0];ast.In→AddGcColumn4InCond(detacher.go):遍历IN的每个参数,逐个构造tidb_shard(a) = value AND a = arg的LogicAnd,多个子项再以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 = 100、WHERE a IN (100, 200),优化器会按前文链路自动改写;也支持在查询里显式书写 tidb_shard(a) = 值。
4. 用 EXPLAIN 验证走索引。观察执行计划:应当看到访问条件里出现 tidb_shard(a) = ... AND a = ...,并命中该唯一索引(PointGet/IndexRangeScan 取决于改写后形态),而不是全表扫描。
5. 留意限制。该列相关的非等值条件、GROUP BY/ORDER BY、JOIN 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计划会退化为IndexRangeScan,WHERE 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 与缓存/排序类优化,换取的是横向扩容时写入吞吐接近线性增长——是否采用,取决于业务对"单机峰值性能"与"集群规模扩展性"的取舍。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00