SQLx 源码深度解析

第6章 Arguments 与 IsNull:参数绑定的生命周期约束

作者 杨艺韬 · 11,933 字 · 发布于 · 更新于

Arguments<'q> 只有四个方法,却要把异构的 Rust 值消化成某一家数据库的线路参数格式。三家驱动在这一层的结构差异,比在 SQL 层大得多。

本章要点

  • Arguments<'q> trait(sqlx-core/src/arguments.rs:12)只有四个方法:reserve / add / len / format_placeholder——却承担了"把异构 Rust 类型消化成某一家 DB 的线路参数格式"的全部职责。
  • PgArguments 本体只有 types + buffer 两个字段(sqlx-postgres/src/arguments.rs:74-81),复杂度全在 PgArgumentBuffer 里——它自己是 buffer + count + patches + type_holes 四件套(sqlx-postgres/src/arguments.rs:26-47):buffer 是字节流,patches 是"值级延迟回填回调",type_holes 是"OID 回填标记"。type_holes 这套复杂度来自 Postgres 二进制格式:数组和复合类型的值字节里本身就要写元素 / 字段的类型 OID,而自定义类型的 OID 要到运行时才能查到。
  • MySqlArguments 是 values + types + null_bitmap(sqlx-mysql/src/arguments.rs:9-14)——MySQL binary protocol 的 null 信息在一个独立位图里,不嵌入值编码。
  • SqliteArguments<'q> 是 Vec<SqliteArgumentValue<'q>>(sqlx-sqlite/src/arguments.rs:22-25)——每个值是一个带生命周期的枚举变体,对应 C API 的 sqlite3_bind_* 一族函数。
  • IntoArguments trait 是一层薄的"我能转成 Arguments"适配层——主要用户是 query(sql).bind(...) 链的内部类型化,以及外部 crate(想传入"参数列表的值"形态时)。
  • format_placeholder 决定跨 DB 的 SQL 变体:Postgres 用 $1, $2, ...,MySQL / SQLite 用 ?——QueryBuilder::push_bind 通过这个方法在拼 SQL 时写出正确的占位符。
  • ImmutableArguments<'q, DB>(sqlx-core/src/arguments.rs:54)的文档注释说它是 query! 宏用来挡住多余 .bind() 的——但 0.8.6 里全树 grep 只有定义和它自己那条 IntoArguments impl 两处命中,宏并不用它。真正挡住 query! / query_as! 多余 .bind() 的是它们(在查询有输出列时)展开成 Map(sqlx-core/src/query.rs:36),而 Map 压根没有 bind 方法(§6.8)。

6.1 问题引入:异构类型如何塞进同一个集合

先看一段最常见的参数绑定代码:

let result = sqlx::query("UPDATE users SET email = $1 WHERE id = $2 AND created > $3")
    .bind("alice@example.com")    // &str
    .bind(42_i32)                 // i32
    .bind(OffsetDateTime::now_utc())  // time::OffsetDateTime
    .execute(&pool).await?;

三次 .bind(...) 传入三种完全不同的 Rust 类型——&str、i32、OffsetDateTime。它们的内存表达完全不同:&str 是指针 + 长度、i32 是 4 字节栈值、OffsetDateTime 是一个 16 字节的结构体(日期 + 时刻 + UTC 偏移;time 0.3.55 下 size_of 实测为 16)。

这三个值最终要经 Postgres 的 Extended Query 协议发给服务端。协议对参数的约定是:

  • 每个参数有一个类型 OID(整型),放在 Parse 消息的参数类型列表里——从哪里来?
  • 每个参数在 Bind 消息里有一段字节数据(变长,带 4 字节长度前缀)——按什么顺序排?
  • 每个参数可能是 NULL(Bind 里用长度 -1 标记)——哪里标?

这三条信息必须在发送 Parse / Bind 之前已经备齐——协议的每条消息都带长度前缀,只能整条拼好再发,不能"部分发然后补一段"。这意味着 sqlx 要有一个中间容器,在用户 .bind(x) 的时刻把参数信息收集起来,到真正发送时再拼装成 Postgres 的线路格式。

这个容器就是 Arguments<'q>。

但问题是:.bind(x).bind(y).bind(z) 的类型是不同的,怎么能都存进同一个 Arguments?答案分三层:

  1. Encode + Type trait bound(第 5 章)让任意类型都能 encode 成 buffer 字节。
  2. Arguments::add<T>(sqlx-core/src/arguments.rs:20-22)是泛型方法——接受任何满足 Encode + Type 的 T,在方法内部把 T 消化成字节。
  3. 底层 buffer 是同构的容器——Postgres / MySQL 是 Vec<u8> 字节序列,所有类型 encode 后都变成"一串字节 + 一个 TypeInfo";SQLite 是值枚举的向量(§6.6)。

这一章就是拆开这三层,看 sqlx 怎么把"异构 Rust 类型"挤进"同构字节流"。

6.2 Arguments<'q> trait 的四个方法

sqlx-core/src/arguments.rs:11-30:

pub trait Arguments<'q>: Send + Sized + Default {
    type Database: Database;

    /// Reserves the capacity for at least `additional` more values (of `size` total bytes) to
    /// be added to the arguments without a reallocation.
    fn reserve(&mut self, additional: usize, size: usize);

    /// Add the value to the end of the arguments.
    fn add<T>(&mut self, value: T) -> Result<(), BoxDynError>
    where T: 'q + Encode<'q, Self::Database> + Type<Self::Database>;

    /// The number of arguments that were already added.
    fn len(&self) -> usize;

    fn format_placeholder<W: Write>(&self, writer: &mut W) -> fmt::Result {
        writer.write_str("?")
    }
}

四个方法,三个必实现 + 一个默认。trait 超 bound Send + Sized + Default——Default 是要求可以空构造(PgArguments::default() 就是空的 args),Send 是要能跨 await。

  • reserve(additional, size)——预分配两个容量:参数个数 + 字节总数。这两个维度都重要:types 向量按个数分配,buffer 按字节分配。0.8.6 里只有查询宏的展开会调 reserve(sqlx-macros-core/src/query/args.rs:113-117),在逐个添加参数之前一次性预留;QueryBuilder 和 query().bind() 都不调它(§6.11)。
  • add<T>(value)——trait 里唯一接收参数值的方法——消化用户传入的任意 T。必须满足 T: 'q + Encode<'q, DB> + Type<DB>:生命周期 'q 保证 T 内部的借用活到参数消费完、Encode/Type 保证能编码和声明类型。
  • len()——已经 add 了多少个参数。Query::bind 用它算出出错时报告的参数编号(sqlx-core/src/query.rs:91);Postgres 的 format_placeholder 读的也是同一个计数 buffer.count。
  • format_placeholder(writer)——往 writer 写一个占位符。MySQL / SQLite 用 trait 的默认实现写 ?;Postgres 覆盖成 ${count},而 QueryBuilder::push_bind 是先 add 再 format_placeholder,所以写出来的正是刚刚那个参数的编号 $1、$2(§6.7)。

这四个方法构成一个最小契约:预分配 → 逐个添加 → 查计数 → 生成占位符。四个接口各有调用方,但没有哪个调用方全用:QueryBuilder::push_bind(第 10 章)用 add + format_placeholder;Query::bind 用 len + add,占位符由 SQL 字符串自己给;reserve 只由 query! 宏展开调用。

6.3 IntoArguments trait 与 impl_into_arguments_for_arguments! 宏

紧接着 Arguments 的定义,sqlx-core/src/arguments.rs:32-34:

pub trait IntoArguments<'q, DB: Database>: Sized + Send {
    fn into_arguments(self) -> <DB as Database>::Arguments<'q>;
}

一个方法:把 self 转成 DB::Arguments<'q>。这是一层平凡的适配器——为什么要单独一个 trait?

6.3.1 为什么需要 IntoArguments

