SQLx 源码深度解析

第3章 Database trait 家族:用 GAT 收束异构驱动

作者 杨艺韬 · 9,507 字 · 发布于 · 更新于

"A trait is a contract between the implementor and the user— and a good contract says exactly what's needed, no more, no less." —— 任何一个 trait 的第一次 code review

本章要点

  • Database trait(sqlx-core/src/database.rs:72)是整个 sqlx 类型体系的收束点——把驱动特有的 11 种类型(7 个普通关联类型 + 4 个 GAT)全部声明为关联类型,再加两个 const(NAME / URL_SCHEMES),让上层 API 只需要 DB: Database 一个泛型参数。
  • 其中四个是泛型关联类型(GAT)type ValueRef<'r>type Arguments<'q>type ArgumentBuffer<'q>type Statement<'q>——它们带生命周期参数。GAT 在 Rust 1.65(2022)才稳定,sqlx 0.7 是第一个利用 GAT 的版本。
  • 没有 GAT 的 0.6 时代,sqlx 用 HasValueRef<'r> 这样的"带生命周期的 helper trait"绕过——这种 workaround 让 trait 边界变得极其冗长,每个 where 子句都要重复写三到五个约束。
  • NAMEURL_SCHEMES 两个关联常量让 Any 驱动能在运行时按 URL scheme 分发(见第 2 章)——这不是显眼的特性,但决定了"同一个 AnyConnection 能连所有 DB"这件事能不能做到。
  • HasStatementCache 是一个无方法的 marker trait——Postgres / MySQL 实现它,SQLite 不实现。上层 API(Query::persistentConnection::cached_statements_size)通过 where DB: HasStatementCache 在类型系统层面把"语句缓存"这个能力锁进类型。
  • 对照 Postgres / MySQL / SQLite 三家的 impl Database,你会发现每一行的具体类型都不同但结构完全一样——这就是 trait 家族设计要达成的"等价替换"形态。

3.1 问题引入:一个 DB 要扛 11 个关联类型

上一章画过这行代码:

let user: User = sqlx::query_as!(User, "SELECT id, name FROM users WHERE id = $1", id)
    .fetch_one(&pool)
    .await?;

以及它展开后生成的那段匿名 Future:

::sqlx::__query_with_result::<::sqlx::Postgres, _>(...)
    .try_map(|row: ::sqlx::postgres::PgRow| { ... })

注意第二行里的两个类型——PostgresPgRow。如果你把 Postgres 换成 MySql,那第二行的 PgRow 必须同步换成 MySqlRow——这是 sqlx-core 的 API 在声明时就约束好的。sqlx::query_as 不知道具体是哪家数据库、不知道具体的 Row 类型长什么样——它只知道"你给我一个 DB: Database,我从 DB::Row 里解码"。

问题来了:这个"只知道 DB"的设计要求 RowColumnValueArguments 等所有具体类型都能从 DB 这一个泛型参数推出来。Rust 里唯一能做到这件事的机制是关联类型(associated type)

pub trait Database {
    type Row;
    type Column;
    type Value;
    // ...
}

fn fetch_one<DB: Database>(...) -> <DB as Database>::Row { ... }

但 sqlx 的 11 个关联类型里有四个带生命周期参数

type ValueRef<'r>: ValueRef<'r, Database = Self>;
type Arguments<'q>: Arguments<'q, Database = Self>;
type ArgumentBuffer<'q>;
type Statement<'q>: Statement<'q, Database = Self>;

type ValueRef<'r> 不是普通关联类型——它是泛型关联类型(GAT)。GAT 在 Rust 1.65(2022 年 11 月)才稳定,sqlx 0.7(2023 年 7 月)是第一个大版本全面用 GAT 的。0.6 及之前的 sqlx 用了一套非常笨拙的绕法——本章第 3.3 节会详细看那段历史。

这一章的任务,就是把 Database trait 的每一个关联类型、每一个 trait 边界拆开,解释它们为什么必须这样写、GAT 消除了哪些痛苦、HasStatementCache 这种"无方法标记 trait"怎么当作类型系统里的能力开关。读完本章,你对 DB: Database 这个约束在上层 API 的每一处出现都能立刻在脑子里展开成完整的关联类型集合。

3.2 Database trait 的全貌

sqlx-core/src/database.rs:72-113 的原文(本章后续讨论都以此为锚):

pub trait Database: 'static + Sized + Send + Debug {
    type Connection: Connection<Database = Self>;
    type TransactionManager: TransactionManager<Database = Self>;

    type Row: Row<Database = Self>;
    type QueryResult: 'static + Sized + Send + Sync + Default + Extend<Self::QueryResult>;
    type Column: Column<Database = Self>;
    type TypeInfo: TypeInfo;

    type Value: Value<Database = Self> + 'static;
    type ValueRef<'r>: ValueRef<'r, Database = Self>;

    type Arguments<'q>: Arguments<'q, Database = Self>;
    type ArgumentBuffer<'q>;

    type Statement<'q>: Statement<'q, Database = Self>;

    const NAME: &'static str;
    const URL_SCHEMES: &'static [&'static str];
}

