Substrait 跨引擎查询计划表示深度实战:从代数算子、类型系统到扩展机制与生产落地
如果你维护过一个数据平台,一定经历过这样的痛苦:同一条 SQL,在 Spark 里跑出一个计划,在 Trino 里跑出另一个,在 DuckDB 里又是第三个。它们语义相同,却无法互相交换。每接入一个新引擎,就要重新写一遍优化规则、重新实现一遍函数签名、重新踩一遍类型映射的坑。
Substrait 想解决的正是这件事——它不做执行引擎,只做「计划的语言」。用一句更工程化的话讲:Substrait 是查询计划领域的 Protobuf + LLVM IR,把「关系代数」从具体引擎中剥离出来,做成可序列化、可验证、可跨进程传输的标准化表示。
一、为什么需要中间表示:N×M 问题的本质
假设你有 N 个前端(SQL 解析器、DataFrame API、BI 工具的语义层)和 M 个后端(Spark、Trino、Velox、DataFusion、Gandiva)。没有中间层时,集成成本是 N×M。
引入 Substrait 后变成 N+M:每个前端只需产出 Substrait 计划,每个后端只需消费 Substrait 计划。
这正是 Velox 与 Apache Gluten 的架构选择。Gluten 把 Spark 的物理计划转成 Substrait,交给 Velox 原生执行——Spark 一侧不需要知道 Velox 的任何细节,Velox 一侧也不需要引入 Spark 依赖。这个解耦带来的收益是巨大的:2024 年之后,Intel、Meta、Bytedance 在该链路上的合作基本都收敛到了 Substrait 上。
二、核心数据模型:Rel、Expression 与 Type
Substrait 的整个规范建立在三个互相引用的 Protobuf 消息之上。
2.1 Rel:关系算子的唯一载体
所有算子都是 Rel 这个 oneof 的成员。注意它不区分逻辑与物理——这是新手最容易困惑的地方:
message Rel {
oneof rel_type {
ReadRel read = 1;
FilterRel filter = 2;
FetchRel fetch = 3;
AggregateRel aggregate = 4;
SortRel sort = 5;
ProjectRel project = 6;
JoinRel join = 7;
SetRel set = 8;
// ... 扩展算子走 ExtensionLeafRel / ExtensionMultiRel
}
}
Rel 之间通过 RelCommon 传递元数据,其中最关键的是 direct 引用:
message RelCommon {
message Direct {
}
oneof emit_kind {
Direct direct = 1; // 输出该 Rel 的全部字段
Emit emit = 2; // 输出字段的映射/重排
}
}
下游算子引用上游字段时,用的是 以输入 schema 为基准的零基下标(field index),而不是名字。这个设计极其重要:它意味着 Substrait 计划天然摆脱了列名绑定的歧义,也不存在 SQL 里那种「两个同名列分不清来自哪张表」的问题。
一个 ProjectRel 长这样:
ProjectRel {
common { direct {} }
input { read { ... } }
expressions {
scalar_function {
function_reference: 0 # 指向扩展声明中的函数
output_type { string { nullability: NULLABILITY_NULLABLE } }
arguments { value { selection { direct_reference { struct_field { field: 1 } } } } }
arguments { value { literal { string: "_suffix" } } }
}
}
}
2.2 类型系统:比 SQL 更严格
Substrait 的类型系统强制显式 nullability,这比绝大多数 SQL 引擎都严格:
message Type {
enum Nullability {
NULLABILITY_UNSPECIFIED = 0;
NULLABILITY_NULL = 1;
NULLABILITY_NULLABLE = 2;
NULLABILITY_REQUIRED = 3;
}
oneof kind {
Boolean bool = 1;
I8 i8 = 2; I16 i16 = 3; I32 i32 = 4; I64 i64 = 5;
FP32 fp32 = 6; FP64 fp64 = 7;
String string = 8;
Decimal decimal = 9;
Timestamp timestamp = 12;
Date date = 13;
List list = 27;
Map map = 28;
UserDefined user_defined = 29; # 引擎私有类型逃生舱
}
}
实战观点:nullability 是 Substrait 落地时最容易引发返工的点。很多引擎(尤其是 Java 系的 Calcite 派生系统)在类型层面根本不追踪「是否可空」,转换时只能全部标成 NULLABLE。这会直接导致下游引擎的某些优化(如 Velox 的 null-propagation 剪枝、filter 下推的恒真/恒假判定)失效。我的建议是:在 Producer 侧做一次「nullability 推导 pass」,基于谓词和算子语义回填,成本不高但收益显著。
2.3 Expression:函数引用而非函数名
这是 Substrait 与 SQL AST 最大的设计差异——表达式里没有函数名的字符串,只有 function_reference: uint32。名字去哪了?在扩展声明里。
三、扩展机制:Substrait 的灵魂
规范和实现解耦的关键在 extensions 段。计划文件里带一个 Extensions 消息,把 function_reference 映射到真实的 URI:
# extensions/functions_arithmetic.yaml
scalar_functions:
- name: add
description: Add two values.
impls:
- args:
- name: x
value: i8
- name: y
value: i8
options: { overflow: { values: [SILENT, SATURATE, ERROR] } }
variadic_behavior: EXACT
return: i8
- name: extract
description: Extract a component from a timestamp.
impls:
- args:
- name: timestamp
value: timestamp
options: { component: { values: [YEAR, ISO_YEAR, US_YEAR, MONTH, DAY] } }
return: i64
Simple Extensions 是 YAML 定义的标准函数库(arithmetic、comparison、boolean、string、datetime 等),由社区维护,各引擎共同实现。Advanced Extensions 则允许把任意 Protobuf 塞进 advanced_extension.optimization 字段,承载引擎私有的优化提示。
生产实践中最常用的模式是:
Rel {
read {
common {
direct { }
advanced_extension {
optimization { # Any 类型,引擎私有
[type.googleapis.com/gluten.ClickhouseOptimization] {
hint: "..."
}
}
}
}
}
}
这里有个必须踩过的坑:Substrait 规范对「未知 advanced extension」的语义是「可忽略」,也就是说,如果你把影响正确性的信息(比如某种特定的 null 处理语义)放进 advanced extension,那么在一个不认识它的引擎上,计划会静默地跑出错误结果。判断标准很简单:只把性能优化类信息放进 advanced extension,正确性相关的语义必须走 simple extension 或调整算子结构。
四、Variadic 与函数签名解析
Substrait 对可变参数函数的建模值得单独讲,因为它直接暴露了设计者的严谨:
message FunctionArgument {
oneof argument_type {
Type value = 1;
TypeEnum type = 2;
EnumArgument enum = 3;
}
}
enum VariadicBehavior {
VARIADIC_BEHAVIOR_UNSPECIFIED = 0;
VARIADIC_BEHAVIOR_OFF = 1; // 参数个数固定
VARIADIC_BEHAVIOR_ON = 2; // 可变参数,类型可不同
}
配合 Argument 里的可选 variadic 标记,Substrait 能表达「coalesce(x, y, z, ...) 任意个同类型参数」这类签名。而 EnumArgument(如 extract 的 component)更进一步——枚举值不是以字符串形式出现在计划里,而是编码成整数索引,既省空间又避免了拼写错误。
五、生产者与消费者的能力协商
现实中没有引擎能实现 Substrait 的全部算子与函数。规范用两条机制处理这个落差:
- Simple Extensions 声明:Producer 在计划里附上它用到的函数 URI 列表,Consumer 启动时检查自己是否支持。
- Advanced Extension 的增强:无法优雅表达的算子走
ExtensionSingleRel等逃生舱。
一个健壮的 Producer 应该做的是能力协商而非「发完就不管」:
# 伪代码:Producer 侧的降级策略
def produce(plan, consumer_caps):
for node in plan.walk():
if isinstance(node, AggregateRel) and not consumer_caps.supports("aggregate"):
# 降级:把聚合拆成 expandable 的 read + 上层计算
node = node.lower_to_read_and_compute()
for fn in node.function_refs():
if fn.uri not in consumer_caps.functions:
raise UnsupportedFunction(fn) # 显式失败优于静默错误
return plan
实战观点:静默失败的代价远高于显式失败。我见过最糟糕的一次线上事故,是 Producer 把不支持的聚合函数降级成了 ExtensionSingleRel,Consumer 不认识就直接忽略了整个算子,结果返回全量数据而不是聚合值——下游报表数字翻了几十倍,排查花了两天。宁可让计划转换失败并回退到原引擎执行。
六、Validator:被低估的基础设施
Substrait 提供了一个 substrait-validator 工具,它可以脱离任何引擎,独立校验一份计划是否结构合法(类型是否匹配、字段下标是否越界、function_reference 是否在扩展表中存在)。
# 校验 plan.json(Substrait 的 JSON 表示)
substrait-validator plan.json
# 输出:
# Plan is valid.
把它接进 CI 是每个 Substrait Producer 项目最值得做的第一件事。它能在单元测试阶段抓出 90% 的低级错误,而这些错误如果留到集成阶段,排查成本是数量级的差距。
更进一步,可以构建 round-trip 测试:Producer 产出计划 → Validator 校验 → Consumer 消费执行 → 与直接用原引擎执行的结果做逐行比对。Gluten 的测试体系就大量采用这种模式,覆盖了上千条 TPC-H/TPC-DS 查询。
七、性能考量:序列化开销是否可接受?
这是被问得最多的问题。答案是:在合理的计划规模下,Protobuf 编解码开销在毫秒级,完全可以忽略。
原因是查询计划本身很小——一条 TPC-DS 查询的 Substrait 计划通常在几十 KB 量级,相比它要处理的数据量(GB 到 TB),序列化开销占比低于万分之一。真正的瓶颈从来不是协议,而是:
- 字段类型的双向映射:尤其是 decimal 的 precision/scale、timestamp 的时区语义。Substrait 的
timestamp明确区分「带时区」与「不带时区」,而很多引擎内部其实只用一种表示。 - Decimal 的溢出语义:
SILENT/SATURATE/ERROR三种 overflow 行为必须对齐,否则同一条查询在两条链路上结果不同。 - 字符串与排序规则:Substrait 目前对 collation 的支持仍在演进中,涉及
ORDER BY的跨引擎一致性需要格外小心。
八、我眼中的 Substrait 定位
Substrait 不是一个「银弹」。它解决的是一个非常具体的问题:计划的可交换性。它不解决优化器质量、不解决执行效率、不解决资源调度。
但它的价值恰恰在于这种克制。当 Velox、DataFusion、Gandiva、ClickHouse 都在消费 Substrait,当 Calcite、SQLGlot、Ibis 都在生产 Substrait,数据引擎生态就第一次有了「编译前端/后端」的分层可能——就像 LLVM 让编译器作者不必从零实现每个 target 的后端一样。
三个务实的落地建议:
- 先做 Consumer,再做 Producer。消费别人的计划能快速暴露你对规范理解的偏差,而产出计划需要你对语义有完整把握。
- 把 Validator 纳入 CI,把 round-trip 测试纳入 nightly。这两件事的投入产出比远超任何优化规则。
- 严格区分「优化提示」与「正确性语义」。这是 advanced extension 使用的唯一红线。
最后一句判断:Substrait 目前还处在生态早期,规范版本演进较快(1.0 之后仍在迭代),如果你的场景是单一引擎内部优化,它带来的收益有限;但如果你在做跨引擎执行、嵌入式查询加速、或者语义层到多引擎的下推,它已经是当前业界唯一成熟的选择,值得投入。
*延伸阅读:Substrait 官方规范(substrait.io)、Velox 的 Substrait 消费者实现、Apache Gluten 的 Spark→Substrait→Velox 链路。*

发表评论 取消回复