考虑 query(sql).bind(...) 这条链的类型流转。Query<'q, DB, A> 里的 A 参数位置放的是什么?答案是 A: IntoArguments<'q, DB>——它可以是 PgArguments 本身、也可以是某种"未成形的参数列表"。

有了 IntoArguments,Query 可以接受多种参数容器:

  1. 直接的 PgArguments——用户可以手动构造一个 PgArguments,经 query_with(sql, args) 传入。
  2. ImmutableArguments<'q, DB>——一个只实现 IntoArguments、不实现 Arguments 的只读变体(§6.8 会说明它在 0.8.6 里已经没有使用者)。
  3. 可能有的自定义容器——第三方 crate 可以实现 IntoArguments 来"把自己的形态转成 sqlx 的 Arguments"。

如果 Query 直接要求 A: Arguments<'q, DB>,上面第 2 和第 3 条就不可行——ImmutableArguments 不是 Arguments(它阻止继续 bind),自定义容器也不想实现完整的 Arguments(只想提供一次性转换)。IntoArguments 把"能提供参数集合"和"能承载 add 操作"这两件事解耦——前者是必要的,后者是可选的。

6.3.2 impl_into_arguments_for_arguments! 宏为什么存在

sqlx-core/src/arguments.rs:36-51 的宏:

// NOTE: required due to lack of lazy normalization
#[macro_export]
macro_rules! impl_into_arguments_for_arguments {
    ($Arguments:path) => {
        impl<'q>
            $crate::arguments::IntoArguments<
                'q,
                <$Arguments as $crate::arguments::Arguments<'q>>::Database,
            > for $Arguments
        {
            fn into_arguments(self) -> $Arguments {
                self
            }
        }
    };
}

注释 "required due to lack of lazy normalization" 是老熟人了——第 4 章 §4.6.1 讲 0.6 的 impl_executor_for_* 宏时遇到过同一句。这条宏和注释从 0.5.0 起就在(git show v0.5.0:sqlx-core/src/arguments.rs),比 0.8.0 的 GAT 化早得多。

这条宏要做的事逻辑上等价于一个 blanket impl:

// 直接这么写编译不过
impl<'q, A> IntoArguments<'q, <A as Arguments<'q>>::Database> for A
where A: Arguments<'q>,
{
    fn into_arguments(self) -> A { self }
}

照 0.8.6 的 trait 形状搭一个最小模型用 rustc 1.96 编译,这样写报的是 error[E0053]: method `into_arguments` has an incompatible type for trait:trait 要求返回 <<A as Arguments<'q>>::Database as Database>::Arguments<'q>,编译器证明不了这个绕了一圈的关联类型投影就是 A。源码注释把这类问题归因于 lazy normalization。sqlx 的解法是用宏让每个驱动展开一次具体 impl。调用点放在各驱动的 crate 根(src/lib.rs)而不是 arguments.rs——因为宏是 #[macro_export] 的,展开处只要能看见目标类型即可:

// 宏本身定义在 sqlx-core/src/arguments.rs:38;三处调用都在各驱动的 crate 根,
// 不在 arguments.rs 里:
// sqlx-postgres/src/lib.rs:79
impl_into_arguments_for_arguments!(PgArguments);
// sqlx-mysql/src/lib.rs:67
impl_into_arguments_for_arguments!(MySqlArguments);
// sqlx-sqlite/src/lib.rs:115
impl_into_arguments_for_arguments!(SqliteArguments<'q>);
// 还有第四处,给 Any 驱动用:sqlx-core/src/any/mod.rs:59
impl_into_arguments_for_arguments!(AnyArguments<'q>);

每个驱动展开后都变成具体类型的 impl,返回类型直接就是具体的 PgArguments 等,不再需要编译器去规约投影。"泛型 impl 写不成、改用宏逐个展开"这类补丁在 sqlx 里不止一处——trait 家族设计优雅,但实现时要处处向编译器的能力让步。

6.4 PgArguments 内部:最复杂的一家

Postgres 的 PgArguments 形态是三家里最复杂的。sqlx-postgres/src/arguments.rs:74-81:

pub struct PgArguments {
    // Types of each bind parameter
    pub(crate) types: Vec<PgTypeInfo>,

    // Buffer of encoded bind parameters
    pub(crate) buffer: PgArgumentBuffer,
}

表面上只有两个字段。但 PgArgumentBuffer 自己又分四份(sqlx-postgres/src/arguments.rs:26-47):

pub struct PgArgumentBuffer {
    buffer: Vec<u8>,                          // 实际字节流
    count: usize,                             // 参数计数
    patches: Vec<Patch>,                      // 延迟回填回调
    type_holes: Vec<(usize, HoleKind)>,       // OID 回填位置
}

四件套合在一起才是 Postgres 参数的完整状态。buffer 是已编码字节、count 是数量、patches 是"某些字节需要后期修改"的标记、type_holes 是"某些 OID 位置还不知道真实值"的占位。

让我们逐个拆。

6.4.1 buffer:带长度前缀的字节流

PgArgumentBuffer::encode(sqlx-postgres/src/arguments.rs:167-195)是最核心的方法:

pub(crate) fn encode<'q, T>(&mut self, value: T) -> Result<(), BoxDynError>
where T: Encode<'q, Postgres>,
{
    value_size_int4_checked(value.size_hint())?;

    // reserve space to write the prefixed length of the value
    let offset = self.len();
    self.extend(&[0; 4]);

    // encode the value into our buffer
    let len = if let IsNull::No = value.encode(self)? {
        value_size_int4_checked(self.len() - offset - 4)?
    } else {
        debug_assert_eq!(self.len(), offset + 4);
        -1_i32
    };

    // write the len to the beginning of the value
    self[offset..(offset + 4)].copy_from_slice(&len.to_be_bytes());
    Ok(())
}

这条 encode 的流程是先留 4 字节占位 → encode 值 → 回填长度。流程图:

flowchart TD
    A["reserve 4 bytes for length prefix"]
    B["offset = buffer.len()"]
    C["buffer.extend &#x5B;0, 0, 0, 0&#x5D;"]
    D["value.encode buffer"]
    E{"IsNull?"}
    F1["len = buffer.len - offset - 4"]
    F2["len = -1"]
    G["buffer offset..offset+4 = len.to_be_bytes"]
    A --> B --> C --> D --> E
    E -->|No| F1
    E -->|Yes| F2
    F1 --> G
    F2 --> G

这条"先留坑 → 填值 → 回填长度"模式在二进制协议里非常常见——因为编码前你还不知道 value 会写多少字节(变长类型尤其),所以只能留坑等写完再回填。-1 的特殊值表达 NULL 是 Postgres 协议的规定——长度字段是 i32,正数表示字节数、0 表示零长度值、-1 表示 NULL(区别于零长度)。

6.4.2 patches:值级的延迟回填

PgArgumentBuffer 的 patches 是 sqlx 最巧妙的设计之一。它记录了"某个 buffer 偏移需要根据实际参数类型做后期修改"。

Patch 结构(sqlx-postgres/src/arguments.rs:55-61):

struct Patch {
    buf_offset: usize,
    arg_index: usize,
    callback: Arc<dyn Fn(&mut [u8], &PgTypeInfo) + 'static + Send + Sync>,
}

callback 是一个函数闭包:接收"buffer 的一段可变切片"和"这个参数的实际 TypeInfo",修改 buffer。它服务的是"先写占位,等参数类型确定后再改正式字节"的情况——0.8.6 全树只有一个调用点,处理 JSON 的两种变体(JSON vs JSONB):

  • JSONB 的 wire format 是 0x01 + utf8_json_string(第一个字节是版本号)。
  • JSON 的 wire format 是 utf8_json_string(无版本前缀)。

Encode 写字节时并不知道这条语句最终给这个参数槽定下的类型是 JSON 还是 JSONB——那要等 prepare 之后,由服务端返回的参数描述(metadata.parameters)传进回填步骤(§6.4.3)才知道。sqlx 的做法很讨巧(sqlx-postgres/src/types/json.rs:61-77):先按 JSONB 写,同时登记一个 patch,等参数类型定下来后如果发现是 JSON 就把那个版本字节改成空格——空格在 JSON 文本里是可忽略的前导空白:

fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result<IsNull, BoxDynError> {
    // we have a tiny amount of dynamic behavior depending if we are resolved to be JSON
    // instead of JSONB
    buf.patch(|buf, ty: &PgTypeInfo| {
        if *ty == PgTypeInfo::JSON || *ty == PgTypeInfo::JSON_ARRAY {
            buf[0] = b' ';
        }
    });

    // JSONB version (as of 2020-03-20)
    buf.push(1);

    // the JSON data written to the buffer is the same regardless of parameter type
    serde_json::to_writer(&mut **buf, &self.0)?;

    Ok(IsNull::No)
}

这条机制对用户几乎隐形——只有 sqlx-postgres/src/types/json.rs 自己用得到。但它是"泛型 encode 遇到方言特有行为时的一个扩展点"——不需要破坏 Encode trait 的简洁签名,也能支持复杂协议。

6.4.3 type_holes:OID 回填

type_holes 是另一条"先留坑"的路径——专门处理要写进值字节里的自定义类型 OID。

Postgres 里参数的类型 OID 有两个去处:一是 Parse 消息的参数类型列表;二是数组、复合类型(record)的二进制值本身——数组头里要写元素类型 OID,record 的每个字段前要写字段类型 OID。内置类型(INT4 = 23、TEXT = 25)的 OID 是硬编码常量,但用户自定义类型(CREATE TYPE color AS ENUM (...) 或 CREATE TYPE address AS (...))的 OID 是运行时由 Postgres 分配的——每个数据库实例可能不同。

对第二个去处,sqlx 的解决方案是"先写 0 占位 OID、记下 hole、等运行时查数据库再回填"。调用 patch_type_by_name / patch_array_type 的只有数组和 record 的编码(sqlx-postgres/src/types/array.rs:169-170、sqlx-postgres/src/types/record.rs:47-48)。patch_type_by_name(sqlx-postgres/src/arguments.rs:215-225):

pub(crate) fn patch_type_by_name(&mut self, type_name: &UStr) {
    let offset = self.len();
    self.extend_from_slice(&0_u32.to_be_bytes());  // 写 4 字节的 0
    self.type_holes.push((offset, HoleKind::Type { name: type_name.clone() }));
}

之后 PgArguments::apply_patches(sqlx-postgres/src/arguments.rs:110-138)在真正发 Bind 消息前被调用,回填所有 hole:

for (offset, kind) in type_holes {
    let oid = match kind {
        HoleKind::Type { name } => conn.fetch_type_id_by_name(name).await?,
        HoleKind::Array(array) => conn.fetch_array_type_id(array).await?,
    };
    buffer[*offset..(*offset + 4)].copy_from_slice(&oid.0.to_be_bytes());
}

这一步是 async 的——它可能要向数据库发一条 SELECT $1::regtype::oid 来查 OID(sqlx-postgres/src/connection/describe.rs:363-381)。每个自定义类型名第一次遇到时查一次,之后命中连接上的 cache_type_oid(一个 HashMap<UStr, Oid>,见 sqlx-postgres/src/connection/mod.rs:65)。

这个机制解决了什么问题? 考虑:

#[derive(sqlx::Type)]
#[sqlx(type_name = "color")]
enum Color { Red, Green, Blue }

sqlx::query("INSERT INTO ... VALUES ($1)").bind(vec![Color::Red])...

编码 Vec<Color> 时,数组头里要写元素类型 color 的 OID,而 sqlx 不知道它在当前这个数据库里是多少——每个数据库里 color 可能分配到不同的 OID。硬编码是不行的;Encode 又是同步的,没法当场查。所以 sqlx 的路径是"先编码值、记下 OID hole、在真正发送前查好并回填"——查完缓存到连接上,后续同类型不再查。

单独 bind(Color::Red) 则不走 type_holes:它的值字节只是文本 red,OID 只出现在 Parse 的参数类型列表里,由 prepare() 在发 Parse 前逐个 resolve_type_id 查好(sqlx-postgres/src/connection/executor.rs:44-46),用的是同一个 fetch_type_id_by_name 和同一份缓存。

6.4.4 snapshot / reset_to_snapshot:add 失败的回滚

PgArguments::add(sqlx-postgres/src/arguments.rs:84-106)的 try-encode-else-rollback 流程:

pub(crate) fn add<'q, T>(&mut self, value: T) -> Result<(), BoxDynError>
where T: Encode<'q, Postgres> + Type<Postgres>,
{
    let type_info = value.produces().unwrap_or_else(T::type_info);
    let buffer_snapshot = self.buffer.snapshot();

    if let Err(error) = self.buffer.encode(value) {
        self.buffer.reset_to_snapshot(buffer_snapshot);
        return Err(error);
    };

    self.types.push(type_info);
    self.buffer.count += 1;
    Ok(())
}

snapshot 记录四个状态长度(buffer、count、patches、type_holes),reset 时 truncate 回去。这是 "add 是事务" 的保证——如果 encode 失败(例如一个非法的 UTF-8 字符串),buffer 不会留下"写了一半"的垃圾字节。

MySQL 的 add 里也有类似保护(§6.5),但只 truncate 一个 values buffer——因为 MySQL 没有 patches / type_holes 这种状态。

6.4.5 自定义 enum 的完整 OID 回填流程

把 type_holes 的实际用法用一次具体场景串起来。假设用户有:

#[derive(sqlx::Type)]
#[sqlx(type_name = "mood", rename_all = "lowercase")]
enum Mood { Happy, Sad, Excited }

let mut args = PgArguments::default();   // 需要 use sqlx::Arguments;
args.add(vec![Mood::Happy])?;

Vec<Mood> 的类型信息来自 derive 生成的 PgHasArrayType(PgTypeInfo::array_of("mood")),元素 Mood 的 Encode 实现(由 derive 生成)写的是文本。这次 add 大致会走这条路径:

flowchart TD
    A["user: add vec of Mood::Happy"]
    B["produces is None, type_info = PgTypeInfo::array_of mood"]
    C["buffer.encode: write 4 bytes of 0 as length placeholder"]
    D["array header: ndim = 1, flags = 0"]
    E["element type: patch_type_by_name mood writes 4 zero bytes, pushes a type hole"]
    F["len = 1, lower bound = 1, then element: length prefix + bytes happy"]
    G["backfill length of the whole array value"]
    H["types.push array_of mood"]
    I["on execute: prepare resolves mood array OID for Parse"]
    J["apply_patches iterates type_holes"]
    K["fetch_type_id_by_name mood → OID e.g. 16384"]
    L["write 16384 big endian into hole offset"]
    M["send Bind message with patched buffer"]
    A --> B --> C --> D --> E --> F --> G --> H --> I --> J --> K --> L --> M

关键是最后几步——OID 的解析发生在真正发 Bind 消息之前、不在 add 时刻。原因:

  1. add 是同步的(Encode::encode 是同步函数,不能 await)——但 OID 查询要发 SQL 到服务端,必须 async。
  2. add 时不查 OID 让"多次 add 同类型"不会发 N 次查询。

fetch_type_id_by_name 内部先查 cache_type_oid,未命中才发一条 SELECT $1::regtype::oid(bind 上类型名 mood),拿到 OID 后写回缓存。之后整个连接生命周期不再重复查——注意它是一个不设上限的 HashMap 而不是 LRU,唯一整体 clear() 的地方是用户调用 clear_cached_statements() 时(sqlx-postgres/src/connection/mod.rs:209-211)。

对用户透明的是:你第一次 bind 一个自定义类型时,背后可能多了一次 SQL 查询(一次额外往返);之后命中缓存,不再查询。这条设计落在 PgConnectionInner::cache_type_oid / cache_elem_type_to_array 两个字段上(sqlx-postgres/src/connection/mod.rs:65-66)。

6.5 MySqlArguments:三件套

sqlx-mysql/src/arguments.rs:9-14 的 MySqlArguments:

pub struct MySqlArguments {
    pub(crate) values: Vec<u8>,
    pub(crate) types: Vec<MySqlTypeInfo>,
    pub(crate) null_bitmap: NullBitMap,
}

三件套:values 裸字节缓冲、types 类型信息数组、null_bitmap 独立的 NULL 位图。

为什么 MySQL 要单独的 NullBitMap? 因为 MySQL 的 binary protocol(COM_STMT_EXECUTE 消息)里 NULL 信息是独立一段 bitmap,排在参数类型和参数值之前(sqlx-mysql/src/protocol/statement/execute.rs:21-35),而不是用"长度 = -1"在值内部表达。每 8 个参数用 1 字节位图——第 k 位为 1 表示第 k 个参数是 NULL。

NullBitMap::push(sqlx-mysql/src/arguments.rs:67-77)很直白:

fn push(&mut self, is_null: IsNull) {
    let byte_index = self.length / (u8::BITS as usize);
    let bit_offset = self.length % (u8::BITS as usize);

    if bit_offset == 0 {
        self.bytes.push(0);
    }

    self.bytes[byte_index] |= u8::from(is_null.is_null()) << bit_offset;
    self.length += 1;
}

每次 add 把 IsNull::Yes/No 映射成位图的一位。NULL 在 Postgres 里要占一个 4 字节的 -1 长度,在 MySQL 里只占 1 bit、值区不写任何字节;代价是解析方要先看 bitmap 才知道哪些参数没有值字节。另外,sqlx 发的 COM_STMT_EXECUTE 每次都带上所有参数的类型(每个参数 2 字节:类型码 + 无符号标志,sqlx-mysql/src/protocol/statement/execute.rs:23-33),而 Postgres 的参数类型只在 Parse 时发一次。

注意 MySqlArguments 的 Arguments 实现没有覆盖 format_placeholder——它使用 trait 的默认 ? 实现。这一条就是 MySQL SQL 和 Postgres SQL 最直观的差别:WHERE id = ? vs WHERE id = $1。

6.5.1 MySQL 的 LENENC 长度编码

MySQL binary protocol 的字符串和 blob 用 LENENC(Length-Encoded Integer)做长度前缀——这是一种变长整数编码:

  • 0-250:单字节
  • 251:NULL 标记(COM_QUERY 文本结果集使用;STMT_EXECUTE 不使用)
  • 252:后跟 2 字节小端长度
  • 253:后跟 3 字节小端长度
  • 254:后跟 8 字节小端长度

encode "hello" 只花 1 + 5 = 6 字节(第一字节 5、然后 5 字节 UTF-8);encode 一个 300 字节的字符串花 1 + 2 + 300 字节(第一字节 252、然后两字节小端 300、然后数据)。

相比之下 Postgres 永远用 固定 4 字节长度前缀——5 字节字符串占 4 + 5 = 9 字节、300 字节字符串占 4 + 300 = 304 字节。短值 MySQL 占便宜(1 字节 vs 4 字节)、长值差不多。

单看长度前缀这一项,同样一条带短字符串参数的 query,MySQL 每个短字符串参数比 Postgres 少 3 字节。这点差别只有在参数量极大时才值得一提,一般场景可以忽略。

MySQL 侧 str 的 Encode 本身只有一行:buf.put_str_lenenc(self)(sqlx-mysql/src/types/str.rs:35-41)。

真正的 LENENC 编码在 MySqlBufMutExt(sqlx-mysql/src/io/buf_mut.rs:12-42)里(下面连同 put_str_lenenc 一起逐字贴出):

    fn put_uint_lenenc(&mut self, v: u64) {
        // https://dev.mysql.com/doc/internals/en/integer.html
        // https://mariadb.com/kb/en/library/protocol-data-types/#length-encoded-integers

        let encoded_le = v.to_le_bytes();

        match v {
            0..=250 => self.push(encoded_le[0]),
            251..=0xFF_FF => {
                self.push(0xfc);
                self.extend_from_slice(&encoded_le[..2]);
            }
            0x1_00_00..=0xFF_FF_FF => {
                self.push(0xfd);
                self.extend_from_slice(&encoded_le[..3]);
            }
            _ => {
                self.push(0xfe);
                self.extend_from_slice(&encoded_le);
            }
        }
    }

    fn put_str_lenenc(&mut self, v: &str) {
        self.put_bytes_lenenc(v.as_bytes());
    }

    fn put_bytes_lenenc(&mut self, v: &[u8]) {
        self.put_uint_lenenc(v.len() as u64);
        self.extend(v);
    }

这个 helper 在 sqlx-mysql 里被多个类型复用——所有变长类型(字符串、blob、decimal、json)都走它。Postgres 没有这层复杂度——4 字节固定前缀,encode 实现更简单但带宽消耗略多。

6.6 SqliteArguments:枚举向量

sqlx-sqlite/src/arguments.rs:12-25:

pub enum SqliteArgumentValue<'q> {
    Null,
    Text(Cow<'q, str>),
    Blob(Cow<'q, [u8]>),
    Double(f64),
    Int(i32),
    Int64(i64),
}

pub struct SqliteArguments<'q> {
    pub(crate) values: Vec<SqliteArgumentValue<'q>>,
}

完全不同的形态——不是字节流,而是类型化的值向量。每个参数作为一个 SqliteArgumentValue 变体存储。

为什么这么简单?因为 SQLite 的 binding 不走线路协议——而是通过 C API 的 sqlite3_bind_* 一族函数:

  • SqliteArgumentValue::Int(n) → sqlite3_bind_int(stmt, index, n)
  • SqliteArgumentValue::Int64(n) → sqlite3_bind_int64(stmt, index, n)
  • SqliteArgumentValue::Double(f) → sqlite3_bind_double(stmt, index, f)
  • SqliteArgumentValue::Text(s) → sqlite3_bind_text64(stmt, index, s.as_ptr(), s.len(), SQLITE_TRANSIENT, SQLITE_UTF8)
  • SqliteArgumentValue::Blob(b) → sqlite3_bind_blob64(stmt, index, b.as_ptr(), b.len(), SQLITE_TRANSIENT)

(见 sqlx-sqlite/src/statement/handle.rs:218-265。)

  • SqliteArgumentValue::Null → sqlite3_bind_null(stmt, index)

没有序列化、没有字节流——参数就是一个枚举 tagged union,执行前直接按变体分发到对应的 C 函数。

'q 生命周期存在是因为 Cow<'q, str> 和 Cow<'q, [u8]> 可能借用外部数据。如果你写 query(sql).bind(my_str)(my_str: &str)——bind 时只存下 Cow::Borrowed,不拷贝字符串(sqlx-sqlite/src/types/str.rs:21)。第 3 章 §3.10 讨论过的 "SqliteArguments 保留 'q 生命周期而 PgArguments / MySqlArguments 不需要" 就是这条源码的体现。

但这份借用活不到 SQLite 真正执行。SqliteArguments::into_static(sqlx-sqlite/src/arguments.rs:47-56,pub(crate))做的是"切断外部生命周期"的转换——把所有 Borrowed 变 Owned:

pub(crate) fn into_static(self) -> SqliteArguments<'static> {
    SqliteArguments {
        values: self.values.into_iter().map(SqliteArgumentValue::into_static).collect(),
    }
}

它的调用方是 ConnectionWorker::execute(sqlx-sqlite/src/connection/worker.rs:368):SQLite 连接的语句在一条专用工作线程上执行,每次执行都先把参数 into_static,再连同 SQL 一起发给那条线程。所以借用的 &str / &[u8] 在这里被拷贝一次,之后 SQLITE_TRANSIENT 又让 SQLite 自己再拷一份。MySQL 和 Postgres 的 args 因为是字节流不需要这步(字节已经拷贝到 buffer 了,没有外部借用)。

6.7 format_placeholder:跨 DB 的 SQL 差异

三家的实现:

DB 占位符 实现位置
Postgres $1, $2, $3, ... sqlx-postgres/src/arguments.rs:156-158 覆盖
MySQL ? 默认(sqlx-core/src/arguments.rs:27-29)
SQLite ? 默认(手写 SQL 也可用 ?NNN / $NNN 编号参数,sqlx-sqlite/src/arguments.rs:86-97 按编号取值;QueryBuilder 生成的是 ?)

PgArguments::format_placeholder(sqlx-postgres/src/arguments.rs:156-158)的覆盖:

fn format_placeholder<W: Write>(&self, writer: &mut W) -> fmt::Result {
    write!(writer, "${}", self.buffer.count)
}

注意 self.buffer.count ——写出来的编号完全取决于调用顺序。看 QueryBuilder::push_bind(sqlx-core/src/query_builder.rs:148-165):它先 arguments.add(value)(count 从 0 变成 1)、再调 arguments.format_placeholder(&mut self.query)(写 $1)。所以 format_placeholder 看到的是已经 add 之后的 count——编号天然从 1 开始。这就是为什么直接写 ${count} 而不是 ${count + 1} 能正确产出 $1, $2, $3。

MySQL 和 SQLite 的默认实现就简单了——永远写 ?,不需要知道 count。? 按出现顺序对应参数数组的位置:MySQL 由服务端对应,SQLite 由 sqlx 的绑定循环逐个递增下标(sqlx-sqlite/src/arguments.rs:101-104)。

6.7.1 ? vs $1 的深层含义

这两种占位符表达的是协议设计哲学的差异:

  • Postgres 用 $1, $2——显式编号。同一个参数可以在 SQL 里出现多次(WHERE id = $1 OR parent_id = $1)、参数顺序和 bind 顺序可以不匹配。这给了复杂 SQL 巨大灵活性。
  • MySQL / SQLite 用 ?——按出现顺序。每个 ? 对应下一个 bind 参数,如果你要同一个值用两次,必须 bind 两次。(SQLite 手写 SQL 时也可以用 ?NNN / $NNN 编号参数来复用,MySQL 不行。)

sqlx 的 QueryBuilder 在跨 DB 的时候会帮你处理——用 push_bind(value) 自动调 format_placeholder,生成适配当前 DB 的占位符。这让用户代码可以从 Postgres 迁移到 MySQL 只改驱动不改 SQL 逻辑——这也是第 10 章 QueryBuilder 的价值之一。

6.8 ImmutableArguments:一段注释还在、使用者已经没了的防护

sqlx-core/src/arguments.rs:53-60 有一个看起来奇怪的类型:

/// used by the query macros to prevent supernumerary `.bind()` calls
pub struct ImmutableArguments<'q, DB: Database>(pub <DB as Database>::Arguments<'q>);