一共十一个关联类型 + 两个关联常量 + 四条 super-trait bound('static + Sized + Send + Debug)。把这十一个类型按功能分成四组:

graph TD
    DB[trait Database]
    subgraph 生命周期载体
      C1[Connection]
      C2[TransactionManager]
    end
    subgraph 读取链
      R1[Row]
      R2[Column]
      R3[Value]
      R4["ValueRef<'r>"]
      R5[QueryResult]
    end
    subgraph 写入链
      W1["Arguments<'q>"]
      W2["ArgumentBuffer<'q>"]
      W3["Statement<'q>"]
    end
    subgraph 元数据
      M1[TypeInfo]
    end
    DB --> C1 & C2
    DB --> R1 & R2 & R3 & R4 & R5
    DB --> W1 & W2 & W3
    DB --> M1

后文的 3.4–3.7 节按这四组依次展开。

先看几条 super-trait bound:

  • 'staticDatabase 实现者(Postgres / MySql / Sqlite)都是零大小 unit struct——pub struct Postgres;——所以没有非 'static 引用。但 'static 不是自动推出来的,它是给下游 API 用的:"凡是 DB: Database,我就能把 DB 放进 Arc 里、跨线程传递、跑在 tokio::spawn 里"。没有它,上层所有 where 'static 的泛型边界都会断掉。
  • Sized:意味着 DB 不能是 dyn Database 形式——这和 Any 驱动形成对比:Any 用 Box<dyn AnyConnectionBackend> 绕开 Sized 约束,但它不是 Database 而是自己的 Any unit type 实现 Database
  • Send:必须能跨线程——因为 Pool<DB>PoolInner 会被多个 tokio task 共享。
  • Debug:为了让错误信息、tracing span 能打印出驱动名字。

这四条 bound 加起来把 Database 实现者钉死为"一个零开销的类型 token"——它不持有任何状态,只是类型系统里的一个标识,告诉 Executor::fetch 等泛型方法应该去找哪一套具体的 Row / Column / Arguments 实现。

3.2.1 对照 diesel 的 Backend trait

Rust 生态里做"驱动抽象"的另一条路线是 diesel。把 diesel 2.x 的 Backend trait 和 sqlx 的 Database 放在一起比,能看出两种哲学的差异:

// diesel 2.x 的 Backend trait(简化)
pub trait Backend: Sized + Send {
    type QueryBuilder: QueryBuilder<Self>;
    type RawValue<'a>;
    type BindCollector<'a>: BindCollector<'a, Self>;
}

// 和
pub trait SqlDialect: Backend {
    type ReturningClause;
    type OnConflictClause;
    type InsertWithDefaultKeyword;
    type BatchInsertSupport;
    // ...
}

几条对比:

  1. diesel 的关联类型数量相当QueryBuilder / RawValue / BindCollector + SqlDialect 里七八个),但语义完全不同——diesel 的关联类型描述"方言特性"(是否支持 RETURNING、ON CONFLICT),sqlx 的关联类型描述"数据形态"(Row、Column、Value)。
  2. diesel 没有 sqlx 的 Row / Column——因为 diesel 的结果映射是通过 FromSqlRow 派生宏按 DSL 表达式静态展开的,不需要运行时的 Row 抽象。
  3. diesel 有 sqlx 没有的 QueryBuilder——这是 diesel DSL 的 SQL 字符串生成器。sqlx 不需要,因为用户写的 SQL 就是原始字符串,不经过构造器翻译。

两种设计的本质差异可以浓缩成一句话:diesel 的 Backend trait 描述的是"如何从 Rust 表达式生成 SQL",sqlx 的 Database trait 描述的是"如何把 SQL 字节流和连接池接入 Rust 的泛型系统"。前者是"出"的问题,后者是"入"的问题——两种工具包的方向完全不同。

这个对照也解释了为什么 sqlx 的 Any 驱动能存在而 diesel 没有等价物——sqlx 抽象的"数据形态"在三家 DB 下大致等价,运行时擦除一层并不太失真;而 diesel 抽象的"方言特性"在三家 DB 下差异巨大(Postgres 的 RETURNING、SQLite 的 RETURNING-via-triggers、MySQL 的 last_insert_id),运行时无法擦除得干净。

3.3 为什么是 GAT:没有泛型关联类型之前的痛苦

要解释 GAT 的必要性,先看没有 GAT 时 sqlx 0.6 是怎么写的。

0.6 版本(以 sqlx-core 0.6.3 为代表)的 Database trait 大致长这样:

// 0.6 时代的简化版
pub trait Database:
    for<'r> HasValueRef<'r, Database = Self>
    + for<'q> HasArguments<'q, Database = Self>
    + for<'q> HasStatement<'q, Database = Self>
{
    type Connection: Connection<Database = Self>;
    type Row: Row<Database = Self>;
    // ...
}

pub trait HasValueRef<'r> {
    type Database: Database;
    type ValueRef: ValueRef<'r, Database = Self::Database>;
}

pub trait HasArguments<'q> {
    type Database: Database;
    type Arguments: Arguments<'q, Database = Self::Database>;
    type ArgumentBuffer;
}

pub trait HasStatement<'q> {
    type Database: Database;
    type Statement: Statement<'q, Database = Self::Database>;
}

注意这三个 "Has*" trait:每个都带一个生命周期参数,把原本应该是 GAT 的类型变成"HKT 仿真"——通过 for<'r> 全称量化 bound 给 Database trait 附加约束。

这种写法的代价是,上层 API 每次用 DB::Arguments<'q> 都要写一长串 bound。比如 0.6 版的 Query::execute 要这样声明:

pub fn execute<'e, 'q, E, A>(
    self,
    executor: E,
) -> impl Future<Output = Result<DB::QueryResult, Error>> + Send + 'e
where
    'q: 'e,
    E: Executor<'e, Database = DB>,
    DB: Database + for<'r> HasValueRef<'r, Database = DB>,
    A: 'q + IntoArguments<'q, DB>,
    DB: for<'q2> HasArguments<'q2, Database = DB>,
    // ... 还要再写两三条

每次用到 DB::Arguments<'q> 都要追加一条 DB: HasArguments<'q2, ...>——因为编译器不会自动帮你"把 type Arguments 这个从属投影出来"(这就是 higher-kinded type 不直接支持的核心痛点)。社区把这种模式戏称为"HRTB soup"(for<'r> ... bound 的一碗汤)。

GAT 稳定之后,这一切变成:

pub trait Database: 'static + Sized + Send + Debug {
    type ValueRef<'r>: ValueRef<'r, Database = Self>;
    type Arguments<'q>: Arguments<'q, Database = Self>;
    // ...
}

上层 API 可以直接写 fn execute<'q, DB: Database>(args: DB::Arguments<'q>)——编译器自动投影,不再需要 HasArguments 这种中间层。sqlx-core 0.7Query 类型(sqlx-core/src/query.rs:72-96)能够写得远比 0.6 干净:

pub struct Query<'q, DB: Database, A> {
    pub(crate) statement: Either<&'q str, &'q DB::Statement<'q>>,
    pub(crate) arguments: Option<Result<A, BoxDynError>>,
    pub(crate) database: PhantomData<DB>,
    pub(crate) persistent: bool,
}

&'q DB::Statement<'q> 这个类型在 0.6 时代需要约 5 行 where 才能表达,GAT 之后直接写在字段位置——这是 GAT 对 sqlx 的最大价值

从 0.6 升到 0.7 的 migration 指南里有一段直接说明了动因(见 sqlx 0.7 的 CHANGELOG):

