SQLx 源码深度解析

第1章 为什么需要 SQLx:Rust 异步数据库生态的十字路口

作者 杨艺韬 · 10,273 字 · 发布于 · 更新于

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 的时候会:

  1. 读取环境变量 DATABASE_URL 或 .env 文件。
  2. 连接到那个 URL 指向的真实数据库。
  3. 对 SQL 字符串执行 DESCRIBE / PREPARE(视数据库而定)。
  4. 拿到每一列的类型、可空性;拿到每一个参数槽的期望类型。
  5. 用这些信息生成一段匿名结构体代码,让 fetch_one 的返回值是 { id: i32, name: String, email: String }(若 email 列可空,则是 Option<String>)。
  6. 同时检查 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 看似简单(嵌入式、无网络协议),但它需要把同步的 libsqlite3 C 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:定义所有协议无关的抽象。Database trait(sqlx-core/src/database.rs:72)、Executor trait(sqlx-core/src/executor.rs:33)、Connection trait(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-macro crate 不能被库当作普通依赖引用——所以真正的实现放到普通库 crate sqlx-macros-core 里,才能像普通库一样被依赖(它的 crate 文档还专门提醒自定义驱动作者精确锁定其版本)。注意 sqlx-cli 的 prepare 子命令并不链接它,而是重新编译你的 crate、让宏自己把缓存落盘(第 2 章)。
  • sqlx-macros-core:query! 宏的真正实现在这里。它包含连接数据库、执行 DESCRIBE、解析结果、生成 TokenStream 的全部逻辑,以及 offline 缓存的读写。
  • sqlx-postgres / sqlx-mysql / sqlx-sqlite:各自实现 Database trait 以及它的所有关联类型——协议编解码、类型系统映射、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,
    })
})

这里有几个关键观察:

  1. 类型是在编译期就塞进 try_get_unchecked::<i32, _>(0) 的——而不是运行时查。这些 i32 / String 是宏在"真实连数据库 DESCRIBE 回来"的信息上推出来的(见 sqlx-macros-core/src/query/output.rs:142-148 的 try_get_unchecked——即 ColumnType::Exact 分支)。
  2. 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" 后缀的含义:跳过的是编译期校验。
  3. bind_args 是一次性构造的,不是链式 .bind()——这让编译器可以一次性在这里把所有参数的类型约束都加上。
  4. 整段展开没有任何 ? 在顶层——它返回的是一个 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 条件",你有两个选择:

  1. 用 QueryBuilder:它是一个带保护的字符串拼接器,提供 push("WHERE id = ") 和 push_bind(id) 两种操作。前者拼 SQL 字符串(由你负责避免注入),后者追加参数槽并记录值。这就是第 10 章的主题。
  2. 写多个 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 线程,由它去执行。

这意味着两件事:

  1. 如果你的业务代码忘了 tx.commit().await? 就让 tx 离开作用域,sqlx 不保证 rollback 消息立刻到达数据库——从数据库的视角看,那个事务可能还在"打开着",持有行锁。时间窗可能很短(事务的连接来自连接池时,归还时的 ping 会顺带把排队的 rollback 发出去,见 sqlx-core/src/pool/connection.rs 的 return_to_pool),也可能很长(事务开在一条自有连接上、而这条连接之后不再被使用)。
  2. 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 标签,本专栏不涵盖。

从这张时间线读出的几个设计演进趋势:

  1. 从单体到拆分。0.1 到 0.6 是"sqlx-core 一个 crate 里用 mod postgres / mod mysql / mod sqlite 包打所有数据库"的时代;0.7 的 #2039 才把驱动真正拆成独立发布的 crate。这让"我只用 Postgres"的用户不再被迫编译 MySQL 和 SQLite 的那部分代码。
  2. runtime 抽象很早就有。0.1 只有 async-std,0.2 就加上了 runtime-tokio feature;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。
  3. 0.7 的 Executor 破坏性变更是被迫的。驱动拆成独立 crate 之后,sqlx-core 里手写的 impl Executor for &mut Transaction 就面临一个选择:写成 blanket impl 会因缺少惰性归一化而让编译器溢出、把实现推到每个驱动 crate 里又会撞上孤儿规则、要么就让 Transaction 通过 DerefMut 暴露连接、让用户写 &mut *tx。sqlx 选了第三种——也是本章在"让步二·附"里提到的那个破坏性变更。
  4. 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 本章小结

本章建立了贯穿全专栏的核心心智模型。回顾一下关键判断:

  1. Rust 数据库生态存在三条主流路线:Diesel(编译期 DSL、同步优先)、tokio-postgres(裸协议、原生异步)、SeaORM(ActiveRecord ORM)。每条路线解决一个痛点、放弃另一些灵活性。
  2. sqlx 选择了第四路径:非 ORM、异步原生、编译期校验 SQL 字面量。在 §1.2.4 的对比表里,只有它同时占据"字符串 SQL"与"编译期校验"。四种路线对同一个"更新邮箱"任务的写法对比(§1.2.5)直观展示了代码量与抽象层次的差别。
  3. sqlx 的三条设计主张是对已有路线的直接回应:非 ORM(对 SeaORM)、异步原生(对 Diesel)、编译期校验(对 tokio-postgres)。
  4. sqlx 工作区由 7 个 crate 组成,代码量分布(§1.4 表)显示驱动 crate 比核心还重——Postgres 单独 19 841 行。依赖方向严格单向:facade → core ← 驱动、macros → macros-core。这条边界让新增一个数据库变得结构清晰。
  5. 一行 query_as! 展开后(§1.5.1)是一段类型被编译期钉死的 try_map 链,每一列通过 try_get_unchecked::<具体类型, _>(索引) 取出。这就是"编译期校验"这四个字在代码层面的具体形态。
  6. 选择 sqlx 意味着接受三个让步,外加一个隐藏的:没有 DSL、没有惰性关联、query! 绑定单一数据库、Transaction::drop 只能触发"尽力 rollback"(受 Rust async 语言限制)。每一个让步都有明确的工程理由或语言限制。
  7. 0.6 → 0.7 的 Executor 破坏性变更(§1.6 让步二·附)是理解 sqlx 设计风格的关键例子:宁可让用户多写一个字符(&mut *tx),也不保留一个在拆分后的 crate 架构下既会撑爆 trait 求解、又会撞上孤儿规则的 impl。
  8. 版本演进(§1.8)从 0.1 到 0.8.6 的里程碑显示 sqlx 是一步步从"sqlx-core 单体"演进到"core + 独立驱动 + 可复用 macros-core"的——今天源码里一些看似绕弯的设计,背后都有版本演进的痕迹。
  9. 一次完整请求(§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 驱动要单独吃一份特殊待遇。