impl<'q, DB: Database> IntoArguments<'q, DB> for ImmutableArguments<'q, DB> {
    fn into_arguments(self) -> <DB as Database>::Arguments<'q> {
        self.0
    }
}

注释那一行(上面代码块的第一行)说它是给 query 宏挡多余 .bind() 用的。但在 0.8.6 的整棵树里 grep -rn ImmutableArguments 只有两处命中——上面这个定义,和紧跟着的那条 IntoArguments impl。宏并不用它:sqlx-macros-core/src/query/args.rs:113 生成的 query_args 就是一个 <DB as Database>::Arguments<'_>,外面裹一层 Result,直接交给 __query_with_result。翻 git 历史:0.2 时代曾有一个同名类型,配合 bind_all 被宏用过(git grep ImmutableArguments v0.2.6),0.3 起消失;今天这个带注释的版本是 2020 年实现 query_scalar! 时重新加进来的(commit 7c32928e,首见于 0.5.0),那次提交的宏代码也没有引用它。所以这条注释与 0.8.6 的现状不符。

那 0.8.6 里到底是什么挡住了多余的 .bind()?答案在返回类型上。

先看 bind 长在哪:Query::bind 写在 impl<'q, DB: Database> Query<'q, DB, <DB as Database>::Arguments<'q>>(sqlx-core/src/query.rs:75)这个 impl 块里——参数容器换成任何别的类型,bind 这个方法就整个消失。ImmutableArguments 的注释设想的正是靠这一点做防护。