The internal type system has been rewritten to use generic associated types (GATs), eliminating the need for the HasValueRef / HasArguments / HasStatement trait soup. Downstream drivers and users of custom Database impls will need Rust 1.65+.

这是 sqlx 最低 Rust 版本从 1.60 跳到 1.65 的直接原因——它吃了一个 MSRV bump 来换 trait 家族的清爽。这条 trade-off 放在 2023 年的 Rust 生态里相当合理,因为那时候主流项目已经普遍在 1.70+ 了。

3.3.1 GAT 带来的新代价

GAT 不是免费午餐。sqlx 的实际使用里有两类新问题:

问题 1:lifetime inference 失败。GAT 下 DB::Arguments<'q>'q 必须由编译器从上下文推导。在一些复杂的异步调用链里,编译器会推不出来,报出 "implementation is not general enough" 之类的错误。sqlx 0.7 的 Issue tracker 里有若干这类帖子——用户需要手动标注 'q,或者用 type alias 把 GAT 投影固化。

问题 2:Object safety 丢失。带 GAT 的 trait 是 non-object-safe 的——你不能 dyn Database。这正是为什么 Any 驱动需要自己的 AnyConnectionBackend trait(所有方法用 BoxFuture 返回,无 GAT)而不能直接 dyn Database。第 2.6 节讨论的"运行时多态叛徒"在类型系统层面的另一种解释就是:GAT + object-safety 天然不兼容,Any 必须另起炉灶

这两个代价对 sqlx 的影响都是可控的——第一个问题靠文档和错误信息缓解,第二个问题通过 Any 驱动的独立 trait 绕开。GAT 总体是净收益。

3.3.2 如何读懂 sqlx 的 trait 边界编译错

GAT + trait 家族的一个直接副作用:编译错信息会变长。新手常常遇到这种错:

error[E0277]: the trait bound `&mut Transaction<'_, Postgres>: Executor<'_>`
              is not satisfied
   --> src/main.rs:42:5
    |
 42 |     sqlx::query!("SELECT ...").fetch_one(&mut tx).await?;
    |                                ^^^^^^^^^ the trait `Executor<'_>`
    |                                          is not implemented for
    |                                          `&mut Transaction<'_, Postgres>`
    |
    = help: the following other types implement trait `Executor<'c>`:
            &'c Pool<DB>
            &'c mut <DB as Database>::Connection

这条错误在第 1 章"让步二·附"提到过——0.8 之后 &mut Transaction 不再自动是 Executor。但错误信息里藏着第二条线索:help 里列出的候选实现是用关联类型 <DB as Database>::Connection 描述的。读懂它需要先读懂 Database trait 的关联类型投影语法。

三个常见 sqlx 错误信息模式:

错误模式 含义 解决
trait bound <X as Database>::Row: Row not met 某个 Row 关联类型的 super-trait bound 缺失 一般是 impl 顺序问题
implementation is not general enough GAT 生命周期推断失败 手动标注 'q
<X as Database>::ValueRef<'_> doesn't implement ... ValueRef 的 GAT 投影缺少某个 trait 实现 检查 Decode<DB> 的覆盖

读 sqlx 错误信息的第一步永远是<X as Database>::Y 翻译成具体类型——<Postgres as Database>::Row 就是 PgRow<Postgres as Database>::ValueRef<'_> 就是 PgValueRef<'_>。一旦翻译过来,问题就从抽象 trait bound 变成具体类型缺 impl,修起来容易得多。

3.4 读取链:Row / Column / Value / ValueRef / QueryResult

这是 Database trait 里最大的一组关联类型——五个。它们共同描述"从数据库读回一行,怎么拆开、怎么解码"。

3.4.1 Row:一行数据的抽象

sqlx-core/src/row.rs:14-16Row trait:

pub trait Row: Unpin + Send + Sync + 'static {
    type Database: Database<Row = Self>;
    // ...
}

Unpin + Send + Sync + 'static 四条约束——这比 Database 本身还严格。每一条都有直接用途:

  • Unpin:允许 Row 在异步流里自由移动(BoxStream<Row> 的要求)。
  • Send + Sync:Row 经常在 tokio task 之间流转。
  • 'static:Row 不能借用外部数据。注意这和 ValueRef<'r> 形成对比——Row 持有值,ValueRef 持有引用。

Row::Database = Self:这是自反关联类型约束。它说"我这个 Row 的 Database 关联类型,指回去得是能产生我的那个 Database"。展开成具体:PgRow::Database = Postgres,而 Postgres::Row = PgRow——两者形成闭环。这条约束让编译器能在 fn take<R: Row>(r: R) -> R::Database::Row 这种泛型代码里做等价替换。

Row trait 的方法包括 try_get<T>(index) / try_get_raw(index) / columns() / try_column(index) / len()——全部都返回泛型或关联类型的值。具体实现由驱动自己给出:PgRow 持有 Postgres 线路协议解析后的 DataRow 字节,MySqlRow 持有 MySQL 的字段值数组,SqliteRow 持有 SQLite statement handle 的 column() 指针快照。三家的存储格式完全不同,但都满足 Row trait。

3.4.2 Column:列元数据

sqlx-core/src/column.rs:6-22

pub trait Column: 'static + Send + Sync + Debug {
    type Database: Database<Column = Self>;
    fn ordinal(&self) -> usize;
    fn name(&self) -> &str;
    fn type_info(&self) -> &<Self::Database as Database>::TypeInfo;
}

Column 是列元数据——它不持有数据值,只持有"这一列叫什么、类型是什么、顺序是多少"。和 Row 的关系是:"每一 Row 里有一组 Column"——Row::columns() 返回 &[DB::Column]

一个很重要的观察:Columntype_info 返回的是 &DB::TypeInfo 而不是 DB::TypeInfo 的克隆。这是因为列类型信息在一个预处理语句生命周期内是不变的(PostgreSQL 的 RowDescription 在 Parse 阶段就被服务端返回了),后续每次读行都引用同一份。把 TypeInfo 设计成"按引用传"避免了每行一次的克隆开销。

3.4.3 Value 与 ValueRef:数据持有权的分裂

这一对是整个读取链最微妙的设计。

sqlx-core/src/value.rs:9

pub trait Value {
    type Database: Database<Value = Self>;
    fn as_ref(&self) -> <Self::Database as Database>::ValueRef<'_>;
    fn type_info(&self) -> Cow<'_, <Self::Database as Database>::TypeInfo>;
    fn is_null(&self) -> bool;
    // decode / try_decode 等方法
}

