Skip to content

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

"An argument list is where the database's view of types meets Rust's view of types— and only one of them can be wrong." —— 参数编码错误调试的朴素观察

本章要点

  • Arguments<'q> trait(sqlx-core/src/arguments.rs:12)只有四个方法:reserve / add / len / format_placeholder——却承担了"把异构 Rust 类型消化成某一家 DB 的线路参数格式"的全部职责。
  • PgArguments 内部是 types + buffer + patches + type_holes 四件套(sqlx-postgres/src/arguments.rs:77-82)——buffer 是字节流,patches 是"值级延迟回填回调",type_holes 是"OID 回填标记"。这套复杂度来自 Postgres Extended Query 协议要求 bind 阶段就提供 OID。
  • MySqlArgumentsvalues + types + null_bitmapsqlx-mysql/src/arguments.rs:11-14)——MySQL binary protocol 的 null 信息在一个独立位图里,不嵌入值编码。
  • SqliteArguments<'q>Vec<SqliteArgumentValue<'q>>sqlx-sqlite/src/arguments.rs:15-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> 是一个 #[derive] 宏生成代码的内部类型——防止用户错误地对 query! 已经绑定好的参数再调 .bind()

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

回到本书开头那段代码:

rust
let user = 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
    .fetch_one(&pool).await?;

三次 .bind(...) 传入三种完全不同的 Rust 类型——&stri32OffsetDateTime。它们的内存表达完全不同:&str 是指针 + 长度、i32 是 4 字节栈值、OffsetDateTime 是一个 16 字节左右的结构体(天数 + 纳秒 + 时区)。

这三个值最终要被发到 Postgres 服务端的 Extended Query Bind 消息里。Bind 消息对服务端的约定是

  • 每个参数有一个类型 OID(整型)——从哪里来?
  • 每个参数有一段字节数据(变长)——按什么顺序排?
  • 每个参数可能是 NULL(用长度 -1 标记)——哪里标?

这三条信息必须在发送 Bind 消息时已经备齐——因为 Postgres 的协议是流式的,你不能"部分发然后补一段"。这意味着 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>arguments.rs:19)是泛型方法——接受任何满足 Encode + Type 的 T,在方法内部把 T 消化成字节。
  3. 底层 bufferVec<u8> 或类似的字节序列——所有类型 encode 后都变成"一串字节 + 一个 TypeInfo"。

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

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

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

rust
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 按字节分配。QueryBuilder::push_valuesquery! 宏会在开始前一次性 reserve。
  • add<T>(value)——trait 里唯一的泛型方法——消化用户传入的任意 T。必须满足 T: 'q + Encode<'q, DB> + Type<DB>:生命周期 'q 保证 T 内部的借用活到参数消费完、Encode/Type 保证能编码和声明类型。
  • len()——已经 add 了多少个参数。format_placeholder 会用它来生成下一个占位符的位置索引。
  • format_placeholder(writer)——往 writer 写当前要添加的下一个参数的占位符。MySQL / SQLite 默认实现写 ?;Postgres 要覆盖成 $1$2(参数位置)。

这四个方法构成一个最小契约:预分配 → 逐个添加 → 查计数 → 生成占位符QueryBuilder(第 10 章)会用完整的这四个接口;query() 函数只用 add 和隐式的占位符(由 SQL 字符串自己给)。

6.3 IntoArguments trait 与 impl_into_arguments_for_arguments!

紧接着 Arguments 的定义,arguments.rs:32-34

rust
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 本身、也可以是某种"未成形的参数列表"。

有了 IntoArgumentsQuery 可以接受多种参数容器:

  1. 直接的 PgArguments——用户可以手动构造一个 PgArguments 传入。
  2. ImmutableArguments<'q, DB>——query! 宏内部用的只读变体(§6.10)。
  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! 宏为什么存在

arguments.rs:36-51 的宏:

rust
// 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.2 和第 5 章 §5.6 都遇到过。

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

rust
// 想写但写不出来
impl<'q, A> IntoArguments<'q, <A as Arguments<'q>>::Database> for A
where A: Arguments<'q>,
{
    fn into_arguments(self) -> A { self }
}

