Skip to content

第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 字符串。usersidUser::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 位置编译期校验异步模型抽象层级适合场景
dieselDSL强(表达式树)同步优先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?;

概念最多:EntityModelActiveModelSet——但对"改字段就保存"这个 CRUD 模式来说最顺手。

这段对比可以用一句话总结:sqlx 的代码量和 Diesel 相当、类型保护和 Diesel 同级,但 SQL 仍然是一条你可以直接拷到 psql 执行的字符串。这正是它的独特卖点。

1.3 sqlx 的设计立场:三条核心主张

sqlx 的设计文档和源码注释里反复出现三条主张。每一条都是对上一节三条路线的直接回应。

1.3.1 非 ORM:只做 SQL 执行 + 类型映射

打开 sqlx 的顶层 crate(facade)看它导出的符号:PoolExecutorConnectionQueryQueryAsTransactionFromRowEncodeDecodeType——全部是"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——学习曲线集中在 ExecutorQueryFromRow 三个家族上,没有"实体建模"这一整套额外负担。

这条立场也决定了本书的基调:我们不会讨论 ORM 的设计权衡,不会解释 ActiveRecord 与 DataMapper 的差别。我们只讨论 sqlx 是怎么把一条 SQL 字符串变成一个带类型的 Future 的。

1.3.2 异步原生:每一个 I/O 动作都是 Future

翻开 sqlx-core/src/connection.rs:14Connection trait 的 closepingbegin 都返回 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 返回 BoxStreamPool::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 的时候会:

  1. 读取环境变量 DATABASE_URL.env 文件。
  2. 连接到那个 URL 指向的真实数据库。
  3. 对 SQL 字符串执行 DESCRIBE / PREPARE(视数据库而定)。
  4. 拿到每一列的类型、可空性;拿到每一个参数槽的期望类型。
  5. 用这些信息生成一段匿名结构体代码,让 fetch_one 的返回值是 { id: i32, name: String, email: Option<String> }
  6. 同时检查 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=truequery! 宏就会从缓存读而不连数据库。我们会在第 11 章深入看 sqlx-macros-core/src/query/data.rs 里的 QueryDataoffline 模块怎么实现这件事。

1.4 源码地图:7 个 crate 的职责划分

要读 sqlx 的源码,第一件事是看清它的 crate 边界。0.8.6 版本的工作区长这样:

每个 crate 的职责可以一句话概括,旁边附上 0.8.6 版本的代码量(find src -name "*.rs" | xargs wc -l):

Crate文件数行数职责
sqlx51 301Facade:pub use + feature gate,无业务逻辑
sqlx-core9514 400协议无关的 trait 家族、Pool、Transaction
sqlx-macros1101#[proc_macro] 入口,委托给 macros-core
sqlx-macros-core173 585query! 实现、derive 展开、离线缓存
sqlx-postgres10819 841Postgres 线路协议 + 类型系统
sqlx-mysql729 195MySQL/MariaDB 线路协议
sqlx-sqlite5110 061SQLite 线程池包装(基于 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 看似简单(嵌入式、无网络协议),但它需要把同步的 libsqlite3 C 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(mysqlpostgressqlite)re-export 驱动。没有任何逻辑。
  • sqlx-core:定义所有协议无关的抽象。Database trait(database.rs:72)、Executor trait(executor.rs:33)、Connection trait(connection.rs:14)、Poolpool/mod.rs:260)、Transactiontransaction.rs:86)、Encode / Decode / TypeRow / Column / ValueArguments——这些都在这里。是整个项目的骨架。
  • 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-macro crate 不能被库当作普通依赖引用——所以真正的实现放到 sqlx-macros-core 才能被其他工具(比如 sqlx-cliprepare 子命令)复用。
  • sqlx-macros-corequery! 宏的真正实现在这里。它包含连接数据库、执行 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! 的完整路径

把本章开头那段代码再放一次:

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-191quote_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,
    })
})

这里有几个关键观察:

  1. 类型是在编译期就塞进 try_get_unchecked::<i32, _>(0)——而不是运行时查。这些 i32 / String 是编译器在"真实连数据库 DESCRIBE 回来"的信息上推出来的(见 output.rs:142-148ColumnType::Exact 分支)。
  2. try_get_unchecked 不做运行时类型检查——因为"类型已经在编译期被校验过了"这个前提被 checked = true 明确标记。如果你用的是 query_as_unchecked! 或者是列被 as "x: _" 覆盖成 wildcard,才会 fallback 到 try_get 做运行时检查。这就是 "unchecked" 后缀的含义。
  3. bind_args 是一次性构造的,不是链式 .bind()——这让编译器可以一次性在这里把所有参数的类型约束都加上。
  4. 整段展开没有任何 ? 在顶层——它返回的是一个 Map 类型(Query::try_map 的返回值),整个是一个"准备好 .awaitimpl 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 条件",你有两个选择:

  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.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 意图,真正的协议消息要等下一次用这个连接时才发出去(或者由连接池在归还时触发)。

这意味着两件事:

  1. 如果你的业务代码忘了 tx.commit().await? 就让 tx 离开作用域,sqlx 不保证 rollback 消息立刻到达数据库——从数据库的视角看,那个事务可能还在"打开着",持有行锁。时间窗可能很短(几十毫秒到一次连接归还前),也可能很长(如果连接不再被使用)。
  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》第 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() 内部用的 AsyncSemaphoresqlx-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 的每个参数实现 FromRequestPartsState<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 accepttokio-1.x/src/net/tcp/listener.rsTcpListener::accept() 返回 (TcpStream, SocketAddr)