sqlx-core/src/value.rs:99

pub trait ValueRef<'r>: Sized {
    type Database: Database;
    fn to_owned(&self) -> <Self::Database as Database>::Value;
    fn type_info(&self) -> Cow<'_, <Self::Database as Database>::TypeInfo>;
    fn is_null(&self) -> bool;
}

Value拥有所有权的值——它独立存在,不借用 Row。ValueRef<'r>借用的值——生命周期 'r 挂到了 Row 上,意味着"只要 Row 还活着,我这个 ValueRef 就有效"。

为什么要两个? 想象 row.try_get::<i32, _>(0) 这个调用的流程:

  1. try_get 需要从第 0 列拿到一个原始值。
  2. 原始值在 PostgreSQL 里可能只是 PgDataRow 缓冲区里的几个字节——拷贝出来当 owned value 是浪费。
  3. 所以 Row::try_get_raw 先返回一个 ValueRef<'r>——只是一个指针 + 长度,生命周期绑到 row 上。
  4. 真正 decode 成 i32 的时候,Decode::decode 接收的是 ValueRef,不是 Value

换句话说,ValueRef 是 decode 路径上的默认形态,Value 只在需要"把值抽出来跨行生存"时用。后者的典型用例是 query_scalar! 宏返回单列值——它内部会 ValueRef::to_owned() 成 Value,因为返回的值不能借用即将析构的 Row。

ValueRef::to_owned 的实现根据数据库有重大差异:

  • Postgres / MySQL:"基本是一次引用计数递增,O(1)"(原文见 value.rs:105)——因为底层 buffer 用 Arc<[u8]>bytes::Bytes 共享。
  • SQLite:必须真实拷贝——SQLite 的 sqlite3_column_blob 指针在 row 步进后就失效,没法靠引用计数保留。

这条差异很重要。它意味着你在 Postgres 驱动下做 let v: Value = row.value(0).to_owned(); 几乎零成本,在 SQLite 下则是一次 memcpy。第 7 章会专门讲这个差异如何影响 query! 的内部选择。

3.4.4 一次 row.try_get::<i32, _>(0) 的类型流

把前面几个关联类型串起来,看 let id: i32 = row.try_get(0)?; 这一行背后的类型动线:

sequenceDiagram
    participant U as 用户代码
    participant Row as PgRow<br/>(DB::Row)
    participant VR as PgValueRef<'r><br/>(DB::ValueRef<'_>)
    participant TI as PgTypeInfo<br/>(DB::TypeInfo)
    participant Dec as Decode::<i32, Postgres>
    participant T as Type::<i32, Postgres>
    participant Ret as i32
    U->>Row: try_get::<i32, _>(0)
    Row->>Row: try_get_raw(0)
    Row-->>VR: ValueRef<'_>
    Row->>T: compatible(&value.type_info())
    T->>TI: 读取 TypeInfo::name()
    TI-->>T: "INT4"
    T-->>Row: true (i32 ↔ INT4)
    Row->>Dec: decode(value)
    Dec->>VR: 读取字节
    Dec-->>Ret: i32 或 Error
    Ret-->>U: Ok(123)

关键节点:

  1. try_get_raw 先返回 ValueRef<'_>——'_ 生命周期绑到 &self(row)。编译器在这一步强制"ValueRef 不能活过 row"。
  2. Type::<i32, Postgres>::compatible 做类型兼容检查——i32 声明自己对应 "INT4" / "INT" / "SERIAL" 等,检查通过才进入 decode。这步失败会返回 Error::Decode("mismatched type")
  3. Decode::<i32, Postgres>::decodeValueRef 读字节——Postgres 的 i32::decodevalue.as_bytes_buffer().read_i32::<BigEndian>()(按大端读四字节)。
  4. 整条路径零 owned 分配——ValueRef 借用 row 的 buffer,i32 是栈上 4 字节。对比如果你写 let id: String = row.try_get(0)?,Decode 实现会 ValueRef::as_str()?.to_owned() 真做一次堆分配。

这张图解释了为什么 sqlx 号称"零拷贝解码"——不是指所有类型都没有拷贝,而是指只有必要时才拷贝(值类型零、引用类型 / 字符串按需)。这条路径的精妙处是它全部发生在 &self 的借用期内,不需要跨 await——这是 ValueRef 'r 生命周期的真正价值。

3.4.5 QueryResult:写操作的结果

type QueryResult: 'static + Sized + Send + Sync + Default + Extend<Self::QueryResult>;

这是唯一没有独立 trait 的关联类型——它是一个值类型(不是 trait bound)。PgQueryResult 是一个 struct 带 rows_affected: u64,MySQL 多一个 last_insert_id: u64,SQLite 多一个 changes: u64。没有公共 trait,只有公共的值蓝图。

关键是 Extend<Self::QueryResult> 这条 super-trait:PgQueryResult 实现 Extend<PgQueryResult> 意味着多个 QueryResult 可以"累加"成一个——这是 Executor::execute_many 流式执行多条语句时的合并需求。翻 sqlx-postgres/src/query_result.rs 能看到 impl Extend<PgQueryResult> for PgQueryResult 的实现——只是把 rows_affected 加起来。

3.5 写入链:Arguments / ArgumentBuffer / Statement

读取链完了,看写入链。

3.5.1 Arguments:参数集合

sqlx-core/src/arguments.rs:12

pub trait Arguments<'q>: Send + Sized + Default {
    type Database: Database;
    fn reserve(&mut self, additional: usize, size: usize);
    fn add<T>(&mut self, value: T) -> Result<(), BoxDynError>
    where T: 'q + Encode<'q, Self::Database> + Type<Self::Database>;
    fn len(&self) -> usize;
    fn format_placeholder<W: Write>(&self, writer: &mut W) -> fmt::Result { ... }
}

Arguments<'q>一组待发送的参数'q 生命周期挂在参数的潜在借用上——如果你 .bind(&some_string),那 some_string 的生命周期至少活到查询执行完,这条 bound 由 'q 守护。

注意 add 方法的 where T: 'q + Encode<'q, Self::Database> + Type<Self::Database> ——把 Encode 和 Type 绑在一起。这意味着你加一个参数时,系统同时要求"你能 encode 成字节"且"你声明了对应的数据库类型"。我们会在第 5 章详细看 EncodeType 这一对。