再看宏各自展开成什么:

  • 有输出列的 query! / query_as! 走 quote_query_as(sqlx-macros-core/src/query/output.rs:183-191),末尾是 .try_map(|row| ...),返回的是 Map<'q, DB, F, A>(sqlx-core/src/query.rs:36)。Map 上根本没有 bind 方法,所以对一条 SELECT 的 query! 结果再挂 .bind(x) 编译不过(0.8.6 + SQLite 实测):

    error[E0599]: no method named `bind` found for struct `sqlx::query::Map<'q, DB, F, A>` in the current scope
  • query_scalar! 是个例外:它走 quote_query_scalar(同文件 :194),展开成 __query_scalar_with_result::<DB, T, _>(sql, args),得到 QueryScalar<'q, DB, O, <DB as Database>::Arguments<'q>>;而 QueryScalar::bind(sqlx-core/src/query_scalar.rs:51)恰好就长在这个参数类型上。所以对一条 query_scalar! 再挂一个 .bind(x),在 0.8.6 里能编译——多出来的那个参数要到执行时才见分晓:Postgres 服务端会因参数个数不符拒绝这条 Bind(按协议判断,未实跑);SQLite 则静默忽略多出的参数,照常返回结果(0.8.6 实测;绑定循环只按语句自身的参数个数迭代,见 sqlx-sqlite/src/arguments.rs:82-117)。

  • 没有输出列的 query!(如不带 RETURNING 的 UPDATE / INSERT)也是例外:展开时若所有列都是 void,就直接产出 __query_with_result(sql, args)(sqlx-macros-core/src/query/mod.rs:267-278),得到的是带 DB::Arguments<'q> 的 Query,bind 照样可用。0.8.6 + SQLite 实测:对一条 UPDATE ... WHERE id = ? 的 query! 再挂 .bind(x),能编译并执行成功。

6.8.1 "用返回类型做防护"这条思路本身

即便 ImmutableArguments 已经赋闲,"让不该调的方法在这个类型上根本不存在"这条思路仍然是 sqlx 的一贯做法,Map 没有 bind 就是它的现役形态。对比两条替代方案:

  • 运行时检查:给生成的 Query 一个标志位,.bind() 时检查、已绑定就 panic 或返回 error。
  • 文档警告:不做任何检查,只在文档里写"不要对 query! 的结果 bind"。

类型层拒绝比这两条都好,理由有三:

  1. 失败发生在编译期更便宜。运行时 panic 可能直到某次生产请求才触发,而且错误信息是 "bound more arguments than expected" 这种模糊描述。类型错误则是"这个方法不存在",IDE 补全都不会给你。
  2. 对新手友好。sqlx 用户里有大量从 Python / Ruby ORM 迁过来的开发者,他们习惯"给一个 query 对象 bind 东西"的心智模型。让编译器直接说"你想做的事在这个上下文里不对",比运行时报错省一次调试。
  3. 符合 Rust 生态的 type-state 模式——像 http 的 RequestBuilder 也是用类型参数区分状态的。

但 query_scalar! 和无输出列的 query! 这两条漏网之路也说明:这类防护是靠"恰好没有实现"而不是靠"显式禁止"达成的,一旦某条展开路径的返回类型碰巧带上了 DB::Arguments<'q>,防护就自动失效,编译器也不会给任何提示。ImmutableArguments 之所以还留在源码树里没被删掉,大概也是因为它是 pub 的、删掉算 breaking change。

6.9 三家对照表

把前面所有内容收在一张表里:

字段 / 行为 PgArguments MySqlArguments SqliteArguments<'q>
值存储 PgArgumentBuffer::buffer: Vec<u8> values: Vec<u8> values: Vec<SqliteArgumentValue<'q>>
类型存储 types: Vec<PgTypeInfo> types: Vec<MySqlTypeInfo> (变体内嵌含)
NULL 处理 长度前缀 -1 在值内部 null_bitmap: NullBitMap 独立位图 SqliteArgumentValue::Null 变体
类型 OID / ID types 数组 + type_holes 延迟回填 types 数组存 ColumnType 枚举 不需要(C API 直接传值)
延迟回填机制 patches + type_holes 无 无
占位符 $1, $2 ? ?(也接受 ?NNN / $NNN)
生命周期参数 无(PgArguments 静态) 无 <'q> 保留引用
add 失败回滚 四字段 snapshot/reset values.truncate values.truncate
总代码量 296 行 108 行 155 行

几条观察:

  1. 代码量差异巨大——sqlx-postgres/src/arguments.rs 一个文件就超过另外两家之和。这是协议复杂度的直接反映:Postgres 的 Extended Query + 自定义类型 OID 让它需要 patches 和 type_holes 两套机制。
  2. SQLite 唯一保留 'q——因为它用 Cow;其他两家编码后字节已经脱离原值,不需要生命周期。
  3. NULL 处理三家完全不同——值内标记(Postgres)、独立 bitmap(MySQL)、枚举变体(SQLite)。IsNull 在 sqlx-core 是统一的,但落到驱动层各自消化。
  4. MySQL 的 types 数组存的是 MySqlTypeInfo { r#type: ColumnType, flags, max_size } 而非 OID——因为 MySQL 没有 Postgres 那种数据库级 OID 概念,字段类型是协议枚举中的变体。

6.10 一次 add 的完整流程对比

把用户代码 .bind("hello".to_string()) 在三家 DB 下发生的事一表对齐:

步骤 Postgres MySQL SQLite
1. produces() 通常 None → 用 type_info() = TEXT None → ColumnType::VarString —
2. 记录类型 types.push(PgTypeInfo::TEXT) types.push(MySqlTypeInfo { ... }) (不需要)
3. snapshot 保存 4 个长度 保存 values.len() 保存 values.len()
4. encode(预留长度) buffer.extend(&[0;4]) —(无长度前缀) —
5. encode(值) buffer.extend(b"hello") values.extend(...)(带 LENENC 前缀) values.push(Text(Cow::Owned("hello".into())))
6. encode(回填长度) buffer[0..4] = 5_i32.to_be_bytes() — —
7. 计数 count += 1 types.push + null_bitmap.push(No) (不需要)
8. 发送 Bind 消息里把 buffer 原样发过去 COM_STMT_EXECUTE 包里发 bitmap + 类型 + values 遍历 values 调 sqlite3_bind_text64

这张表最能看出三家 DB 的线路差异如何影响 sqlx 的编码路径。同样的 Rust 值,字节表达截然不同——但用户 API 完全一致(.bind(...))。这就是 sqlx-core trait 家族抽象带来的"实现复杂、接口简单"价值。

6.11 批量插入 1000 行的路径

把本章内容放在一个真实场景——批量 INSERT——里看。假设业务需要一次插入 1000 条日志记录:

let mut qb = QueryBuilder::<Postgres>::new("INSERT INTO logs (level, message, created) ");

qb.push_values(entries.iter().take(1000), |mut b, entry| {
    b.push_bind(entry.level)
     .push_bind(&entry.message)
     .push_bind(entry.created);
});

qb.build().execute(&pool).await?;

这条 push_values 调用会把 3000 次 push_bind 分别触发——QueryBuilder::push_bind 的函数体只有两句实活:arguments.add(value) 和 arguments.format_placeholder(&mut self.query)(sqlx-0.8.6/sqlx-core/src/query_builder.rs:148-165)。

PgArguments 的内存路径:

  1. 每次 push_bind(entry.level) 里:
    • args.add(entry.level)(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:84)先 let buffer_snapshot = self.buffer.snapshot(),再 self.buffer.encode(value);encode(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:167)先 self.extend(&[0; 4]) 预留 4 字节长度位,写完真值再 self[offset..offset+4].copy_from_slice(&len.to_be_bytes()) 回填。types.push(type_info) 排在 encode 成功之后——encode 失败要走 reset_to_snapshot 回滚,这时 types 还没被污染。
    • format_placeholder 往 SQL 字符串写 ${count},count 就是 self.buffer.count,每成功 add 一次加 1(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:156-158)。
  2. push_bind(&entry.message)(&String,靠 sqlx-0.8.6/sqlx-core/src/encode.rs:57 的 impl<'q, T, DB> Encode<'q, DB> for &'_ T 和 sqlx-0.8.6/sqlx-core/src/types/mod.rs:234 的 impl<T: ?Sized + Type<DB>, DB> Type<DB> for &'_ T 转发):同样是预留 4 字节 → 写 UTF-8 字节 → 回填长度。
  3. 完成后 SQL 字符串是 INSERT INTO logs ... VALUES ($1, $2, $3), ($4, $5, $6), ..., ($2998, $2999, $3000)。
  4. 发送时 PgArguments::apply_patches(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:110,是个 async fn,要拿 &mut PgConnection 去查未知类型的 OID)对 0 个 patches 和 0 个 type_holes(内置类型)跑 no-op。
  5. Bind 消息发到服务端——整个 buffer 一次 write。

一个容易想当然的地方:这条路径上没有任何 reserve。Arguments trait 确实有 fn reserve(&mut self, additional: usize, size: usize)(sqlx-0.8.6/sqlx-core/src/arguments.rs:17),三家驱动也都实现了(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:144、sqlx-0.8.6/sqlx-mysql/src/arguments.rs:43、sqlx-0.8.6/sqlx-sqlite/src/arguments.rs:61),但 0.8.6 全树把 QueryBuilder 从头翻到尾一次都没调过它——sqlx-0.8.6/sqlx-core/src/query_builder.rs 里没有一处 reserve。所以:

  • types 向量:靠 Vec 的倍增扩容长到 3000 个 PgTypeInfo(内部是 PgType 枚举,最大变体 DeclareWithName(UStr),64 位平台上几十字节量级),中途会 realloc 若干次。
  • buffer:同样是倍增扩容。平均每个参数 32 字节(4 字节 INT4 + 较短字符串 + 长度前缀)的话,终态在 96 KB 量级。
  • SQL 字符串:self.query 也是普通 String,倍增扩容到约 20 KB(3000 个 $N 占位 + INSERT 模板)。

要想消掉参数这一侧的几轮 realloc,得你自己先建一个 PgArguments 调 reserve,再用 QueryBuilder::with_arguments(init, args) 交给它(sqlx-0.8.6/sqlx-core/src/query_builder.rs:67-79;QueryBuilder 不对外暴露内部的 arguments,SQLite 版同样写法 0.8.6 实测可用)——这是一个留给调用方的 opt-in 钩子,不是框架替你做的优化。顺带一提,即便调了也估不准:&str / String 并没有覆盖 size_hint(第 5 章 §5.4.3),默认实现是 mem::size_of_val(self)(sqlx-0.8.6/sqlx-core/src/encode.rs:51-54),对 String 只有 24 字节,字符串列的字节量会被系统性低估。

MySQL 路径的差异(sqlx-0.8.6/sqlx-mysql/src/arguments.rs:9-37):

  1. 没有 patches/type_holes;MySqlArguments 只有 values: Vec<u8> / types: Vec<MySqlTypeInfo> / null_bitmap: NullBitMap 三个字段。
  2. null_bitmap 需要分配 ceil(3000/8) = 375 字节——它随每次 add 的 is_null 一位一位 push(sqlx-0.8.6/sqlx-mysql/src/arguments.rs:34),发包时整段贴在 Execute 报文里(sqlx-0.8.6/sqlx-mysql/src/protocol/statement/execute.rs:22)。
  3. LENENC 让短字符串占 1 字节前缀而不是 4:这批数据里只有 message 一列是变长字符串,1000 行省下约 3 KB。
  4. values 不预留长度前缀空间,encode 更直接。

SQLite 路径的差异(sqlx-0.8.6/sqlx-sqlite/src/arguments.rs:12-24):

  1. 根本不 encode 成字节——3000 个 SqliteArgumentValue 变体存在 values: Vec<SqliteArgumentValue<'q>> 里。
  2. 枚举的六个变体是 Null / Text(Cow<'q, str>) / Blob(Cow<'q, [u8]>) / Double(f64) / Int(i32) / Int64(i64),整体大小由最大变体 Text / Blob 决定——胖指针加容量再加判别式,几十字节量级。
  3. 真正 bind 发生在 sqlite3_bind_* 调用时,按变体 match 分发。
  4. 不走网络——省掉了协议序列化和一次网络往返,这是 SQLite 在本地批量写入场景的结构性优势。

这个例子能具象地感受到三家 Arguments 实现的设计差异如何在批量操作下放大——PgArguments 把所有参数攒成一段连续字节、适合一次 I/O 发出去;SQLite 完全不做协议序列化、也不走网络;MySQL 的 LENENC 在短字符串为主时前缀开销最小。没有一家全面领先——场景决定优劣,这就是第 22 章生产实战会反复讨论的事。

6.12 本章小结

本章把 sqlx 参数绑定的内部消化过程完整打开:

  1. Arguments trait 四件套(§6.2)——reserve / add / len / format_placeholder 是最小契约,add 是唯一接收参数值的方法,承担消化异构类型的核心职责。
  2. IntoArguments 的存在理由(§6.3)——让 Query<'q, DB, A> 的 A 能接受多种参数容器(Arguments 自身、ImmutableArguments、第三方适配),而 bind 只长在 A = DB::Arguments<'q> 那一个 impl 块上。impl_into_arguments_for_arguments! 宏是又一处"泛型 impl 写不成、改用宏逐个展开"的补丁(源码注释归因于 lazy normalization,0.5.0 起就在,早于 GAT 化)。
  3. PgArgumentBuffer 四件套(§6.4)——buffer + count + patches + type_holes(PgArguments 本体只有 types 和 buffer 两个字段)。buffer 是带长度前缀的字节流(-1 长度表达 NULL)、patches 是值级延迟回填(JSONB 版本字节是主要场景)、type_holes 是 OID 回填(自定义类型必需)。snapshot + reset_to_snapshot 让 add 事务化。
  4. MySqlArguments 三件套(§6.5)——values + types + null_bitmap。独立 NullBitMap 是 MySQL binary protocol 的要求。
  5. SqliteArguments 枚举向量(§6.6)——Vec<SqliteArgumentValue<'q>>,每个参数作为 tagged union 存储。带 'q 生命周期以支持 bind 时的 Cow 借用;每次执行前 into_static 切断借用变 owned,再交给工作线程。
  6. format_placeholder 的 $1 vs ?(§6.7)——Postgres 的编号式允许复用和重排,MySQL/SQLite 的 ? 按出现顺序。QueryBuilder::push_bind 按此生成跨 DB 代码。
  7. 多余 .bind() 的编译期防护(§6.8)——有输出列的 query! / query_as! 展开成 Map,而 Map 没有 bind 方法,所以多绑一个参数编译不过。注意 sqlx-core/src/arguments.rs:54 那个 ImmutableArguments 虽然注释说自己干这活,0.8.6 里已经没有任何使用者;而 query_scalar!(展开成 QueryScalar<.., DB::Arguments<'q>>)和没有输出列的 query!(展开成 Query<.., DB::Arguments<'q>>),反倒是能 .bind() 的漏网之鱼。
  8. 三家对照(§6.9–§6.10)——值存储、类型存储、NULL 处理、生命周期、代码量全部不同。统一在 Arguments trait 下、差异封装在驱动实现里。

下一章我们进入 Row 与 Column 的细节——已经从 §3.4 知道了它们的基本形态,第 7 章要看 PgRow / MySqlRow / SqliteRow 的具体存储以及 try_get 在三家下的完整路径。

6.12.1 Arguments 相关的版本变更

比起凭印象讲"演进史",直接对着 CHANGELOG 看更可靠。和 Arguments / Encode 直接相关的几条改动都集中在 0.8.0(2024-07-22)这一次 breaking release 里:

  • [#2973] Generic Associated Types in Database, replacing HasValueRef, HasArguments, HasStatement —— GAT 化。在此之前,参数容器要靠 HasArguments<'q> 这类辅助 trait 绕出来;0.8.0 之后 Database::Arguments<'q> 是一个直接的关联类型投影,Query<'q, DB, A> 里的 A 才能自然表达。0.7.0 的 release note 里已经预告过这件事("We're expecting to do another major refactor of traits to incorporate generic associated types (GAT)"),并在同一段里点名了 lazy normalization 是绕不过去的限制。注意本章的 impl_into_arguments_for_arguments!、第 5 章的 impl_encode_for_option! 都不是这次改造留下的——前者 0.5.0 就在,后者 0.4.0 就在,GAT 化只是把它们的签名从 HasArguments<'q> 改写成了 GAT 形式。
  • [#3126] Make Encode return a result —— Encode::encode / encode_by_ref 的返回类型从 IsNull 变成 Result<IsNull, BoxDynError>,Arguments::add 也跟着变成 Result<(), BoxDynError>。这是一次语义增强:编码失败可以作为错误往上抛,而不是 panic 或者写出一段坏字节。本章 §6.4.4 的 snapshot / reset_to_snapshot 回滚机制就是配套这次改动的——既然 add 会失败,就得保证失败后 buffer 干净。
  • [#2960] Removed Send trait bound from argument binding —— 放宽了 bind 的约束。

对照当前 0.8.6 的源码可以看到这三条都已经落地:add 返回 Result、ArgumentBuffer<'q> 是 GAT、Arguments<'q> 的超 trait bound 是 Send + Sized + Default(Send 在容器本身上,而不在被 bind 的值上)。

本章讲的"生命周期约束"在 0.8.6 之后又有一次大改,属于 0.9.0(2026-05-21)的 Breaking 变更,不适用于本专栏对照的 0.8.6:[#3957] 让 SQLite 不再借用绑定值、删掉 SqliteArguments 的生命周期;[#3958] 删掉 AnyArguments 的生命周期;[#3960] 删掉 Arguments trait 本身的生命周期参数——0.9.0 里是 pub trait Arguments: Send + Sized + Default,add 变成 fn add<'t, T>(...) where T: Encode<'t, Self::Database> + Type<Self::Database>,IntoArguments<DB>、Database::Arguments 也随之不带 'q(git show v0.9.0:sqlx-core/src/arguments.rs)。按 0.9.0 CHANGELOG 的说法,这一条和 [#3723](query*() 改收 impl SqlSafeStr)合起来,才让查询对象可以是 Query<'static, DB>、能被函数按值返回。

这几步的共同方向是"更精确地表达约束"——把原本只能在运行期发现的失败搬到类型签名里,把原本要靠辅助 trait 模拟的关联类型换成语言原生的 GAT。

6.13 设计判断题

几道实操判断题,测试你对本章内容的工程化理解:

Q1:PgArguments::add 的 snapshot 机制能不能换成"先尝试 encode 到临时 buffer,成功后再 append"?

A:能但更贵。snapshot 是个只有四个 usize 的 PgArgumentBufferSnapshot { buffer_length, count, patches_length, type_holes_length }(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:266-271),失败时 reset_to_snapshot 对三个 Vec 各做一次 truncate 是 O(1)(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:250-264)——buffer 的已分配内存直接复用。"先临时再 append"要额外一个 Vec 分配+两次 memcpy(临时→正式),add 成功路径的稳态开销更大。现有的 snapshot/truncate 方案让 add 成功走得极快,失败才付代价——这是 happy path 友好的经典做法。

Q2:为什么不把 PgArgumentBuffer::patches 挪到 PgArguments 外层?

A:封装。patches 是 buffer 内部状态——Patch 里的 buf_offset(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:57)是 buffer 里的索引,apply_patches 用它切出 &mut buffer[patch.buf_offset..] 再喂给 callback(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:122-127)。如果 patches 在外层,callback 要接受 &mut Vec<u8> 和 offset 两个参数——而且外层代码要知道"哪些 patch 属于哪次 add",bookkeeping 成本巨大。patches 挪在 buffer 内让"自增的长度让 patches 的 offset 永远有效"这条不变量不会被破坏。

Q3:SqliteArguments 的 Cow<'q, str> 设计,能不能全部改成 String(切断 'q 参数)?

A:在 0.8.6 里,收益主要在 API 上,而不在拷贝次数上。Cow::Borrowed 让 bind(my_str) 这类用法不必先把 &str 转成 owned String;但执行时 ConnectionWorker::execute 会先 into_static,把借用的文本拷成 owned 再发给工作线程(§6.6),随后 SQLITE_TRANSIENT 又让 SQLite 自己再拷一份。所以借用只是把第一次拷贝从 bind 推迟到了执行,并没有做到零拷贝;代价则是 'q 生命周期参数一路传染到 Query<'q, ..>。0.9.0 的 #3957 正是朝另一个方向改的:不再借用绑定值,删掉了 SqliteArguments 的生命周期(§6.12.1)。

Q4:format_placeholder 签名用 fn format_placeholder<W: Write>(&self, writer: &mut W) -> fmt::Result 而不是 fn next_placeholder(&self) -> String——为什么不返回 String?

A:避免分配。trait 上的默认实现只写一个 ?(sqlx-0.8.6/sqlx-core/src/arguments.rs:27-29),Postgres 覆写成 write!(writer, "${}", self.buffer.count)(sqlx-0.8.6/sqlx-postgres/src/arguments.rs:156-158)。它的典型调用方 QueryBuilder::push_bind 直接把自己的 self.query 这个 String 作为 writer 传进去(sqlx-0.8.6/sqlx-core/src/query_builder.rs:160-162),全程不分配;如果返回 String,每次 bind 多一次堆分配。这是标准库 fmt::Write trait 的哲学——"写入别人的 buffer 而不是返回新 String"——sqlx 直接继承这条设计。

Q5:ImmutableArguments 的字段为什么是 pub 的?

A:按注释设想的用法,宏展开出来的代码住在用户的 crate 里,要能字面构造 ::sqlx::arguments::ImmutableArguments(args) 就必须字段公开——它是个元组结构体 pub struct ImmutableArguments<'q, DB: Database>(pub <DB as Database>::Arguments<'q>)(sqlx-0.8.6/sqlx-core/src/arguments.rs:54),pub(crate) 会把 sqlx-macros-core 生成的代码挡在外面。这是"宏生成代码需要访问库内部类型"的常见权宜之计。不过要注意:0.8.6 里这条通路没人走(§6.8),全树只剩定义本身;字段保持 pub 更像是不想为一个赋闲类型再做一次 breaking change。

6.14 Arguments 作为"类型安全的胶水"

最后退一步看本章讨论的内容。Arguments<'q> 这个 trait 承担的角色是把 Rust 的静态类型系统和数据库的动态协议粘合起来——这是 sqlx 最基础也最关键的一层胶水。

如果没有这层抽象,每个数据库的参数绑定要写得完全不同:

  • 和 PostgreSQL 打交道时要用 tokio-postgres::ToSql + &[&dyn ToSql] 切片。
  • 和 MySQL 打交道时要用 mysql_async::Params 枚举。
  • 和 SQLite 打交道时要用 rusqlite::params! 宏。

sqlx 的 .bind(x) 统一了这三条路——但不是靠"最小公共子集"的抽象(那会让每家都只能做最原始的参数绑定),而是靠 trait 家族让每家保留完整能力:

  • Postgres 保留延迟回填 OID(自定义类型支持)、JSONB 版本字节 patch。
  • MySQL 保留紧凑的 LENENC 长度编码、独立 null bitmap。
  • SQLite 保留零序列化的 C API 直通、bind 时对 &str / &[u8] 的 'q 借用。

统一接口 + 实现层完全异构——这是本章讨论的 Arguments 实现的精髓。上层用户的代码 .bind(x).bind(y) 跨 DB 不变,底层每家的 encode 路径却完全不同。这条设计的价值在于"换驱动不换业务代码"——从 Postgres 迁到 MySQL 可能要改几条 SQL 的方言,但 Rust 侧的 bind 链、query_as! 宏、FromRow 都不动。

这种"接口统一,实现完全异构"的模式也是 Rust 生态里其它优秀抽象的特征:

  • tokio 的 AsyncRead / AsyncWrite——接口统一为 poll_read/poll_write,TcpStream / File / pipe 各家实现完全不同。
  • serde 的 Serialize——接口统一成 "访问 serializer",json / yaml / bincode 内部实现迥异。
  • sqlx 的 Arguments——接口统一成 add,PgArguments / MySqlArguments / SqliteArguments 形态截然不同。

读通这种设计的价值是:当你自己设计 trait 家族时,你学会不要求实现者"看起来一样",只要求它们"对外行为一致"。Arguments trait 对外只承诺"你 bind 进来的值会按顺序送到 DB"——不要求实现用字节流还是枚举向量,不要求 NULL 表达方式,不要求类型标识形态。只要对外承诺兑现,实现可以任意优化。

6.15 与 serde 的对照:两个序列化抽象的哲学

读完 Arguments 再看 serde 的 Serializer trait,你会发现两者解决的问题本质相似——把 Rust 值序列化到某种外部格式——但设计走向完全不同。

serde 的 Serializer(《Serde 元编程》专栏第 3 章):

pub trait Serializer: Sized {
    type Ok;
    type Error: Error;
    type SerializeSeq: SerializeSeq;
    type SerializeMap: SerializeMap;
    // ... 十几个关联类型和方法
    fn serialize_i32(self, v: i32) -> Result<Self::Ok, Self::Error>;
    fn serialize_str(self, v: &str) -> Result<Self::Ok, Self::Error>;
    // ...
}

sqlx 的 Arguments:

// sqlx-core/src/arguments.rs:12(省略注释与 format_placeholder 的默认实现)
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;
}

三点关键差异:

  1. serde 有数十个 serialize_* 方法(serialize_i32 / serialize_str / serialize_seq 等),sqlx 只有一个 add<T> 泛型方法。Serde 的做法是"Serializer 知道每种类型怎么序列化",sqlx 是"Arguments 不知道类型、由 Encode 实现决定字节"。
  2. serde 的关联类型把序列化状态机显式化(SerializeSeq / SerializeMap / SerializeStruct),sqlx 的 Arguments 就是平的——参数按顺序 append,没有嵌套结构。这反映了 SQL 参数的线性本质(不像 JSON 有嵌套)。
  3. sqlx 的 format_placeholder 让 Arguments 参与 SQL 拼接——这是 sqlx 独有的需求,serde 不需要。

两者都是优秀抽象,形态不同是因为被抽象的领域不同:

  • serde 抽象的是"Rust 值树 → 外部格式"的递归映射。
  • sqlx Arguments 抽象的是"Rust 值序列 → DB 协议参数"的平化映射。

读完这两章你会更清晰地看出:好的 trait 设计贴合被抽象领域的结构,而不是追求形式上的统一。serde 要支持嵌套就用关联类型表达子序列化器;sqlx 只需要平参数就用 add<T> 一个方法搞定——各自都是"最小合适"的。

6.16 下一章指路

下一章我们详细拆 Row 与 Column——你能拿到什么、怎么拿到、零拷贝到哪一步。