Rust in Everything
基于 sqlx 0.9.0

怎么读这一卷

主线位置:三条主线的总览。本页把查询主线、连接池主线和编译期检查主线各画一遍,标出每一步落在哪个函数上、哪一页讲它;「一次查询的全过程」用一个完整的例子把查询主线和连接池主线走一遍,并用抓包证明每一步发了哪些协议消息。

flowchart TB
  start[从这里开始] --> q{读这一卷为了什么}
  q -->|会用| use1[01 全景]
  use1 --> use2["02 Executor 与 Query"]
  use2 --> use3[04 连接池]
  use3 --> use4[07 设计取舍]
  q -->|读懂实现| all[00 到 10 按顺序]

锁定版本

项本卷本站 Cargo.lock
sqlx 及各子 crate0.9.00.8.6(经 sea-orm 1.1.20 间接依赖)
tag / commitv0.9.0 / 75bc048(2026-05-21)v0.8.6
CHANGELOG 记的发布日期2026-05-06—
rust-version1.94.0—
edition2021—

工作区里 sqlx、sqlx-core、sqlx-macros、sqlx-macros-core、sqlx-postgres、sqlx-mysql、sqlx-sqlite、sqlx-cli 共用工作区 Cargo.toml 的 [workspace.package] 里的 version = "0.9.0",彼此之间的依赖都写成 =0.9.0,必须一起升级。各 crate 的分工见源码地图。

0.9.0 发布时仓库从 launchbadge 搬到了新组织。CHANGELOG 的 Important Announcements 写的是:

Shortly after this release is published, the SQLx repository will be transferred to a new GitHub organization:
https://github.com/transact-rs/

理由是「SQLx has not been owned or maintained by LaunchBadge, LLC. for a few years now」。tag 里的 Cargo.toml 和 README 仍然写着 https://github.com/launchbadge/sqlx,旧链接会重定向;本卷的源码链接一律用 https://github.com/transact-rs/sqlx/blob/v0.9.0/...,行号取自 tag。