format_placeholder 的默认实现是 ?——MySQL 和 SQLite 都用 ?,Postgres 要自定义为 $1 / $2 / ...(按参数位置带编号)。这条看似无关紧要的 API 决定了"同一份 Rust 代码能不能跨 DB 复用"——第 10 章 QueryBuilder 就靠它。

3.5.2 ArgumentBuffer:编码缓冲区

type ArgumentBuffer<'q>;唯一没有 trait bound 的关联类型——它没有 impl X<'q> 之类的约束。这意味着每家驱动可以完全自由地选择缓冲区形态:

  • PostgresPgArgumentBuffersqlx-postgres/src/arguments.rs:26-48)——一个带 patchestype_holes 的复杂结构:

    pub struct PgArgumentBuffer {
        buffer: Vec<u8>,
        count: usize,
        patches: Vec<Patch>,             // 延迟补写(比如 `JSONB` 的变长前缀)
        type_holes: Vec<(usize, HoleKind)>, // OID 还没解析时先留洞,之后回填
    }
  • MySQLtype ArgumentBuffer<'q> = Vec<u8>; —— 一个裸字节缓冲。没有复杂结构,因为 MySQL 的参数编码是一次性决定的(预处理阶段类型已经锁死)。

  • SQLitetype ArgumentBuffer<'q> = Vec<SqliteArgumentValue<'q>>; —— 一个带生命周期的值向量(SQLite 的参数不是按字节编码的,是按类型 tag + 值的变体存的)。

三家的 ArgumentBuffer 形态截然不同——这就是为什么没有共同 trait 约束。但这个关联类型仍然存在于 Database trait 里,因为 Arguments<'q>::ArgumentBuffer<'q>Encode 实现引用:Encode::encode_by_ref(&self, buf: &mut DB::ArgumentBuffer<'_>)——每个 Encode 实现需要知道"我应该往哪种容器里写"。

3.5.3 Statement:预处理语句的抽象

sqlx-core/src/statement.rs:19

pub trait Statement<'q>: Send + Sync {
    type Database: Database;
    fn to_owned(&self) -> <Self::Database as Database>::Statement<'static>;
    fn sql(&self) -> &str;
    fn parameters(&self) -> Option<Either<&[<Self::Database as Database>::TypeInfo], usize>>;
    fn columns(&self) -> &[<Self::Database as Database>::Column];
    // ...
}

Statement 代表已经被驱动 prepare 过的 SQL 语句——它缓存了 SQL 文本、参数类型、列元数据。Connection::prepare_with 返回的就是 Statement<'q>

parameters 返回 Option<Either<&[TypeInfo], usize>> 这个怪异的类型,是因为不同数据库对"参数类型"的支持水平不同

  • Postgres:完整的参数类型——服务端在 Parse 响应里返回每个 $1$2 的 OID。所以是 Either::Left(&[PgTypeInfo])
  • SQLite:只有参数个数——sqlite3_bind_parameter_count(stmt) 告诉你有几个 ?,但不告诉你每个 ? 期望什么类型。所以是 Either::Right(usize)
  • MySQL:介于两者之间——服务端说得模糊,所以 sqlx MySQL 驱动通常也返回 Either::Right(usize)

这个 Either 类型是 sqlx 表达"能力差异"的一种手法——不是把每个驱动的 parameters 签名分叉,而是让统一签名返回一个枚举,调用方按 arm 分别处理。query! 宏的编译期校验就用这个差异:Postgres 下能检查每个参数的 Rust 类型匹不匹配 OID,SQLite 下只能检查参数个数匹不匹配。

3.6 元数据:TypeInfo

sqlx-core/src/type_info.rs:4

pub trait TypeInfo: Debug + Display + Clone + PartialEq<Self> + Send + Sync {
    fn is_null(&self) -> bool;
    fn name(&self) -> &str;
    fn type_compatible(&self, other: &Self) -> bool
    where Self: Sized { self == other }
    fn is_void(&self) -> bool { false }
}

TypeInfo 是数据库方的类型信息——"这一列是 INT4 还是 TEXT 还是 VARCHAR(255)"这种。不要和 Rust 侧的 Type<DB> trait 混淆——后者讲的是"Rust 的 i32 对应 DB 的哪个 TypeInfo"。这两者构成一对:

  • TypeInfo:DB → 自己对自己的描述。
  • Type<DB>(第 5 章):Rust 类型 → DB TypeInfo 的映射。

ClonePartialEq 是关键 super-trait——TypeInfo 经常需要按值传递、互相比较(比如"这一列的 TypeInfo 和我期望的是不是一致")。

具体实现差异:

  • PostgresPgTypeInfo 内部是 PgType 枚举 + 可选的 OID。PgType::Int4 / PgType::Text / PgType::Custom(UStr)(用户自定义类型)。
  • MySQLMySqlTypeInfoColumnType + char_set——因为 MySQL 的 VARCHAR 分字符集。
  • SQLiteSqliteTypeInfoDataType 枚举(Null/Int/Real/Text/Blob)——SQLite 动态类型系统,只有五种。

is_void 方法默认 false,唯一的例外是 Postgres 的 void 类型(SELECT pg_sleep(1) 的返回)——这个方法让 query! 宏知道"这列是 void 就别给 Rust 建一个字段"。

3.7 外层锚点:Connection / TransactionManager

这两个关联类型都在上一章已经见过——它们是 Database trait 引用另外两套 trait 家族的锚点。

  • type Connection: Connection<Database = Self> ——PgConnection / MySqlConnection / SqliteConnection。第 12 章详细。
  • type TransactionManager: TransactionManager<Database = Self> ——PgTransactionManager / MySqlTransactionManager / SqliteTransactionManager。第 15 章详细。

它们的存在让 Database trait 不仅描述"数据格式",还描述"操作入口"——你从 DB::Connection 能获取连接,从 DB::TransactionManager 能开事务。所以 Database trait 是整个 driver 的类型级 manifest,读它就知道"这家驱动能提供什么"。

3.7.1 ConnectionTransactionManager 的约束反转

这两个关联类型的约束用的是反向闭环

type Connection: Connection<Database = Self>;
type TransactionManager: TransactionManager<Database = Self>;

