第 12 篇 讲清楚了 Coprocessor 在单个 Region 上执行 DAG 的机制。但一条 SQL 从进入 TiDB 到变成一批发往具体 Region 的 Coprocessor 请求,中间还有一层没讲:TiDB 怎么知道该往哪些 Region 发、发几份、发错了怎么重试。这一层常被读者跳过——很容易把它和”优化器怎么选执行计划”混在一起讨论,结果讲成了一篇通用查询优化教程。
本文严格限定边界:只讲计划如何路由到 Region,不讲优化器内部怎么估代价、怎么选 join 顺序。TiDB 的解析、逻辑优化、物理优化、代价模型细节,本系列不复述;需要通用查询优化机制(CBO、统计信息、join 重排)时,请参考 query-engine 系列,那里针对 Trino/Spark/DuckDB 讲的 planner 结构可以类比阅读,但不是 TiDB 的实现细节。
版本锚定:TiDB 7.x/8.x。模块划分依据 TiDB Development Guide(A 级,官方开发文档);Region 路由实现依据 tikv/client-go(A 级源码)。
一、SQL 处理六步与本文的落点
TiDB 开发指南把一条 SQL 的处理拆成六步:语法解析(parsing)→ 合法性校验 → 构建查询计划 → 优化查询计划 → 依据计划生成执行器 → 执行并返回结果。这六步分别落在几个 Go 包里:
| 阶段 | 包 | 本文是否展开 |
|---|---|---|
| 协议层 | pingcap/tidb/server |
不展开 |
| 语法解析 | pingcap/tidb/parser |
不展开 |
| 计划构建与优化 | pingcap/tidb/planner |
不展开(CBO、join 重排属优化器内部逻辑) |
| 执行器生成与执行 | pingcap/tidb/executor |
只看它如何调用下一层 |
| KV 请求组装与聚合 | pingcap/tidb/distsql |
本文主线 |
| KV 客户端与 Region 路由 | tikv/client-go(pingcap/tidb/kv
接口之下) |
本文主线 |
distsql
包的职责用官方开发指南的话说是”把请求发给 TiKV,并聚合 TiKV
返回的结果”——这正是”计划落到 Region”这一层的入口。
flowchart LR
P["Parser<br/>AST"] --> PL["Planner<br/>logical + physical plan<br/>(not covered here)"]
PL --> EX["Executor<br/>Volcano-style operators"]
EX --> DS["distsql<br/>build CopTask per Region"]
DS --> CG["client-go RegionCache<br/>route by key range"]
CG --> TK["TiKV Coprocessor<br/>(见第 12 篇)"]
TK --> DS
DS --> EX
EX --> CLIENT["Result to client"]
二、从 key range 到 Region:谁负责切分
物理计划里的
TableScan/IndexScan
算子携带的是逻辑上的 key range(例如”表 t
的全表范围”或”索引上某个前缀范围”)。这个 key range
和具体落在哪些 Region 无关——range 是 SQL
语义层面的概念,Region 是存储层面的物理分片。
把 range 映射到 Region 的工作由 distsql 与
client-go 的 RegionCache
完成:
distsql拿到执行器需要读取的 key range 列表;- 调用
RegionCache,按 Region 的[StartKey, EndKey)边界把这些 range 切成若干段,每段对应一个已知的 Region; - 为每个 Region 生成一个 CopTask(Coprocessor Task),包含该 Region 的 ID、epoch、副本地址、以及落在这个 Region 内的 key range 子集;
- 把编译好的 DAG(第 12
篇)和 CopTask 一起打包成
Coprocessor.Request,发往对应 TiKV 节点。
RegionCache 本地缓存的是”哪个 key range
属于哪个 Region、Region 的 Leader 在哪”,这份缓存来自 PD(第
7 篇讲的 store/region heartbeat
体系)首次查询后本地保存,命中缓存时不需要每次都问 PD。
sequenceDiagram
participant EX as TiDB Executor
participant DS as distsql
participant RC as client-go RegionCache
participant PD
participant R1 as TiKV Region-1
participant R2 as TiKV Region-2
EX->>DS: 需要读取 key range [a, z)
DS->>RC: 按 Region 边界切分 range
RC->>RC: 本地缓存命中?
alt 缓存未命中
RC->>PD: GetRegion(key)
PD-->>RC: Region 元信息 + Leader
end
RC-->>DS: CopTask[Region-1: [a,m)], CopTask[Region-2: [m,z)]
DS->>R1: Coprocessor.Request(DAG + [a,m))
DS->>R2: Coprocessor.Request(DAG + [m,z))
R1-->>DS: 局部结果
R2-->>DS: 局部结果
DS-->>EX: 聚合后的结果流
一次全表扫描如果跨了 100 个 Region,distsql
就会并发发出 100 个 CopTask——并发度由
tidb_distsql_scan_concurrency
等参数控制,不是无限并发,否则会对 TiKV 造成瞬时压力。
三、三种下推协议在路由层的差异:Cop、BatchCop、MPP
第二节讲的”按 Region 切
CopTask”,只是三种下推协议里的第一种,且是唯一默认对 TiKV 和
TiFlash 都适用的一种。当查询涉及 TiFlash(第 14
篇)时,distsql/client-go
实际可选的路由方式还有另外两种(TiDB System Variables
文档,A
级:tidb_allow_batch_cop、tidb_allow_mpp、tidb_allow_tiflash_cop):
| 协议 | 请求粒度 | 目标引擎 | 控制变量 |
|---|---|---|---|
| Cop(第二节讲的默认路径) | 每个 Region 一个独立的 Coprocessor 请求 | TiKV / TiFlash 均可 | 默认路径,tidb_allow_tiflash_cop
控制是否允许优化器选它作为 TiFlash 侧的兜底方案 |
| BatchCop | 同一个 TiFlash 节点上的多个 Region 请求合并成一批,一次 RPC 发出 | 仅 TiFlash | tidb_allow_batch_cop(默认允许) |
| MPP | 每个 TiFlash 节点只收到一个 MPP Task,内部自己遍历本节点持有的所有相关 Region | 仅 TiFlash | tidb_allow_mpp(默认开启,优先级最高) |
这三种协议在路由层解决的是同一个问题的三种粒度:Cop 是”每个 Region 一次 RPC”,Region 数量越多、请求数越多,对 TiFlash 而言这个开销尤其明显——官方 PR 讨论(A 级)直接指出,如果一张表有几千个 Region,Cop 模式下 TiFlash 单节点要接收几千个独立的 Coprocessor 请求,“显然对 TiFlash 更不友好”;MPP 把粒度提升到”每个 TiFlash 节点一个请求”,节点内部自己负责遍历所属的全部 Region,请求数从”Region 数”降到”参与查询的 TiFlash 节点数”。BatchCop 是这两者之间的过渡形态:仍然按 Region 生成任务,但在发送前按目标节点合并打包。
flowchart TB
subgraph cop ["Cop: one request per Region"]
R1["Region 1"] --> C1["Coprocessor.Request 1"]
R2["Region 2"] --> C2["Coprocessor.Request 2"]
R3["Region 3"] --> C3["Coprocessor.Request 3"]
end
subgraph mpp ["MPP: one task per TiFlash node"]
NODE["TiFlash Node A<br/>(holds Region 1,2,3 replicas)"] --> T1["Single MPP Task<br/>iterates all local regions"]
end
TiDB 优化器决定用哪种协议的代价模型细节(何时选
MPP、何时退回
BatchCop/Cop)属于第五节标注的”边界之外”——本节只钉三种协议在路由粒度上的机制差异,不展开选择背后的代价估算。tidb_allow_mpp
默认优先级最高,意味着同一张开了 TiFlash
副本的表,大多数可下推查询会优先尝试凑成 MPP Task,只有在
MPP 不适用(例如查询形态不支持)时才退回 BatchCop 或
Cop。
四、Region 路由错误与重试
Region 的 Leader 会因为调度(第 7、8
篇讲的
transfer-leader、split/merge)随时变化,RegionCache
里的信息注定会过期。TiKV
在这种情况下不会静默返回错误结果,而是返回带明确语义的 Region
错误,常见两种:
NotLeader:请求打到了非 Leader 副本,响应里通常带上”当前 Leader 是谁”的提示,client-go据此更新缓存并重试;EpochNotMatch:Region 发生了 split/merge,epoch(版本号)不匹配,说明这个 CopTask 描述的 key range 已经不对应现在的 Region 边界了,需要重新向 PD 查询最新的 Region 信息再重建 CopTask。
这两类错误都在 client-go
层被捕获并触发退避重试(backoff),对 TiDB 执行器和上层 SQL
是不可见的——除非重试次数耗尽或退避超时,才会作为查询失败向上抛。这意味着:“SQL
偶尔变慢几十毫秒”很多时候不是优化器选错了计划,而是 Region
路由缓存刚好失效,正在走一轮重试。排查此类抖动时,应该先看
client-go/TiDB 的 region-error、backoff
相关指标,而不是急着去重新分析执行计划(常见故障排查细节见 第 17
篇)。
四、并发度与批量客户端:路由层的资源控制
distsql 拆出的 CopTask
并不是无限并发发送的,几个系统变量直接控制着”同一时刻最多有多少个
Region 请求在飞行”:
| 变量 | 控制对象 | 影响 |
|---|---|---|
tidb_distsql_scan_concurrency |
普通表/索引扫描的 CopTask 并发度 | 过小拖慢大范围扫描;过大瞬时冲击 TiKV |
tidb_index_lookup_concurrency |
Index Lookup Join 第二阶段(回表)并发度 | 索引下推后按主键回表读整行,是独立的一轮 CopTask |
tidb_index_lookup_join_concurrency |
Index Lookup Join 整体并发 | 影响多少个外表行并发触发内表探测 |
tidb_max_chunk_size |
每批结果 Chunk 的行数 | 影响 TiDB 与 TiKV 之间单批数据量,间接影响内存占用 |
Index Lookup Join 值得单独一提,因为它是”计划落到 Region”路径里少见的、需要两轮 CopTask 的场景:第一轮按索引条件在 Region 上扫出满足条件的主键,第二轮再用这些主键去各自所在的 Region 发起点查/批量点查(Batch Point Get)取整行数据。这两轮请求打到的 Region 集合通常完全不同——第一轮命中索引所在的 Region,第二轮命中主键所在的 Region(如果索引和表数据本身分 Region 存储,两者不会重叠)。这是一个纯粹的路由层事实:Join 算法选没选 Index Lookup Join 是优化器的决定(不展开),但选定之后它具体怎么变成两轮 Region 请求,属于本文范围。
批量点查(Batch Point
Get)同样值得一提:当计划已经收集到一批离散的主键(例如
WHERE id IN (1, 5, 9, ...) 或 Index Lookup Join
回表阶段),distsql/client-go
会把这些 key 按照所属 Region 分组,同一 Region 内的多个 key
合并成一次批量 Get 请求,而不是逐个 key 发一次 RPC——这是减少
RPC 次数的路由层优化,与 Coprocessor DAG
是两条并行存在的路径。
五、边界之外:优化器决定了”发什么”,本文只讲”怎么发”
需要反复强调的边界:本文完全不讨论 TiDB 优化器如何决定物理计划的形态——包括但不限于:
- 逻辑优化的谓词下推、列裁剪、子查询展开;
- 基于统计信息的代价估算;
- Join 顺序与算法选择(Hash Join / Sort Merge Join / Index Lookup Join);
- 是否走 TiFlash MPP(第 14 篇、db-frontier/12 §2.3 已给出代价模型轮廓)。
这些属于 TiDB planner
包的内部逻辑,本系列的定位是 TiKV 存储与复制内核,不是 SQL
优化器教程。想理解通用的 CBO、统计信息、join 重排机制,去读
query-engine
系列第 4–5 篇(面向 Trino/Spark,机制可类比,但不是 TiDB
源码)。本文只回答一个问题:一旦物理计划确定了要读哪些
key range,这些 range 是怎么变成具体打到某个 Region 的 RPC
请求的。
flowchart TD
START["Physical plan decided<br/>(planner internals, out of scope)"]
START --> Q1{"Plan needs KV read?"}
Q1 -->|"point get / batch point get"| PG["Direct KV Get by encoded key<br/>skip full CopTask machinery"]
Q1 -->|"range scan"| RANGE["distsql builds CopTask per Region"]
RANGE --> ROUTE["RegionCache routes to Leader"]
ROUTE --> ERR{"NotLeader / EpochNotMatch?"}
ERR -->|"yes"| RETRY["Backoff, refresh RegionCache, rebuild CopTask"]
RETRY --> ROUTE
ERR -->|"no"| COP["TiKV Coprocessor executes DAG<br/>(见第 12 篇)"]
需要点一句的是点查(Point Get / Batch Point
Get):如果计划已经能确定精确的单个或若干个
key(例如按主键等值查询),TiDB 会跳过 CopTask/DAG
这套机制,直接用 KV 接口对具体 key 发起 Get,同样要经过
RegionCache
路由,但不需要构造执行器链——这是”打到
Region”路径里最短的一条,也是解释”为什么点查通常比范围扫描快一个量级”的路由层原因之一(存储层原因见第
2 篇 MVCC 读路径)。
六、常见误解
误解一:TiDB
的性能上限由优化器选择的计划决定。
计划选择固然重要,但很多线上抖动来自路由层:Region
迁移后的一轮 NotLeader
重试、RegionCache 冷启动时对 PD
的密集查询,都会在计划完全正确的情况下拉高单次查询延迟。诊断慢查询时,“计划对不对”和”这次请求有没有撞上路由重试”是两个独立的问题,需要分开看。
误解二:一条 SQL 对应一次 Coprocessor
请求。 只要扫描范围跨越多个
Region,distsql 就会拆出多个并发的
CopTask,各自独立完成重试与超时;一条看起来简单的全表扫描背后可能是几十到几百个并发
RPC。
七、小结
三句话小结:
- TiDB 计划落到 Region 靠
distsql切分 key range 为 CopTask、client-go RegionCache负责路由,与优化器内部如何选计划是两层独立的问题。 - Region
路由信息会因调度而过期,
NotLeader/EpochNotMatch触发的退避重试是查询延迟抖动的常见来源,且对上层 SQL 透明。 - 点查绕开 CopTask/DAG 机制直接发 KV Get,是”打到 Region”最短路径;范围扫描才会经过第 12 篇讲的 Coprocessor DAG 执行链路。
参考资料
- TiDB Development Guide,Understand TiDB(pingcap.github.io/tidb-dev-guide,A 级,模块划分与六步处理流程)。
- TiDB Docs,TiDB Computing、TiDB Architecture(docs.pingcap.com,A 级)。
- TiDB Docs,SQL Optimization Process(docs.pingcap.com/tidb/dev/sql-optimization-concepts,A 级,标注为不展开的优化器边界)。
- tikv/client-go 源码:
RegionCache、Region error 处理路径(A 级)。 - 本系列 第 12 篇 Coprocessor、第 7–8 篇 PD 调度与 Split/Merge。
- query-engine 系列第 4–5 篇(通用 CBO/Join 重排机制类比阅读,非 TiDB 实现)。
返回 系列目录
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【TiKV / HTAP 内核】HTAP / 分布式 KV 全景:TiDB–TiKV–PD–TiFlash 四件套
定位 TiKV 7.x/8.x 在 RocksDB 单机 LSM、Raft 协议与 HTAP 范式之间补的那一层:TiDB、TiKV、PD、TiFlash 四组件各自的抽象边界,以及本系列 18 篇的读法。
【TiKV / HTAP 内核】Coprocessor:DAG 下推与向量化边界
拆解 TiKV Coprocessor 如何接收 TiDB 下发的 DAG 请求、在 Region snapshot 上跑批量执行器;划清行存下推与 query-engine 系列 MPP 向量化的边界,不重讲 MPP。
【TiKV / HTAP 内核】Region · Multi-Raft · PD · Percolator · TiFlash
补齐 RocksDB 单 Region、Raft 协议与 HTAP 范式之间的工程链路:以 TiKV 7.x/8.x 拆解 Region、Multi-Raft、raftstore、PD/TSO 与 Percolator 事务,并以 TiFlash Learner 收束新鲜度;CockroachDB 对照与选型。
【TiKV / HTAP 内核】Key 编码与 MVCC:三 CF 与时间戳
拆解 TiKV 的 user key + start_ts/commit_ts 编码规则与 default/lock/write 三 CF 分工,并说明为什么这套 MVCC 与 RocksDB Snapshot 的 sequence 号是两层完全不同的机制。