SQLx 源码深度解析
第6章 Arguments 与 IsNull:参数绑定的生命周期约束
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_*一族函数。IntoArgumentstrait 是一层薄的"我能转成 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 只有定义和它自己那条IntoArgumentsimpl 两处命中,宏并不用它。真正挡住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?答案分三层:
Encode + Typetrait bound(第 5 章)让任意类型都能 encode 成 buffer 字节。Arguments::add<T>(sqlx-core/src/arguments.rs:20-22)是泛型方法——接受任何满足Encode + Type的 T,在方法内部把 T 消化成字节。- 底层 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 可以接受多种参数容器:
- 直接的
PgArguments——用户可以手动构造一个 PgArguments,经query_with(sql, args)传入。 ImmutableArguments<'q, DB>——一个只实现IntoArguments、不实现Arguments的只读变体(§6.8 会说明它在 0.8.6 里已经没有使用者)。- 可能有的自定义容器——第三方 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 [0, 0, 0, 0]"]
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 时刻。原因:
- add 是同步的(
Encode::encode是同步函数,不能 await)——但 OID 查询要发 SQL 到服务端,必须 async。 - 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"。
类型层拒绝比这两条都好,理由有三:
- 失败发生在编译期更便宜。运行时 panic 可能直到某次生产请求才触发,而且错误信息是 "bound more arguments than expected" 这种模糊描述。类型错误则是"这个方法不存在",IDE 补全都不会给你。
- 对新手友好。sqlx 用户里有大量从 Python / Ruby ORM 迁过来的开发者,他们习惯"给一个 query 对象 bind 东西"的心智模型。让编译器直接说"你想做的事在这个上下文里不对",比运行时报错省一次调试。
- 符合 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 行 |
几条观察:
- 代码量差异巨大——
sqlx-postgres/src/arguments.rs一个文件就超过另外两家之和。这是协议复杂度的直接反映:Postgres 的 Extended Query + 自定义类型 OID 让它需要 patches 和 type_holes 两套机制。 - SQLite 唯一保留
'q——因为它用Cow;其他两家编码后字节已经脱离原值,不需要生命周期。 - NULL 处理三家完全不同——值内标记(Postgres)、独立 bitmap(MySQL)、枚举变体(SQLite)。
IsNull在 sqlx-core 是统一的,但落到驱动层各自消化。 - 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 的内存路径:
- 每次
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)。
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 字节 → 回填长度。- 完成后 SQL 字符串是
INSERT INTO logs ... VALUES ($1, $2, $3), ($4, $5, $6), ..., ($2998, $2999, $3000)。 - 发送时
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。 - 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):
- 没有 patches/type_holes;
MySqlArguments只有values: Vec<u8>/types: Vec<MySqlTypeInfo>/null_bitmap: NullBitMap三个字段。 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)。- LENENC 让短字符串占 1 字节前缀而不是 4:这批数据里只有
message一列是变长字符串,1000 行省下约 3 KB。 - values 不预留长度前缀空间,encode 更直接。
SQLite 路径的差异(sqlx-0.8.6/sqlx-sqlite/src/arguments.rs:12-24):
- 根本不 encode 成字节——3000 个
SqliteArgumentValue变体存在values: Vec<SqliteArgumentValue<'q>>里。 - 枚举的六个变体是
Null/Text(Cow<'q, str>)/Blob(Cow<'q, [u8]>)/Double(f64)/Int(i32)/Int64(i64),整体大小由最大变体Text/Blob决定——胖指针加容量再加判别式,几十字节量级。 - 真正 bind 发生在
sqlite3_bind_*调用时,按变体 match 分发。 - 不走网络——省掉了协议序列化和一次网络往返,这是 SQLite 在本地批量写入场景的结构性优势。
这个例子能具象地感受到三家 Arguments 实现的设计差异如何在批量操作下放大——PgArguments 把所有参数攒成一段连续字节、适合一次 I/O 发出去;SQLite 完全不做协议序列化、也不走网络;MySQL 的 LENENC 在短字符串为主时前缀开销最小。没有一家全面领先——场景决定优劣,这就是第 22 章生产实战会反复讨论的事。
6.12 本章小结
本章把 sqlx 参数绑定的内部消化过程完整打开:
- Arguments trait 四件套(§6.2)——
reserve / add / len / format_placeholder是最小契约,add是唯一接收参数值的方法,承担消化异构类型的核心职责。 - 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 化)。 PgArgumentBuffer四件套(§6.4)——buffer + count + patches + type_holes(PgArguments本体只有types和buffer两个字段)。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生命周期以支持 bind 时的 Cow 借用;每次执行前into_static切断借用变 owned,再交给工作线程。 - format_placeholder 的
$1vs?(§6.7)——Postgres 的编号式允许复用和重排,MySQL/SQLite 的?按出现顺序。QueryBuilder::push_bind按此生成跨 DB 代码。 - 多余
.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()的漏网之鱼。 - 三家对照(§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
Encodereturn 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
Sendtrait 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;
}
三点关键差异:
- 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.16 下一章指路
下一章我们详细拆 Row 与 Column——你能拿到什么、怎么拿到、零拷贝到哪一步。