第一行读作"我这个 DatabaseConnection 关联类型,必须指向一个具体的 PgConnection 之类,而那个 Connection 的 Database 关联类型又必须等于我"。

为什么这么绕?因为如果不加反向约束,下面这种病态实现就能编译:

// 假设没有 Database = Self 约束
impl Database for Postgres {
    type Connection = MySqlConnection;  // 反手实现一个 MySQL 的 Connection
    // ...
}

那当用户调用 Pool::<Postgres>::acquire,底层调用 Postgres::Connection::open(),就会得到一个 MySQL 连接——类型系统彻底破产。Connection<Database = Self> 这条约束杜绝这种跨驱动张冠李戴。

这种"trait A 引用 trait B 的实现,要求 B 的关联类型回指 A"的模式叫做等式关联类型约束(associated type equality constraint),是 Rust trait 家族设计的基石。sqlx 的 11 个关联类型里每一个有 trait bound 的都带 Database = Self 约束,包括 Row<Database = Self>Column<Database = Self>Value<Database = Self>——这条约束像胶水把 11 个关联类型粘成一个不可拆的类型包

TransactionManager 的反向闭环同理。它的 trait 签名(sqlx-core/src/transaction.rs:15):

pub trait TransactionManager {
    type Database: Database;
    fn begin(conn: &mut <Self::Database as Database>::Connection) -> BoxFuture<'_, Result<(), Error>>;
    fn commit(...) -> BoxFuture<'_, Result<(), Error>>;
    fn rollback(...) -> BoxFuture<'_, Result<(), Error>>;
    fn start_rollback(conn: &mut <Self::Database as Database>::Connection);
}

注意 begin 的参数类型是 &mut <Self::Database as Database>::Connection——它从 TransactionManager::Database 跳回 Database::Connection。这条类型遍历经过两次关联类型投影Database 拿到 Self::Database,再拿 Connection)。GAT 之前这会是一个 for<'c> HasConnection<'c> 之类的 helper trait,0.7 之后直接一行表达。

3.7.2 'static + Send + Debug 的传播

回到 Database 本身 trait Database: 'static + Sized + Send + Debug 的这四条 super-trait bound,它们通过关联类型系统传播到所有下游代码。举个具体的传播链:

  • Database: Send → 要求 DB::Connection: Connection<Database = Self>Connection: Send(来自 connection.rs:14pub trait Connection: Send)→ 要求 PgConnection: Send
  • Database: 'staticRow: 'static(来自 row.rs:14pub trait Row: Unpin + Send + Sync + 'static)→ PgRow: 'static
  • Database: Debug → 出现在 tracing::error!("query on {DB:?} failed: {err}"){DB:?} 里。

这四条 bound 的作用是"告诉编译器:任何持有 DB: Database 的泛型代码都可以自动获得 DB::Connection: Send 等派生能力"——让你在 fn with_conn<DB: Database>(pool: &Pool<DB>) 里直接 tokio::spawn(pool.acquire()),而不用手写十条 DB::Connection: Send + 'static 的 where 子句。这是 super-trait bound 的传递性给上层带来的"一次声明,处处可用"收益。

3.8 关联常量:NAMEURL_SCHEMES

被绝大多数读者忽略的两行:

const NAME: &'static str;
const URL_SCHEMES: &'static [&'static str];
  • Postgres::NAME = "PostgreSQL"URL_SCHEMES = &["postgres", "postgresql"]
  • MySql::NAME = "MySQL"URL_SCHEMES = &["mysql", "mariadb"]
  • Sqlite::NAME = "SQLite"URL_SCHEMES = &["sqlite"]

这两个常量的用途有二:

  1. Any 驱动的 URL scheme 分发(第 2.6 节):install_drivers 注册的 AnyDriver 结构体就持有这两个值,AnyConnection::connect("postgres://...") 路径里的 scheme 匹配直接用 URL_SCHEMES.contains(&scheme)
  2. 错误信息 / tracing spanNAME 出现在 Error::Database 的格式化输出里、tracing::info_span!("query", db = DB::NAME, ...) 里。

这两个常量以"关联常量"而不是"关联类型"的形态存在,是因为它们的值不随实现方变化而必须同名——Postgres 的名字必须叫 "PostgreSQL"(URL scheme 依赖),不能叫别的。关联常量能在 const fn 里使用(这对 Any 驱动的 AnyDriver::without_migrate::<DB>() 是必要的,见 2.6.5 节)。

3.9 HasStatementCache:无方法标记 trait

sqlx-core/src/database.rs:113-114

/// A [`Database`] that maintains a client-side cache of prepared statements.
pub trait HasStatementCache {}

一个 没有方法的 trait。Postgres 和 MySQL 实现它(impl HasStatementCache for Postgres {}impl HasStatementCache for MySql {}),SQLite 不实现——因为 SQLite 驱动不做跨 fetch 的语句缓存,每次 sqlite3_prepare_v2 都新建。

这个 "标记 trait"(marker trait)的作用是把一项能力编码进类型系统。上层 API 用 where DB: HasStatementCache 守护"只有支持缓存的 DB 才能用这些方法"。

具体例子 1:Connection::cached_statements_sizesqlx-core/src/connection.rs:126-132):

fn cached_statements_size(&self) -> usize
where
    Self::Database: HasStatementCache,
{
    0
}

这条 where 子句让 SqliteConnection::cached_statements_size() 编译期就不存在——你在 SQLite 连接上调用这个方法,编译器直接报"method not found",而不是运行时返回 0 后你疑惑为什么缓存永远 0。

具体例子 2:Query::persistentsqlx-core/src/query.rs:123-139):

impl<'q, DB, A> Query<'q, DB, A>
where
    DB: Database + HasStatementCache,
{
    pub fn persistent(mut self, value: bool) -> Self { ... }
}

persistent 方法只在 DB: HasStatementCache 时存在。SQLite 下你写 query("...").persistent(true) 会编译失败——因为 SQLite 不实现 HasStatementCache

这种"把运行时能力差异搬到类型系统"的做法,是 Rust trait 家族设计里最精妙的一招。它避免了"在 SQLite 连接上传一个 persistent=true 被悄悄忽略"这类隐式降级——错误提前到编译期。

顺便提一下,Any 驱动(sqlx-core/src/any/database.rs,全文 39 行)实现了 HasStatementCache——这不是因为 Any 总能缓存,而是 Any 需要保持和它"可能包装任一驱动"的泛用性一致,实际行为取决于内层具体驱动。