但 Rust 编译器在解 <A as Arguments<'q>>::Database 这个关联类型投影时无法 lazy normalize——需要立即知道 A 的具体类型才能展开。解法和 impl_encode_for_option! 一样:用宏让每个驱动展开一次具体 impl。驱动 crate 里写:

rust
// sqlx-postgres/src/arguments.rs(通过宏展开)
impl_into_arguments_for_arguments!(PgArguments);
// sqlx-mysql/src/arguments.rs
impl_into_arguments_for_arguments!(MySqlArguments);
// sqlx-sqlite/src/arguments.rs
impl_into_arguments_for_arguments!(SqliteArguments<'q>);

每个驱动展开后都变成具体类型的 impl,编译器不再需要 lazy normalization。这是 sqlx 整个 codebase 里 GAT 限制的第三处重大影响——trait 家族设计优雅但实现时要到处打补丁。

6.4 PgArguments 内部:最复杂的一家

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

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

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

表面上只有两个字段。但 PgArgumentBuffer 自己又分四份(arguments.rs:27-48):

rust
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::encodearguments.rs:156-182)是最核心的方法:

rust
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 值 → 回填长度。流程图:

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

6.4.2 patches:值级的延迟回填

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

Patch 结构(arguments.rs:57-62):

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

callback 是一个函数闭包:接收"buffer 的一段可变切片"和"这个参数的实际 TypeInfo",修改 buffer。实际应用场景是 Arc<dyn Encode> 这种需要"先写占位,等真实类型确定后再写正式字节"的情况——sqlx 源码目前主要用于 JSON 的两种变体(JSON vs JSONB):

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

Encode 实现无法提前知道用户 bind 时 Postgres 参数槽期望的是 JSON 还是 JSONB(用户可能只写了 SELECT $1,让服务端从上下文推断)。所以 Encode 可以 buf.patch(|bytes, ty| { if ty.is_jsonb() { bytes[0] = 1; } }) ——等服务端返回真实类型后再 patch。

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

6.4.3 type_holes:OID 回填

type_holes 是另一条"先留坑"的路径——专门处理自定义类型的 OID

Postgres 的 Bind 消息里每个参数有一个 OID 标识类型。内置类型(INT4 = 23、TEXT = 25)的 OID 是硬编码常量,但用户自定义类型CREATE TYPE color AS ENUM (...)CREATE TYPE address AS (...))的 OID 是运行时由 Postgres 分配的——每个数据库实例可能不同。

sqlx 的解决方案是"先写 0 占位 OID、记下 hole、等运行时查数据库再回填"。patch_type_by_namearguments.rs:209-217):

rust
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_patchesarguments.rs:111-141)在真正发 Bind 消息前被调用,回填所有 hole:

rust
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 oid FROM pg_type WHERE typname = $1 来查 OID。每个自定义类型名第一次遇到时查一次,之后由连接缓存(PgConnection::fetch_type_id_by_name 自己做 LRU)。

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

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

sqlx::query("INSERT INTO ... VALUES ($1)").bind(Color::Red)...

Encode Color 时,sqlx 不知道 "color" 这个 Postgres 类型在当前这个数据库的 OID 是多少——每个数据库里 color 可能分配到不同的 OID。硬编码是不行的;每次 bind 都先查数据库太慢。所以 sqlx 的路径是**"先编码值、记下 OID hole、在真正发送前批量查并回填"**——查完缓存到连接上,后续 bind 同类型零开销。

6.4.4 snapshot / reset_to_snapshot:add 失败的回滚

PgArguments::addarguments.rs:85-107)的 try-encode-else-rollback 流程:

rust
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 的实际用法用一次具体场景串起来。假设用户有:

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

let mut args = PgArguments::default();
args.add(Mood::Happy)?;

Mood 的 Encode 实现(由 derive 生成)大致会走这条路径:

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

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

fetch_type_id_by_name 内部做 LRU 缓存——第一次查 mood 发一条 SELECT typname, oid FROM pg_type WHERE typname = 'mood',结果缓存在 PgConnection 的类型注册表里。之后整个连接生命周期不再重复查。

