查询转换管线
摘要
Readyset 的这篇技术博客深入讲解其查询转换管线如何将 SQL 重写为数据流图——包括二元等值连接、非关联子查询改写以及派生表内联等约束与优化策略,以便增量维护物化视图。
暂无内容
查看缓存全文
缓存时间: 2026/10/06 23:54
# Readyset 如何重写你的 SQL:查询转换流水线揭秘
来源:https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline
## 为什么查询重写对数据流很重要(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#why-query-rewriting-matters-for-dataflow)
大多数数据库的工作方式都一样:一个查询到来,引擎构建执行计划,扫描表,连接行,过滤,聚合,然后返回结果。查询每执行一次,这一切工作都要从头再做一遍。这种拉取(pull-based)模型支撑了关系数据库数十年,但它有一个内在代价:读取延迟与查询复杂度以及所触及数据的规模成正比。
Readyset 采用了根本不同的方式。Readyset 不是按需重新执行查询,而是把每个查询编译成一个**数据流图(dataflow graph)**——一张由运算符(连接、过滤、聚合、投影)组成的网络,随着底层数据变化而持续维护查询结果。当上游数据库中的一行被插入、更新或删除时,这一变更会沿图传播,缓存结果随之增量更新。读取变成了对预计算物化视图的查找,而不是完整的查询执行。
这就是关键区别:传统引擎在**每次**执行查询时优化查询**如何**执行。Readyset 只在创建缓存时优化**一次**,然后永远增量维护结果。代价是,查询必须表达成数据流引擎能够编译的形式——而这种形式比 SQL 所允许的更加受限。
## 数据流引擎的要求(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#what-the-dataflow-engine-requires)
SQL 是一种声明式语言。同一个逻辑查询可以用许多等价方式书写:相关子查询、派生表、`CTE`、`LATERAL` 连接、嵌套聚合。传统优化器把它们视为可互换的表示,并且不管语法如何,都会选出最好的执行计划。
Readyset 的数据流编译器不是传统优化器。它把 SQL 翻译成一个流式运算符的有向无环图(DAG),其中每个运算符接收来自其输入的变更,并向其输出发射变更。这种架构带来了 SQL 语法所没有的结构性约束:
**带有相等谓词的二元连接。**数据流图中的每个连接都恰好连接两个输入,并通过列相等谓词(`a.id = b.id`)相连。引擎利用这些等式来维护基于哈希的连接状态。范围谓词、基于表达式的连接键,或多表 `ON` 条件,都不能被直接支持。
**不支持相关执行。**在传统引擎中,相关子查询对外部查询的每一行执行一次——即嵌套循环模式。数据流图没有"每一行外部记录"的概念。每个运算符看到的都是来自其输入的完整变更流。相关子查询必须被重写成数据流能够增量维护的等价连接。
**倾向于扁平的连接结构。**派生表(`FROM` 中的子查询)被数据流引擎支持,但有代价:派生表会编译成一个完全物化的中间节点,而且无法参数化。输入数据的每一种不同组合都会产生一个存储结果,无论外部查询是否需要它。将派生表内联——把它的 `FROM` 项、`WHERE` 过滤器和投影吸收到外部查询中——可以消除这种中间物化,让引擎直接在基础表上构建参数化查找。重写流水线会在语义安全的范围内积极内联派生表,只在内联会改变查询语义的情况下保留物化形式。
**支持的连接类型。**引擎支持 `INNER JOIN`、`LEFT OUTER JOIN` 和 `CROSS JOIN`。`RIGHT JOIN` 和 `FULL OUTER JOIN` 不被支持,因为在流式场景下对它们做增量维护需要同时追踪两侧的"未匹配"——这是一个困难得多的问题。
**聚合边界。**`GROUP BY` 和聚合函数(`COUNT`、`SUM` 等)会编译成维护运行累计值的有状态运算符。引擎要求每个被聚合的查询至少投影一个聚合派生的表达式,并且 `GROUP BY` 的键必须是显式的列引用(不能是位置序号或别名)。
这些约束意味着:一个语法合法的 SQL 查询——PostgreSQL 或 MySQL 会毫无怨言地执行的查询——可能无法被 Readyset 直接编译,或者可能编译出效率不如预期的数据流图。查询重写流水线就是为了弥合这一差距而存在的。
## 重写流水线:从 SQL 桥接到数据流(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#the-rewrite-pipeline-bridging-sql-to-dataflow)
当用户为某个查询执行 `CREATE CACHE` 时,Readyset 会把该查询送入一个多遍重写流水线,将任意 SQL 转换成数据流引擎所期望的规范形式。该流水线分为三个块:
| 块 | 目的 | 示例 |
| --- | --- | --- |
| **A — 规范化** | 去语法糖、解析 schema、限定列名 | `SELECT *` 变成 `SELECT t.id, t.name, ...` |
| **B — 深度重写** | 去相关子查询、展平派生表、优化 | `WHERE id IN (SELECT ...)` 变成一次连接 |
| **C — 清理** | 删除冗余子句、参数化字面量 | 当结果可证明为单行时移除 `ORDER BY id LIMIT 10` |
每一遍都是语义保持的:重写后的查询在所有可能的数据上都返回与原始查询相同的结果。各遍之间是层层递进的——块 A 把 SQL 规范化成一种规范形式,使块 B 的变换能够可靠地在它之上运作,而块 C 则清理块 B 留下的残留。
### 块 A:让 SQL 规范化(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#block-a-making-sql-canonical)
在任何深度变换发生之前,查询必须处于一种可预测的形态。块 A 就是干这个的:
- **Schema 解析**把表名和列名绑定到 Readyset 从上游数据库复制过来的实际 schema 元数据上。这对后续需要知道主键、唯一约束和列类型的各遍至关重要。
- **星号展开**把 `SELECT *` 替换为显式的列清单。下游的每一遍都期望看到具名列,而不是通配符。
- **列名限定**确保每个列引用都带上其表名前缀(`id` 变成 `t.id`)。当多张表中有同名列时,这可以防止歧义。
- **USING 去语法糖**把 `JOIN ... USING(id)` 转换成 `JOIN ... ON (a.id = b.id)`。数据流引擎使用的是 `ON` 谓词,而不是 `USING` 子句。
经过块 A 之后,查询已经被完整解析、限定和去语法糖化——为后续的变换打下了一个干净的基础。
### 块 B:重头戏(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#block-b-the-heavy-lifting)
块 B 才是真正干活的地方。这些遍把数据流引擎无法处理的 SQL 构造转换成它能够处理的等价构造。顺序很重要——每一遍都为下一遍铺平道路。
#### 数组构造器重写(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#array-constructor-rewrite)
这是块 B 的第一遍,也是 PostgreSQL 特有的。PostgreSQL 的 `ARRAY(SELECT ...)` 构造器会从子查询的行中生成一个数组值——一种"每一外部行一个标量聚合"的形态,数据流引擎没有对应的运算符。流水线把每一处出现都重写成一个 `LATERAL LEFT JOIN`,其主体用 `array_agg(...)` 包裹原来的子查询,外层配一个 `COALESCE(..., ARRAY[])`,使空子查询返回空数组而不是 `NULL`。构造器内部的 `ORDER BY` 和 `DISTINCT` 会被复制到 `array_agg` 调用中(当子查询同时带有 `LIMIT`/`Top-K` 时,这是正确性所必需的)。由于这一遍最先执行,它把一种后续各遍根本认不出来的 SQL 构造,转成了后续遍已经知道如何处理的 `LATERAL` 连接形态。
```
-- Before: ARRAY(SELECT ...) constructor — no direct dataflow operator
SELECT u.name,
ARRAY(SELECT p.title FROM posts p WHERE p.user_id = u.id) AS post_titles
FROM users u
-- After: LATERAL + array_agg + COALESCE — shapes that the rest of the pipeline understands
SELECT u.name,
COALESCE(array_subq.agg_result, ARRAY[]) AS post_titles
FROM users u
LEFT JOIN LATERAL (
SELECT array_agg(inner_subq.title) AS agg_result
FROM (SELECT p.title FROM posts p WHERE p.user_id = u.id) inner_subq
) array_subq ON TRUE
```
#### 冗余连接消除(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#redundant-join-elimination)
ORM 生成的查询往往包含冗余的自连接——同一张表按主键与自己连接,而所有投影的列都来自其中一侧。流水线会尽早检测并消除这些冗余,从而在后续更重的变换之前降低连接图的复杂度。
#### 左脊提升(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#left-spine-hoisting)
在对子查询做去相关之前,流水线会尝试把 `FROM` 中最左侧的派生表内联——但只在查询的顶层做。这正是 Top-K 模式(`ORDER BY ... LIMIT`)最有价值的位置:在顶层,`LIMIT` 可以被参数化(例如 `LIMIT ?`),数据流编译器就能部署一个原生的 Top-K 节点——一个专门用于增量维护前 N 行的流式运算符。如果子查询保持嵌套、留给后面通用的去相关遍来处理,Top-K 就会被替换成一个基于 `ROW_NUMBER()` 的过滤器——功能上正确但效率更低,而且 `LIMIT` 也不再能参数化了。
```
-- Before: Top-K nested in a derived table — dataflow would emit a ROW_NUMBER filter
SELECT sq.id, sq.name, sq.score
FROM (SELECT id, name, score FROM products ORDER BY score DESC LIMIT ?) AS sq
-- After left-spine hoist: Top-K at the top level — native, parameterized Top-K node
SELECT id, name, score
FROM products
ORDER BY score DESC
LIMIT ?
```
有了位于顶层的 `ORDER BY` 和 `LIMIT`,数据流编译器就能识别出 Top-K 形态,并部署原生的流式运算符。`LIMIT` 仍然是一个参数(`?`),因此一条缓存的数据流图可以服务于 `LIMIT 10`、`LIMIT 50`、`LIMIT 1000` 的所有变体。列重绑定、`ORDER BY` 重写以及 `LIMIT`/`OFFSET` 的组合,都由流水线中每一个内联点所复用的同一个内联 API 处理。
#### 子查询去相关(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#subquery-decorrelation)
这是流水中最复杂的变换,也是前面那些遍存在的原因——它们准备好查询结构,好让去相关能够看见并处理所有相关引用。考虑:
```
SELECT o.id, o.total
FROM orders o
WHERE o.total > (SELECT AVG(total) FROM orders WHERE region = o.region)
```
子查询引用了外部查询的 `o.region`——它是*相关*的。传统引擎会对外部查询的每一行执行一次子查询。Readyset 的数据流没有逐行执行模型,所以流水线把它重写成一次连接:
```
SELECT o.id, o.total
FROM orders o
INNER JOIN (
SELECT region, AVG(total) AS avg_total
FROM orders
GROUP BY region
) AS sq ON o.region = sq.region
WHERE o.total > sq.avg_total
```
相关谓词 `region = o.region` 变成了一个 `GROUP BY` 键和一个连接条件。子查询现在成了一个可以进一步展平的派生表。数据流引擎把它编译成一个连接运算符,增量维护各地区的平均值。
这种去相关覆盖了 `EXISTS`、`NOT EXISTS`、`IN`、`NOT IN`、标量子查询以及 `LATERAL` 连接。每一种都有自己的语义——带 `COUNT` 的 `LATERAL` 需要用 `LEFT JOIN` 配合 `COALESCE` 来保留零计数语义;与祖父作用域相关的嵌套 `LATERAL` 需要包装器展平来消除作用域边界;而针对可能为 `NULL` 的操作数的 `IN`/`NOT IN`/标量子查询,则需要三值逻辑探针,其适用性取决于运算符以及谓词出现的位置——这将在下面介绍。
#### 针对 `IN`、`NOT IN` 和标量子查询的三值逻辑(https://readyset.io/blog/how-readyset-rewrites-your-sql-inside-the-query-transformation-pipeline#three-valued-logic-for-in-not-in-and-scalar-subqueries)
SQL 布尔表达式会求值为 `TRUE`、`FALSE` 或 `NULL`——即三值逻辑(3VL)。大多数运算符对此是透明的,但针对可能产生 `NULL` 的子查询的 `IN`/`NOT IN` 则不然,而且正确的处理方式取决于谓词出现的**位置**:
- **`WHERE` 中的 `NOT IN`** 需要 3VL 守卫。如果右操作数哪怕产生一个 `NULL`,本应为 `FALSE` 的 `NOT IN` 谓词就会变成 `NULL`——而在 `WHERE` 中,`NULL` 会把这一行过滤掉,而原本 `FALSE` 是想保留它(或者反过来,取决于周围的逻辑)。朴素地去相关成 `LEFT ANTI JOIN` 会丢失这一区别,返回错误结果。
- **`WHERE` 中的 `IN`** 则**不**需要 3VL 守卫。无论是 `FALSE`(无匹配,右操作数没有 `NULL`)还是 `NULL`(无匹配,右操作数有 `NULL`),都会导致 `WHERE` 丢弃该行,所以这一区别无关紧要——朴素地去相关成半连接就已经是正确的。
- **`SELECT` 列表中的 `IN` 或 `NOT IN`** 无论运算符是什么,始终需要 3VL 守卫。谓词的值会被投影到查询之外,调用方可能用 `IS NULL` 测试它、把它传给另一个表达式,或者与另一个布尔值比较——`TRUE`/`FALSE`/`NULL` 的区别必须被精确保留。
- **`SELECT` 列表中的标量子查询**与这些情形并列:它们对 `NULL` 与空结果的语义要求同一套探针机制。
Readyset 通过在去相关后的子查询旁安装**探针连接(probe joins)**来处理所有这些情形。两个探针共同覆盖了完整的真值表:
- **NP(null-present,存在 NULL)**:`EXISTS(rhs WHERE first_field IS NULL)`——右操作数在这个相关分区中是否会产生 `NULL`?
- **EP(existence,存在性)**:`EXISTS(rhs)`——右操作数在这个相关分区中是否非空?
两者都被物化为 `LEFT LATERAL` 连接,投影出一个哨兵列 `present_`,供下游谓词用 `IS [NOT] NULL` 进行测试。一个基于 `lhs IS NULL`、`NP.present_`、`EP.present_` 以及反连接匹配结果的小型布尔公式,就能得出 SQL 语义所要求的精确 3VL 结果。
**只有当空值推断无法证明探针是多余的时,才会安装探针。**在物化任一探针之前,流水线会对谓词两侧进行可空性分析:
- 如果**右操作数**的比较列可以被证明非空(依据 `NOT NULL` 约束、非可空表达式,或保证输出非 `NULL` 的分组/聚合),则**跳过 NP 探针**——`NULL` 永远不可能出现,因此它守护的 3VL 分支是不可达的。
- 如果**左操作数**的表达式可以被证明非空,则**跳过 EP 探针**——3VL 公式中 "`LHS` 为 `NULL`" 的分支永远不会触发。
- 只有可空性分析无法在静态上排除的分支,才会产生运行时探针。
这一点很重要,因为每个探针都是一个额外的 `LEFT LATERAL` 连接:额外的节点、额外的状态、在每一次上游变更上额外的工作。抑制那些可证明为多余的探针,能让数据流图保持最小化,并与查询的实际需求保持一致。
**相同的探针只会物化一次并被复用。**真实查询中经常
相似文章
自然语言到SQL翻译的螺母与螺栓:模型流水线优化方法及其交互的系统性分析
本文系统地分析了多个用于自然语言到SQL翻译的流水线扩展组件,包括中间表示、合成数据、预处理和重排器,使用SmBoP和RASAT架构,发现它们的交互比简单组合所有组件更重要。
超越正确性:解决Agentic Text-to-SQL中的欠规范问题
该论文提出PlanPool方法,通过将澄清计划外部化为可变问题池,解决agentic Text-to-SQL系统中过早终止澄清的问题,从而减少静默失败并提升歧义覆盖能力。
DelightQL 查询语言
DelightQL 是一种可转译为 SQL 的查询语言,借鉴逻辑编程和函数式编程,使查询简洁、可复用,并能够构建嵌套数据结构,同时保留 SQL 的关系运算能力。
@bkdgiffug: 老板临时要销售报表,不用再手写SQL熬夜了。 WrenAI 是个开源生成式 BI 引擎,连上数据库后,直接用自然语言提问,它负责生成 SQL、图表和分析结果。 它还有语义层,把业务指标和数据关系提前定义好,减少 Agent 瞎猜字段、口径…
WrenAI 是一个开源的生成式 BI 引擎,允许用户通过自然语言提问生成 SQL、图表和分析结果,并带有语义层以提高准确性。
QueryFormer:KDD Cup 2026 腾讯 UniRec 挑战赛的获胜方案
QueryFormer 是一种统一的 Transformer 架构,它赢得了 KDD Cup 2026 腾讯 UniRec 挑战赛,专注于点击后转化率预测,重点在查询生成和高效扩展。