3.9.1 Rust 生态里的 marker trait 家谱

HasStatementCache 这种"无方法 trait 作为能力开关"的做法在 Rust 生态里有一整条谱系。把 sqlx 放进来对照:

Marker trait 属于哪个库 表达的能力 使用形态
Send std 可以跨线程转移所有权 where T: Send
Sync std &T 可以跨线程共享 where T: Sync
Unpin std (通过 Pin) 不受固定语义约束 where T: Unpin
Copy std 按位拷贝即可复制 where T: Copy
DerefMut std 可以派生可变引用 where T: DerefMut
HasStatementCache sqlx-core 支持客户端预处理语句缓存 where DB: HasStatementCache
DatabaseExt sqlx-macros-core 参与 query! 的编译期校验 where DB: DatabaseExt
TypeChecking sqlx-core 能把 DB 类型映射到 Rust 类型字符串 where DB: TypeChecking
Service (空 marker 版本) tower(某些变体) 某些中间件约束 不是真空 trait,这里只是形态对照
http_body::Body hyper 能当 HTTP body 流 where B: Body

观察几条规律:

  1. 无方法 marker 大多表达"能力开关"或"抽象断言"——它们不是告诉你"应该调什么方法",而是告诉类型系统"我有这项能力"。HasStatementCache 精确属于这一类。
  2. sqlx 的三个 markerHasStatementCache / DatabaseExt / TypeChecking)分布在两个 crate——HasStatementCache 在 sqlx-core(用户可见),后两个在 sqlx-macros-core(宏内部)。这个拆分和 §2.3.1 讨论的 crate 边界一致。
  3. 没有"方法"的 trait 也可以继承有方法的 trait——比如 DatabaseExt: Database + TypeChecking(§2.3.1),它自身有 3 个方法但主要用法是作为"组合能力断言"。marker trait 的边界其实是模糊的:只要这个 trait 的主要使用方式是作 bound 而不是调方法,就属于这一族。

学会用 marker trait 表达能力差异,是 Rust trait 家族设计的基本功。HasStatementCache 这一行短短的 pub trait HasStatementCache {} 背后,代表的是一种"把动态 bool 搬到静态类型系统"的思路——从 SQLite 的 capability 缺失直接推导出"persistent(true) 在 SQLite 上编译不过"。

3.9.2 如果缺了某个关联类型会怎样

Database 的每一个关联类型做减法实验,看看少了它会坏在哪:

  • 少了 Row:整条 Executor::fetch 路径断裂——BoxStream<'e, Result<DB::Row, Error>> 签名写不出来。
  • 少了 ColumnStatement::columns 返回类型丢失,query! 的列类型映射没处落脚。
  • 少了 TypeInfoType<DB> trait 的 type_info() 返回类型丢失;更严重的是第 5 章的 Encode/Decode 整套断了。
  • 少了 Valuerow.value(0) 返回什么?无法抽取一个能跨行生存的值。
  • 少了 ValueRef<'r>try_get_raw 断——必须先做 to_owned() 再 decode,所有解码都变成按值传递,性能大幅下降。
  • 少了 Arguments<'q>query(sql).bind(x) 无处存值,Query 类型整个需要重做。
  • 少了 ArgumentBuffer<'q>Encode::encode_by_ref 的 buf 参数类型丢失。
  • 少了 Statement<'q>Connection::prepare_with 断,所有"用相同 SQL 多次查询"的优化消失。
  • 少了 QueryResultexecute 无法告诉用户"影响了几行"。
  • 少了 TransactionManager:没法开事务,Connection::begin 断。
  • 少了 NAME:错误信息和 tracing span 缺数据库名——可以重建但麻烦。
  • 少了 URL_SCHEMES:Any 驱动彻底废——无法按 URL scheme 分发。

这个减法实验本身不算新知识,但它证明了每一个关联类型都不是装饰——都对应着上层某一条具体的 API 路径或用户场景。这也回答了"为什么是 11 个不多不少"——sqlx 团队在 0.1 到 0.8 的演进里逐个加上来,每一个都因具体需求而生。

3.10 三家对照:Postgres / MySQL / SQLite 的 impl Database

把三家的 impl Database 并排摆一起(源文件分别是 sqlx-postgres/src/database.rs:14sqlx-mysql/src/database.rs:12sqlx-sqlite/src/database.rs:13):

关联类型 Postgres MySQL SQLite
Connection PgConnection MySqlConnection SqliteConnection
TransactionManager PgTransactionManager MySqlTransactionManager SqliteTransactionManager
Row PgRow MySqlRow SqliteRow
QueryResult PgQueryResult MySqlQueryResult SqliteQueryResult
Column PgColumn MySqlColumn SqliteColumn
TypeInfo PgTypeInfo MySqlTypeInfo SqliteTypeInfo
Value PgValue MySqlValue SqliteValue
ValueRef<'r> PgValueRef<'r> MySqlValueRef<'r> SqliteValueRef<'r>
Arguments<'q> PgArguments MySqlArguments SqliteArguments<'q>
ArgumentBuffer<'q> PgArgumentBuffer Vec<u8> Vec<SqliteArgumentValue<'q>>
Statement<'q> PgStatement<'q> MySqlStatement<'q> SqliteStatement<'q>
NAME "PostgreSQL" "MySQL" "SQLite"
URL_SCHEMES &["postgres", "postgresql"] &["mysql", "mariadb"] &["sqlite"]
HasStatementCache 实现 实现 不实现

几条对比观察:

  1. 12 行关联类型 + 2 行常量 + 1 行 marker trait——三家结构完全平行,只是每个位置的具体类型不同。这就是 trait 家族设计的目标形态:等价替换
  2. Arguments<'q> 的生命周期参数:Postgres 和 MySQL 是 PgArgumentsMySqlArguments(无 'q),因为它们编码后就是字节 buffer 自包含了;SQLite 是 SqliteArguments<'q> 保留生命周期——因为 SQLite 的参数值(SqliteArgumentValue::Text(&str))可能借用外部字符串,不会预先序列化。
  3. ArgumentBuffer<'q> 的形态差异最大——Postgres 的 PgArgumentBuffer 有 patch 和 type-hole 机制(延迟回填 OID),MySQL 直接裸字节,SQLite 是值向量。这揭示了三家协议的根本差异:Postgres 有 OID 的运行时查表、MySQL 有 COM_STMT_EXECUTE 的二进制格式、SQLite 有 C API 的"值按 tag 传"。后续第 16-18 章分别展开。
  4. SQLite 唯一不实现 HasStatementCache——这决定了 query("...").persistent(true) 只在 Postgres / MySQL 下可用。