对用户透明的是:你第一次 bind 一个自定义类型时,背后可能多了一次 SQL 查询(几毫秒开销);之后都是零开销。这条设计在 sqlx-postgres/src/connection/describe.rs 里的 type_cache 字段落实。

6.5 MySqlArguments:三件套

sqlx-mysql/src/arguments.rs:11-14MySqlArguments

rust
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 在消息开头,而不是用"长度 = -1"在值内部表达。每 8 个参数用 1 字节位图——第 k 位为 1 表示第 k 个参数是 NULL。

NullBitMap::pusharguments.rs:66-74)很直白:

rust
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 映射成位图的一位。比 Postgres 省 4 字节每参数(前者有长度前缀),但 bind 消息的整体布局更复杂——你要先看 bitmap 知道哪些参数是 NULL、然后跳过它们的字节。

注意 MySQL 的 Arguments::add 默认没有 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 上的 bind 消息比 Postgres 小几字节。在高吞吐场景(每秒上万次 query)累计下来有可观的带宽差。

MySQL 的 encode 字符串的代码(sqlx-mysql/src/types/str.rs 里 str 的 Encode)大致这样:

rust
fn encode_by_ref(&self, buf: &mut Vec<u8>) -> Result<IsNull, BoxDynError> {
    // 写 LENENC 长度前缀
    match self.len() {
        n if n < 251 => buf.push(n as u8),
        n if n < 65536 => { buf.push(252); buf.extend(&(n as u16).to_le_bytes()); },
        n if n < 16_777_216 => { buf.push(253); buf.extend(&(n as u32).to_le_bytes()[..3]); },
        n => { buf.push(254); buf.extend(&(n as u64).to_le_bytes()); },
    }
    buf.extend_from_slice(self.as_bytes());
    Ok(IsNull::No)
}

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

6.6 SqliteArguments:枚举向量

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

rust
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_text(stmt, index, s.as_ptr(), s.len(), SQLITE_TRANSIENT)
  • SqliteArgumentValue::Blob(b)sqlite3_bind_blob(stmt, index, b.as_ptr(), b.len(), SQLITE_TRANSIENT)
  • SqliteArgumentValue::Nullsqlite3_bind_null(stmt, index)

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

'q 生命周期存在是因为 Cow<'q, str>Cow<'q, [u8]> 可能借用外部数据。如果你写 query(sql).bind(&my_string)——my_string 的引用就通过 Cow::Borrowed 活到 statement 执行完。第 3 章 §3.10 讨论过的 "SqliteArguments 保留 'q 生命周期而 PgArguments / MySqlArguments 不需要" 就是这条源码的体现。

SqliteArguments::into_staticarguments.rs:49-56)提供了"切断外部生命周期"的转换——把所有 Borrowed 变 Owned:

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

这个方法让"预编译 + 稍后执行"成为可能——你现在 bind 一些借用数据、然后 into_static 把 args 脱离当前作用域、扔到后台 task 里稍后执行。MySQL 和 Postgres 的 args 因为是字节流不需要这步(字节已经拷贝到 buffer 了,没有外部借用)。

6.7 format_placeholder:跨 DB 的 SQL 差异

四个默认实现:

DB占位符实现位置
Postgres$1, $2, $3, ...sqlx-postgres/src/arguments.rs:149 覆盖
MySQL?默认(sqlx-core/src/arguments.rs:24-26
SQLite?默认(或 ?NNN 位置参数——sqlite 支持多种但 sqlx 默认用 ?

PgArguments::format_placeholdersqlx-postgres/src/arguments.rs:148-151)的覆盖:

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

注意 self.buffer.count ——这个计数在 add 之前/之后是不同的值。QueryBuilder::push_bind 调用顺序是:

  1. format_placeholder → 写 "$1"
  2. add(value) → count 变成 1

所以**format_placeholder 写的是"下一个参数的位置"**——第 0 个参数 add 之前 count 是 0,但占位符要写 $1,所以实际上是 ${count + 1}?不对——看源码是 ${count}

来看 QueryBuilder::push_bindsqlx-core/src/query_builder.rs 大约 140 行左右)的调用顺序:它先 args.add(value)?(count 变成 1)、args.format_placeholder(&mut self.query)(写 $1)。所以 format_placeholder 看到的是已经 add 之后的 count——编号从 1 开始计数。这就是为什么 ${count} 能正确产出 $1, $2, $3

