< 返回版块

Philip Z 发表于 2026-09-06 21:54

Tags:teaql, aicoding

一次相差 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 表达能力来看,这条语句没有问题:

  1. 按照 Recording 对 Relation 分组;
  2. 对每组 Relation 排序;
  3. 每组最多保留 10 条;
  4. 最终返回最新的 100 条 Recording。

它看起来也很“优雅”:一个 SQL 语句、一个窗口函数,一次完成查询。

问题在于,数据库可能需要先对整个 Relation 集合进行分组和排序,然后才能知道哪些 Relation 属于最终选中的 100 条 Recording。

在这次 MusicBrainz 测试中,这个直观查询为了最终返回 103 条 Relation,对大约 270 万行数据进行了排名处理。

应用只需要一个很小的页面,数据库却先完成了一次全局工作。

业务请求其实给出了更强的边界

原始请求并不是:

找出所有 Recording 的前 10 条 Relation,再从中选择 100 条 Recording。

它真正表达的是:

  1. 先确定最新的 100 条目标 Recording;
  2. 只为这 100 条 Recording 查询 Relation;
  3. 每个 Recording 最多加载 10 条 Relation;
  4. 只加载这些 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;
  • 查询需要继承租户、权限、版本和审计策略;
  • 查询携带 commentpurpose 等可观察性信息。

有了这些语义,运行时就可以先选择根对象,再把关联查询限定在这些根对象之内。

应用代码不需要退回手写 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/

评论区

写评论

还没有评论

1 共 0 条评论, 1 页