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

【TiKV / HTAP 内核】Coprocessor:DAG 下推与向量化边界

文章导航

分类入口
databasestorage
标签入口
#tikv#coprocessor#dag#pushdown#tipb#vectorized-execution#tidb

目录

第 11 篇 讲完悲观事务落锁与 ResolveLock,锁与数据都已经能正确落在 lock/write CF 上。但一条 SELECT count(*) FROM t WHERE age > 18 如果先把全表数据传回 TiDB 再算,网络就会成为瓶颈——TiDB 团队的解法是把过滤、聚合、TopN 这类计算下推到 TiKV 本地执行,只把结果送回来。这个下推入口叫 Coprocessor

常见的误解是把 Coprocessor 当成”TiKV 能跑任意 SQL”。实际上 TiKV 从不解析 SQL 文本,它只执行 TiDB 编译好、序列化成 protobuf 的一棵执行器 DAG(Directed Acyclic Graph)。本文只钉这一层:DAG 请求长什么样、TiKV 怎么在 Region snapshot 上把它跑起来、下推到哪一步就不再下推,以及这套”行存批量下推”和 query-engine 系列里讲的分布式 MPP 向量化执行是不是一回事(不是)。

版本锚定:TiKV 7.x/8.x;DAG 协议定义见 pingcap/tipbpingcap/kvproto(A 级,源码仓库)。执行器实现见 tikv/tikv 仓库 src/coprocessor/components/tidb_query_executorscomponents/tidb_query_expr


一、DAG 请求:TiDB 编译,TiKV 执行

TiDB 侧的 optimizer 决定”这段计划能不能下推”之后,会把子计划编译成一个 tipb.DAGRequest,通过 kvproto 定义的 Coprocessor::Request 发给相关 Region 的 TiKV 节点。TiKV 开发指南(A 级,官方文档)把这个入口描述为两件事的组合:一份计划(control flow)加一份数据(data flow)——计划描述”怎么算”,Region 的本地存储提供”算什么”。

Request 的关键字段(kvproto,简化):

message Request {
  kvrpcpb.Context context = 1;
  int64 tp = 2;        // 请求类型:DAG / Analyze / Checksum
  bytes data = 3;       // 序列化后的 DAGRequest
  repeated KeyRange ranges = 4; // 本次请求覆盖的 key 范围(落在该 Region 内)
  uint64 start_ts = 7;  // 快照读时间戳
}

data 字段里是一棵执行器链,每个节点是 tipb 里定义的一种 executor:

Executor 作用 输入
TableScan 按主键范围扫行 Region 的 key range
IndexScan 按索引范围扫 Region 的 key range
Selection 过滤(WHERE TableScan / IndexScan
Aggregation 聚合(COUNT/SUM/AVG TableScan / IndexScan / Selection
TopN 排序取前 N 同上
Limit 截断结果行数 同上
Projection 列投影/表达式计算 同上

表达式(Selection 的过滤条件、Aggregation 的聚合函数参数)用统一的 Expr 结构描述,分三类:列引用(Column)、常量(Constant)、标量函数(ScalarFunction),每个函数在 tipb 里有固定的整数 ID——这保证了 TiDB 与 TiKV 对同一个下推表达式的理解完全一致,不依赖字符串解析。

flowchart TD
  SQL["TiDB: SELECT count(*) FROM t WHERE age > 18"]
  SQL --> PLAN["Planner builds pushdown fragment"]
  PLAN --> DAG["tipb.DAGRequest<br/>Aggregation -> Selection -> TableScan"]
  DAG --> ENC["Encode into Coprocessor.Request.data"]
  ENC --> RPC["gRPC Coprocessor call, per Region"]
  RPC --> TIKV["TiKV Endpoint on each Region"]
  TIKV --> RESULT["Partial count per Region"]
  RESULT --> MERGE["TiDB sums partial counts"]

这张图和 TiKV 官方”Distributed SQL”文档给出的例子一致:一次 count(*) + filter 会在每个相关 Region 上独立跑一份 Aggregation -> Selection -> TableScan 链,TiDB 只需要把各 Region 返回的局部 count 相加。


二、TiKV 端处理:Endpoint 到批量执行器

请求到达 TiKV 后,入口是 Endpointsrc/coprocessor/endpoint.rs,A 级源码)。核心步骤(源码注释原文即如此描述):

  1. 解析原始 Request 为 DAG / Checksum / Analyze 三种之一;
  2. 根据请求的 start_ts 从底层存储引擎取一个 snapshot;
  3. 用 snapshot 和请求细节构建对应的 RequestHandler
  4. 在独立的 Coprocessor 线程池上运行一次(unary)或多次(streaming);
  5. 把结果打包成 Response 返回。
sequenceDiagram
    participant TiDB
    participant EP as TiKV Endpoint
    participant SS as SnapshotStore (MVCC)
    participant DAG as DagHandler
    participant EXE as Batch Executors

    TiDB->>EP: Coprocessor.Request(DAGRequest, ranges, start_ts)
    EP->>EP: check_memory_locks(是否有未决锁挡路)
    EP->>SS: 按 start_ts 取 Region snapshot
    EP->>DAG: DagHandlerBuilder::build(deadline, batch_row_limit, quota_limiter)
    DAG->>EXE: 构造 executor 链(TableScan -> Selection -> Aggregation)
    EXE->>SS: 按 KeyRange 迭代读取
    EXE-->>DAG: 批量结果 Chunk
    DAG-->>EP: SelectResponse
    EP-->>TiDB: Coprocessor.Response

check_memory_locks 这一步值得单独说:Coprocessor 读走的是 start_ts 时刻的快照,如果该范围内有更晚写入但仍未提交/未 resolve 的锁挡在路径上,请求要么等待要么触发 第 11 篇讲过的 ResolveLock 流程——Coprocessor 并不是绕开 MVCC 的旁路,它复用与普通读请求相同的锁语义。

TiKV 从早期”逐行遍历执行器树”演进为批量执行器tidb_query_executors/tidb_query_expr):每个 executor 一次处理一批行(Chunk),表达式在一批数据上向量化求值,而不是逐行调用函数。这一步把 Coprocessor 的 CPU 效率往前推了一大截,但要强调它解决的问题和下一节要划清的”MPP 向量化”不是同一个尺度的问题。


