一次相差 2378 倍的 SQLx 查询:TeaQL 如何把业务边界变成执行计划
最近我们在 MusicBrainz 数据集上测试了这样一个业务请求:
查询最新的 100 条、存在关联作品的 Recording,并为每条 Recording 加载最多 10 条 Work 关联。
我们分别用两种 SQLx 查询方案执行了这个请求。两条路径返回了完全相同的结果:
- 100 条 Recording;
- 103 条 Relation;
- 103 个 Link;
- 103 个 LinkType;
- 相同的 Work ID 校验值。
但两种 SQLx 实现的耗时分别是:
| 实现方式 | PostgreSQL 中位耗时 |
|---|---|
| SQLx:直观的全局窗口查询 | 5871.169 ms |
| SQLx:先选根对象的两阶段查询 | 2.469 ms |
| TeaQL Rust:类型化受治理图查询 | 2.864 ms |
前两项是在同一个 SQLx 对照实验中测得的,性能差距约为 2378 倍。
TeaQL 的数据来自另一轮保留测试,用于提供背景参考,并不参与这个 2378 倍比值的计算。
因此,这个结果并不是说:
TeaQL 的 PostgreSQL 驱动比 SQLx 快 2000 多倍。
TeaQL 和优化后的 SQLx 最终都让数据库执行了范围明确的工作。真正造成巨大差异的,是两种执行计划所处理的数据量。
“显而易见”的 SQLx 写法
面对“每个父对象最多取 N 条子记录”这样的需求,熟悉 SQL 的开发者通常会想到窗口函数:
WITH ranked AS (
SELECT
relation.*,
row_number() OVER (
PARTITION BY relation.entity0
ORDER BY relation.link_order ASC, relation.id DESC
) AS rn
FROM recording_work_relation relation
)
SELECT ...
FROM recording
JOIN ranked
ON ranked.entity0 = recording.id
AND ranked.rn <= 10
WHERE ...
ORDER BY recording.id DESC
LIMIT 100;
从 SQL 表达能力来看,这条语句没有问题:
- 按照 Recording 对 Relation 分组;
- 对每组 Relation 排序;
- 每组最多保留 10 条;
- 最终返回最新的 100 条 Recording。
它看起来也很“优雅”:一个 SQL 语句、一个窗口函数,一次完成查询。
问题在于,数据库可能需要先对整个 Relation 集合进行分组和排序,然后才能知道哪些 Relation 属于最终选中的 100 条 Recording。
在这次 MusicBrainz 测试中,这个直观查询为了最终返回 103 条 Relation,对大约 270 万行数据进行了排名处理。
应用只需要一个很小的页面,数据库却先完成了一次全局工作。
业务请求其实给出了更强的边界
原始请求并不是:
找出所有 Recording 的前 10 条 Relation,再从中选择 100 条 Recording。
它真正表达的是:
- 先确定最新的 100 条目标 Recording;
- 只为这 100 条 Recording 查询 Relation;
- 每个 Recording 最多加载 10 条 Relation;
- 只加载这些 Relation 引用的 Link、LinkType 和 Work。
一旦先确定根对象,后续所有工作都可以限定在这 100 个 Recording ID 之内。
这意味着数据库不必对 270 万条 Relation 做全局排名。它只需要处理当前页面真正可能用到的数据。
这不是数据库驱动的性能差异,而是业务边界是否及时进入执行计划的差异。
优化后的 SQLx:先选择根对象
为了验证这一点,我们又手工编写了一版优化后的 SQLx 查询。
它采用两阶段执行:
第一阶段:查询根对象
先找出符合条件的 100 个 Recording ID:
SELECT recording.id
FROM recording
WHERE EXISTS (
SELECT 1
FROM recording_work_relation relation
WHERE relation.entity0 = recording.id
)
ORDER BY recording.id DESC
LIMIT 100;
第二阶段:只处理这些根对象的关联
然后把第一阶段得到的 ID 作为参数传给第二个查询:
WITH ranked AS (
SELECT
relation.*,
row_number() OVER (
PARTITION BY relation.entity0
ORDER BY relation.link_order ASC, relation.id DESC
) AS rn
FROM recording_work_relation relation
WHERE relation.entity0 = ANY($1)
)
SELECT ...
FROM ranked
JOIN link ...
JOIN link_type ...
JOIN work ...
WHERE ranked.rn <= 10;
窗口函数仍然存在,但它只对已经选中的 Recording 所对应的 Relation 进行排名。
查询结果没有变化,耗时却从 5871.169 ms 降到了 2.469 ms。
这说明 SQLx 本身没有性能问题。只要开发者明确写出正确的执行方案,SQLx 同样可以非常快。
真正的问题是:应用开发者是否应该在每一个类似场景里,亲自发现、实现并维护这种优化?
TeaQL 如何表达同一个请求
TeaQL 是一个模型驱动的应用运行时。它从语义模型生成类型化的查询 API、已加载状态表达式和受治理的图写入 API。
它目前可以面向 Rust、Java、TypeScript、Go、Swift、.NET 和 Python 生成相应的语言 API。
在 TeaQL 中,应用代码声明的是业务请求的形状。简化后可以理解为:
Q::recordings()
// 只选择存在 Work 关联的 Recording
.which_have_work_relations()
// 加载 Recording 的 Work 关联
.select_work_relations_with(
Q::recording_work_relations()
.select_link()
.select_link_type()
.select_work()
.order_by_link_order_asc()
.order_by_id_desc()
.limit(10)
)
// 根对象按 ID 倒序,只取 100 条
.order_by_id_desc()
.limit(100)
// 保留查询意图和操作目的
.comment("加载 MusicBrainz Recording-Work 图")
.purpose("展示有界的 Recording-Work 明细")
.execute_for_list(&context)
.await?;
以上代码是为了说明请求结构而做的简化示意,完整可执行版本可以在公开的基准仓库中找到。
这里最重要的并不是 Rust API 的具体命名,而是 TeaQL 能同时获得这些信息:
- 根对象最多 100 条;
- 每个根对象的 Relation 最多 10 条;
- Relation 有明确的排序方式;
- 只需要加载被引用的 Link、LinkType 和 Work;
- 查询需要继承租户、权限、版本和审计策略;
- 查询携带
comment和purpose等可观察性信息。
有了这些语义,运行时就可以先选择根对象,再把关联查询限定在这些根对象之内。
应用代码不需要退回手写 SQL,也不需要自己维护两条查询之间的语义一致性。
代码量并没有出现“魔法般”的减少
我们还统计了三种实现中用于声明和执行查询的非空代码行数。
连接池初始化、计时、结果校验和报告代码没有计入;应用直接持有的 SQL 文本则计入代码量。
| 实现方式 | 查询代码行数 |
|---|---|
| SQLx:直观的全局窗口查询 | 26 行 |
| SQLx:先选根对象的专家方案 | 34 行 |
| TeaQL:完整展开的类型化图查询 | 27 行 |
这个数字并不夸张,TeaQL 也没有通过代码高尔夫制造一种“只需一行”的错觉。
它完整展开后的查询,与直观 SQL 的代码量基本相当。
区别主要在于这些代码表达和保留了什么:
- 优化后的 SQLx 需要维护两条 SQL;
- 应用负责传递根对象 ID;
- 应用负责数组参数绑定;
- 两个查询阶段必须使用一致的过滤条件;
- 开发者还要自己组装类型化对象图。
TeaQL 则在同一个请求中声明一次根对象边界、关联边界和治理语义,由运行时负责将它们转化为相应的执行步骤。
所以这里的优势不是“代码更短”,而是减少需要由应用开发者手工维护的执行细节。
性能只是问题的一半
有经验的 SQL 开发者完全可以写出 2.469 ms 的 SQLx 方案。这次实验已经证明了这一点。
更值得关注的问题是:每一次手工优化都会增加新的策略执行点。
例如,在两阶段查询中:
- 第一条查询可能包含租户条件,第二条遗漏了;
- 根对象应用了权限范围,子对象却没有;
- 一边应用了软删除或版本策略,另一边没有;
- 隐私字段屏蔽、审计信息和链路追踪可能在改写过程中丢失。
当 AI 编程工具参与查询优化时,这类问题会更加隐蔽。生成的 SQL 可能确实更快,却在第二阶段意外加载了其他租户的子对象。
TeaQL 的目标并不是阻止优化,而是让两个查询阶段继续处于同一个受治理的执行模型中。
性能优化可以由运行时完成,但租户隔离、授权范围、版本策略、加载状态以及操作意图不能因此丢失。
“一个 SQL”不一定比“多个 SQL”更高效
这个实验也说明,单纯用查询次数评价性能是不够的。
“一次数据库往返”听起来通常优于“两次数据库往返”,但一个看起来很漂亮的单语句查询,可能让数据库处理数百万条与当前页面无关的数据。
相比之下,两条范围明确的查询可能只处理几百条记录。
因此,更有意义的问题不是:
这个功能执行了几条 SQL?
而是:
数据库为完成这个业务请求,实际做了多少工作?
查询次数只是成本的一部分。参与扫描、连接、排序和排名的数据量,往往更重要。
这次基准证明了什么
它证明了两件事情。
第一,业务 API 中的语义信息可以帮助运行时避免大量无效的数据库工作。
第二,根对象数量、关联数量和排序规则等业务边界,如果能在执行计划形成之前被保留下来,就可能产生非常显著的性能差异。
它没有证明什么
这次实验并不意味着:
- TeaQL 的 PostgreSQL 驱动天然比 SQLx 快;
- 所有窗口函数都很慢;
- 多条 SQL 永远优于一条 SQL;
- 2378 倍可以推广到其他数据集、硬件、索引或数据库;
- TeaQL 在所有场景中都比 SQLx 快 2378 倍。
更准确的结论是:
直观的 SQLx 查询为了返回当前页面所需的 103 条 Relation,对约 270 万行数据进行了排名。先应用业务边界,再执行窗口排名后,绝大部分工作都消失了。
TeaQL 的价值不在于拥有一个“更快的数据库驱动”,而在于让正确的边界可以被声明、类型检查、复用,并在统一的治理模型中执行。
如何复现实验
完整的测试代码和证据保存在公开仓库中:
https://github.com/teaql/teaql-runtime-benchmark
其中包含四组相关测试:
- B001:Rust TeaQL、Diesel 和 SeaORM 在 MusicBrainz 上的类型化图查询;
- B002:PostgreSQL 和 DuckDB 上的原生 JDBC 测试;
- B003:Java TeaQL 在 PostgreSQL 和 DuckDB 上的测试;
- B004:本文使用的两种 Rust SQLx 查询方案及正确性校验。
B004 保存了数据来源、查询结构、运行环境、预热次数、测量结果、返回数量和校验值,还包含一个可执行的代码行数统计工具。
2378 倍是一个真实、可复现但适用范围明确的结果。
更广泛的 TeaQL 主张并不是“永远比 SQLx 快”,而是:
让好的执行方案成为可声明、类型化、可复用且受治理的业务查询,而不是散落在应用中的一次性手工优化。
英文原文:
https://teaql.io/blog/teaql-2000x-obvious-sqlx-query/
评论区
写评论还没有评论