本站的代码不直接调 sqlx,而是经 sea-orm 1.1.20 用 0.8.6(sea-orm 1.1.20 的 Cargo.toml 要求 sqlx 0.8.4 以上)。0.8.6 到 0.9.0 是破坏性升级,CHANGELOG 的 Breaking 一节里和 Postgres 有关的几项:query*() 只收 SqlSafeStr(#3723)、Arguments 去掉生命周期参数(#3960)、sqlx.toml 配置文件(#3383)、query! 描述语句时强制 generic plan(#3541)、MSRV 升到 1.94。它们落在哪几页、会不会影响本站,见升级到 0.9。

三条主线

sqlx 的核心是三样东西:一组和数据库无关的 trait(Database、Executor、Encode / Decode),一个通用的连接池,一组编译期连库检查 SQL 的宏。全卷沿三条线展开,每页开头都会标出本页在哪条主线上。

查询主线:一次 fetch_one

例子是:

let row: (i64, String) = sqlx::query_as("select id, title from posts where id = $1")
  .bind(1_i64)
  .fetch_one(&pool)
  .await?;
flowchart TB
  qa["query_as 收 SqlSafeStr"] --> bind["bind 写进 PgArguments"]
  bind --> f1["fetch_one 调 fetch_optional"]
  f1 --> poolex["Pool 的 Executor"]
  poolex --> acq["acquire 一条连接"]
  acq --> run["PgConnection::run"]
  run --> cache{语句缓存命中}
  cache -->|否| prep["Parse Describe Sync"]
  prep --> bindmsg
  cache -->|是| bindmsg["Bind Execute Close Sync"]
  bindmsg --> rows["DataRow 包成 PgRow"]
  rows --> dec["FromRow 和 Decode"]
步骤源码讲它的页
1. query_as 收一个 impl SqlSafeStr(0.9 起只有 &'static str 和 AssertSqlSafe 实现它),造出 Query:SQL、arguments: Some(Ok(空参数))、persistent: truequery.rs、query_as.rs、sql_str.rsQuery、QueryAs 与 SqlSafeStr
2. Query::bind 调 Arguments::add,Encode 把值写进 PgArguments 的缓冲区并记下类型;编码失败只把错误存起来,执行时才报query.rs、encode.rs同上、类型:Type、Encode、Decode
3. QueryAs::fetch_one 就是 fetch_optional 加一句 ok_or(Error::RowNotFound);fetch_optional 把 Query 交给执行器,拿回 PgRow 后调 O::from_rowquery_as.rsRow 与 FromRow
4. &Pool 的 Executor:pool.acquire().await?.fetch_optional(query),先借一条连接(连接池主线),再交给 &mut PgConnection 的 Executorpool/executor.rsExecutor 与 Acquire
5. PgConnection::run:wait_until_ready 读掉上一次剩下的消息;参数超过 u16 就报 too many arguments for queryconnection/executor.rs扩展查询协议
6. get_or_prepare 按 SQL 原文查 StatementCache(LRU,默认容量 100);没命中就 prepare:发 Parse(语句名 sqlx_s_N)+ Describe + Sync,单独等一个来回,收 ParameterDescription / RowDescription同上同上、协议消息
7. Bind(未命名 portal,参数和结果都要二进制格式)+ Execute(limit: 0)+ Close(未命名 portal)+ Sync,一次 flush同上扩展查询协议
8. 一个 try_stream! 逐条读:DataRow 包成 PgRow(所有行共用一个 Arc<PgStatementMetadata>),CommandComplete 记行数,ReadyForQuery 结束;fetch_optional 留下第一行,但一直读到 ReadyForQuery同上一次查询的全过程
9. FromRow 对元组逐列 try_get,Decode 把二进制值变成 i64、Stringfrom_row.rs、sqlx-postgres/src/types/Row 与 FromRow、Postgres 的类型

只有经 query*() 构造、arguments 是 Some 的查询才走第 6、7 步。把 &str 直接交给 Executor,或者用 raw_sql,Execute::take_arguments 返回 Ok(None),run 改发一条简单查询协议的 Query 消息,结果是文本格式。不 bind 的 query("...") 仍然走预备语句,这一点一次查询的全过程里有抓包。

连接池主线:一次 acquire 和归还

flowchart TB
  acq["Pool::acquire"] --> to["acquire_timeout 内循环"]
  to --> permit["acquire_permit 拿许可"]
  permit --> pop{"pop_idle 有空闲"}
  pop -->|有| chk["check_idle_conn 发 ping"]
  chk -->|好| got[借出 PoolConnection]
  chk -->|坏| conn
  pop -->|没有| inc["try_increment_size"]
  inc --> conn["connect 新建连接"]
  conn --> got
  got --> dropc["Drop 时 spawn 归还"]
  dropc --> ret["return_to_pool 再 ping"]
  ret --> rel["release 先入队后放许可"]
步骤源码讲它的页
1. Pool::acquire 转给 PoolInner::acquire:整个循环包在 rt::timeout(acquire_timeout) 里(默认 30 秒,超时报 PoolTimedOut)pool/mod.rs、pool/inner.rs获取连接
2. acquire_permit 从信号量拿一个许可,许可总数就是 max_connections(默认 10);tokio 下这个信号量就是 tokio::sync::Semaphore,fair 默认 true同上同上、tokio 卷的 batch_semaphore
3. pop_idle 从空闲队列(ArrayQueue)弹一条,交给 check_idle_conn:test_before_acquire(默认 true)时 ping,再跑 before_acquire 回调pool/inner.rs获取连接
4. 没有空闲连接就 try_increment_size(不超过 max_connections),拿到一个 DecrementSizeGuard 再 connect;连接失败时 guard 被 drop,计数减回去同上同上、建立连接
5. 默认值都在 PoolOptions::new:max_connections 10、min_connections 0、acquire_timeout 30 秒、idle_timeout 10 分钟、max_lifetime 30 分钟pool/options.rsPoolOptions
6. PoolConnection 的 Drop 只做一件事:rt::spawn(self.return_to_pool())pool/connection.rs归还与维护
7. return_to_pool:池已关闭或超过 max_lifetime 就关掉;跑 after_release;再 ping 一次(顺带把 Transaction 在 Drop 里排队的 ROLLBACK 发出去、把没读完的消息读掉);最后 release:先推回空闲队列,再释放许可同上同上、事务
8. spawn_maintenance_tasks:按 idle_timeout 和 max_lifetime 中较小的那个周期,回收空闲太久或活得太久的连接;只有关掉了连接的那一轮才补足 min_connectionspool/inner.rs归还与维护

max_lifetime 不在借出时检查:check_idle_conn 只管 ping 和 before_acquire,超龄的连接要等归还时或维护任务来关。

编译期检查主线:一次 query!

flowchart TB
  mac["query! 宏"] --> exp["expand_input"]
  exp --> env{"有 DATABASE_URL"}
  env -->|有| live["编译时连库"]
  env -->|没有| off["读 .sqlx 的 JSON"]
  live --> desc["describe 拿类型"]
  desc --> null["推断可空性"]
  null --> gen["生成代码"]
  off --> gen
  gen --> chk["参数类型检查"]
  gen --> rec["匿名 Record 结构体"]
步骤源码讲它的页
1. sqlx::query! 是 macro_rules!,展开成过程宏 expand_query!,后者只调 sqlx_macros_core 的 expand_inputsrc/macros/mod.rs、sqlx-macros/src/lib.rsquery! 的流水线
2. 读 DATABASE_URL(变量名可以在 sqlx.toml 里改):有值且没设 SQLX_OFFLINE=true 就是 QueryDataSource 的 Live;否则依次在 SQLX_OFFLINE_DIR、本 crate 的 .sqlx、workspace 的 .sqlx 找 query-<hash>.json,都没有就报 set `DATABASE_URL` to use query macros online, or run `cargo sqlx prepare` to update the query cachequery/mod.rs离线模式与 sqlx.toml
3. 在线时 expand_with 调 describe_blocking:编译期间用一个 current-thread 运行时连库(连接按 URL 缓存),新建的 Postgres 连接先执行 SET SESSION plan_cache_mode = 'force_generic_plan'(0.9 新增),再对 SQL 做 Parse + Describedatabase/mod.rsdescribe
4. get_nullable_for_columns 查 pg_attribute.attnotnull,nullables_from_explain 再跑 EXPLAIN (VERBOSE, FORMAT JSON) EXECUTE ... 找外连接里可能变成 NULL 的列sqlx-postgres/src/connection/describe.rs同上
5. expand_with_data 生成代码:quote_args 为每个参数生成一段只为触发类型错误的代码;quote_query_as 生成一个匿名 Record 结构体,推断不出可空性的列按可空处理(describe.nullable(i).unwrap_or(true)),成为 Option<T>query/args.rs、query/output.rs生成的代码
6. 列名后缀覆盖推断:ColumnDecl::parse 认 !(非空)、?(可空)、: T(指定类型)query/output.rs同上

前两条主线在程序运行时发生,第三条在 cargo build 时发生:query! 展开出来的代码最后还是调第一条主线上的 query_with / fetch_one,只是 SQL 的参数类型、结果列的类型和可空性在编译时就对过了数据库。

阅读路线

前置知识

  • async/await、Future、Stream,以及 tokio 的 spawn 和超时。sqlx 的 rt::spawn、rt::timeout 在 tokio 下就是 tokio::spawn、tokio::time::timeout,本卷不重讲,见 tokio 卷的 spawn 全路径和 Sleep 的一生。
  • Postgres 的几个概念:预备语句(先解析、后带参数执行)、事务、类型 OID。协议消息的格式在 03 Postgres 驱动里从头讲,不需要事先读过协议文档。
  • serde:Json<T> 和 .sqlx/*.json 用到它,见 serde 卷。

实验环境

关于行为的说法(发了哪几条协议消息、命中没有、池子里有几条连接、报什么错、rustc 说了什么)都配一个小程序和它的真实输出。平台是 M2 Max、macOS、rustc 1.99.0、Postgres 16.14(本机容器 postgres:16-alpine)。实验 crate 的依赖锁定版本:

sqlx = { version = "=0.9.0", features = ["runtime-tokio", "tls-rustls-ring-webpki", "postgres", "macros", "migrate", "json", "chrono", "uuid", "sqlx-toml"] }
tokio = { version = "1", features = ["full"] }

看语句日志:RUST_LOG=sqlx=debug

每次执行,run 都会建一个 QueryLogger,它被 drop 时在 sqlx::query 这个 target 下打一条事件,默认级别是 Debug,执行超过 1 秒改用 Warn。程序里要有一个读 RUST_LOG 的订阅者:

use sqlx::postgres::PgPoolOptions;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
  tracing_subscriber::fmt()
    .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
    .without_time()
    .init();
  let pool = PgPoolOptions::new()
    .max_connections(1)
    .connect(&std::env::var("DATABASE_URL")?)
    .await?;
  let (n, s): (i64, String) = sqlx::query_as("select $1::int8, $2::text")
    .bind(42_i64)
    .bind("sqlx")
    .fetch_one(&pool)
    .await?;
  println!("{n} {s}");
  pool.close().await;
  Ok(())
}

RUST_LOG=sqlx=debug 下的输出:

DEBUG sqlx::query: summary="select $1::int8, $2::text" db.statement="" rows_affected=1 rows_returned=1 elapsed=1.537708ms elapsed_secs=0.001537708
42 sqlx

summary 是 SQL 的前 4 个词(parse_query_summary)。这条 SQL 只有 3 个词,摘要就是原文,所以 db.statement 为空;SQL 更长时摘要后面加 …,完整的 SQL 放进 db.statement。

📝NOTE
没有蠢问题

问: 想看 sqlx 发了哪些协议消息,把 RUST_LOG 调到 sqlx=trace 行不行?

答: 不行。上面的程序在 RUST_LOG=sqlx=trace 下输出和 debug 一字不差,还是那一条 sqlx::query 事件。sqlx 只在语句粒度上打日志,Parse、Bind、Sync 这些消息不出现在任何日志里。要看消息,得在客户端和服务端之间放一个代理把字节解出来,本卷用一个几十行的 tokio TCP 代理,代码和输出见一次查询的全过程。服务端的 log_statement 也能看到语句,但那是全局设置,会影响同一个库上的其他会话。

看协议消息:一个本地代理

代理监听 127.0.0.1 的一个随机端口,把连接转给 Postgres,同时按协议格式(一个类型字节、一个 4 字节长度)切出每条消息,每次读到数据就打一行:方向,加上这次读到的完整消息。连接串里的主机和端口换成代理的,sslmode 设为 disable,否则字节是加密的。用它看到的东西,例如第一次执行发了 Parse、第二次没发,都在一次查询的全过程。

小结

  • 全卷对着 sqlx 0.9.0(tag v0.9.0,commit 75bc048)写,仓库已经搬到 transact-rs/sqlx;本站经 sea-orm 间接用 0.8.6。
  • 查询主线:query_as → bind → fetch_one(即 fetch_optional)→ &Pool 借连接 → PgConnection::run → 语句缓存没命中就 Parse + Describe → Bind + Execute + Close + Sync → DataRow → FromRow。
  • 连接池主线:拿许可 → 弹空闲连接并 ping,或者新建 → 用完 Drop 时 spawn 归还 → 再 ping → 先入队再放许可;max_lifetime 只在归还和维护时检查。
  • 编译期检查主线:query! 编译时连库(或读 .sqlx)→ describe + 可空性推断 → 生成参数检查和 Record 结构体。
  • 语句日志用 RUST_LOG=sqlx=debug;协议消息日志里看不到,要用代理。

💬 关联讨论

发起讨论
加载中...