三、下推边界:哪些能下推,哪些不能

Coprocessor 只认识 tipb 里已经定义好的 executor 和表达式函数。TiDB 优化器在生成物理计划时要显式判断”这个算子/函数能不能下推”,判断不通过就把这部分留在 TiDB 端执行。

类别 可下推 不下推(TiDB 端执行)
扫描 TableScan、IndexScan
过滤/投影 Selection、Projection 复杂的用户自定义函数、部分未实现的内置函数
聚合 Aggregation(SUM/COUNT/AVG/MIN/MAX 等),两阶段(局部聚合 + 最终聚合) 需要跨行状态的窗口函数(早期版本)
排序/截断 TopN、Limit 全局 ORDER BY 的最终归并
连接 Hash Join / Sort Merge Join / Index Lookup Join 均在 TiDB 端做,Coprocessor 不做跨 Region 的 join

Join 永远不下推到单个 Region 的 Coprocessor:join 的两侧数据分布在不同 Region、甚至不同表,Coprocessor 的输入只是”这一个 Region 的一段 key range”,天然做不了跨 Region 关联。TiDB 的 distsql 层负责把多个 Region 的 Coprocessor 结果拉回来,在自己的执行器里做 join、全局排序归并、最终聚合——这一层怎么把计划切成”哪些留在 TiDB、哪些发给 Region”,属于 第 13 篇 的范围,本篇不展开。


四、与 query-engine 系列向量化的边界

TiKV Coprocessor 的”批量执行器”和分布式 OLAP 查询引擎系列里讲的向量化执行、morsel-driven 调度,字面上都叫”向量化”,但解决的是不同尺度的问题,不应该混为一谈:

维度 TiKV Coprocessor 批量执行 query-engine 系列的 MPP 向量化
执行单元 单个 Region 的 key range,行存 RocksDB 整个分布式查询的 Stage/Task,跨节点
数据来源 三 CF 上的行存 MVCC 记录,现读现转批 列存文件(Parquet)或列存引擎自身批结构
调度粒度 一个 Coprocessor 线程池内的请求 Coordinator/Worker 间的 Split、Exchange、shuffle
目标 减少 TiDB-TiKV 网络传输、把简单算子搬到数据所在节点 让整条分布式计划在多机上并行跑满 CPU
类比 更接近”存储引擎自带的谓词/聚合下推” 更接近一个完整的分布式执行引擎

一句话边界:Coprocessor 是行存 KV 层的下推优化,不是一个通用分布式查询执行器。TiFlash 才是 TiDB 生态里真正对标”列存 + MPP”的组件(见第 14 篇),本文不重复 MPP shuffle、Exchange 这些机制,需要通用 MPP 内核细节时请直接读 query-engine 系列。


五、隔离与资源控制

Coprocessor 请求和事务读写共享同一个 TiKV 进程,如果不加隔离,一个扫全表的分析请求可以把在线事务的延迟顶上去。TiKV 在这一层做了几件事(源码 src/coprocessor/,A 级):

这些控制项是”行存下推系统”必须补的作业——数据分析请求的资源特征(长尾、大扫描量)和事务请求(短、高频)天差地别,共享进程但不共享调度策略会互相拖累。

分页与流式返回是另一个容易被忽略的隔离手段:一次全表扫描如果一口气把所有匹配行编码进单个 Response,内存占用会随扫描量线性增长。paging_size 参数让 Endpoint 把大结果切成若干页,每页跑完就先返回,由 TiDB 侧决定要不要继续拉下一页;is_streaming 请求则允许 Endpoint 对同一个 DAG 反复调用 RequestHandler,每次产出一批结果就发送一次,而不是等全部算完再一次性打包。这两者都是”避免单次 Coprocessor 请求占用过多内存”的工程手段,与前面的 Deadline/QuotaLimiter 一起构成完整的资源边界。


六、DAG 之外:Analyze 与 Checksum 请求