MySQL 和 SQLite 的默认实现就简单了——永远写 ?,不需要知道 count。服务端看到 ? 按出现顺序对应参数数组的位置。

6.7.1 ? vs $1 的深层含义

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

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

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

6.8 ImmutableArguments:宏系统的内部防护

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

rust
/// 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
    }
}

注释 "used by the query macros to prevent supernumerary .bind() calls" 是关键——防止用户对 query! 宏已经绑定好的参数再手动 bind。

考虑这种场景:

rust
sqlx::query!("SELECT ... WHERE id = $1", user_id).bind(other_value).fetch_one(&pool).await?;
//                                                 ^^^^^^^^^^^^^^
// 用户想多 bind 一个,但 SQL 里只有 $1 一个占位符

如果 query! 返回的 Query 带 PgArguments,用户 .bind(other_value) 就能合法追加——执行时 SQL 里只消化一个参数,多的被忽略或报错(运行时)。sqlx 选择了更严格的保护:让 query! 宏返回的 Query 的 Arguments 类型是 ImmutableArguments<'q, DB>——而 Query::bind 只对 Arguments<'q> 可用,ImmutableArguments 不是 Arguments(它只实现 IntoArguments)。

结果是编译期拒绝:用户写 query!(..., id).bind(x) 编译失败,错误信息是 bind method not found on ImmutableArguments。这是 trait 实现集合控制编译期安全性的又一案例(和第 4 章 §4.5.1 "不为 String 实现 Execute" 同一类设计技巧)。

6.8.1 为什么不是运行时检查就好

站在工程角度,这条"编译期拒绝多余 bind"的做法值得讨论。替代方案是:

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

sqlx 选了最严格的——类型系统拒绝。为什么?

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

这条设计的代价是 ImmutableArguments 这个内部类型——一个 public struct 但没有公共方法(除了 IntoArguments::into_arguments)。普通用户代码看不到它;但如果你读 sqlx-macros-core 的生成代码,它会出现在 query! 展开的 Query 类型签名里。这是一个"内部类型 leak 到用户可见 API"的例子——sqlx 用类型别名 + 文档 hidden 尽量让它不碍眼。

6.9 三家对照表

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

字段 / 行为PgArgumentsMySqlArgumentsSqliteArguments<'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 / IDtypes 数组 + type_holes 延迟回填types 数组 FieldType 枚举不需要(C API 直接传值)
延迟回填机制patches + type_holes
占位符$1, $2???NNN
生命周期参数无(PgArguments 静态)<'q> 保留引用
add 失败回滚四字段 snapshot/resetvalues.truncatevalues.truncate
总代码量~290 行~108 行~155 行

几条观察:

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

6.10 一次 add 的完整流程对比

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

步骤PostgresMySQLSQLite
1. produces()通常 None → 用 type_info() = TEXTNone → TEXTFieldType::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 += 1types.push + null_bitmap.push(No)(不需要)
8. 发送Bind 消息里把 buffer 原样发过去COM_STMT_EXECUTE 包里发 bitmap + values遍历 values 调 sqlite3_bind_text

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

6.10.1 批量插入 1000 行的路径

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

rust
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 分别触发——每次触发至少一次 Arguments::add + 一次 format_placeholder