这张表不是让你背下来的——它是让你在后面读具体驱动实现时脑子里有一张"同构映射"的参照。当你读 sqlx-postgres/src/row.rs 看到 PgRow 时,你立刻知道它的位置对应 DB::Row,功能和 SqliteRow 等价——只是载荷格式不同。

3.11 本章小结

本章把 sqlx 整个类型体系的"收束点"Database trait 拆开,可以带走以下关键判断:

  1. 11 个关联类型 + 2 个关联常量 + 4 条 super-trait bound 共同描述了"一个 sqlx 驱动应该提供什么"——读取链(Row/Column/Value/ValueRef/QueryResult)、写入链(Arguments/ArgumentBuffer/Statement)、元数据(TypeInfo)、外层锚点(Connection/TransactionManager)、名字识别(NAME/URL_SCHEMES)。
  2. GAT(泛型关联类型)是让这套设计成立的语言前提(§3.3)。没有 GAT 的 sqlx 0.6 必须用 HasValueRef<'r> / HasArguments<'q> / HasStatement<'q> 三个 helper trait + for<'r> HRTB 绕过;0.7 吃了一个 MSRV bump 到 1.65 来换得清爽的 trait 家族。
  3. GAT 的新代价是 object-safety 丢失——你不能 dyn Database。Any 驱动(§2.6)必须另起炉灶用 AnyConnectionBackend trait 绕过,这条代价预期之内。
  4. Value 与 ValueRef 的分裂(§3.4.3)反映了"数据持有权"的工程现实——ValueRef 是 decode 路径默认形态(零拷贝),Value 用于值需要跨 row 生存的场景。Postgres 的 to_owned 是 O(1) 引用计数,SQLite 的 to_owned 必须 memcpy——差异在上层 API 不可见但性能可感。
  5. ArgumentBuffer 没有共同 trait(§3.5.2)——三家形态截然不同:Postgres 的 PgArgumentBuffer 带 patch/hole、MySQL 是 Vec<u8>、SQLite 是 Vec<SqliteArgumentValue<'q>>。这是刻意不抽象的一处:协议差异太大,硬抽象只会让每家都被迫做运行时检查。
  6. HasStatementCache 是把能力编码进类型系统的标记 trait(§3.9)——Postgres / MySQL 实现,SQLite 不实现。Query::persistentConnection::cached_statements_sizewhere DB: HasStatementCache 让"在 SQLite 下调这些方法"变成编译错误而不是运行时静默。
  7. NAME 与 URL_SCHEMES 两个常量(§3.8)让 Any 驱动可以在运行时按 URL scheme 分发,也让错误信息和 tracing span 能携带驱动名字。
  8. 三家对照表(§3.10)显示整个 trait 家族的"等价替换"结构——12 行平行,每家只是具体类型不同。这就是 trait 家族设计的终极目标。

3.11.1 判断题:如果换你设计

最后做几道"设计选择"判断题——把本章的理解直接转成工程直觉:

Q1:如果你要加一个 type Transaction<'c> 关联类型专门表示事务,让用户写 DB::Transaction<'c>::commit() 而不是 conn.begin().await?.commit().await?——这算好设计吗?

A:不算。Transaction<'c, DB> 作为一个具体 struct 已经够用(sqlx-core/src/transaction.rs:86),它本身不带驱动特有行为——三家 DB 的区别被 TransactionManager trait 消化。再加一层 type Transaction<'c> 关联类型会变成空壳,让 trait 家族膨胀无收益。判断原则:只有当"具体类型在不同 DB 下形态不同"时才值得加关联类型——Transaction 本身是泛型 struct,不满足这条。

Q2:如果把 NAME: &'static str 换成 fn name() -> &'static str 方法,行为等价吗?

A:等价,但 NAME 的关联常量形态能在 const fn 里用。回想 §2.6.5 里 AnyDriver::without_migrate::<DB>() 是 const fn——它要读 DB::NAMEDB::URL_SCHEMES。如果改成 fn name(),这条 const fn 路径会断(或者 Rust 的 const fn 能力要往前走一大步)。所以选择关联常量是被 Any 驱动的 const 注册路径倒推出来的。

Q3:ArgumentBuffer<'q> 无 trait bound 是偷懒吗?

A:不是偷懒,是不硬抽。这个缓冲区的形态在 Postgres / MySQL / SQLite 下差异是本质的——硬抽一个 trait Buffer { fn write_bytes(...); } 会让 SQLite 的 Vec<SqliteArgumentValue<'q>> 必须做一次 serialize-to-bytes-then-parse 的无谓开销。不抽,把表达权交给驱动,反而让每家 encode 路径最优。判断原则:抽象应当消除重复、而不是掩盖差异——后者往往导致泄漏。

Q4:为什么 TypeInfo 不叫 DbTypeColumnType

A:因为 TypeInfo 承担的不只是"列类型"——它也被 Arguments::add 的类型检查、Value::type_infoStatement::parameters 共享。ColumnType 会狭化其语义;DbType 歧义——Rust 侧的 Type<DB> trait 也算"类型"。TypeInfo 这个名字精确对应"数据库方提供的类型描述信息",和 Rust 侧的 Type 一目了然区分。好的 trait 命名像好的变量命名——多花十分钟,长期收益巨大。

做完这四道题,你对 sqlx Database trait 的每一处选择就有了自己能解释的直觉,而不只是"背下来"。下一章进入 Executor 时,你会发现同样的设计权衡在不同 trait 上反复出现——这就是 trait 家族设计的整体性。

3.12 下一章指路

下一章,我们进入 Executor trait——Database trait 描述"驱动应该提供什么类型",Executor 描述"连接、Pool、Transaction 应该共同满足什么操作接口"。我们会看到 fetch / fetch_many / execute 这些方法如何围绕 DB: Database 构建、为什么 0.8 不再支持 impl Executor for &mut Transaction(本专栏第 1 章已经预告过,到第 4 章我们会从 trait 边界视角再看一遍)。