Coprocessor::Requesttp 字段并不只对应 DAG 一种类型,Endpoint 实际处理三类请求(TiKV 源码注释原文已经点明):

请求类型 用途 触发场景
DAG 执行 TiDB 下推的执行器链,返回查询结果 普通 SELECT/聚合/TopN 查询
Analyze 采样并统计列的直方图、NDV 等 ANALYZE TABLE,为优化器的代价模型提供统计信息
Checksum 计算指定范围内数据的校验和 数据校验、迁移/备份后的一致性核验

Analyze 请求同样跑在 Coprocessor 的线程池与资源限额之内,区别只是 handler 换成统计采样逻辑而不是执行器链;它同样需要 start_ts 来保证采样的是一个确定的快照,而不是在数据持续变化时采到不一致的样本。这也解释了为什么 ANALYZE TABLE 在大表上会显著消耗 Coprocessor 资源:它本质上仍是一次全表规模的 Coprocessor 扫描,只是产出统计信息而不是查询结果。

Checksum 请求与 SQL 查询无关,通常由 TiDB 生态里的数据迁移、备份恢复工具触发,用于比较两份数据(例如迁移前后)是否完全一致——它复用同一套 Endpoint 入口,是因为”按 Region 并行扫一遍数据”这件事本身与查询下推是同构的,没有必要另起一套 RPC 通道。


七、Coprocessor Cache:结果级缓存

除了资源限额,TiKV 还提供了一层结果缓存:Endpoint 在构建 SnapshotStore 时会带上 is_cache_enabledcache_match_version 两个信息(源码 src/coprocessor/endpoint.rs)。工作方式是:TiDB 客户端可以对同一个 CopTask 缓存上一次的执行结果和对应的 Region 数据版本;下一次发起相同的请求时,如果 Region 的数据版本没有变化,TiKV 可以直接告诉客户端”复用你本地缓存的结果”,不需要重新跑一遍执行器链。

这层缓存对”重复扫描同一段几乎不变的数据”场景(例如维度表、慢变的配置表反复被同一批分析 SQL 扫描)收益明显——本质上是把”执行结果”缓存在离 TiDB 更近的位置,减少 Coprocessor 的重复计算与网络传输。它依赖的是 Region 数据版本号的单调性:只要数据发生变化,版本号就会前进,客户端缓存立刻失效,不存在读到脏结果的风险。


八、常见误解

误解一:Coprocessor 能执行任意 SQL。 不能。它只执行 TiDB 编译好的 tipb DAG,函数 ID、执行器种类都是提前约定好的有限集合;遇到不支持的表达式,TiDB 优化器直接不下推这部分。

误解二:下推越多、性能一定越好。 不一定。Coprocessor 的批量执行仍然是行存扫描 + 逐列表达式求值,与真正的列存向量化(TiFlash/ClickHouse 式)相比,读放大和 CPU 效率天花板更低。对于扫描比例极高的分析查询,下推到 Coprocessor 只是”少传网络”,不能把行存变成列存;这类负载更适合走 TiFlash。


九、开放问题

  1. Coprocessor v2 插件化:TiKV 提供了动态加载 Coprocessor 插件的机制,允许非 TiDB 场景直接在 Region 上跑自定义计算逻辑。这条路径与”DAG 下推”是两种不同的可扩展性设计(协议约定 vs 动态代码),插件化在生产环境的隔离与升级成本尚缺乏公开的大规模案例总结。
  2. 行存下推与列存 MPP 的选择时机:TiDB 优化器基于代价模型决定一张表走 TiKV Coprocessor 还是 TiFlash MPP(db-frontier/12 §2.3 已给出简化代价模型),但代价模型对倾斜数据、非均匀选择率的鲁棒性,官方文档未给出可验证的量化界限。

十、小结

三句话小结:

  1. TiKV Coprocessor 只执行 TiDB 编译好的 tipb DAG,不解析 SQL、不做跨 Region join。
  2. TableScan/IndexScan/Selection/Aggregation/TopN/Limit/Projection 可下推,Join 与全局排序归并留在 TiDB 端。
  3. Coprocessor 的”批量执行”是行存下推优化,不等价于 query-engine 系列讲的分布式列存 MPP 向量化,二者尺度不同、不能互相替代。

参考资料

  1. TiKV 官方文档,Distributed SQL(tikv.org/deep-dive,A 级)。
  2. TiKV Development Guide,Coprocessor Overview(tikv.github.io/tikv-dev-guide,A 级)。
  3. TiKV 源码:src/coprocessor/endpoint.rssrc/coprocessor/mod.rs(tikv/tikv,A 级)。
  4. tipb / kvproto 仓库:Coprocessor.Requesttipb.DAGRequest 定义(pingcap/tipb、pingcap/kvproto,A 级)。
  5. 分布式 OLAP 查询引擎系列(向量化/MPP 执行模型对照,不重复其机制细节)。
  6. 本系列 第 11 篇 悲观事务与 ResolveLock(锁与 Coprocessor 快照读的交互)。

上一篇悲观事务与 ResolveLock

下一篇TiDB SQL 层边界:如何打到 Region

返回 系列目录

同主题继续阅读

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


By .