优化 #[sqlx::test] 的重建时间
摘要
一位 Rust 开发者对 SQLx 测试的增量重建时间进行了性能分析和优化,识别了调试信息生成和过程宏开销等瓶颈,并提出了加速测试编译的改进方案。
<p><a href="https://lobste.rs/s/xhplww/optimizing_sqlx_test_rebuild_time">评论</a></p>
查看缓存全文
缓存时间: 2026/06/22 01:29
# 优化 `#[sqlx::test]` 重建时间
来源: https://kobzol.github.io/rust/2026/06/21/optimizing-sqlx-test-rebuild-time.html
> 如果你的项目中有大量 `#[sqlx::test]` 测试,你可能会发现这篇文章特别有用。
过去几年我参与的一个上游 Rust 项目是 `bors` (https://github.com/rust-lang/bors) 的重写,这是我们在合并所有 `rust-lang/rust` (https://github.com/rust-lang/rust) PR 时使用的合并队列机器人。如果你想了解更多关于这个机器人的信息,可以看看我在 RustWeek 2026 上的演讲 (https://www.youtube.com/watch?v=NhTwLwznok8)。
我对 `bors` 的集成测试套件感到相当自豪,我为此付出了很多努力,多亏了它,机器人自 2026 年 1 月投入生产以来几乎完美运行(尽管 GitHub 最近经常出问题……)。但有一件事我不太满意,那就是 `bors` 的增量重建时间,尤其是它的测试套件。在我的笔记本上,每次更改后重建测试需要很长时间(约 8-10 秒),这对生产力来说相当糟糕。
最近我终于抽出时间对其构建时间进行了性能分析 (https://kobzol.github.io/rust/2026/06/21/optimizing-sqlx-test-rebuild-time.html#fn:samply),并发现这是由几个因素共同导致的:
- 生成调试信息需要很长时间。这是一个已知问题 (https://kobzol.github.io/rust/rustc/2025/05/20/disable-debuginfo-to-improve-rust-compile-times.html),但在这个案例中,我不想放弃调试信息,因为我确实经常调试并单步执行 `bors` 的测试。
- `rustc` 加载和持久化增量会话需要很长时间。我打算研究一下这个问题。
- 可能是因为所有的调试信息(最终二进制文件大约 220 MiB),`lld` 链接测试需要整整一秒钟 (!)。使用 `wild` (https://github.com/wild-linker/wild) 只需要大约 200ms。
- 我在 `bors` 中大量使用的 `sqlx::test` 编译时间很长。这是本文的重点。
## sqlx 测试编译缓慢
作为参照,我的基准测试使用 `touch && time cargo test --no-run`。即使只做一个无操作更改,重新编译测试也需要大约 7.5 秒,这非常慢。
当然,众所周知 `sqlx` (https://github.com/transact-rs/sqlx) 的过程宏会减慢编译时间,因为它做了很多“有趣”的事情 (https://kobzol.github.io/rust/2026/06/21/optimizing-sqlx-test-rebuild-time.html#fn:sqlx-db)。然而,我遇到的情况可能不那么明显。在我的案例中,`sqlx` 实际上甚至没有连接到数据库!因为我使用 `SQLX_OFFLINE=1` 编译,除非我直接处理 SQL 查询。而且*是的*,我按照 `sqlx` 文档的建议 (https://github.com/transact-rs/sqlx#compile-time-verification) 将 `sqlx-macros` crate 的 `opt-level` 设置为 `3`。
那么这里发生了什么?要弄清楚这一点,重要的是要理解当你有一个像这样的测试时会发生什么:
```rust
#[sqlx::test]
async fn test_foo(pool: sqlx::PgPool) {}
```
`#[sqlx::test]` 属性非常有用,因为它会在测试执行前创建一个新数据库,然后在上面运行迁移,最后给你一个数据库连接池,这样你就可以针对真实的数据库运行测试,而不是模拟的 `HashMap` (https://kobzol.github.io/rust/2026/06/21/optimizing-sqlx-test-rebuild-time.html#fn:testing)。
等等,我说迁移了吗?嗯,它从哪里找到迁移?当然是从磁盘上!每次使用 `#[sqlx::test]` 都会从磁盘上的一个目录中收集所有迁移,然后读取、解析、验证和哈希每个迁移。也许与直觉相反,这部分并不慢!事实证明,Rust 实际上相当快(谁知道呢,对吧??),如果你没有大量迁移,I/O 可能也不是问题 (https://kobzol.github.io/rust/2026/06/21/optimizing-sqlx-test-rebuild-time.html#fn:windows)。
更糟糕的是这些宏生成的输出。对于每个这样的测试,宏都会生成一个完整的迁移列表,包括它们的文本内容和以字节数组形式提供的校验和,作为 Rust 源代码中的常量。所以,如果你展开宏,在每个测试之前你会看到类似这样的内容:
```rust
args.migrator(&::sqlx::migrate::Migrator {
migrations: ::std::borrow::Cow::Borrowed(&[
::sqlx::migrate::Migration {
version: 20240517094752i64,
description: ::std::borrow::Cow::Borrowed("create build"),
migration_type: ::sqlx::migrate::MigrationType::ReversibleUp,
sql: ::std::borrow::Cow::Borrowed("CREATE TABLE ..."),
no_tx: false,
checksum: ::std::borrow::Cow::Borrowed(&[193u8, 202u8, ...]),
},
::sqlx::migrate::Migration { ... },
]),
});
```
上面的例子缩短了,跳过了很多东西。实际的生成代码会长得多,当然它会随着迁移数量(和内容)扩展。
现在,如果你的源代码中只有一次这样的代码,那还不算太糟。然而,在 `bors` 中,有大约 350 个 `sqlx` 测试和 30 个迁移。到了这个规模,影响就开始迅速累积了。
为了验证我的假设——迁移可能导致了构建缓慢——我测试了如果只有一次迁移会发生什么(删除其余的)。果然,重建时间立即从 ~7.5s 降到了 ~5s!更有说服力的是,`cargo expand --lib --tests` 的输出大小从 30 次迁移时的 32 MiB (!) 降到了只有一次迁移时的 6 MiB。多编译 26 MiB 的 Rust 代码当然不是免费的。
不过,不仅仅是生成代码的编译时间。在性能分析中,看起来在过程宏执行期间使用 `quote` crate 将所有迁移描述数据转换为 tokens 也花费了不可忽视的时间。这种行为可能非常隐蔽,因为在项目开始时,重建很快(或者至少*更快*)。但是随着每个测试和每个迁移的添加,重建时间逐渐增加,慢慢变得难以忍受。
我尝试了去年我在编译器中实现的实验性过程宏缓存功能 (https://github.com/rust-lang/rust/pull/145354),但这没有帮助。可能是因为编译生成代码所需的时间远远超过运行过程宏本身的时间,所以即使过程宏本身被缓存,也没有帮助。不过,`#[sqlx::test]` 是一个*属性*宏,而不是 *derive* 宏,所以这个标志不适用于这里。感谢 `@futile` 的指正。
## 可以做什么?
首先,我开始思考如何减少生成代码的大小,例如用更紧凑的形式表示校验和字节数组。虽然这可能会有所帮助,但我意识到只要我们在每个测试旁边生成所有迁移的代码,仍然会有太多代码。
然后我尝试修补 `sqlx`,将迁移的加载从编译时移到(测试)运行时,以去掉所有内联的迁移。这确实达到了预期的效果!重建时间降到了 ~5s,并且(至少在 `bors` 的例子中),`cargo expand` 输出减少到 ~6 MiB,而测试的执行时间没有受到可测量的影响。这个改动甚至不复杂,所需要的就是生成在运行时调用加载迁移函数的代码,而不是在过程宏中调用该函数然后将所有迁移的描述嵌入到生成的源代码中。
所以本质上,从这样(伪代码):
```rust
fn sqlx_proc_macro() -> TokenStream {
let migrations = generate_migrations();
quote! {
Migrator { migrations: #migrations }
}
}
```
变成这样:
```rust
fn sqlx_proc_macro() -> TokenStream {
quote! {
Migrator { migrations: ::sqlx::generate_migrations() }
}
}
```
然而,在运行时加载迁移可能会有一些缺点,例如测试不再自包含。我去了 sqlx Discord (https://github.com/transact-rs/sqlx/blob/main/CONTRIBUTING.md) 服务器,询问是否有任何建议。我的建议是 sqlx 要么在运行时加载迁移(如上所述),要么提供一个包含迁移的共享变量,所有测试都引用它,以避免生成的代码膨胀。
有趣的是,有人回复告诉我第二种解决方案已经实现了(嗯,某种程度上)。实际上,你可以在 `#[sqlx::test]` 中指定一个包含要应用的迁移的变量的路径:
```rust
// 宏生成迁移,我们将其存储在一个变量中。
const MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
// 每个测试只是引用这个变量,而不是在测试的源代码旁边内联所有迁移。
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_1(pool: sqlx::PgPool) {}
#[sqlx::test(migrator = "crate::MIGRATOR")]
async fn test_2(pool: sqlx::PgPool) {}
```
> 我不确定这里使用 `const` 还是 `static` 更好,也没有测量到重建时间的差异。但对我来说 `static` 更有意义,以确保数据在最终二进制文件中只存在一次。
这个解决方案对 `bors` 来说效果很好!在通过一些“查找+替换”魔法为所有 `#[sqlx::test]` 实例添加了 `migrator` 参数 (https://github.com/rust-lang/bors/pull/771) 后,重建时间降到了 ~5s。
虽然这个方法有效,但我不喜欢的一点是,我必须记得在所有测试中添加繁琐的 `migrator = "crate::MIGRATOR"` 属性,以避免迁移代码膨胀问题。我认为,在 `sqlx` 的 `0.9` 版本添加的 `sqlx.toml` 配置文件中为 `migrator` 参数指定一个默认值会很优雅,这样默认就会使用共享变量,而不需要额外考虑。我提出了一个问题 (https://github.com/transact-rs/sqlx/issues/4318) 来建议这个功能。让我们看看 sqlx 维护者的想法,尽管我能理解他们可能对这种功能有一些顾虑。
无论如何,即使这个手动解决方案也很有帮助,让我的测试重建时间变快了一些。不过仍然有很大的改进空间,因为 5s 仍然很慢。也许在 `#[sqlx::test]` 文档 (https://docs.rs/sqlx/latest/sqlx/attr.test.html) 中提及这个“暗坑”会很有用。我在这里 (https://github.com/transact-rs/sqlx/pull/4320) 提出了这个建议。
## 结论
我认为这篇文章提供了两个主要收获:
- 如果你的项目有很多 `#[sqlx::test]` 测试,并且你受到重建时间长的困扰,尝试使用 `migrator = "..."` 技巧,看看是否有帮助。
- 如果你有过程宏生成了大量代码,使用 `cargo expand` 测量代码的*字节大小*可以是编译时间的一个相对较好的预测指标 :)
如果你有关于使用 `sqlx` 时优化重建时间的进一步建议,请在 Reddit (https://www.reddit.com/r/rust/comments/1ubuw7i/optimizing_sqlxtest_rebuild_time/) 上告诉我。
相似文章
@eladgil: https://x.com/eladgil/status/2079561730263531771
Cursor 团队的 AI 代理根据手册将 SQLite 重构成 Rust 代码,并成功通过所有测试,成本因模型组合不同而相差 15 倍。
只改一个函数,性能提升10倍?解读一个 Rust 性能 PR
一篇深度博文,通过基准测试复现和代码分析,解读 GreptimeDB 中让 Prometheus 读取转换速度提升 10 倍的 Rust 性能 PR。
如何在2026年7月加速Rust编译器
Nicholas Nethercote报道了Rust编译器近期性能改进,包括平均墙钟时间总体减少5.59%,rustdoc大幅加速总计28%,以及通过PR和PGO训练更改实现的显著Clippy优化。
@tolgaogzz: 我和我的最佳 codex 将 eslint-plugin-svelte 移植到了 Rust,运行速度提升约 330 倍,所有规则的所有测试用例均通过…
一位开发者借助 Codex 将 eslint-plugin-svelte 移植到 Rust,实现了约 330 倍的速度提升,同时通过了所有测试用例。
6倍更快的二分查找:从编译代码到机械共鸣
本文详细介绍了Rust中二分查找的一系列底层优化,通过利用分支预测和SIMD等CPU架构特性,实现了6倍的加速,并将其应用于scikit-learn梯度提升用例中。