PgArguments 的内存路径

  1. 开始前 QueryBuilder 内部估算并调 args.reserve(3000, estimated_bytes)——typesbuffer 都一次分配到位。
  2. 每次 push_bind(entry.level) 里:
    • format_placeholder 往 SQL 字符串写 $N(N 从 1 递增到 3000)。
    • args.add(entry.level)types.push(INT4)buffer.extend(&[0;4]) 预留长度、buffer.extend(&level.to_be_bytes()) 写 4 字节、回填长度。
  3. push_bind(&entry.message)(String 借用):types.push(TEXT)、预留长度、buffer.extend(entry.message.as_bytes())、回填长度。
  4. 完成后 SQL 字符串是 INSERT INTO logs ... VALUES ($1, $2, $3), ($4, $5, $6), ..., ($2998, $2999, $3000)
  5. 发送时 PgArguments::apply_patches 对 0 个 patches 和 0 个 type_holes(内置类型)跑 no-op。
  6. Bind 消息发到服务端——整个 buffer 一次 write。

内存分配统计(得益于 reserve):

  • types 向量:1 次分配、3000 个 PgTypeInfo(约 72 字节每个)≈ 216 KB。
  • buffer:1 次分配(reserve)、平均每个参数 32 字节(4 字节 INT4 + 较短字符串 + 长度前缀)≈ 96 KB。
  • SQL 字符串:1 次分配(QueryBuilder 内部 reserve)、约 20 KB(3000 个 $N 占位 + INSERT 模板)。

关键:除了 reserve 时的三次分配,整个 1000 行批插过程零 realloc。如果没有 size_hint 的精确覆盖(第 5 章 §5.4.3),每次 buffer extend 可能触发 Vec 的 2 倍扩容——最坏情况 15 次 realloc(200 KB → 400 KB → 800 KB → ...),每次都要 memcpy 已有数据。

MySQL 路径的差异

  1. 没有 patches/type_holes。
  2. null_bitmap 需要分配 ceil(3000/8) = 375 字节。
  3. LENENC 让短字符串占 1 字节前缀而不是 4,3000 条日志累计节省约 9 KB。
  4. values 不预留长度前缀空间,encode 更直接。

SQLite 路径的差异

  1. 根本不 encode 成字节——3000 个 SqliteArgumentValue 变体存在 values 向量里。
  2. 每个变体大约 32 字节(最大 variant Text(Cow)(tag + String header) = 32 字节)。
  3. 真正 bind 发生在 sqlite3_bind_* 调用时,按变体 match 分发。
  4. 不走网络——这 1000 行插入通常比 Postgres/MySQL 快一个数量级(前提:SQLite 的 busy timeout 允许独占)。

这个例子能具象地感受到三家 Arguments 实现的设计差异如何在批量操作下放大——PgArguments 在"类型一致、数量多"场景下吞吐最佳(因为字节流适合一次 I/O 发送);SQLite 在"本地、无协议序列化"场景下延迟最低;MySQL 在"短字符串为主"时带宽最省。没有一家全面领先——场景决定优劣,这就是第 22 章生产实战会反复讨论的事。

6.11 本章小结

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

  1. Arguments trait 四件套(§6.2)——reserve / add / len / format_placeholder 是最小契约,add 是唯一的泛型方法,承担消化异构类型的核心职责。
  2. IntoArguments 的存在理由(§6.3)——让 Query::bind 能接受多种参数容器(Arguments 自身、ImmutableArguments、第三方适配)。impl_into_arguments_for_arguments!是又一处 GAT lazy normalization 限制的补丁。
  3. PgArguments 四件套(§6.4)——buffer + count + patches + type_holes。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 生命周期以支持 Cow 借用;into_static 切断借用变 owned。
  6. format_placeholder 的 $1 vs ?(§6.7)——Postgres 的编号式允许复用和重排,MySQL/SQLite 的 ? 按出现顺序。QueryBuilder::push_bind 按此生成跨 DB 代码。
  7. ImmutableArguments 的编译期防护(§6.8)——query! 宏返回的类型不实现 Arguments,阻止用户多余 .bind。这是 trait 实现集合做编译期约束的又一案例。
  8. 三家对照(§6.9–§6.10)——值存储、类型存储、NULL 处理、生命周期、代码量全部不同。统一在 Arguments trait 下、差异封装在驱动实现里。

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

6.11.1 Arguments trait 的版本演进

Arguments 经历过至少三次重要形态变化:

0.5 时代 — Arguments 只接受 T: 'q + Encode<DB>,不要求 Type<DB>。这导致运行时错误率高:你 bind(some_value) 如果没为该类型实现 Type 就直到发包时才报错——而且错误信息是"type_info not implemented"——新手摸不着头脑。

0.6 时代 — 增加了 T: Type<DB> 的 bound。bind 错误提前到编译期。同时引入 IntoArguments trait——为第三方参数容器留扩展点(但社区几乎没人用)。

0.7 时代 — GAT 之后 Arguments<'q> 变成带生命周期参数的 trait。DB::Arguments<'q> 这一投影成立,Query<'q, DB, A> 里的 A 类型终于能自然表达。impl_into_arguments_for_arguments! 宏在这一版被迫引入(因为 lazy normalization 限制)。

0.8 时代(本书版本) — Arguments::add 的返回类型从 () 变成 Result<(), BoxDynError>——允许 encode 失败返回错误而不是 panic。这是一次语义增强:0.7 里非法值(如 NaN 当 INT)会 panic,0.8 里返回错误让上层统一处理。

0.9.0-alpha.1 传闻——计划引入 "stateful Arguments",允许 encode 过程中 arg 之间的引用。具体设计还未公开。

每一次变更都是"更精确地表达约束"——从 T: EncodeT: Encode + Type、从 () 返回到 Result 返回、从 no GAT 到 GAT——每一步都消除了一类运行时错误或接口不自然。这也是 sqlx 作为生态成熟工具包应有的演进轨迹:外部接口的精简 = 内部约束的丰富

6.12 设计判断题

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

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

A:能但更贵。snapshot 只记录四个整数(offsets),失败时 truncate 是 O(1)——buffer 的已分配内存直接复用。"先临时再 append"要额外一个 Vec 分配+两次 memcpy(临时→正式),add 成功路径的稳态开销更大。现有的 snapshot/truncate 方案让 add 成功走得极快,失败才付代价——这是 happy path 友好的经典做法。

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

A:封装。patches 是 buffer 内部状态——callback 里引用的 buf_offset 是 buffer 里的索引。如果 patches 在外层,callback 要接受 &mut Vec<u8> 和 offset 两个参数——而且外层代码要知道"哪些 patch 属于哪次 add",bookkeeping 成本巨大。patches 挪在 buffer 内让"自增的长度让 patches 的 offset 永远有效"这条不变量不会被破坏。

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

A:API 会变差。如果 SqliteArguments 的值只能是 String,那 bind(&my_string) 这种常见用法必须 clone——因为你只能传 owned String 进去。SQLite 本身执行前就会把 text 按 SQLITE_TRANSIENT copy 进引擎(SQLite 的 C API 要求),但 sqlx 希望在 bind 和执行之间的时间里不做不必要的拷贝——用 Cow::Borrowed 借用用户的 &str,执行时才拷贝。代价是 'q 生命周期参数;收益是"bind + 立即执行"路径零拷贝。

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

A:避免分配。format_placeholder 的典型调用方 QueryBuilder::push_bind 已经有一个 String 要写入,直接 write!(self.query, "{count}") 不分配;如果返回 String,每次 bind 多一次堆分配。这是标准库 fmt::Write trait 的哲学——"写入别人的 buffer 而不是返回新 String"——sqlx 直接继承这条设计。

Q5:ImmutableArguments 为什么是 pub struct ... (pub ...) 而不是 pub struct ... (pub(crate) ...)

A:让 query! 宏生成的代码能构造它。sqlx-macros-core 生成的代码里有 sqlx::arguments::ImmutableArguments(args) 这种字面构造——如果字段 pub(crate) 就构造不了(macros-core 是另一个 crate)。让字段公开是"宏生成代码属于用户 crate、但需要访问 sqlx 内部类型"的常见权宜之计。替代方案是提供一个 new(args) -> Self 函数——但 #[allow(non_camel_case_types)] 和宏代码风格一致性让字面构造更简洁。

6.13 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 直通、'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.14 与 serde 的对照:两个序列化抽象的哲学

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

serde 的 Serializer(《Serde 元编程》第 4 章):

rust
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

rust
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.15 下一章指路

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

基于 VitePress 构建