SQLx 源码深度解析
第1章 为什么需要 SQLx:Rust 异步数据库生态的十字路口
Rust 的数据库层有三条成熟的路:Diesel 的编译期 DSL、tokio-postgres 的裸协议异步、SeaORM 的 ActiveRecord。sqlx 选的是第四条——不包装 SQL,而是把真实 schema 带进类型系统。
本章要点
- Rust 数据库生态的三条主流技术路线:Diesel 的编译期 DSL、tokio-postgres 的裸协议异步、SeaORM 的 ActiveRecord ORM——各自解决的痛点不同。
- sqlx 的三条核心设计主张:非 ORM(不包装 SQL)、异步原生(每个 I/O 都是
Future)、编译期校验(把真实 schema 带进类型系统)。 - sqlx 的库本体由 7 个 crate 组成(工作区里另有
sqlx-cli、sqlx-test与示例):sqlx(facade)、sqlx-core(trait 与协议无关部分)、sqlx-macros+sqlx-macros-core(过程宏)、sqlx-postgres/sqlx-mysql/sqlx-sqlite(驱动)。 - 选择 sqlx 意味着接受三个让步:没有 DSL、没有惰性关联、query! 宏绑定单一数据库——每一个让步都有清晰的工程理由。
- 本专栏是 Rust 后端系列的第六个专栏,把 sqlx 作为 Rust 后端栈的持久层来讲解——《Rust 编译器与运行时揭秘》(
async fn状态机)、《Tokio 源码深度解析》(调度与 I/O)、《Hyper 与 Tower:工业级 HTTP 栈》(HTTP 协议栈)、《Axum 设计与实现》(Web 框架)讲完了请求抵达数据库之前的链路,本专栏接着讲数据库这一层。
1.1 一个真实场景:Rust 后端选型的十字路口
你刚开始一个新的 Rust 后端项目。技术栈已经敲定了大半:Tokio 做异步运行时、Axum 做 HTTP 路由、serde 做 JSON 序列化。业务主线第一条 User Story 是"给前端写一个 GET /users/{id} 接口"。到了数据访问层这一步,你打开 crates.io,搜"postgres",前几条结果是:
diesel:最古老也最成熟的 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 数据库工具的三种设计哲学
本节横向对比所依据的版本:Diesel v2.3.9、tokio-postgres v0.7.9(本专栏锁定的对比版本,已在本机 checkout 逐条核对)。SeaORM 建立在 sqlx 之上,本节只按其公开设计描述、不逐行引源码。对比别人的库时版本尤其要紧——同一个宏自 2.2.0 起就换了名字。
在 sqlx 出现之前,Rust 生态已经形成了三条稳定的技术路线。这三条路线各自解决一个不同的痛点,也各自有无法弥补的短板。要理解 sqlx 的设计动机,必须先看清这三条路线的形态。
1.2.1 Diesel 派:编译期 DSL
Diesel 的核心主张是:SQL 是一种不安全的字符串,类型系统无法跨越字符串边界检查它;因此 Diesel 用 Rust 类型系统重新表达 SQL,把 SQL 变成一棵可由编译器检查的表达式树。
一条 Diesel 查询长这样:
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) 返回一个 Grouped<Eq<id, Bound<Integer, i32>>> 表达式类型(即 diesel::dsl::Eq<id, i32>,见 diesel/src/expression/helper_types.rs:23)、.select() 接受实现了 SelectableExpression 的参数。编译器可以在 cargo check 阶段验证:你 filter 的列确实属于这张表;你绑定的参数类型确实和列类型一致;你 select 出来的列和 User 结构体的字段精确对齐。
代价是DSL 的表达力天花板永远低于 SQL 本身。写 GROUP BY ROLLUP、窗口函数、递归 CTE、json_agg、任何数据库独有的函数——你都得去查 Diesel 的 define_sql_function! 宏(sql_function! 这个老名字自 Diesel 2.2.0 起已废弃,见 diesel_derives/src/lib.rs:1230;2.3 又加了属性宏形式的 declare_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 查询长这样:
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 + Sync)] 传递(tokio-postgres/src/client.rs:272),行通过索引或列名解码。没有编译期校验、没有自动映射到结构体、没有内建连接池。这一切都要靠生态周边(deadpool-postgres 做连接池、手写 From<Row> 做映射)。
tokio-postgres 的优点是极致薄。它几乎就是"PostgreSQL 协议的异步 Rust 绑定",没有附加概念,运行时开销很小,对每一个字节在线路上的行为都有完全控制。这使得它成为"写高性能基础设施组件"的首选——比如在 Rust 里实现一个 Postgres 代理、CDC 消费者、连接池中间件。
但它不适合业务应用:业务层每一次查询都要手写七行 glue code,类型映射全靠自己对齐。不过「错误粒度粗」这个常见吐槽并不成立——tokio-postgres 0.7.9 的 Kind 有 17 个变体(tokio-postgres/src/error/mod.rs:340),ToSql(usize) / FromSql(usize) 带参数下标、Column(String) 带列名、Parameters(usize, usize) 带期望与实际个数,消息形如 error serializing parameter 2、invalid column `email` (:384-386)。它缺的是编译期校验,不是运行期的错误信息。
1.2.3 SeaORM 派:ActiveRecord ORM
SeaORM 从另一个角度回答"怎么写 Rust 后端":把数据库实体当作对象,让开发者不写 SQL。
一条 SeaORM 查询长这样:
use entity::user;
let found: Option<user::Model> = user::Entity::find_by_id(user_id).one(db).await?;
没有 SQL,也没有 DSL——只有一个实体的静态方法。SeaORM 会根据实体定义(可由 sea-orm-cli 从现有数据库生成,也可手写)自动构造查询、执行、反序列化。这对于"CRUD 八成、复杂查询两成"的业务非常友好:80% 的代码读起来像 Active Record,20% 的复杂场景仍然可以 fallback 到 raw SQL。
SeaORM 的代价是又是一层抽象。它在 sqlx 之上又包一层实体建模、查询构造、迁移管理。这意味着你需要同时理解 sqlx 的 trait 家族 和 SeaORM 的 EntityTrait / ActiveModelTrait——调试的时候需要穿两层。SeaORM 本身把 sqlx 当作运行时引擎,这也反过来说明了 sqlx 定位的清晰:sqlx 负责把 SQL 字节和连接池落到 Future,ORM 负责在其上搭业务实体(sqlx 这一侧的"落到 Future"具体就是 Executor trait 的那一排 BoxFuture / BoxStream 返回值,见 sqlx-0.8.6/sqlx-core/src/executor.rs:33-184)。
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 版(同步):
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 版(异步):
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 版(异步):
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 是字面量、参数按位置传、整条链路编译期校验。query_as! 的宏体只有两条规则——($out_struct:path, $query:expr) 和带参数的 ($out_struct:path, $query:expr, $($args:tt)*),都是转手给 sqlx_macros::expand_query!(sqlx-0.8.6/src/macros/mod.rs:571-578)。注意它不走 FromRow:query_as! 由宏在编译期按 RETURNING 的列顺序直接生成 Ok(User { id: row.try_get_unchecked(0)?, .. }) 这样的字段赋值(sqlx-0.8.6/sqlx-macros-core/src/query/output.rs:147 与 sqlx-0.8.6/sqlx-macros-core/src/query/output.rs:189),FromRow 是运行期的 query_as::<_, User>() 那条路才用的(第 8 章)。
SeaORM 版(异步):
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 相当、列名和类型错误同样在编译期暴露,但 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-43 的 pub use sqlx_core::* 一组,以及 133-159 里 encode / decode / query 三个子模块的再导出)。
你不会在 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:
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 没有任何同步的 I/O API——它从诞生的第一天就是为 Tokio / async-std 这样的异步运行时设计的。
这条立场直接来自 2019 年 Rust 异步生态的爆发:async/await 语法在 Rust 1.39(2019-11-07)正式稳定,async-std 1.0 与 Tokio 0.2 也在同一年落地。Diesel 诞生于 2015 年的同步时代,其 RunQueryDsl 上是 fn get_result<U>(self, conn: &mut Conn) -> QueryResult<U> 这样的同步签名(diesel/src/query_dsl/mod.rs:1774)——这种签名没法平滑接入 .await。sqlx 的作者(launchbadge 团队)在 2019 年开始这个项目时,直接跳过了"同步优先、异步适配"这条路,全栈 async。
代价是 sqlx 无法在非异步环境里用——你不能在 main() 里不启动运行时就直接 pool.acquire()?。但对于今天的 Rust 后端项目,这个代价通常无关紧要:服务本来就跑在 Tokio 之上,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: String }(若email列可空,则是Option<String>)。 - 同时检查
user_id的 Rust 类型是否能Encode到$1的期望类型。
如果任意一步失败——SQL 写错了、列名拼错了、参数类型不匹配——编译器报错,而不是运行时报错。
这正是 sqlx 最有辨识度的地方。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 / DynQueryData 怎么实现离线缓存的读写。
1.4 源码地图:7 个 crate 的职责划分
要读 sqlx 的源码,第一件事是看清它的 crate 边界。0.8.6 版本的工作区长这样:
graph TD
subgraph 用户依赖的 facade
SQLX[sqlx<br/>0.8.6]
end
subgraph 协议无关的核心
CORE[sqlx-core<br/>Database · Executor · Pool trait]
end
subgraph 过程宏
MAC[sqlx-macros<br/>宏入口]
MACCORE[sqlx-macros-core<br/>实际实现 + 数据库连接]
end
subgraph 驱动实现
PG[sqlx-postgres]
MY[sqlx-mysql]
LT[sqlx-sqlite]
end
SQLX --> CORE
SQLX --> MAC
SQLX --> PG
SQLX --> MY
SQLX --> LT
MAC --> MACCORE
MACCORE --> PG
MACCORE --> MY
MACCORE --> LT
PG --> CORE
MY --> CORE
LT --> CORE
每个 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 专用 worker 线程包装(基于 libsqlite3-sys) |
几个从这张表里读出的结论:
- 协议 crate 比核心 crate 还重。
sqlx-postgres(19 841 行)单独就比sqlx-core(14 400 行)多。这说明数据库协议的复杂度远超"一个 trait 家族 + 一个连接池"——Postgres 近百种内置类型的映射(PgType枚举)、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——这涉及每个连接一个专用 worker 线程、消息通道、错误码翻译。第 18 章会讲这层包装为什么不是"真 async"。
每个 crate 的职责详细:
sqlx(facade):只做 re-export 和 feature gate。sqlx-0.8.6/src/lib.rs一共 174 行,第 12–43 行是pub use sqlx_core::...,之后是按 feature(mysql、postgres、sqlite)re-export 驱动,最后是encode/decode/query等子模块。没有任何逻辑。sqlx-core:定义所有协议无关的抽象。Databasetrait(sqlx-core/src/database.rs:72)、Executortrait(sqlx-core/src/executor.rs:33)、Connectiontrait(sqlx-core/src/connection.rs:14)、Pool(sqlx-core/src/pool/mod.rs:260)、Transaction(sqlx-core/src/transaction.rs:86)、Encode/Decode/Type、Row/Column/Value、Arguments——这些都在这里。是整个项目的骨架。sqlx-macros:过程宏入口。整个 crate 的lib.rs只做一件事——把expand_query、#[derive(FromRow)]、#[derive(Encode)]等过程宏函数(#[proc_macro]/#[proc_macro_derive])声明出来,每个函数的 body 都是"解析 TokenStream → 调用 sqlx-macros-core → 返回生成的代码"(见sqlx-macros/src/lib.rs:7-23)。之所以拆成两个 crate,是因为过程宏必须以proc-macro = true构建,但proc-macrocrate 不能被库当作普通依赖引用——所以真正的实现放到普通库 cratesqlx-macros-core里,才能像普通库一样被依赖(它的 crate 文档还专门提醒自定义驱动作者精确锁定其版本)。注意sqlx-cli的prepare子命令并不链接它,而是重新编译你的 crate、让宏自己把缓存落盘(第 2 章)。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! 的完整路径
把本章开头那段代码再放一次:
let user: User = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE id = $1",
user_id
).fetch_one(&pool).await?;
这一行代码在编译期和运行时分别会发生什么?我们用一张序列图把全链路画出来。注意,这里涉及的每一步会在后面的章节详细展开,本节只是建立"全景俯视"的心智模型。
sequenceDiagram
autonumber
participant C as 用户代码
participant MAC as sqlx-macros
participant MCORE as sqlx-macros-core
participant DB as 真实 DB / 离线缓存
participant RT as 运行时 (Tokio)
participant POOL as Pool<Postgres>
participant CONN as PgConnection
participant NET as TCP 连接
Note over C,MCORE: 编译期
C->>MAC: query_as!(User, "SELECT ...", user_id)
MAC->>MCORE: 解析字面量 + 参数表达式
MCORE->>DB: DESCRIBE 或读 .sqlx/ JSON
DB-->>MCORE: 列类型 + 参数类型
MCORE-->>C: 生成带类型的 try_map 表达式
Note over C,NET: 运行时
C->>POOL: .fetch_one(&pool).await
POOL->>POOL: AsyncSemaphore.acquire()
POOL->>POOL: idle_conns.pop() 或 new connect()
POOL->>CONN: 借出 PoolConnection
C->>CONN: Executor::fetch_optional
CONN->>NET: Parse + Bind + Execute (Postgres 扩展查询)
NET-->>CONN: RowDescription + DataRow + CommandComplete
CONN->>CONN: 解码到 User 结构体
CONN-->>C: Ok(user)
C->>POOL: drop(PoolConnection) → 归还空闲连接
这张图里几乎每一个步骤都对应本专栏后面的一章:
- 步骤 ①–⑤(编译期)对应第 11 章
query!宏。 - 步骤 ⑥
.fetch_one(&pool)(&Pool作为Executor)对应第 13 章 Pool API。 - 步骤 ⑦–⑧
AsyncSemaphore+idle_conns对应第 14 章 Pool 内部,直接对应源码sqlx-core/src/pool/inner.rs:26-29:
pub(crate) struct PoolInner<DB: Database> {
pub(super) connect_options: RwLock<Arc<<DB::Connection as Connection>::Options>>,
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归还对应第 14 章 §14.9 对PoolConnection::drop的分析。
换句话说,这一行代码串联了本专栏至少 7 个章节的内容。本章只是给你一张地图;接下来的 21 章是这张地图上每一个节点的细节。
1.5.1 宏展开后到底变成了什么
让我们把上面那条 query_as! 展开。sqlx-macros-core/src/query/output.rs:127 的 quote_query_as 函数决定了展开形态,它最终吐出的就是 :183-191 这段 quote!:
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),* })
})
}
对于
sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", user_id)
展开后(简化后)大致是这样:
::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).map(|()| args) }, // 编译期已知 user_id: i32,对应 OID=23;0.8 起 add 返回 Result,
// 所以实际传进去的是 Result<PgArguments, BoxDynError>
).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 回来"的信息上推出来的(见sqlx-macros-core/src/query/output.rs:142-148的try_get_unchecked——即ColumnType::Exact分支)。 try_get_unchecked不做运行时类型检查——因为"类型已经在编译期被校验过了"这个前提被checked = true明确标记。只有列被as "x: _"覆盖成 wildcard 时,才会 fallback 到try_get做运行时检查(sqlx-macros-core/src/query/output.rs:150-158)。而query_as_unchecked!(checked = false)一律生成不带类型参数的try_get_unchecked,类型不对只会在解码时出错(sqlx-macros-core/src/query/output.rs:160-164)——这就是 "unchecked" 后缀的含义:跳过的是编译期校验。bind_args是一次性构造的,不是链式.bind()——这让编译器可以一次性在这里把所有参数的类型约束都加上。- 整段展开没有任何
?在顶层——它返回的是一个Map类型(Query::try_map的返回值)——这只是一个还没执行的查询对象,本身不是Future。你在用户代码里写的.fetch_one(&pool).await?才是真正触发执行的那一步。
理解了这层展开,你就能回答"为什么 query_as! 生成的代码对错误信息友好"这个问题——每一个 row.try_get_unchecked::<i32, _>(0)? 在失败时都返回带列索引的 Error::ColumnDecode { index, source }(sqlx-core/src/row.rs:159-162),source 里是底层的解码错误,而不是一个笼统的"解码失败"。第 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.6 → 0.7 的那次破坏性变更(CHANGELOG 0.7.0,PR #2039)。
在 0.6 及以前,你可以直接把 &mut tx 传给任何接受 impl Executor 的函数:
let mut tx = pool.begin().await?;
let user: User = sqlx::query_as!(User, "SELECT ... WHERE id = $1", id)
.fetch_one(&mut tx) // 0.6 可以这样写
.await?;
tx.commit().await?;
0.7 之后,上面这行编译不过。新的 Executor trait 注释直接写在源码上(sqlx-core/src/executor.rs:25-31):
/// 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.7+ 写法是把解引用加回来:
.fetch_one(&mut *tx).await?
背后的原因是:Transaction<'c, DB> 实现了 DerefMut<Target = DB::Connection>(sqlx-core/src/transaction.rs:224),所以 &mut *tx 就是 &mut DB::Connection,而各驱动都为 &mut PgConnection 这类连接类型实现了 Executor(如 sqlx-postgres/src/connection/executor.rs:380)。0.6 里手写的 impl Executor for &mut Transaction 在拆分后的 crate 架构下无处安放:写成 blanket impl 会因为缺少惰性归一化(lazy normalization)而把编译器的 trait 求解撑到溢出,放到各驱动 crate 里又会撞上孤儿规则(orphan rule)——这两条理由 CHANGELOG 0.7.0 里写得很清楚。最干净的办法就是让 Transaction 通过 DerefMut 暴露内部连接,让用户手动写一次 *。
这个破坏性变更是 crate 拆分的连带代价:按源码注释的说法,要保留那两个 impl 就得整个重写 Executor trait,sqlx 宁可让用户多打一个字符。读通这段源码注释,你对 sqlx 的设计风格会有直接的体感。
让步三:query! 绑定单一数据库
query! 宏需要在编译期连接数据库做 DESCRIBE,这意味着它在一次编译里只能验证一个数据库方言。如果你的应用同时连 Postgres 和 MySQL,query! 里的 SQL 只能按 DATABASE_URL 指向的那个数据库校验——你不能让同一个 crate 里一半 SQL 按 Postgres 校验、另一半按 MySQL 校验。
解决办法在第 11 章会详细讲:要么把多数据库拆成多个 crate,要么用 sqlx::query_as + 手写 FromRow(放弃编译期校验)。
实际上绝大多数业务只会用一个数据库——这个让步大多数团队感觉不到。但它是"编译期触达外部世界"这条路径的必然代价:你在 compile 时只能连一个外部世界。
让步四(隐藏的):Transaction 的 Drop 只是"尽力 rollback"
最后一个让步不是架构选择,而是异步 Rust 的底层限制——值得在本章先提,后面第 15 章再细讲。
Transaction<'c, DB> 的 Drop 实现(sqlx-core/src/transaction.rs:260-275)长这样:
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 意图:Postgres / MySQL 驱动把 ROLLBACK 写进缓冲,真正的协议消息要等下一次用这个连接时才发出去(或者由连接池在归还时触发);SQLite 驱动则是把 rollback 命令投递给该连接的 worker 线程,由它去执行。
这意味着两件事:
- 如果你的业务代码忘了
tx.commit().await?就让tx离开作用域,sqlx 不保证 rollback 消息立刻到达数据库——从数据库的视角看,那个事务可能还在"打开着",持有行锁。时间窗可能很短(事务的连接来自连接池时,归还时的ping会顺带把排队的 rollback 发出去,见sqlx-core/src/pool/connection.rs的return_to_pool),也可能很长(事务开在一条自有连接上、而这条连接之后不再被使用)。 - 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:工业级 HTTP 栈》专栏第 11-17 章),进入 Router 的路径匹配(《Axum 设计与实现》专栏第 2 章),调度到用户的 async fn handler(《Axum 设计与实现》专栏第 5 章)。handler 里通常会这样调用数据库:
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 源码深度解析》专栏第 12 章会讲 tokio::sync::Semaphore 的实现)。
换句话说,sqlx 的连接池建立在 Tokio 的同步原语之上。这条继承链决定了本专栏后半段会不时跳进 Tokio 源码——我们会在第 14 章 Pool 内部对这条链做详细拆解。
同样的协作关系也在类型系统层面成立:Axum 的 Handler<T, S> 要求 handler 除最后一个之外的每个参数实现 FromRequestParts(最后一个可以是 FromRequest),State<PgPool> 正是一个 FromRequestParts 实现。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.40.0/tokio/src/net/tcp/listener.rs |
TcpListener::accept() 返回 (TcpStream, SocketAddr) |
| Axum Serve | axum-full/axum/src/serve/mod.rs |
把 stream 丢给 hyper,然后 spawn 一个任务处理连接 |
| Hyper HTTP/1 | hyper-1.9.0/src/proto/h1/conn.rs |
字节流 → Request<Incoming> |
| Axum Router | axum-full/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.40.0/tokio/src/net/tcp/stream.rs |
try_write + poll_write_ready(sqlx 的 Socket 实现) |
| ↩ 响应 | 同上反向 | 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 |
spawn return_to_pool:ping 后 push 回 idle_conns、release 一个 permit |
| 响应编码 | axum/src/response/* → hyper → tokio |
Json<User> → HTTP 字节 → TCP |
这张表的价值不在于"一次性读完它",而在于"它告诉你每一层在哪个文件里"。当你在生产遇到"请求偶尔超时 2 秒"这种问题时,你有十几个具体的源码位置可以去排查——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-12 | 首个公开版本(首个 tag v0.1.1,2019-12-28)。Postgres 与 MySQL 驱动、query! 宏雏形。 |
| 0.2 | 2020-01 | 通过 runtime-tokio feature 支持 Tokio;begin() 事务 API(Drop 即 ROLLBACK);TLS 连接。 |
| 0.3 | 2020-03 | SQLite 驱动(#129);query_as() 可直接查进元组或 #[derive(FromRow)] 结构体;结果集改为零拷贝解码。 |
| 0.4 | 2020-11 | 0.4.0-beta.1 一次性带来四件大事:offline 元数据缓存(CI 无库编译)、sqlx migrate 迁移、Any 运行时驱动、MSSQL 实验支持;正式版加 rustls。 |
| 0.5 | 2021-02 | 跟进 tokio 1.0 / actix-rt 2.0;#[sqlx(rename)] 改名 #[sqlx(type_name)];DbDone 系列类型改名 DbQueryResult、Done trait 直接删除(rows_affected() 改为 PgQueryResult 等的固有方法,CHANGELOG 0.5.0 #976)。 |
| 0.6 | 2022-06 | 依赖大升级(time 0.3、rustls 0.20、uuid v1);Pool 破坏性调整:connect_timeout 改名 acquire_timeout、Pool::close() 改为及早关闭。 |
| 0.7 | 2023-06 | 正式 crate 拆分(#2039):驱动变成独立发布的 sqlx-postgres / sqlx-mysql / sqlx-sqlite;删除 Transaction 与 PoolConnection 的 Executor impl(必须 &mut *tx);移除 MSSQL;offline 缓存改为每个 query!() 一个 JSON 文件。 |
| 0.8 | 2024-07 | GAT 重构(#2973):HasValueRef / HasArguments / HasStatement 三个辅助 trait 被 Database 的泛型关联类型取代;Encode 改为返回 Result(#3126),Arguments::add 随之返回 Result;syn 升到 2.0。 |
| 0.8.6 | 2025-05-19 | 本专栏锁定版本(commit bab1b02),9 个 PR:CLI 帮助文本着色与换行、非持久化语句改用未命名 statement、宏不再改写环境变量等。 |
| 0.9.0 | 2026-05-21 | 上游已打出 v0.9.0 标签,本专栏不涵盖。 |
从这张时间线读出的几个设计演进趋势:
- 从单体到拆分。0.1 到 0.6 是"
sqlx-core一个 crate 里用mod postgres/mod mysql/mod sqlite包打所有数据库"的时代;0.7 的 #2039 才把驱动真正拆成独立发布的 crate。这让"我只用 Postgres"的用户不再被迫编译 MySQL 和 SQLite 的那部分代码。 - runtime 抽象很早就有。0.1 只有 async-std,0.2 就加上了
runtime-tokiofeature;0.5 跟进 tokio 1.0。0.8.6 里runtime-tokio与runtime-async-std并非互斥——sqlx-core/src/sync.rs和sqlx-core/src/rt/mod.rs的cfg写的是"两个都开时优先用 tokio"。今天绝大多数项目都用runtime-tokio。 - 0.7 的
Executor破坏性变更是被迫的。驱动拆成独立 crate 之后,sqlx-core里手写的impl Executor for &mut Transaction就面临一个选择:写成 blanket impl 会因缺少惰性归一化而让编译器溢出、把实现推到每个驱动 crate 里又会撞上孤儿规则、要么就让Transaction通过DerefMut暴露连接、让用户写&mut *tx。sqlx 选了第三种——也是本章在"让步二·附"里提到的那个破坏性变更。 - offline 模式从 0.4 起就是一等公民(0.4.0-beta.1 引入,0.7 改成一个
query!()一个 JSON 文件以减少合并冲突)。这条路线的坚持是 sqlx 区别于其他"编译期连数据库"思路(比如 diesel 的table!依赖离线 schema 文件)的关键——你既可以在本地开发用在线数据库得到最实时的校验,也可以在 CI 用离线 JSON 文件保持编译期校验的类型安全。第 11 章会细讲这两种模式的切换机制。
这张时间线不是"怀旧"——它告诉你一件事:当你读 sqlx 0.8.6 源码时遇到看起来绕弯的设计,大概率背后有一段从 0.1 到 0.8 的演进痕迹。比如 AsyncSemaphore(sqlx-core/src/sync.rs)为什么不直接用 tokio::sync::Semaphore?因为 0.8.6 仍然支持 async-std:只开 async-std 时它底下换成 futures-intrusive 的公平信号量(sync.rs 开头的注释解释了为什么不用 async-std 自带的那个),开了 tokio 时才是 tokio::sync::Semaphore 的薄包装。
1.9 本章小结
本章建立了贯穿全专栏的核心心智模型。回顾一下关键判断:
- Rust 数据库生态存在三条主流路线:Diesel(编译期 DSL、同步优先)、tokio-postgres(裸协议、原生异步)、SeaORM(ActiveRecord ORM)。每条路线解决一个痛点、放弃另一些灵活性。
- sqlx 选择了第四路径:非 ORM、异步原生、编译期校验 SQL 字面量。在 §1.2.4 的对比表里,只有它同时占据"字符串 SQL"与"编译期校验"。四种路线对同一个"更新邮箱"任务的写法对比(§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.6 → 0.7 的
Executor破坏性变更(§1.6 让步二·附)是理解 sqlx 设计风格的关键例子:宁可让用户多写一个字符(&mut *tx),也不保留一个在拆分后的 crate 架构下既会撑爆 trait 求解、又会撞上孤儿规则的 impl。 - 版本演进(§1.8)从 0.1 到 0.8.6 的里程碑显示 sqlx 是一步步从"sqlx-core 单体"演进到"core + 独立驱动 + 可复用 macros-core"的——今天源码里一些看似绕弯的设计,背后都有版本演进的痕迹。
- 一次完整请求(§1.7.1)跨越 Tokio accept、hyper HTTP 解码、axum Router、sqlx Pool、Postgres 协议五层十几个源码位置。本专栏负责其中 sqlx 的 9 行——但它和前后两端的咬合是讲清楚的前提。
下一章,我们走进 sqlx 的 workspace,看看 7 个 crate 的边界是怎么划出来的——为什么 sqlx-macros 要和 sqlx-macros-core 拆成两份、为什么 Database trait 必须放在 sqlx-core 而不是 sqlx facade、为什么 Any 驱动要单独吃一份特殊待遇。