Appearance
第1章 为什么需要 SQLx:Rust 异步数据库生态的十字路口
"An ORM is a database wrapped in an object with a database inside." —— 社区流传的 ORM 吐槽
本章要点
- Rust 数据库生态的三条主流技术路线:Diesel 的编译期 DSL、tokio-postgres 的裸协议异步、SeaORM 的 ActiveRecord ORM——各自解决的痛点不同。
- sqlx 的三条核心设计主张:非 ORM(不包装 SQL)、异步原生(每个 I/O 都是
Future)、编译期校验(把真实 schema 带进类型系统)。 - sqlx 工作区由 7 个 crate 组成:
sqlx(facade)、sqlx-core(trait 与协议无关部分)、sqlx-macros+sqlx-macros-core(过程宏)、sqlx-postgres/sqlx-mysql/sqlx-sqlite(驱动)。 - 选择 sqlx 意味着接受三个让步:没有 DSL、没有惰性关联、query! 宏绑定单一数据库——每一个让步都有清晰的工程理由。
- 本书把 sqlx 作为 Rust 后端栈的持久层来讲解——位于《Rust 编译器与运行时揭秘》(
async fn状态机)、《Tokio 源码深度解析》(调度与 I/O)、《Hyper 与 Tower:工业级 HTTP 栈》(HTTP 协议栈)、《Axum 设计与实现》(Web 框架)之下——前四本讲完 Rust 应用到数据库之前的全链路、本书补上最后一环。
1.1 一个真实场景:Rust 后端选型的十字路口
你刚开始一个新的 Rust 后端项目。技术栈已经敲定了大半:Tokio 做异步运行时、Axum 做 HTTP 路由、serde 做 JSON 序列化。业务主线第一条 User Story 是"给前端写一个 GET /users/:id 接口"。到了数据访问层这一步,你打开 crates.io,搜"postgres",前几条结果是:
diesel:17 000 行 Rust、300 多万下载、最古老也最成熟的 Rust ORM,主打"编译期 SQL 校验"和"类型安全的查询 DSL"。代价是默认同步、异步支持要靠diesel-async。tokio-postgres:原生异步、协议级实现,零抽象成本。代价是 SQL 以字符串形式出现、参数绑定手写、行解码手写、错误处理手写。sqlx:号称"Rust 异步 SQL 工具包",在query!宏里同时拥有"SQL 字符串"和"编译期类型校验",连接池内建,Postgres / MySQL / SQLite 三家通吃。sea-orm:基于 sqlx 包装的 ActiveRecord 风格 ORM,提供find_by_id/insert/update这种实体级 API,适合不想写 SQL 的团队。
你对着这四个选项犹豫了半个小时,最后选了 sqlx。为什么?因为你既不想手写参数绑定和行解码,也不想学一门 DSL,更不想把所有 SQL 都隐藏到 ActiveRecord 后面。你想继续写 SQL——但要让编译器帮你检查 SQL 写得对不对。
这正是 sqlx 存在的理由:在写 SQL 的自由度和类型系统的保护之间,找到了一条第四路径。
本章要回答的就是这条路径到底是什么、它怎么做到的、以及它付出了哪些代价。读完本章,你不会写出一行 sqlx 的代码,但你会建立起整本书的核心心智模型。
1.2 Rust 数据库工具的三种设计哲学
在 sqlx 出现之前,Rust 生态已经形成了三条稳定的技术路线。这三条路线各自解决一个不同的痛点,也各自有无法弥补的短板。要理解 sqlx 的设计动机,必须先看清这三条路线的形态。
1.2.1 Diesel 派:编译期 DSL
Diesel 的核心主张是:SQL 是一种不安全的字符串,类型系统无法跨越字符串边界检查它;因此 Diesel 用 Rust 类型系统重新表达 SQL,把 SQL 变成一棵可由编译器检查的表达式树。
一条 Diesel 查询长这样:
rust
use diesel::prelude::*;
use crate::schema::users::dsl::*;
let target: User = users
.filter(id.eq(user_id))
.select(User::as_select())
.first(conn)?;没有任何 SQL 字符串。users、id、User::as_select() 都是 Rust 类型——users 是表标记、id.eq(x) 返回一个 Eq<id, Bound<Integer>> 表达式类型、.select() 接受实现了 SelectableExpression 的参数。编译器可以在 cargo check 阶段验证:你 filter 的列确实属于这张表;你绑定的参数类型确实和列类型一致;你 select 出来的列和 User 结构体的字段精确对齐。
代价是DSL 的表达力天花板永远低于 SQL 本身。写 GROUP BY ROLLUP、窗口函数、递归 CTE、json_agg、任何数据库独有的函数——你都得去查 Diesel 的 sql_function! 宏、自己把这些构造登记到 DSL 里。Diesel 的用户最常抱怨的一句话是"我 SQL 本来十秒能写出来,换成 Diesel DSL 要查一小时文档"。
Diesel 的第二个代价是默认同步。.first(conn)? 直接阻塞线程。要异步化,需要 diesel-async——但那是一个独立 crate,trait 边界不完全兼容、生态支持弱于 Diesel 本身。
1.2.2 tokio-postgres 派:裸协议异步
tokio-postgres 走另一个极端:不做抽象。它只实现 PostgreSQL 的线路协议(简单查询 Q 消息、扩展查询 P/B/E 消息、COPY 协议等),以及把字节流包装成 Future。
一条 tokio-postgres 查询长这样:
rust
let (client, connection) = tokio_postgres::connect(&url, NoTls).await?;
tokio::spawn(connection);
let row = client
.query_one("SELECT id, name, email FROM users WHERE id = $1", &[&user_id])
.await?;
let id: i32 = row.get(0);
let name: String = row.get(1);
let email: String = row.get(2);SQL 是字符串,参数用 &[&dyn ToSql] 传递,行通过索引或列名解码。没有编译期校验、没有自动映射到结构体、没有内建连接池。这一切都要靠生态周边(deadpool-postgres 做连接池、手写 From<Row> 做映射)。
tokio-postgres 的优点是极致薄。它几乎就是"PostgreSQL 协议的异步 Rust 绑定",没有附加概念,运行时开销最低,对每一个字节在线路上的行为都有完全控制。这使得它成为"写高性能基础设施组件"的首选——比如在 Rust 里实现一个 Postgres 代理、CDC 消费者、连接池中间件。
但它不适合业务应用。业务层每一次查询都要手写七行 glue code,错误信息只有 InvalidInput 这种粗粒度字符串,调试体验接近于写 C。
1.2.3 SeaORM 派:ActiveRecord ORM
SeaORM 从另一个角度回答"怎么写 Rust 后端":把数据库实体当作对象,让开发者不写 SQL。
一条 SeaORM 查询长这样:
rust
use entity::user;
let found: Option<user::Model> = user::Entity::find_by_id(user_id).one(db).await?;没有 SQL,也没有 DSL——只有一个实体的静态方法。SeaORM 会根据实体定义(由它自己的 migration CLI 或 entity 宏生成)自动构造查询、执行、反序列化。这对于"CRUD 八成、复杂查询两成"的业务非常友好:80% 的代码读起来像 Active Record,20% 的复杂场景仍然可以 fallback 到 raw SQL。
SeaORM 的代价是又是一层抽象。它在 sqlx 之上又包一层实体建模、查询构造、迁移管理。这意味着你需要同时理解 sqlx 的 trait 家族 和 SeaORM 的 EntityTrait / ActiveModelTrait——调试的时候需要穿两层。SeaORM 本身把 sqlx 当作运行时引擎,这也反过来说明了 sqlx 定位的清晰:sqlx 负责把 SQL 字节和连接池落到 Future,ORM 负责在其上搭业务实体。
1.2.4 对比总览
把四条路线放在同一张表里看:
| 工具 | SQL 位置 | 编译期校验 | 异步模型 | 抽象层级 | 适合场景 |
|---|---|---|---|---|---|
| diesel | DSL | 强(表达式树) | 同步优先 | ORM | 不想写 SQL、可接受 DSL 天花板 |
| tokio-postgres | 字符串 | 无 | 原生异步 | 协议层 | 极致性能、基础设施组件 |
| sqlx | 字符串 | 强(字面量 query!) | 原生异步 | 工具包 | 写 SQL、要异步、要类型保护 |
| sea-orm(on sqlx) | 实体方法 | 弱 | 原生异步 | ActiveRecord | 大量 CRUD 的业务应用 |
从这张表可以读出一条关键信息:"写字符串 SQL 且保留编译期校验"这个象限,在 sqlx 之前是空的。Diesel 的校验强但代价是 DSL;tokio-postgres 的字符串自由但没有校验。sqlx 做的事情,是用过程宏把字符串和编译期校验这两个看似矛盾的目标连在一起。
1.2.5 四种写法对同一任务:按 ID 更新用户邮箱并返回更新后的整行
换一个具体任务来丈量这四条路线的表达差异。业务需求是"把用户 user_id 的 email 改成 new_email,返回更新后的 User 整行"。这是一条 UPDATE ... RETURNING *。
Diesel 版(同步):
rust
use diesel::prelude::*;
use crate::schema::users::dsl::*;
let updated: User = diesel::update(users.filter(id.eq(user_id)))
.set(email.eq(new_email))
.get_result(conn)?;DSL 足够优雅,但 schema::users::dsl 必须由 table! 宏预先生成,而且 User 要派生 Queryable。
tokio-postgres 版(异步):
rust
let row = client
.query_one(
"UPDATE users SET email = $1 WHERE id = $2 RETURNING id, name, email",
&[&new_email, &user_id],
)
.await?;
let updated = User {
id: row.get(0),
name: row.get(1),
email: row.get(2),
};参数按索引绑定、行按索引解码、User 完全手动构造。任何列名拼写错误都只会在运行时报错。
sqlx 版(异步):
rust
let updated = sqlx::query_as!(
User,
"UPDATE users SET email = $1 WHERE id = $2 RETURNING id, name, email",
new_email,
user_id,
)
.fetch_one(&pool)
.await?;SQL 是字面量、参数按位置传、结果直接 FromRow、整条链路编译期校验。
SeaORM 版(异步):
rust
let mut active: user::ActiveModel = user::Entity::find_by_id(user_id)
.one(db).await?.ok_or(NotFound)?.into();
active.email = Set(new_email);
let updated: user::Model = active.update(db).await?;概念最多:Entity、Model、ActiveModel、Set——但对"改字段就保存"这个 CRUD 模式来说最顺手。
这段对比可以用一句话总结:sqlx 的代码量和 Diesel 相当、类型保护和 Diesel 同级,但 SQL 仍然是一条你可以直接拷到 psql 执行的字符串。这正是它的独特卖点。
1.3 sqlx 的设计立场:三条核心主张
sqlx 的设计文档和源码注释里反复出现三条主张。每一条都是对上一节三条路线的直接回应。
1.3.1 非 ORM:只做 SQL 执行 + 类型映射
打开 sqlx 的顶层 crate(facade)看它导出的符号:Pool、Executor、Connection、Query、QueryAs、Transaction、FromRow、Encode、Decode、Type——全部是"SQL 执行"和"类型映射"层面的概念(见 sqlx-0.8.6/src/lib.rs:12-38)。
你不会在 sqlx 里看到:
User::find()这样的实体方法——没有 Entity、没有 Model。user.posts这样的关联导航——没有 has_many / belongs_to。User::where(...)这样的查询构造器 DSL——只有QueryBuilder,而QueryBuilder是"动态拼 SQL 字符串"的工具,不是 DSL。- Migration 的 schema-diff 自动推导——
sqlx migrate管迁移文件的版本、checksum 与应用顺序,但迁移 SQL 必须你自己写。
sqlx 的每一个公共 API 都围绕"一条 SQL 语句怎么执行、怎么把结果解码成 Rust 值"。这让它在概念量上远小于 ORM——学习曲线集中在 Executor、Query、FromRow 三个家族上,没有"实体建模"这一整套额外负担。
这条立场也决定了本书的基调:我们不会讨论 ORM 的设计权衡,不会解释 ActiveRecord 与 DataMapper 的差别。我们只讨论 sqlx 是怎么把一条 SQL 字符串变成一个带类型的 Future 的。
1.3.2 异步原生:每一个 I/O 动作都是 Future
翻开 sqlx-core/src/connection.rs:14,Connection trait 的 close、ping、begin 都返回 BoxFuture:
rust
pub trait Connection: Send {
type Database: Database<Connection = Self>;
type Options: ConnectOptions<Connection = Self>;
fn close(self) -> BoxFuture<'static, Result<(), Error>>;
fn close_hard(self) -> BoxFuture<'static, Result<(), Error>>;
fn ping(&mut self) -> BoxFuture<'_, Result<(), Error>>;
fn begin(&mut self) -> BoxFuture<'_, Result<Transaction<'_, Self::Database>, Error>>
where
Self: Sized;
// ...
}同样的风格贯穿 Executor::fetch 返回 BoxStream、Pool::acquire 返回 impl Future。sqlx 没有任何同步 API——它从诞生的第一天就是为 Tokio / async-std 这样的异步运行时设计的。
这条立场直接来自 2018 年左右 Rust 异步生态的爆发:Tokio 0.2、async-std 1.0、async/await 语法正式稳定。Diesel 诞生于 2015 年的同步时代,其 trait 上全是 fn run(&self, conn: &mut Conn) -> Result<T>——这种签名没法平滑接入 .await。sqlx 的作者(launchbadge 团队)在 2019 年开始这个项目时,直接跳过了"同步优先、异步适配"这条路,全栈 async。
代价是 sqlx 无法在非异步环境里用——你不能在 main() 里不启动运行时就直接 pool.acquire()?。但对于 2026 年今天的 Rust 后端项目,这个代价几乎不存在:tokio + axum + sqlx 已经是事实上的默认栈。
1.3.3 编译期校验:把真实 schema 带进类型系统
这是 sqlx 最"反直觉"也最有辨识度的一条主张。
sqlx::query!("SELECT id, name, email FROM users WHERE id = $1", user_id) 在 cargo check 的时候会:
- 读取环境变量
DATABASE_URL或.env文件。 - 连接到那个 URL 指向的真实数据库。
- 对 SQL 字符串执行
DESCRIBE/PREPARE(视数据库而定)。 - 拿到每一列的类型、可空性;拿到每一个参数槽的期望类型。
- 用这些信息生成一段匿名结构体代码,让
fetch_one的返回值是{ id: i32, name: String, email: Option<String> }。 - 同时检查
user_id的 Rust 类型是否能Encode到$1的期望类型。
如果任意一步失败——SQL 写错了、列名拼错了、参数类型不匹配——编译器报错,而不是运行时报错。
这件事在 Rust 生态里没有先例。Diesel 的"编译期校验"是用 schema.rs 文件里的 table! 宏展开成类型,然后在 DSL 级别检查——从未真正连上数据库。sqlx 的做法是过程宏在编译期触达外部世界,这在 Rust 语言设计上本来是灰色地带(proc-macro 原则上应当是纯函数),但因为 sqlx 做得足够谨慎(只做只读 DESCRIBE、不做 DDL、不做数据写入),社区接受了这种用法。
编译期连数据库带来一个现实问题:CI 怎么办? CI 环境里通常没有数据库。sqlx 的解法是 cargo sqlx prepare:在本地开发机上跑这条命令,它会把所有 query! 的 schema 元数据序列化到 .sqlx/ 目录的 JSON 文件里;提交到 git 后,CI 设置 SQLX_OFFLINE=true,query! 宏就会从缓存读而不连数据库。我们会在第 11 章深入看 sqlx-macros-core/src/query/data.rs 里的 QueryData 和 offline 模块怎么实现这件事。
1.4 源码地图:7 个 crate 的职责划分
要读 sqlx 的源码,第一件事是看清它的 crate 边界。0.8.6 版本的工作区长这样:
每个 crate 的职责可以一句话概括,旁边附上 0.8.6 版本的代码量(find src -name "*.rs" | xargs wc -l):
| Crate | 文件数 | 行数 | 职责 |
|---|---|---|---|
sqlx | 5 | 1 301 | Facade:pub use + feature gate,无业务逻辑 |
sqlx-core | 95 | 14 400 | 协议无关的 trait 家族、Pool、Transaction |
sqlx-macros | 1 | 101 | #[proc_macro] 入口,委托给 macros-core |
sqlx-macros-core | 17 | 3 585 | query! 实现、derive 展开、离线缓存 |
sqlx-postgres | 108 | 19 841 | Postgres 线路协议 + 类型系统 |
sqlx-mysql | 72 | 9 195 | MySQL/MariaDB 线路协议 |
sqlx-sqlite | 51 | 10 061 | SQLite 线程池包装(基于 libsqlite3-sys) |
几个从这张表里读出的结论:
- 协议 crate 比核心 crate 还重。
sqlx-postgres(19 841 行)单独就比sqlx-core(14 400 行)多。这说明数据库协议的复杂度远超"一个 trait 家族 + 一个连接池"——Postgres 的 400+ 内置类型、COPY 协议、LISTEN/NOTIFY、逻辑复制每一项都要几百行。 sqlx-macros只有 101 行。一个 crate 只有一个文件、一百行代码,是 Rust 生态里非常典型的"proc-macro crate wrapper"模式——因为proc-macro = true的 crate 不能被任何库代码 use,必须单独存在。真正的实现在sqlx-macros-core。- SQLite 驱动(10 061 行)出人意料地和 MySQL(9 195 行)接近。SQLite 看似简单(嵌入式、无网络协议),但它需要把同步的
libsqlite3C API 包装成async——这涉及 I/O 线程池、消息通道、错误码翻译。第 18 章会讲这层包装为什么不是"真 async"。
每个 crate 的职责详细:
sqlx(facade):只做 re-export 和 feature gate。sqlx-0.8.6/src/lib.rs一共 174 行,前 50 行是pub use sqlx_core::...,后面是按 feature(mysql、postgres、sqlite)re-export 驱动。没有任何逻辑。sqlx-core:定义所有协议无关的抽象。Databasetrait(database.rs:72)、Executortrait(executor.rs:33)、Connectiontrait(connection.rs:14)、Pool(pool/mod.rs:260)、Transaction(transaction.rs:86)、Encode/Decode/Type、Row/Column/Value、Arguments——这些都在这里。是整个项目的骨架。sqlx-macros:过程宏入口。整个 crate 的lib.rs只做一件事——把expand_query、#[derive(FromRow)]、#[derive(Encode)]等#[proc_macro]函数声明出来,每个函数的 body 都是"解析 TokenStream → 调用 sqlx-macros-core → 返回生成的代码"(见sqlx-macros-0.8.6/src/lib.rs:7-24)。之所以拆成两个 crate,是因为过程宏必须以proc-macro = true构建,但proc-macrocrate 不能被库当作普通依赖引用——所以真正的实现放到sqlx-macros-core才能被其他工具(比如sqlx-cli的prepare子命令)复用。sqlx-macros-core:query!宏的真正实现在这里。它包含连接数据库、执行DESCRIBE、解析结果、生成 TokenStream 的全部逻辑,以及 offline 缓存的读写。sqlx-postgres/sqlx-mysql/sqlx-sqlite:各自实现Databasetrait 以及它的所有关联类型——协议编解码、类型系统映射、Connection生命周期。这三个 crate 不依赖彼此,但都依赖sqlx-core。
依赖是单向的:所有驱动依赖 core、core 不依赖任何驱动。这条单向性让"新增一个数据库"变成一件结构清晰的事:只要新 crate 实现 Database 和所有关联类型,就能接入 Pool / Executor / query! 的整个上层生态,不需要修改 core。我们在第 2 章会专门讲这条边界。
1.5 一次 query_as! 的完整路径
把本章开头那段代码再放一次:
rust
let user: User = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
user_id
).fetch_one(&pool).await?;这一行代码在编译期和运行时分别会发生什么?我们用一张序列图把全链路画出来。注意,这里涉及的每一步会在后面的章节详细展开,本节只是建立"全景俯视"的心智模型。
这张图里几乎每一个步骤都对应本书后面的一章:
- 步骤 ①–⑤(编译期)对应第 11 章
query!宏。 - 步骤 ⑥
Pool::fetch_one对应第 13 章 Pool API。 - 步骤 ⑦–⑧
AsyncSemaphore+idle_conns对应第 14 章 Pool 内部,直接对应源码sqlx-core/src/pool/inner.rs:28-29:
rust
pub(crate) struct PoolInner<DB: Database> {
pub(super) idle_conns: ArrayQueue<Idle<DB>>,
pub(super) semaphore: AsyncSemaphore,
// ...
}- 步骤 ⑨
PoolConnection对应第 13 章sqlx-core/src/pool/connection.rs。 - 步骤 ⑩–⑫ Extended Query 协议对应第 16 章 Postgres 驱动。
- 步骤 ⑬ 解码对应第 5-8 章类型映射。
- 步骤 ⑭–⑮
Drop归还对应第 13 章 Pool 中PoolConnection::drop的分析。
换句话说,这一行代码串联了本书至少 7 个章节的内容。本章只是给你一张地图;接下来的 22 章是这张地图上每一个节点的细节。
1.5.1 宏展开后到底变成了什么
让我们把上面那条 query_as! 展开。sqlx-macros-core/src/query/output.rs:183-191 的 quote_query_as 函数决定了展开形态:
rust
quote! {
::sqlx::__query_with_result::<#db_path, _>(#sql, #bind_args).try_map(|row: #row_path| {
use ::sqlx::Row as _;
#(#instantiations)*
::std::result::Result::Ok(#out_ty { #(#ident: #var_name),* })
})
}对于
rust
sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", user_id)展开后(简化后)大致是这样:
rust
::sqlx::__query_with_result::<::sqlx::Postgres, _>(
"SELECT id, name, email FROM users WHERE id = $1",
{ let mut args = ::sqlx::postgres::PgArguments::default();
args.add(user_id); // 编译期已知 user_id: i32,对应 OID=23
args },
).try_map(|row: ::sqlx::postgres::PgRow| {
use ::sqlx::Row as _;
#[allow(non_snake_case)]
let sqlx_query_as_id = row.try_get_unchecked::<i32, _>(0usize)?.into();
#[allow(non_snake_case)]
let sqlx_query_as_name = row.try_get_unchecked::<String, _>(1usize)?.into();
#[allow(non_snake_case)]
let sqlx_query_as_email = row.try_get_unchecked::<String, _>(2usize)?.into();
::std::result::Result::Ok(User {
id: sqlx_query_as_id,
name: sqlx_query_as_name,
email: sqlx_query_as_email,
})
})这里有几个关键观察:
- 类型是在编译期就塞进
try_get_unchecked::<i32, _>(0)的——而不是运行时查。这些i32/String是编译器在"真实连数据库 DESCRIBE 回来"的信息上推出来的(见output.rs:142-148的ColumnType::Exact分支)。 try_get_unchecked不做运行时类型检查——因为"类型已经在编译期被校验过了"这个前提被checked = true明确标记。如果你用的是query_as_unchecked!或者是列被as "x: _"覆盖成 wildcard,才会 fallback 到try_get做运行时检查。这就是 "unchecked" 后缀的含义。bind_args是一次性构造的,不是链式.bind()——这让编译器可以一次性在这里把所有参数的类型约束都加上。- 整段展开没有任何
?在顶层——它返回的是一个Map类型(Query::try_map的返回值),整个是一个"准备好.await的impl Future<Output = Result<User, Error>>"。你在用户代码里写的.fetch_one(&pool).await?才是真正触发执行的那一步。
理解了这层展开,你就能回答"为什么 query_as! 生成的代码对错误信息友好"这个问题——每一个 row.try_get_unchecked::<i32, _>(0)? 在失败时都带着明确的列索引和期望类型,而不是一个笼统的"解码失败"。第 11 章会把这层展开的每一步都拆开讲,包括 Exact / Wildcard / OptWildcard 这三种 ColumnType 的使用场景。
1.6 三个让步:设计决策不是免费午餐
选择 sqlx 等于接受它的三个让步。理解这三个让步是否可接受,是技术选型阶段必须做的思考。
让步一:没有 DSL
sqlx 不提供 users.filter(id.eq(x)).select(...) 这种查询构造器。要拼"动态 WHERE 条件",你有两个选择:
- 用
QueryBuilder:它是一个带保护的字符串拼接器,提供push("WHERE id = ")和push_bind(id)两种操作。前者拼 SQL 字符串(由你负责避免注入),后者追加参数槽并记录值。这就是第 10 章的主题。 - 写多个
query!分支,根据条件选择不同的字面量。代价是重复,收益是每一条分支都被编译期校验。
这两条路线都比 Diesel DSL 丑。但它们都有一个 DSL 没有的优点:你写的 SQL 就是发到数据库的 SQL。Diesel 用户调试复杂查询时,常常要打开 EXPLAIN 才知道 DSL 被翻译成了什么。sqlx 用户的 SQL 是字面量——打开 Postgres 日志,看到的就是你写的那一行。
让步二:没有惰性关联
SeaORM 可以写 user.find_related(Post) 触发一次关联查询。sqlx 没有关联——你要 SELECT * FROM posts WHERE user_id = $1,要手写。
这个让步的理由是 N+1 查询问题的可见性。ORM 的惰性关联让 N+1 变得极其隐蔽——你写了一个看似单条的 user.posts,底下可能变成一个循环 N 条查询。sqlx 强迫你显式写 SQL,这让 N+1 无法隐藏:你一定会看到一个 for user in users { ... } 里嵌套的 query,在 code review 阶段就会被揪出来。
当然,这也意味着如果你的业务里 90% 都是简单实体 CRUD,sqlx 会比 ORM 啰嗦——每个实体你都要手写一条 SELECT / INSERT / UPDATE / DELETE。这时候把 sea-orm 叠在 sqlx 上是合理的选择:80% 用 sea-orm 的实体 API,20% 复杂查询 fallback 到 sqlx。
让步二·附:&mut Transaction 不再自动是 Executor
沿着"让步"这条主线还有一个必须提的历史切面:sqlx 0.7 → 0.8 的那次破坏性变更。
在 0.7 及以前,你可以直接把 &mut tx 传给任何接受 impl Executor 的函数:
rust
let mut tx = pool.begin().await?;
let user: User = sqlx::query_as!(User, "SELECT ... WHERE id = $1", id)
.fetch_one(&mut tx) // 0.7 可以这样写
.await?;
tx.commit().await?;0.8 之后,上面这行编译不过。新的 Executor trait 注释直接写在源码上(sqlx-core/src/executor.rs:22-30):
rust
/// The [`Executor`] impls for [`Transaction`](crate::transaction::Transaction)
/// and [`PoolConnection`](crate::pool::PoolConnection) have been deleted because they
/// cannot exist in the new crate architecture without rewriting the Executor trait entirely.
/// To fix this breakage, simply add a dereference where an impl [`Executor`] is expected, as
/// they both dereference to the inner connection type which will still implement it:
/// * `&mut transaction` -> `&mut *transaction`
/// * `&mut connection` -> `&mut *connection`正确的 0.8 写法是把解引用加回来:
rust
.fetch_one(&mut *tx).await?背后的原因是:Transaction<'c, DB> 实现了 DerefMut<Target = DB::Connection>(sqlx-core/src/transaction.rs:224),所以 &mut *tx 就是 &mut DB::Connection,而 &mut C: Connection 是实现 Executor 的。0.7 里手写的 blanket impl Executor for &mut Transaction 在新的 crate 架构下无法和 impl Executor for &mut Connection 共存(会触发 coherence 冲突)——最干净的办法就是让 Transaction 通过 DerefMut 暴露内部连接,让用户手动写一次 *。
这个破坏性变更反映了 sqlx 团队对"类型系统一致性"的坚持:宁可让用户多打两个字符,也不保留一个会在 impl 体系里制造冲突的便捷 blanket。读通这段源码注释,你对 sqlx 的设计风格会有直接的体感。
让步三:query! 绑定单一数据库
query! 宏需要在编译期连接数据库做 DESCRIBE,这意味着它在一次编译里只能验证一个数据库方言。如果你的应用同时连 Postgres 和 MySQL,query! 里的 SQL 只能按 DATABASE_URL 指向的那个数据库校验——你不能让同一个 crate 里一半 SQL 按 Postgres 校验、另一半按 MySQL 校验。
解决办法在第 11 章会详细讲:要么把多数据库拆成多个 crate,要么用 sqlx::query_as + 手写 FromRow(放弃编译期校验)。
实际上 95% 的业务只会用一个数据库——这个让步大多数团队感觉不到。但它是"编译期触达外部世界"这条路径的必然代价:你在 compile 时只能连一个外部世界。
让步四(隐藏的):Transaction 的 Drop 只是"尽力 rollback"
最后一个让步不是架构选择,而是异步 Rust 的底层限制——值得在本章先提,后面第 15 章再细讲。
Transaction<'c, DB> 的 Drop 实现(sqlx-core/src/transaction.rs:260-275)长这样:
rust
impl<'c, DB> Drop for Transaction<'c, DB>
where
DB: Database,
{
fn drop(&mut self) {
if self.open {
// starts a rollback operation
//
// what this does depends on the database but generally this means we queue a rollback
// operation that will happen on the next asynchronous invocation of the underlying
// connection (including if the connection is returned to a pool)
DB::TransactionManager::start_rollback(&mut self.connection);
}
}
}关键是注释里的"starts a rollback operation"和"on the next asynchronous invocation"——Drop::drop 是同步函数,无法 .await,所以它只能标记一个 rollback 意图,真正的协议消息要等下一次用这个连接时才发出去(或者由连接池在归还时触发)。
这意味着两件事:
- 如果你的业务代码忘了
tx.commit().await?就让tx离开作用域,sqlx 不保证 rollback 消息立刻到达数据库——从数据库的视角看,那个事务可能还在"打开着",持有行锁。时间窗可能很短(几十毫秒到一次连接归还前),也可能很长(如果连接不再被使用)。 - Rust 的 async 生态里没有"async drop"——这是语言层面的限制,不是 sqlx 的设计不足。sqlx 能做到的就是"start 一个 rollback,让下次用到这个连接的时候尽量发出去"。
对用户的实际要求:永远显式 tx.commit().await? 或 tx.rollback().await?,不要依赖 Drop。这在本章属于"让步"语境,在第 15 章会变成"实现细节"。
1.7 与 Tokio / Axum 的协作
如果你已经读过本丛书前面几卷,这一节帮你把 sqlx 在"Rust 后端栈"里的位置对齐。
一次典型的 Axum 请求处理从 Serve 接受 TCP 连接开始(《Axum》第 15 章),经过 hyper 的 HTTP/1 或 HTTP/2 解码(《Hyper 与 Tower》第 11-17 章),进入 Router 的路径匹配(《Axum》第 2 章),调度到用户的 async fn handler(《Axum》第 5 章)。handler 里通常会这样调用数据库:
rust
async fn get_user(
State(pool): State<PgPool>,
Path(user_id): Path<i32>,
) -> Result<Json<User>, AppError> {
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
user_id
)
.fetch_one(&pool)
.await?;
Ok(Json(user))
}这里的每一个 .await 都落在 Tokio 的 multi-thread runtime 上(《Tokio》第 4-7 章)。pool.acquire() 内部用的 AsyncSemaphore(sqlx-core/src/sync.rs)在启用 runtime-tokio feature 时就是对 tokio::sync::Semaphore 的薄包装——它的 fair 语义完全继承自 Tokio(《Tokio》第 13 章会讲 tokio::sync::Semaphore 的 intrusive linked list 实现)。
换句话说,sqlx 的连接池建立在 Tokio 的同步原语之上。这条继承链决定了本书后半段会不时跳进 Tokio 源码——我们会在第 14 章 Pool 内部对这条链做详细拆解。
同样的协作关系也在类型系统层面成立:Axum 的 Handler<T, S> 要求 handler 的每个参数实现 FromRequestParts,State<PgPool> 正是一个实现。PgPool 通过 Clone 被拆分到每次请求,但底层的 PoolInner 只有一份——这是 Pool<DB>(Arc<PoolInner<DB>>) 这个 newtype around Arc 设计的直接产物(sqlx-core/src/pool/mod.rs:260)。
理解 sqlx 的 Pool,是理解 Axum 应用为什么只需要一个 Router::with_state(pool.clone()) 就能让所有路由共用连接池的关键。我们在第 13 章会展开。
1.7.1 一次 GET /users/:id 请求的完整调用栈
为了让"Tokio / Axum / sqlx 一起工作"这件事具象化,我们把一次请求从 socket 到数据库的调用栈摊开来看:
| 层 | 关键文件(源码) | 做了什么 |
|---|---|---|
| TCP accept | tokio-1.x/src/net/tcp/listener.rs | TcpListener::accept() 返回 (TcpStream, SocketAddr) |
| Axum Serve | axum/src/serve/mod.rs | 把 stream 丢给 hyper,然后 spawn 一个任务处理连接 |
| Hyper HTTP/1 | hyper/src/proto/h1/conn.rs | 字节流 → Request<Incoming> |
| Axum Router | axum/src/routing/mod.rs | path → MethodRouter → HandlerService |
| Handler | 用户代码 get_user | 提取 State<PgPool> 和 Path<i32>,调用 sqlx |
| sqlx query! | sqlx-macros-core/src/query/output.rs:183 | 展开成 __query_with_result::<Postgres, _>(sql, args) |
| Pool acquire | sqlx-core/src/pool/inner.rs:127 | acquire_permit → AsyncSemaphore::acquire |
| 获连接 | sqlx-core/src/pool/inner.rs:28 | idle_conns.pop()(ArrayQueue<Idle<DB>>)或新建 |
| Pg 协议 | sqlx-postgres/src/connection/executor.rs | 发 Parse/Bind/Execute(Extended Query) |
| TCP write | tokio-1.x/src/net/tcp/stream.rs | AsyncWriteExt::write_all |
| ↩ 响应 | 同上反向 | RowDescription → DataRow → CommandComplete |
| 解码 | sqlx-core/src/row.rs + sqlx-postgres/src/row.rs | row.try_get_unchecked::<i32, _>(0) |
| 归还连接 | sqlx-core/src/pool/connection.rs 的 Drop impl | push 回 idle_conns、release 一个 permit |
| 响应编码 | axum/src/response/* → hyper → tokio | Json<User> → HTTP 字节 → TCP |
这张表的价值不在于"一次性读完它",而在于"它告诉你每一层在哪个文件里"。当你在生产遇到"请求偶尔超时 2 秒"这种问题时,你至少有 13 个具体的源码位置可以去排查——Tokio 的 accept 积压、hyper 的 HTTP/1 keep-alive、axum 的 HandleError、sqlx 的 AsyncSemaphore::acquire 超时、Postgres 的服务端慢查询——每一层都有自己的可观察指标,也都是一本独立的书。
本书负责的是从"Handler" 到 "归还连接" 这 9 行。
1.8 版本演进:sqlx 如何一步步变成今天的样子
了解一个项目的历史能帮助你理解它现在为什么长成这样。sqlx 的关键版本里程碑如下(按 git tag 和 CHANGELOG 梳理):
| 版本 | 时间 | 里程碑 |
|---|---|---|
| 0.1 | 2019-09 | 首个公开版本。只有 Postgres 驱动,基础 query! 宏。 |
| 0.2 | 2019-12 | MySQL 驱动;Pool 第一版基于 futures-intrusive。 |
| 0.3 | 2020-05 | SQLite 驱动;FromRow derive 宏;offline 模式(CI 友好)的雏形。 |
| 0.4 | 2020-10 | 支持 MSSQL(实验性,后续被移除);query_as! 支持 type override(as "x: _" 语法)。 |
| 0.5 | 2021-02 | runtime 抽象(可切换 tokio / async-std / actix);sqlx-cli 正式化。 |
| 0.6 | 2022-06 | 大幅重构:抽离 sqlx-core,把各驱动拆成独立 crate 但仍在同一 workspace;Postgres 增加 pgvector 类型。 |
| 0.7 | 2023-07 | 正式 crate 拆分:sqlx-postgres / sqlx-mysql / sqlx-sqlite 变成独立发布的 crate。 |
| 0.8 | 2024-07 | 破坏性变更:移除 impl Executor for &mut Transaction(必须 &mut *tx);引入 Arguments::add。 |
| 0.8.6 | 2025-05-19 | 本书锁定版本。累积了 Postgres 的 BYTEA 性能修复、tracing 集成改进、macros 错误信息优化。 |
| 0.9.0-α | 2025-10-15 | alpha 版本。传闻中的 Executor trait 重设计,本书不涵盖。 |
从这张时间线读出的几个设计演进趋势:
- 从单体到拆分。0.1 到 0.5 是"一个 crate 包打所有数据库"的时代;0.6 开始抽
sqlx-core;0.7 把驱动拆成独立 crate。这让"我只用 Postgres"的用户不再被迫拉 MySQL 和 SQLite 的依赖树。 - runtime 抽象是中期才加的。早期 sqlx 只支持 async-std(0.1–0.4)、后来加了 tokio 支持(0.5),再后来变成 feature 互斥。今天 2026 年绝大多数项目都用
runtime-tokio,runtime-async-std已经在 0.8 标记为 deprecated。 - 0.8 的
Executor破坏性变更是被迫的。从 0.7 开始驱动是独立 crate 之后,sqlx-core里手写的impl Executor for &mut Transaction就面临一个选择:要么破坏 coherence、要么把实现推到每个驱动 crate 里(等于让每个驱动手写这个 blanket,维护成本爆炸)、要么就让Transaction通过DerefMut暴露连接、让用户写&mut *tx。sqlx 选了第三种——也是本章在"让步二·附"里提到的那个破坏性变更。 - offline 模式从一开始就是一等公民。这条路线的坚持是 sqlx 区别于其他"编译期连数据库"思路(比如 diesel 的
table!依赖离线 schema 文件)的关键——你既可以在本地开发用在线数据库得到最实时的校验,也可以在 CI 用离线 JSON 文件保持编译期校验的类型安全。第 11 章会细讲这两种模式的切换机制。
这张时间线不是"怀旧"——它告诉你一件事:当你读 sqlx 0.8.6 源码时遇到看起来绕弯的设计,大概率背后有一段从 0.1 到 0.8 的演进痕迹。比如 AsyncSemaphore(sqlx-core/src/sync.rs)为什么不直接用 tokio::sync::Semaphore?因为早期支持多 runtime 时必须有一层自己的抽象;即便今天大家都用 tokio,这层抽象也保留了下来(以免以后 runtime 生态再变)。
1.9 本章小结
本章建立了贯穿全书的核心心智模型。回顾一下关键判断:
- Rust 数据库生态存在三条主流路线:Diesel(编译期 DSL、同步优先)、tokio-postgres(裸协议、原生异步)、SeaORM(ActiveRecord ORM)。每条路线解决一个痛点、放弃另一些灵活性。
- sqlx 选择了第四路径:非 ORM、异步原生、编译期校验 SQL 字面量。这条路径在 sqlx 之前是生态空白象限。四种路线对同一个"更新邮箱"任务的写法对比(§1.2.5)直观展示了代码量与抽象层次的差别。
- sqlx 的三条设计主张是对已有路线的直接回应:非 ORM(对 SeaORM)、异步原生(对 Diesel)、编译期校验(对 tokio-postgres)。
- sqlx 工作区由 7 个 crate 组成,代码量分布(§1.4 表)显示驱动 crate 比核心还重——Postgres 单独 19 841 行。依赖方向严格单向:facade → core ← 驱动、macros → macros-core。这条边界让新增一个数据库变得结构清晰。
- 一行
query_as!展开后(§1.5.1)是一段类型被编译期钉死的try_map链,每一列通过try_get_unchecked::<具体类型, _>(索引)取出。这就是"编译期校验"这四个字在代码层面的具体形态。 - 选择 sqlx 意味着接受四个让步:没有 DSL、没有惰性关联、
query!绑定单一数据库、Transaction::drop只能触发"尽力 rollback"(受 Rust async 语言限制)。每一个让步都有明确的工程理由或语言限制。 - 0.7 → 0.8 的
Executor破坏性变更(§1.6 让步二·附)是理解 sqlx 设计风格的关键例子:宁可让用户多写两个字符(&mut *tx),也不保留一个在新 crate 架构下破坏 coherence 的 blanket impl。 - 版本演进(§1.8)从 0.1 到 0.8.6 的里程碑显示 sqlx 是一步步从"单体 crate"演进到"core + 独立驱动 + 可复用 macros-core"的——今天源码里一些看似绕弯的设计,背后都有版本演进的痕迹。
- 一次完整请求(§1.7.1)跨越 Tokio accept、hyper HTTP 解码、axum Router、sqlx Pool、Postgres 协议五层至少 13 个源码位置。本书负责其中 sqlx 的 9 行——但它和前后两端的咬合是讲清楚的前提。
下一章,我们走进 sqlx 的 workspace,看看 7 个 crate 的边界是怎么划出来的——为什么 sqlx-macros 要和 sqlx-macros-core 拆成两份、为什么 Database trait 必须放在 sqlx-core 而不是 sqlx facade、为什么 Any 驱动要单独吃一份特殊待遇。