土法炼钢兴趣小组的算法知识备份

【TiKV / HTAP 内核】TiDB SQL 层边界:一条计划如何打到 Region

文章导航

分类入口
databasestorage
标签入口
#tidb#distsql#region-routing#coprocessor-task#client-go#region-cache#planner-boundary

目录

第 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-gopingcap/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 的工作由 distsqlclient-goRegionCache 完成:

  1. distsql 拿到执行器需要读取的 key range 列表;
  2. 调用 RegionCache,按 Region 的 [StartKey, EndKey) 边界把这些 range 切成若干段,每段对应一个已知的 Region;
  3. 为每个 Region 生成一个 CopTask(Coprocessor Task),包含该 Region 的 ID、epoch、副本地址、以及落在这个 Region 内的 key range 子集;
  4. 把编译好的 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_coptidb_allow_mpptidb_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 错误,常见两种:

这两类错误都在 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 优化器如何决定物理计划的形态——包括但不限于:

这些属于 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。


七、小结

三句话小结:

  1. TiDB 计划落到 Region 靠 distsql 切分 key range 为 CopTask、client-go RegionCache 负责路由,与优化器内部如何选计划是两层独立的问题。
  2. Region 路由信息会因调度而过期,NotLeader/EpochNotMatch 触发的退避重试是查询延迟抖动的常见来源,且对上层 SQL 透明。
  3. 点查绕开 CopTask/DAG 机制直接发 KV Get,是”打到 Region”最短路径;范围扫描才会经过第 12 篇讲的 Coprocessor DAG 执行链路。

参考资料

  1. TiDB Development Guide,Understand TiDB(pingcap.github.io/tidb-dev-guide,A 级,模块划分与六步处理流程)。
  2. TiDB Docs,TiDB ComputingTiDB Architecture(docs.pingcap.com,A 级)。
  3. TiDB Docs,SQL Optimization Process(docs.pingcap.com/tidb/dev/sql-optimization-concepts,A 级,标注为不展开的优化器边界)。
  4. tikv/client-go 源码:RegionCache、Region error 处理路径(A 级)。
  5. 本系列 第 12 篇 Coprocessor第 7–8 篇 PD 调度与 Split/Merge
  6. query-engine 系列第 4–5 篇(通用 CBO/Join 重排机制类比阅读,非 TiDB 实现)。

上一篇Coprocessor:DAG 下推与向量化边界

下一篇TiFlash Learner:日志到列存

返回 系列目录

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。


By .