Appearance
第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。MySqlArguments是values + types + null_bitmap(sqlx-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_*一族函数。IntoArgumentstrait 是一层薄的"我能转成 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 类型——&str、i32、OffsetDateTime。它们的内存表达完全不同:&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?答案分三层:
Encode + Typetrait bound(第 5 章)让任意类型都能 encode 成 buffer 字节。Arguments::add<T>(arguments.rs:19)是泛型方法——接受任何满足Encode + Type的 T,在方法内部把 T 消化成字节。- 底层 buffer 是
Vec<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_values和query!宏会在开始前一次性 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 本身、也可以是某种"未成形的参数列表"。
有了 IntoArguments,Query 可以接受多种参数容器:
- 直接的
PgArguments——用户可以手动构造一个 PgArguments 传入。 ImmutableArguments<'q, DB>——query!宏内部用的只读变体(§6.10)。- 可能有的自定义容器——第三方 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::encode(arguments.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:值级的延迟回填
PgArgumentBuffer 的 patches 是 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_name(arguments.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_patches(arguments.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::add(arguments.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 时刻。原因:
- add 是同步的(Encode::encode 是纯函数不能 await)——但 OID 查询要发 SQL 到服务端,必须 async。
- 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-14 的 MySqlArguments:
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::push(arguments.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::Null→sqlite3_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_static(arguments.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_placeholder(sqlx-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 调用顺序是:
format_placeholder→ 写 "$1"add(value)→ count 变成 1
所以**format_placeholder 写的是"下一个参数的位置"**——第 0 个参数 add 之前 count 是 0,但占位符要写 $1,所以实际上是 ${count + 1}?不对——看源码是 ${count}。
来看 QueryBuilder::push_bind(sqlx-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 选了最严格的——类型系统拒绝。为什么?
- 失败发生在编译期更便宜。运行时 panic 意味着可能直到某次生产环境请求触发才发现——而且错误信息是 "bound more arguments than expected" 这种模糊描述。类型错误则是"这个方法不存在",IDE 补全都不会给你。
- 对新手友好。sqlx 用户里有大量"从 Python / Ruby ORM 迁移过来"的开发者,他们习惯了"给一个 query 对象 bind 东西"的 mental model。sqlx 不想让他们踩这个坑——让编译器直接告诉你"你想做的事在这个上下文里不对"。
- 符合 Rust 生态的 "type-state" 模式——像 http 的
RequestBuilder也是用类型参数区分状态的。用户从其他 Rust 库迁过来时这种设计模式是熟悉的。
这条设计的代价是 ImmutableArguments 这个内部类型——一个 public struct 但没有公共方法(除了 IntoArguments::into_arguments)。普通用户代码看不到它;但如果你读 sqlx-macros-core 的生成代码,它会出现在 query! 展开的 Query 类型签名里。这是一个"内部类型 leak 到用户可见 API"的例子——sqlx 用类型别名 + 文档 hidden 尽量让它不碍眼。
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 数组 FieldType 枚举 | 不需要(C API 直接传值) |
| 延迟回填机制 | patches + type_holes | 无 | 无 |
| 占位符 | $1, $2 | ? | ? 或 ?NNN |
| 生命周期参数 | 无(PgArguments 静态) | 无 | <'q> 保留引用 |
| add 失败回滚 | 四字段 snapshot/reset | values.truncate | values.truncate |
| 总代码量 | ~290 行 | ~108 行 | ~155 行 |
几条观察:
- 代码量差异巨大——PgArguments 是三家总和的一半。这是协议复杂度的直接反映:Postgres 的 Extended Query + 自定义类型 OID 让它需要 patches 和 type_holes 两套机制。
- SQLite 唯一保留
'q——因为它用Cow;其他两家编码后字节已经脱离原值,不需要生命周期。 - NULL 处理三家完全不同——值内标记(Postgres)、独立 bitmap(MySQL)、枚举变体(SQLite)。
IsNull在 sqlx-core 是统一的,但落到驱动层各自消化。 - MySQL 的 types 数组存的是
FieldType枚举而非 OID——因为 MySQL 没有 Postgres 那种数据库级 OID 概念,字段类型是协议枚举中的变体。
6.10 一次 add 的完整流程对比
把用户代码 .bind("hello".to_string()) 在三家 DB 下发生的事一表对齐:
| 步骤 | Postgres | MySQL | SQLite |
|---|---|---|---|
1. produces() | 通常 None → 用 type_info() = TEXT | None → TEXT(FieldType::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_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 的内存路径:
- 开始前
QueryBuilder内部估算并调args.reserve(3000, estimated_bytes)——types和buffer都一次分配到位。 - 每次
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 字节、回填长度。
push_bind(&entry.message)(String 借用):types.push(TEXT)、预留长度、buffer.extend(entry.message.as_bytes())、回填长度。- 完成后 SQL 字符串是
INSERT INTO logs ... VALUES ($1, $2, $3), ($4, $5, $6), ..., ($2998, $2999, $3000)。 - 发送时
PgArguments::apply_patches对 0 个 patches 和 0 个 type_holes(内置类型)跑 no-op。 - 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 路径的差异:
- 没有 patches/type_holes。
null_bitmap需要分配ceil(3000/8) = 375字节。- LENENC 让短字符串占 1 字节前缀而不是 4,3000 条日志累计节省约 9 KB。
- values 不预留长度前缀空间,encode 更直接。
SQLite 路径的差异:
- 根本不 encode 成字节——3000 个
SqliteArgumentValue变体存在values向量里。 - 每个变体大约 32 字节(最大 variant
Text(Cow)是(tag + String header)= 32 字节)。 - 真正 bind 发生在
sqlite3_bind_*调用时,按变体 match 分发。 - 不走网络——这 1000 行插入通常比 Postgres/MySQL 快一个数量级(前提:SQLite 的 busy timeout 允许独占)。
这个例子能具象地感受到三家 Arguments 实现的设计差异如何在批量操作下放大——PgArguments 在"类型一致、数量多"场景下吞吐最佳(因为字节流适合一次 I/O 发送);SQLite 在"本地、无协议序列化"场景下延迟最低;MySQL 在"短字符串为主"时带宽最省。没有一家全面领先——场景决定优劣,这就是第 22 章生产实战会反复讨论的事。
6.11 本章小结
本章把 sqlx 参数绑定的内部消化过程完整打开:
- Arguments trait 四件套(§6.2)——
reserve / add / len / format_placeholder是最小契约,add是唯一的泛型方法,承担消化异构类型的核心职责。 - IntoArguments 的存在理由(§6.3)——让
Query::bind能接受多种参数容器(Arguments 自身、ImmutableArguments、第三方适配)。impl_into_arguments_for_arguments!宏是又一处 GAT lazy normalization 限制的补丁。 - PgArguments 四件套(§6.4)——
buffer + count + patches + type_holes。buffer 是带长度前缀的字节流(-1长度表达 NULL)、patches 是值级延迟回填(JSONB 版本字节是主要场景)、type_holes 是 OID 回填(自定义类型必需)。snapshot + reset_to_snapshot让 add 事务化。 - MySqlArguments 三件套(§6.5)——
values + types + null_bitmap。独立 NullBitMap 是 MySQL binary protocol 的要求。 - SqliteArguments 枚举向量(§6.6)——
Vec<SqliteArgumentValue<'q>>,每个参数作为 tagged union 存储。带'q生命周期以支持 Cow 借用;into_static切断借用变 owned。 - format_placeholder 的
$1vs?(§6.7)——Postgres 的编号式允许复用和重排,MySQL/SQLite 的?按出现顺序。QueryBuilder::push_bind按此生成跨 DB 代码。 - ImmutableArguments 的编译期防护(§6.8)——
query!宏返回的类型不实现 Arguments,阻止用户多余.bind。这是 trait 实现集合做编译期约束的又一案例。 - 三家对照(§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: Encode 到 T: 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;
}三点关键差异:
- serde 有数十个
serialize_*方法(serialize_i32 / serialize_str / serialize_seq 等),sqlx 只有一个add<T>泛型方法。Serde 的做法是"Serializer 知道每种类型怎么序列化",sqlx 是"Arguments 不知道类型、由 Encode 实现决定字节"。 - serde 的关联类型把序列化状态机显式化(
SerializeSeq/SerializeMap/SerializeStruct),sqlx 的 Arguments 就是平的——参数按顺序 append,没有嵌套结构。这反映了 SQL 参数的线性本质(不像 JSON 有嵌套)。 - sqlx 的
format_placeholder让 Arguments 参与 SQL 拼接——这是 sqlx 独有的需求,serde 不需要。
两者都是优秀抽象,形态不同是因为被抽象的领域不同:
- serde 抽象的是"Rust 值树 → 外部格式"的递归映射。
- sqlx Arguments 抽象的是"Rust 值序列 → DB 协议参数"的平化映射。
读完这两章你会更清晰地看出:好的 trait 设计贴合被抽象领域的结构,而不是追求形式上的统一。serde 要支持嵌套就用关联类型表达子序列化器;sqlx 只需要平参数就用 add<T> 一个方法搞定——各自都是"最小合适"的。
6.15 下一章指路
下一章我们详细拆 Row 与 Column——你能拿到什么、怎么拿到、零拷贝到哪一步。