Axum Serveaxum/src/serve/mod.rs把 stream 丢给 hyper,然后 spawn 一个任务处理连接
Hyper HTTP/1hyper/src/proto/h1/conn.rs字节流 → Request<Incoming>
Axum Routeraxum/src/routing/mod.rspath → MethodRouterHandlerService
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 acquiresqlx-core/src/pool/inner.rs:127acquire_permitAsyncSemaphore::acquire
获连接sqlx-core/src/pool/inner.rs:28idle_conns.pop()ArrayQueue<Idle<DB>>)或新建
Pg 协议sqlx-postgres/src/connection/executor.rsParse/Bind/Execute(Extended Query)
TCP writetokio-1.x/src/net/tcp/stream.rsAsyncWriteExt::write_all
↩ 响应同上反向RowDescriptionDataRowCommandComplete
解码sqlx-core/src/row.rs + sqlx-postgres/src/row.rsrow.try_get_unchecked::<i32, _>(0)
归还连接sqlx-core/src/pool/connection.rsDrop implpush 回 idle_conns、release 一个 permit
响应编码axum/src/response/*hypertokioJson<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.12019-09首个公开版本。只有 Postgres 驱动,基础 query! 宏。
0.22019-12MySQL 驱动;Pool 第一版基于 futures-intrusive
0.32020-05SQLite 驱动;FromRow derive 宏;offline 模式(CI 友好)的雏形。
0.42020-10支持 MSSQL(实验性,后续被移除);query_as! 支持 type override(as "x: _" 语法)。
0.52021-02runtime 抽象(可切换 tokio / async-std / actix);sqlx-cli 正式化。
0.62022-06大幅重构:抽离 sqlx-core,把各驱动拆成独立 crate 但仍在同一 workspace;Postgres 增加 pgvector 类型。
0.72023-07正式 crate 拆分sqlx-postgres / sqlx-mysql / sqlx-sqlite 变成独立发布的 crate。
0.82024-07破坏性变更:移除 impl Executor for &mut Transaction(必须 &mut *tx);引入 Arguments::add
0.8.62025-05-19本书锁定版本。累积了 Postgres 的 BYTEA 性能修复、tracing 集成改进、macros 错误信息优化。
0.9.0-α2025-10-15alpha 版本。传闻中的 Executor trait 重设计,本书不涵盖。

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

  1. 从单体到拆分。0.1 到 0.5 是"一个 crate 包打所有数据库"的时代;0.6 开始抽 sqlx-core;0.7 把驱动拆成独立 crate。这让"我只用 Postgres"的用户不再被迫拉 MySQL 和 SQLite 的依赖树。
  2. runtime 抽象是中期才加的。早期 sqlx 只支持 async-std(0.1–0.4)、后来加了 tokio 支持(0.5),再后来变成 feature 互斥。今天 2026 年绝大多数项目都用 runtime-tokioruntime-async-std 已经在 0.8 标记为 deprecated。
  3. 0.8 的 Executor 破坏性变更是被迫的。从 0.7 开始驱动是独立 crate 之后,sqlx-core 里手写的 impl Executor for &mut Transaction 就面临一个选择:要么破坏 coherence、要么把实现推到每个驱动 crate 里(等于让每个驱动手写这个 blanket,维护成本爆炸)、要么就让 Transaction 通过 DerefMut 暴露连接、让用户写 &mut *tx。sqlx 选了第三种——也是本章在"让步二·附"里提到的那个破坏性变更。
  4. offline 模式从一开始就是一等公民。这条路线的坚持是 sqlx 区别于其他"编译期连数据库"思路(比如 diesel 的 table! 依赖离线 schema 文件)的关键——你既可以在本地开发用在线数据库得到最实时的校验,也可以在 CI 用离线 JSON 文件保持编译期校验的类型安全。第 11 章会细讲这两种模式的切换机制。

这张时间线不是"怀旧"——它告诉你一件事:当你读 sqlx 0.8.6 源码时遇到看起来绕弯的设计,大概率背后有一段从 0.1 到 0.8 的演进痕迹。比如 AsyncSemaphoresqlx-core/src/sync.rs)为什么不直接用 tokio::sync::Semaphore?因为早期支持多 runtime 时必须有一层自己的抽象;即便今天大家都用 tokio,这层抽象也保留了下来(以免以后 runtime 生态再变)。

1.9 本章小结

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

  1. Rust 数据库生态存在三条主流路线:Diesel(编译期 DSL、同步优先)、tokio-postgres(裸协议、原生异步)、SeaORM(ActiveRecord ORM)。每条路线解决一个痛点、放弃另一些灵活性。
  2. sqlx 选择了第四路径:非 ORM、异步原生、编译期校验 SQL 字面量。这条路径在 sqlx 之前是生态空白象限。四种路线对同一个"更新邮箱"任务的写法对比(§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.7 → 0.8 的 Executor 破坏性变更(§1.6 让步二·附)是理解 sqlx 设计风格的关键例子:宁可让用户多写两个字符(&mut *tx),也不保留一个在新 crate 架构下破坏 coherence 的 blanket impl。
  8. 版本演进(§1.8)从 0.1 到 0.8.6 的里程碑显示 sqlx 是一步步从"单体 crate"演进到"core + 独立驱动 + 可复用 macros-core"的——今天源码里一些看似绕弯的设计,背后都有版本演进的痕迹。
  9. 一次完整请求(§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 驱动要单独吃一份特殊待遇。

基于 VitePress 构建