SQLx 源码深度解析
第17章 MySQL 驱动:COM_QUERY 与 COM_STMT 的二元选择
MySQL 驱动的体量只有 Postgres 驱动的一半:协议扩展少、类型系统简单。COM_QUERY 与 COM_STMT 的二选一,对应的正是 Postgres 那边 Simple 与 Extended 的分野。
本章要点
- sqlx-mysql(约 9195 行)是 sqlx-postgres 的一半体量——协议细节少(无 COPY / LISTEN / advisory lock 等扩展) + 类型系统简单(MySQL 内建类型比 Postgres 少很多)。
- MySQL 协议两大命令:
COM_QUERY(0x03,文本查询)vsCOM_STMT_PREPARE/EXECUTE/CLOSE(0x16/0x17/0x19,二进制 prepared)。sqlx 默认走 COM_STMT——对等 Postgres 的 Extended Query。 - Handshake V10(
sqlx-mysql/src/connection/establish.rs)—— server 先发 Handshake、client 回 HandshakeResponse41 + auth 数据、然后 Auth 子协议(mysql_native_password / caching_sha2_password)。和 Postgres 的 Startup-first 相反。 - Capabilities 位图——64 位 flag 协商 client 和 server 的能力(SSL、plugin auth、multi-statement、deprecated EOF、session track 等)。双方取交集。
caching_sha2_password是 MySQL 8+ 的默认认证——比mysql_native_password安全(SHA-256 + 盐 + 多轮迭代)。sqlx-mysql/src/connection/auth.rs实现两种 + SHA256 公钥交换逻辑。注意 0.8.6 的 fast auth 因 scramble 拼接顺序错误恒失败、每条新连接都走 full auth,v0.9.0 的 #4245 才修好(§17.4)。MySqlArguments三件套(第 6 章 §6.5)—— values + types + null_bitmap。MySQL binary protocol 的 null 用独立位图而非值内-1长度。MySqlQueryResult有last_insert_id(Postgres 的 QueryResult 没有)——AUTO_INCREMENT列值由这个字段返回。用户代码里let id = result.last_insert_id()经典用法。status_flags(sqlx-mysql/src/connection/mod.rs:39)—— server 在每个 OK / EOF 包里带状态位(SERVER_STATUS_IN_TRANS / SERVER_STATUS_AUTOCOMMIT / SERVER_MORE_RESULTS_EXISTS 等)—— sqlx 缓存这些用于事务状态跟踪。- MariaDB 兼容——sqlx-mysql 同时支持 MySQL 5.7/8.0 和 MariaDB 10+——协议兼容但版本字符串和某些 capability 不同。URL scheme
mariadb://和mysql://都走同一驱动。
17.1 问题引入:MySQL 协议的定位
读完第 16 章 Postgres 驱动,你对 wire protocol 有了完整心智模型。MySQL 协议结构上很类似——握手、认证、查询、结果集——但每一步细节都不同。如果我重复讲一遍每个消息,你会觉得啰嗦。所以本章以对比为主线——讲 MySQL 哪里和 Postgres 相同、哪里不同、为什么不同。
MySQL 协议的几条宏观特征:
- 更老——MySQL 1995 年首发,握手包 HandshakeV10 从 3.21.0 起沿用至今;Postgres 现行的 v3 前后端协议则是 2003 年随 7.4 定型。
- 更简单——MySQL 内建类型少(数值 / 字符串 / 日期时间 + 一些 geometry)、协议扩展少(无 LISTEN/NOTIFY、无 COPY 等价物)。
- 协议版本多——握手包有 v9 / v10 两版,官方文档注明 v9 只用于 3.21.0 之前,现在都用 v10。
- 认证演进快——从 mysql_old_password(不安全)到 mysql_native_password(SHA1)到 caching_sha2_password(SHA256,MySQL 8 默认)。
- 方言多——MySQL vs MariaDB 协议兼容但 SQL 和 capability 略有分叉。
sqlx-mysql 的9195 行代码对比 sqlx-postgres 的 19841 行——体量一半。差距主要在:
- 无 COPY 协议(Postgres 有,MySQL 没有等价批量协议)。
- 无 LISTEN/NOTIFY。
- 无 advisory lock 封装(Postgres 驱动有
PgAdvisoryLock;MySQL 的GET_LOCK()是普通 SQL 函数,sqlx-mysql 只在迁移加锁时自己调了一下,sqlx-mysql/src/migrate.rs:146)。 - 类型系统简单——types/ 目录更小。
但核心的握手 + Extended Query 等价路径和 Postgres 对应——本章讲清楚这些差异就够。
17.2 MySQL 协议概览
MySQL 协议的包结构(和 Postgres 不同):
+-----------------+----------+---------+
| 长度 (3 字节) | 序号 | 载荷 |
| u24 小端 | u8 | N 字节 |
+-----------------+----------+---------+
差异点:
- 3 字节长度(u24)——最大单包 16MB。超过要分片。Postgres 是 4 字节 u32。
- 1 字节序号——每个包有递增序号、从 0 开始、每次新查询重置。Postgres 没有序号。
- 无类型字节——消息类型通过第一字节载荷或上下文识别。Postgres 每个消息都有明确的类型字节。
序号的规则——官方文档只规定它随包递增、每个新命令从 0 开始。sqlx 发包时按它编号(send_packet 先把 sequence_id 清零),收包时直接取对方序号加 1,并不校验是否连续(sqlx-mysql/src/connection/stream.rs:111-140)。
MySQL 的序号 + 分片机制让大数据包(>16MB)能跨多个物理包传——sqlx-mysql 在 sqlx-mysql/src/protocol/packet.rs(发送侧切片)和 sqlx-mysql/src/connection/stream.rs(接收侧拼接)里实现,细节见 §17.14。
17.3 连接握手:Handshake V10
和 Postgres 相反——MySQL 是 server first:client 连 TCP 后、server 先发 Handshake:
Handshake V10 (server → client):
- protocol_version (u8 = 10)
- server_version (null-terminated string)
- thread_id (u32, MySQL 内部 session ID)
- auth_plugin_data_part_1 (8 字节盐)
- filler (1 字节 0x00)
- capability_flags_lower (u16)
- character_set (u8)
- status_flags (u16)
- capability_flags_upper (u16)
- auth_plugin_data_len (u8)
- reserved (10 字节 0x00)
- auth_plugin_data_part_2 (>= 13 字节)
- auth_plugin_name (null-terminated)
Client 接收后解析 server 发的能力 + 盐,然后决定怎么回复。
HandshakeResponse41(client → server):
- capability_flags (u32)
- max_packet_size (u32;sqlx 发的是 `MAX_PACKET_SIZE = 1024`,见 `sqlx-mysql/src/connection/mod.rs:24`)
- character_set (u8, 客户端选的字符集)
- reserved (23 字节)
- username (null-terminated)
- auth_response_length (LENENC)
- auth_response (auth_response_length 字节,即按认证插件算出的 scramble)
- database (null-terminated, 可选)
- auth_plugin_name (null-terminated)
- connect_attrs (LENENC key-value list, 可选)
对比 Postgres:Postgres 是 client 先发 Startup、server 根据 pg_hba.conf 决定认证方式;MySQL 是 server 先发 Handshake 暴露自己的 capabilities、client 从中选能接受的。两种哲学——"client 协商" vs "server 声明 + client 同意"。
17.3.1 Capabilities 位图
sqlx 里 Capabilities 是一个 bitflags 的 u64(sqlx-mysql/src/protocol/capabilities.rs:7-9)。源码注释说得很清楚:MySQL 把 capability flags 定义成 int<4>,是 MariaDB 用另一个字段扩展出了更高的位,sqlx 为了省事把两段合并成一个 u64——所以 1 << 32 往上的 MARIADB_CLIENT_* 几个是 MariaDB 专有。常见位(注意 sqlx 的常量名去掉了 CLIENT_ 前缀):
| 位 | 含义 |
|---|---|
CLIENT_LONG_PASSWORD(0x01) |
4.1.1 起默认视为已置位;sqlx 里这一位叫 MYSQL(MariaDB 的 CLIENT_MYSQL) |
CLIENT_PROTOCOL_41(0x200) |
用协议 v4.1 而非老 v3.23 |
CLIENT_SSL(0x800) |
SSL 支持 |
CLIENT_PLUGIN_AUTH(0x80000) |
支持 auth plugin 协商 |
CLIENT_CONNECT_WITH_DB(0x08) |
初始 db 在 handshake 里指定 |
CLIENT_MULTI_STATEMENTS(0x10000) |
支持一个包多条 SQL |
CLIENT_DEPRECATE_EOF(0x1000000) |
用 OK 包替代 EOF 包 |
Client 和 server 取交集——sqlx-mysql/src/connection/establish.rs:116 的 stream.capabilities &= handshake.server_capabilities; 就是这一步,紧接着 |= Capabilities::PROTOCOL_41 强行保住 4.1 协议。sqlx 想要的那一组在 MySqlStream::with_socket(sqlx-mysql/src/connection/stream.rs:43-54)里写死:PROTOCOL_41 | IGNORE_SPACE | DEPRECATE_EOF | FOUND_ROWS | TRANSACTIONS | SECURE_CONNECTION | PLUGIN_AUTH_LENENC_DATA | MULTI_STATEMENTS | MULTI_RESULTS | PLUGIN_AUTH | PS_MULTI_RESULTS | SSL,指定了 database 时再加 CONNECT_WITH_DB。
17.4 认证:caching_sha2_password 详解
MySQL 8 的默认认证插件是 caching_sha2_password——相比旧的 mysql_native_password(SHA1)更安全。
mysql_native_password 握手(老):
scramble = SHA1(password) XOR SHA1(nonce + SHA1(SHA1(password)))
客户端发 scramble、server 验证。
caching_sha2_password 握手(新):
- Client 发 scramble。官方文档的定义是
XOR(SHA256(password), SHA256(SHA256(SHA256(password)), nonce));sqlx 0.8.6 的scramble_sha256(sqlx-mysql/src/connection/auth.rs:105-130)却把 nonce 放在SHA256(SHA256(password))之前喂给哈希,与 MySQL 服务端Generate_scramble/Validate_scramble的顺序(先摘要、后 nonce)相反——服务端的快速校验因此永远对不上。 - Server 检查缓存(就是"caching"的来源)——如果密码在 server 的
caching_sha2_password缓存里、验证通过。 - 缓存 miss 或校验失败——server 都回 "full auth"(0x04)(MySQL 服务端源码 sha2_password.cc 里的注释:"We either failed to authenticate or did not find entry in the cache")——client 必须用明文密码 + SSL 或 公钥加密再发。
合起来的后果是:sqlx 0.8.6 连 MySQL 8 时每条新连接都走 full auth,缓存形同虚设;认证照样成功,只是不走 TLS 时每次都要多一轮 RSA 公钥交换。这个问题到 v0.9.0 才由 #4245("repair caching_sha2_password fast-auth path")修掉——PR 说明写的正是 "mixed the SHA-256 inputs in the wrong order",并顺带修了 fast auth 成功(0x01 0x03)后没读掉紧随其后的 OK 包的潜伏问题。v0.9.0 还把非 TLS 下的 RSA 加密挪进了需要显式开启的 mysql-rsa feature(#4142)。以上 0.9.0 的两条出自 sqlx 仓库主线 CHANGELOG.md 与 PR 说明;本机无 MySQL,未实跑。
sqlx-mysql 的 sqlx-mysql/src/connection/auth.rs 实现所有这些——包括:
- 发第一轮 scramble。
- 处理 "full auth" fallback(SSL 下发明文、非 SSL 下通过公钥交换发密码)。
- 处理公钥请求(
AuthMoreData带公钥、client 用公钥 RSA 加密密码)。
缓存未命中又不走 TLS 时,认证阶段要多出 AuthMoreData(0x04)、公钥请求、公钥响应、加密密码四个包;走 TLS 时则直接发以 \0 结尾的明文密码(sqlx-mysql/src/connection/auth.rs:140-143)。sqlx-mysql/src/connection/auth.rs 共约 200 行——用 sha1 / sha2 + rsa + rand crate 实现。
Rust client 实现 RSA 加密密码听起来复杂、但 rsa crate 提供高层 API——encrypt_rsa(sqlx-mysql/src/connection/auth.rs:132-168)不到 40 行搞定 "公钥 + 密码 + nonce → 加密字节":密码补 \0 后与 nonce 循环异或,再用 RSA-OAEP(SHA-1)加密。
17.5 COM_QUERY:文本查询
MySQL 的 simple query 等价物是 COM_QUERY:
Client → Server: COM_QUERY (0x03) + sql 字符串
Server → Client: (按查询类型响应)
- UPDATE/INSERT/DELETE/DDL: OK Packet
- SELECT: ResultSet (ColumnCount + ColumnDefinition × N + EOF + Row × M + EOF)
- 错误: ERR Packet
ResultSet 的结构:
[ColumnCount (LENENC integer)]
[ColumnDefinition 1] ... [ColumnDefinition N]
[EOF packet] (老版本) 或 没有(新版本 CLIENT_DEPRECATE_EOF)
[Row 1] ... [Row M] 每行是 LENENC-string × N
[EOF / OK packet]
COM_QUERY 结果用文本格式——每列值是字符串。Row 是 LENENC-string × N——每列值带长度前缀。
NULL 表示:0xfb 单字节标记(不是 LENENC)。这是 MySQL 文本协议特色。
sqlx-mysql 在参数为 None 时走 COM_QUERY——sqlx-mysql/src/connection/executor.rs:152-156 的分支:
} else {
// https://dev.mysql.com/doc/internals/en/com-query.html
self.inner.stream.send_packet(Query(sql)).await?;
(Arc::default(), MySqlValueFormat::Text, true)
}
场景:直接把 &str 交给 executor(conn.execute("..."))、raw_sql,以及 sqlx 自己发的 BEGIN / SET 等——COM_QUERY 简洁、一包搞定。注意 sqlx::query("SELECT 1") 即使一次 bind 都没有,参数也是 Some(空 Arguments)(sqlx-core/src/query.rs:655-664),照样走 COM_STMT(第 9 章 §9.5)。
17.6 COM_STMT:二进制预处理
MySQL 的 Extended Query 等价是 COM_STMT 三件套:
17.6.1 COM_STMT_PREPARE (0x16)
Client → Server: 0x16 + sql
Server → Client: OK:
- statement_id (u32)
- num_columns (u16)
- num_params (u16)
- warnings (u16)
- ColumnDefinition × num_params
- EOF (如果 !DEPRECATE_EOF)
- ColumnDefinition × num_columns
- EOF (如果 !DEPRECATE_EOF)
一条消息发 SQL、一组响应包拿回 statement_id + 参数定义 + 列定义。类似 Postgres 的 Parse + ParameterDescription + RowDescription 合并。
17.6.2 COM_STMT_EXECUTE (0x17)
Client → Server: 0x17:
- statement_id (u32)
- flags (u8, CURSOR_TYPE 等)
- iteration_count (u32 = 1)
- null_bitmap (bytes, (num_params+7)/8)
- new_params_bound_flag (u8 = 1)
- param_types (u16 × num_params)
- param_values (binary × num_params)
核心是 null_bitmap + 参数值——每参数按binary 协议编码。
类型和值的分离:每个参数先发类型 (u16:类型字节 + 无符号标志 0x80,见 sqlx-mysql/src/protocol/statement/execute.rs 的 encode_with)、然后集中发值(按类型紧凑格式)。整数按类型 1/2/4/8 字节小端、字符串是 LENENC-string、日期时间是特殊二进制格式。
Response 和 COM_QUERY 的 ResultSet 类似,但Row 是 binary format(不同于 COM_QUERY 的 text format):
Binary Row:
- header (u8 = 0x00)
- null_bitmap (bytes, (num_fields+9)/8,前 2 位保留)
- field values (类型特定 binary)
Binary row 比 text row 更紧凑——整数 4 字节而不是 "123456" 6 字节——但只有 COM_STMT_EXECUTE 触发的响应才是 binary row(COM_QUERY 始终 text)。
17.6.3 COM_STMT_CLOSE (0x19)
Client → Server: 0x19 + statement_id
(无响应)
没有响应包——单向通知 server 释放 statement。sqlx 在 statement cache 淘汰时发这条(类似 Postgres 的 Close::Statement),非持久化执行、不开缓存时的 prepare_with 和 describe 用完语句也会立刻发。
17.7 sqlx-mysql 的 run 方法
sqlx-mysql/src/connection/executor.rs:102-248 的 run 方法——和 Postgres 的 run 类似结构(以下为简化版):
pub(crate) async fn run<'e, 'c: 'e, 'q: 'e>(
&'c mut self, sql: &'q str, arguments: Option<MySqlArguments>, persistent: bool,
) -> Result<impl Stream<...>, Error>
{
self.inner.stream.wait_until_ready().await?;
self.inner.stream.waiting.push_back(Waiting::Result);
Ok(try_stream! {
let (column_names, format, needs_metadata) = if let Some(args) = arguments {
// COM_STMT_PREPARE + COM_STMT_EXECUTE 路径
if persistent && self.inner.cache_statement.is_enabled() {
let (id, metadata) = self.get_or_prepare_statement(sql).await?;
self.inner.stream.send_packet(StatementExecute { statement: id, arguments: &args }).await?;
(metadata.column_names, MySqlValueFormat::Binary, false)
} else {
// 不进缓存:PREPARE 完执行完立刻 CLOSE
let (id, metadata) = self.prepare_statement(sql).await?;
self.inner.stream.send_packet(StatementExecute { statement: id, arguments: &args }).await?;
self.inner.stream.send_packet(StmtClose { statement: id }).await?;
(metadata.column_names, MySqlValueFormat::Binary, false)
}
} else {
// COM_QUERY 路径
self.inner.stream.send_packet(Query(sql)).await?;
(Arc::default(), MySqlValueFormat::Text, true)
};
// 循环接收 ResultSet 或 OK packet
loop {
let mut packet = self.inner.stream.recv_packet().await?;
if packet[0] == 0x00 || packet[0] == 0xff {
// OK 或 ERR 包
let ok = packet.ok()?;
self.inner.status_flags = ok.status;
r#yield!(Either::Left(MySqlQueryResult {
rows_affected: ok.affected_rows,
last_insert_id: ok.last_insert_id,
}));
// 检查 multi-result
if ok.status.contains(Status::SERVER_MORE_RESULTS_EXISTS) { continue; }
break;
}
// ResultSet: ColumnCount + ColumnDef + Rows + EOF
// ... 略
}
})
}
两条分叉:
- COM_QUERY(参数为
None)—— 简单路径。 - COM_STMT_PREPARE + EXECUTE(参数为
Some,哪怕是空的)——persistent && cache_statement.is_enabled()时走get_or_prepare_statement(进缓存);否则走prepare_statement并在 EXECUTE 之后立刻补一条StmtClose。
17.7.1 与 sqlx-postgres::run 的对比
关键差异:
| 维度 | sqlx-postgres run | sqlx-mysql run |
|---|---|---|
| 发送路径 | 未命中先 Parse + Describe + Sync;再 Bind + Execute + Close(Portal) + Sync | 未命中先 COM_STMT_PREPARE;再 EXECUTE(非持久化补 CLOSE) |
| 消息数(缓存命中) | 4 条(Bind / Execute / Close / Sync) | 1 条(EXECUTE) |
| 同步机制 | Sync 消息 + ReadyForQuery | 序号 + 每响应包带 status_flags |
| 缓存淘汰 | 挤出旧语句时当场发 Close::Statement + Sync 并等 CloseComplete | 挤出旧语句时当场发 COM_STMT_CLOSE,无应答、不等 |
| 多结果集 | 不支持在一条 Execute 内 | SERVER_MORE_RESULTS_EXISTS 标志 + 循环 |
一个值得注意的点:MySQL 支持 "multi-statement"(CLIENT_MULTI_STATEMENTS capability)——一个 COM_QUERY 包多条 SQL、server 顺序执行、返回多个结果集。sqlx-mysql 的 run 循环通过 SERVER_MORE_RESULTS_EXISTS 标志检测并处理。Postgres 不支持一条 prepared statement 内多语句。
17.8 MySQL 特殊:status_flags 跟踪事务
MySqlConnectionInner::status_flags: Status(sqlx-mysql/src/connection/mod.rs:39)——跟踪 server 最新响应里的 status flags。几个关键位:
// sqlx-mysql/src/protocol/response/status.rs:5(外层还有一层 bitflags::bitflags! {})
pub struct Status: u16 {
const SERVER_STATUS_IN_TRANS = 1; // bit 0
const SERVER_STATUS_AUTOCOMMIT = 2; // bit 1
const SERVER_MORE_RESULTS_EXISTS = 8; // bit 3
const SERVER_STATUS_CURSOR_EXISTS = 64; // bit 6
const SERVER_STATUS_LAST_ROW_SENT = 128; // bit 7
// …外加 SERVER_QUERY_NO_GOOD_INDEX_USED (16) / SERVER_QUERY_NO_INDEX_USED (32),
// 以及 SERVER_STATUS_DB_DROPPED (1 << 8) 起到 SERVER_SESSION_STATE_CHANGED (1 << 14) 的七个
}
每个 OK / EOF 响应包都带 status——sqlx 每次响应后更新 status_flags。这条持续状态同步让 sqlx 知道:
- 事务中吗?——
status_flags.contains(SERVER_STATUS_IN_TRANS) - autocommit 吗?——
status_flags.contains(SERVER_STATUS_AUTOCOMMIT) - 还有下一个结果集?——
SERVER_MORE_RESULTS_EXISTS
Postgres 的等价机制是 ReadyForQuery 消息的 transaction_status 字段——Postgres 是每个 Sync 之后给一次,MySQL 是每个 OK / EOF 包都带(多语句时每条语句结束各有一个)。
in_transaction 方法(sqlx-mysql/src/connection/mod.rs:48-53):
pub(crate) fn in_transaction(&self) -> bool {
self.inner.status_flags.intersects(Status::SERVER_STATUS_IN_TRANS)
}
直接查 status——O(1) 没网络开销。它在驱动里唯一的调用点是 MySqlTransactionManager::begin(sqlx-mysql/src/transaction.rs:32-35):执行完 BEGIN 后检查 IN_TRANS 位,没置上就报 Error::BeginFailed;commit / rollback 靠的是 transaction_depth 计数,不看这个位。
17.9 LENENC 变长长度编码
第 6 章 §6.5.1 讲过 LENENC——这里总结一下 MySQL 为什么需要它:
- MySQL 包:3 字节长度(max 16MB per 包)。
- 包内每个长度字段:如果都固定 4 字节,小值浪费;用 LENENC 变长。
LENENC 编码(first byte 决定格式):
- 0-250:单字节。
- 251:NULL 标记(COM_QUERY text 里用,STMT_EXECUTE 不用)。
- 252 + u16 小端:2 字节长度(251-65535)。
- 253 + u24 小端:3 字节长度(65536-16M)。
- 254 + u64 小端:8 字节长度(> 16M,极少用)。
每次用 LENENC 读长度时先读 1 字节再按 case 分支——稍复杂但空间紧凑。sqlx-mysql 把读写封装成 MySqlBufExt::get_uint_lenenc / MySqlBufMutExt::put_uint_lenenc(sqlx-mysql/src/io/buf.rs、sqlx-mysql/src/io/buf_mut.rs),外层代码只关心逻辑整数。
Postgres 的对应是 -1 用于 NULL、正数用于长度——但长度字段永远 4 字节固定。MySQL 的 LENENC 在短值场景(大部分)节省 1-3 字节每值——累加下来在高并发能省带宽。
17.10 字符集协商
MySQL 连接建立时要选字符集——server 支持几十种(utf8mb4 / utf8 / latin1 / gbk / big5 / ...)。每种字符集有若干 collation(排序规则)——每个 collation 有数字 ID。
sqlx-mysql/src/collation.rs(900 行)里是两个 enum:CharSet(41 个字符集,变体名就是字符集名的小写)和 Collation(两百多条,判别值就是 MySQL 的 collation id):
pub(crate) enum Collation {
// ...
latin1_swedish_ci = 8,
utf8mb4_general_ci = 45,
utf8mb4_bin = 46,
binary = 63,
utf8mb4_unicode_ci = 224,
utf8mb4_0900_ai_ci = 255,
// ...
}
Client 在 HandshakeResponse41 里发的是 stream.collation as u8(sqlx-mysql/src/connection/establish.rs:128)——一个 collation id,server 按它决定后续字符串交互。sqlx 的默认是 charset = "utf8mb4"、collation = None(sqlx-mysql/src/options/mod.rs:101-102),None 时取 CharSet::utf8mb4.default_collation() = utf8mb4_unicode_ci(224)——不是 utf8mb4_0900_ai_ci。另外 set_names 选项默认开着,连上后发的那条 SET 里会带上 NAMES <charset> COLLATE <collation>(和 sql_mode、time_zone 合成一条语句,sqlx-mysql/src/options/connect.rs:53-81)。
这条细节在 Postgres 里不存在——Postgres 的客户端编码只是 Startup 里的一个 client_encoding 参数(sqlx 固定传 UTF8,sqlx-postgres/src/connection/establish.rs:31),没有 collation id 这一层。MySQL 的 collation 体系比 Postgres 复杂——是历史包袱。
17.11 MariaDB 兼容性
MariaDB 是 MySQL 的 fork(2009 年分叉)——协议层基本兼容但略有差异:
兼容的部分(大多数):
- Handshake V10、HandshakeResponse41。
- COM_QUERY、COM_STMT_* 家族。
- 基本数据类型和 field types。
差异的部分:
- server_version 字符串——MySQL 是
"8.0.32"、MariaDB 会带5.5.5-前缀(sqlx-mysql/src/protocol/connect/handshake.rs:148的测试用例就是"5.5.5-10.4.7-MariaDB-1:10.4.7+maria~bionic")。sqlx 只把它按.拆成(major, minor, patch)三元组存进MySqlStream::server_version。 - Capability flags——MariaDB 在
1 << 32往上有自己的扩展位:MARIADB_CLIENT_PROGRESS/MARIADB_CLIENT_MULTI/MARIADB_CLIENT_STMT_BULK_OPERATIONS/MARIADB_CLIENT_EXTENDED_TYPE_INFO/MARIADB_CLIENT_CACHE_METADATA/MARIADB_CLIENT_BULK_UNIT_RESULTS(sqlx-mysql/src/protocol/capabilities.rs:91-109)。sqlx 解析 handshake 时会读它们,但不主动申请。 - 错误码——
sqlx-mysql/src/error.rs:168有一个mod mariadb,专门放 MariaDB 独有的错误码(如ER_CONSTRAINT_FAILED)用于错误分类。 - 某些 SQL 方言——MariaDB 支持
RETURNING(MySQL 不支持)、一些函数名差异。
需要澄清一点:sqlx-mysql 里并没有 if is_mariadb 这样的方言分叉。mysql 和 mariadb 只是 MySqlDatabase::URL_SCHEMES(sqlx-mysql/src/database.rs:35)里并列的两个 scheme 名,指向完全相同的代码路径;真正的差异只体现在上面那几处 capability 位和错误码表上。
用户代码视角:写的 SQL 如果只用标准 SQL 子集——两家 DB 都跑。用了 MariaDB 特有的 RETURNING——只能跑 MariaDB。sqlx 不做 SQL 翻译——用户自己保证 SQL 的方言对齐。
17.12 MySQL 特性实战
MySQL 独有的几个实战场景:
17.12.1 last_insert_id
let result = sqlx::query("INSERT INTO users (name) VALUES (?)")
.bind("Alice").execute(&pool).await?;
let new_id = result.last_insert_id(); // AUTO_INCREMENT 的新 ID
MySQL 的 AUTO_INCREMENT + last_insert_id() 是业务常见模式——MySqlQueryResult 直接暴露这个值、省一次 SELECT。
Postgres 的对应是 INSERT ... RETURNING id + fetch_one——一条 SQL 拿回 ID。两种模式各有千秋——MySQL 的 last_insert_id 简洁但只能拿一列(自增 ID),Postgres 的 RETURNING 灵活能拿整行。
17.12.2 ON DUPLICATE KEY UPDATE
sqlx::query("INSERT INTO counters (name, hits) VALUES (?, 1)
ON DUPLICATE KEY UPDATE hits = hits + 1")
.bind("view_count").execute(&pool).await?;
MySQL 的 UPSERT 语法——Postgres 用 INSERT ... ON CONFLICT ... DO UPDATE。两者语法不同但语义类似。列名别用 key(MySQL 保留字),也别用 count:sqlx 握手时申请了 IGNORE_SPACE,官方文档说明这会让内建函数名都成为保留字。
17.12.3 Full-text search
SELECT * FROM articles WHERE MATCH(title, body) AGAINST ('rust sqlx' IN NATURAL LANGUAGE MODE)
MySQL 的原生全文索引——比 Postgres 的 tsvector 简单、功能也少。sqlx 不做特殊封装、普通 query 就能用。
17.12.4 事务隔离级别
MySQL 默认 REPEATABLE READ(比 Postgres 的 READ COMMITTED 更严)——业务迁移从 Postgres 到 MySQL 要注意这条差异可能带来的并发行为变化。
要定制隔离级别,最稳妥的写法是在同一条连接上先执行 SET TRANSACTION ISOLATION LEVEL ...、再 begin()(对 Pool 分两次调用可能拿到两条不同的连接),而不是把两条语句塞进一个 begin_with:SET TRANSACTION 只对下一个事务生效,begin_with 的字符串是原样发出去的一条 COM_QUERY,依赖多语句能力,可读性和可移植性都差。
17.13 sqlx-mysql 和 sqlx-postgres 的全面对比
一张完整对比表:
| 维度 | sqlx-postgres | sqlx-mysql |
|---|---|---|
| 代码量 | 19841 行 | 9195 行 |
| 主要扩展 | LISTEN/NOTIFY, COPY, advisory lock | 无(前两者 MySQL 协议无等价物,advisory lock 未封装) |
| 类型系统 | 丰富(types/ 50 个文件、8888 行) |
简单(types/ 2638 行) |
| 认证机制 | SCRAM-SHA-256(主流) | caching_sha2_password / mysql_native |
| 协议顺序 | client 先发 Startup | server 先发 Handshake |
| 消息类型字节 | 每消息 1 字节类型 | 无(命令码在载荷第一字节) |
| 协议分割 | Sync 消息 | 包序号 |
| 长度编码 | 固定 4 字节 | LENENC 变长 |
| NULL 编码 | 长度字段 -1 | 二进制协议用独立 null_bitmap,文本协议用 0xFB |
| 结果 binary/text | 同时支持(per column) | 同时支持(per query 类型) |
| 批量 INSERT | UNNEST 或 VALUES (...) | VALUES (...) 或 LOAD DATA |
| AUTO_INCREMENT 返回 | RETURNING 语句 | last_insert_id() |
| 事务默认隔离 | READ COMMITTED | REPEATABLE READ |
LISTEN/NOTIFY 监听(PgListener) |
是 | 否(需要轮询 polling) |
| COPY 批量 | 是(极快) | 否(LOAD DATA 走 SQL 层) |
| 字符集 | sqlx 在 Startup 里固定 client_encoding=UTF8 |
连接时选一个 collation id(collation.rs 两百多条) |
17.13.1 MySQL 握手序列图
把 MySQL handshake + auth 用 mermaid 表达:
sequenceDiagram
participant C as sqlx Client
participant S as MySQL Server
C->>S: TCP 连接
S-->>C: Handshake V10 server_version + nonce + caps
Note over C: 按 server 给的 auth_plugin(此处为 caching_sha2_password)算 scramble
C->>S: HandshakeResponse41 username + auth_response + caps
S-->>C: AuthMoreData fast_auth_success (0x03)
S-->>C: OK Packet
Note over C,S: 连接就绪
alt caching_sha2 缓存 miss
S-->>C: AuthMoreData full_auth (0x04)
opt 非 SSL
C->>S: RequestPublicKey
S-->>C: AuthMoreData public_key (PEM)
end
C->>S: 密码(TLS 下明文,否则 RSA 加密的 password XOR nonce)
S-->>C: OK Packet
end
两条路径:
- Fast auth(server 已缓存密码 hash)——Handshake、HandshakeResponse、AuthMoreData(0x03)、OK 四个包完成。
- Full auth(缓存 miss 首次登录)——多 2-3 消息(公钥交换 + RSA 加密)——首次登录后 server 缓存、后续 fast。
这是协议设计的两条路;如 §17.4 所述,sqlx 0.8.6 因 scramble 顺序错误,实际每条新连接都走第 2 条(v0.9.0 修复)。认证只发生在建连时,连接池里复用的连接不会重新认证——池子稳定后影响有限,频繁建连(min_connections 为 0、max_lifetime 很短、serverless 冷启动)时才明显。
17.13.2 COM_STMT 执行序列图
sequenceDiagram
participant C as sqlx Client
participant S as MySQL Server
Note over C: cache miss 情况
C->>S: COM_STMT_PREPARE sql
S-->>C: PREPARE_OK statement_id + param_defs + col_defs
Note over C: 缓存 statement_id
C->>S: COM_STMT_EXECUTE id + null_bitmap + params
S-->>C: ColumnCount + ColumnDef × N
S-->>C: Row × M binary
S-->>C: OK Packet with status_flags
opt 非 persistent
C->>S: COM_STMT_CLOSE id
Note over S: server 释放 statement
end
一次 query约 2 次 RTT(cache miss)或 1 次 RTT(cache hit)。和 Postgres 基本等同——协议差异大、往返次数相同。
17.14 sqlx-mysql 独特的协议细节
几条 sqlx-mysql 独有的协议细节值得单独提一下——它们和 Postgres 没对应:
1. Packet 分片(packet splitting)
MySQL 单物理包最大 16MB(3 字节长度上限)。超过时自动分片——一条逻辑消息拆成多个 physical packet、每个带序号递增。
发送侧在 sqlx-mysql/src/protocol/packet.rs 的 impl ProtocolEncode for Packet<T>——载荷长度 >= 0xFF_FF_FF 时按 chunks_exact(0xFF_FF_FF) 切开、每片补一个新 header,并且长度正好是 0xFFFFFF 的整数倍时还会追加一个零长包作为结束标记。接收侧在 sqlx-mysql/src/connection/stream.rs 的 recv_packet——读到一个正好 0xFF_FF_FF 长的分片就继续 recv_packet_part 拼接,直到某片短于上限。用户代码完全透明。
Postgres 没有这个问题——单消息长度前缀是 i32,上限远超实际使用。
2. EOF packet 还是 OK packet
MySQL 5.7.5 之前,结果集末尾用 EOF 包(0xfe + warnings + status)。5.7.5 起可以协商 CLIENT_DEPRECATE_EOF、改用 OK 包(结果集末尾这个 OK 包的首字节仍是 0xFE,以便和数据行区分),列定义之后的那个 EOF 也省掉。sqlx 默认声明这 capability——所以大部分时候看到的是 OK 包。
这条小细节让 sqlx 代码里的 recv 逻辑要同时处理两种包——按 deprecate_eof 能力分支。代码稍复杂但向后兼容老 MySQL server。
3. LOCAL_INFILE 未处理
MySQL 的 LOAD DATA LOCAL INFILE '/path/...' INTO TABLE ... 让 client 上传文件到 server。协议上 server 回 LOCAL_INFILE 请求、client 流式发文件字节。
sqlx-mysql 未实现 LOCAL_INFILE——握手时也没申请 LOCAL_FILES 能力(MySqlStream::with_socket 的能力集里没有它),客户端代码不能用这个语法。用户需要在 server 侧预先有文件( LOAD DATA INFILE 不带 LOCAL) 或者用多条 INSERT 替代。
4. Multi-statement 支持
MySQL 支持一条 COM_QUERY 发多条 SQL(分号分隔)——由 CLIENT_MULTI_STATEMENTS 位启用。sqlx-mysql 默认就申请了这个位(连同 MULTI_RESULTS / PS_MULTI_RESULTS,见 MySqlStream::with_socket),run() 的接收循环靠 Status::SERVER_MORE_RESULTS_EXISTS 逐个消费多个结果集。
所以 raw_sql("stmt1; stmt2") 这种写法在 sqlx-mysql 上是能跑的——但别把用户输入拼进去:多语句能力放大了 SQL 注入的后果。带参数的查询走 COM_STMT 路径、参数不参与 SQL 文本,才是安全写法。
17.15 sqlx-mysql 和 MariaDB 10+ 的特殊支持
MariaDB 相对 MySQL 有几个独有特性——sqlx 基本不做特殊适配:
1. RETURNING(MariaDB 10.5+)
INSERT INTO users (name) VALUES ('Alice') RETURNING id, name
MariaDB 模仿 Postgres 的 RETURNING。sqlx 不需要特殊代码——SQL 字面量就能用。但只 MariaDB 支持——MySQL 同语法会报语法错。
2. Vector Type(MariaDB 11+)
MariaDB 较新的版本有向量数据类型(用于 ML embedding 之类)。sqlx-mysql 0.8.6 没有对应的 Rust 类型映射。
3. JSON 函数方言差异
MySQL 的 JSON 是原生二进制存储类型,MariaDB 的 JSON 则是 LONGTEXT 的别名,两边的 JSON 函数实现也有出入——用户 SQL 要按实际 DB 方言写。
这些差异提醒一个实用观察:"MySQL 或 MariaDB"不是可互换的——虽然协议兼容,SQL 方言分叉明显。团队选 MariaDB 的应当心理预期会和 MySQL 路线分开,不指望 MySQL 的新特性在 MariaDB 上同步出现。
17.16 MySQL 协议安全性演进
MySQL 协议的安全演进可以简要梳理:
- 4.1 之前(MySQL 3.x/4.0):
mysql_old_password——8-byte scramble。极不安全。 - 4.1 起(MySQL 4.1 / 5.x):
mysql_native_password——20-byte SHA1 scramble。比 old 好但 SHA1 已不推荐。 - 2018 至今(MySQL 8+):
caching_sha2_password——SHA2 + 多轮盐 + 可选公钥交换。目前状态最佳。
sqlx-mysql 支持四种插件(见 §17.27 的 AuthPlugin enum)——但客户端不选插件:插件名由 server 在 Handshake V10 里给出,或者中途用 AuthSwitchRequest(0xfe)要求换一种,sqlx 照着算响应。MySqlConnectOptions 里没有 auth_plugin() 方法;唯一相关的开关是 enable_cleartext_plugin(bool)(默认 false),用来放行 mysql_clear_password。
对用户的实际含义:从 MySQL 5.7 升级到 8.0 时——账号密码要重新 hash(用 ALTER USER ... IDENTIFIED WITH caching_sha2_password BY '...')——老账号沿用 mysql_native_password 在 8.0 上还能用,但官方文档写明它 8.0.34 起弃用、8.4 默认禁用、9.0.0 移除。这是 sqlx 之外的 ops 话题、但影响 sqlx 连接的可用性。
17.17 MySQL 驱动的可读性观察
读完 sqlx-mysql 源码、和 sqlx-postgres 对比,几条可读性观察:
1. sqlx-mysql 代码风格和 Postgres 高度一致——文件组织、函数命名、错误处理模式都对齐,"熟悉一家就能读另一家"。
2. 注释密度和 Postgres 驱动相当——以 // 开头的行在 sqlx-mysql 约占 12%(1121 / 9195),sqlx-postgres 约 13%(2643 / 19841)。
3. 日志/追踪反而更少——grep -c "tracing::" 在 sqlx-mysql 只有 2 处、sqlx-postgres 有 16 处(Postgres 那边光是 NoticeResponse 的动态等级映射就占了好几处)。
4. 测试分两层——协议编解码的 #[test] 就写在消息文件里(比如 handshake.rs 直接喂一串真实抓包字节做 decode 断言),集成测试在仓库根的 tests/mysql/,配合 tests/docker-compose.yml 里的多个 MySQL / MariaDB 版本跑。
5. 零 unsafe——grep -rn unsafe sqlx-mysql/src/ 一条都没有(sqlx-postgres 里唯一一处 unsafe 字样也只是 types/citext.rs 的文档注释)。协议编解码全靠 bytes 的安全 API。
代码品质和 Postgres 等级——社区贡献者来回切换两家驱动时能保持一致的工作体验。这是 sqlx 作为 生态型项目 的工程纪律。
17.18 本章的实战技巧集
最后汇总几条用 sqlx-mysql 的实战技巧:
1. URL scheme 随便挑——mysql://... 和 mariadb://... 在 sqlx-mysql 里是同一条代码路径(MySqlDatabase::URL_SCHEMES 并列的两个名字),写哪个都行,别指望换 scheme 会切换什么兼容模式。
2. 字符集设 utf8mb4——?charset=utf8mb4 在 URL 里、或 MySqlConnectOptions::charset("utf8mb4")。避免 utf8(MySQL 的"utf8"其实是 utf8mb3、不支持 4 字节 UTF-8 字符如 emoji)。
3. 时区默认已经是 UTC——MySqlConnectOptions 的 timezone 默认就是 Some("+00:00")(sqlx-mysql/src/options/mod.rs:112),连上后 sqlx 会替你设。只有明确要跟随 server 时区时才需要 .timezone(None)。
4. 用 caching_sha2 建新用户——CREATE USER 'u'@'%' IDENTIFIED WITH caching_sha2_password BY 'pass'。sqlx 支持四种插件,选哪个由 server 决定,但 mysql_native_password 是 SHA-1 派生、不推荐。
5. 注意 server 的 max_allowed_packet——超过它的 query(大 BLOB / 大文本)会被 server 拒绝。sqlx 客户端侧会自动把 >16MB 的载荷切成多个物理包(§17.14),但 server 端的上限得自己按版本查、按需调大。
6. 小心 LOAD DATA LOCAL INFILE——sqlx-mysql 的 run() 注释里明写 LocalInfileRequest 是 "(unhandled)",走不通;批量导入要么落到 server 本地文件用不带 LOCAL 的 LOAD DATA INFILE,要么拆成批量 INSERT。
7. 事务隔离级别显式——MySQL 默认 REPEATABLE READ,Postgres 默认 READ COMMITTED——迁移时用 after_connect 回调发一条 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED 把整条连接对齐,比每次事务临时改更可靠。
这七条是 sqlx-mysql 生产用户最需要记住的实战要点——配合第 16 章学到的通用 sqlx 知识、你能写出生产级 MySQL Rust 代码。
17.19 为什么 MySQL 协议实现代码量只有 Postgres 的一半
§17.1 提到 MySQL 驱动代码量是 Postgres 的一半——深挖一下为什么:
1. 类型系统简单 - 省 ~6250 行
Postgres 的内建类型多得多(INT4/INT8/TEXT/BYTEA/JSON/JSONB/UUID/INET/CIDR/MACADDR/POINT/LINE/LSEG/BOX/PATH/POLYGON/CIRCLE/TSQUERY/TSVECTOR/XML/JSON... 等等)。sqlx-postgres/src/types/ 共 50 个 .rs 文件、8888 行处理这些类型。
MySQL 的内建类型少得多(Integer / Float / Decimal / String / Binary / Date/Time 系列 + 少量 JSON)。sqlx-mysql/src/types/ 2638 行——约为 Postgres 的 30%。差 ~6250 行。
2. 无协议扩展 - 省 ~1400 行
Postgres 的 copy.rs(352)+ listener.rs(517)+ advisory_lock.rs(421)+ message/copy.rs(141)合计 1431 行。MySQL 无对应、直接省掉。
3. 元数据查询简单 - 省 ~700 行
Postgres 的 nullability 推断要查 pg_attribute 系统表(第 11 章 §11.7)——sqlx-postgres/src/connection/describe.rs 独占 681 行。MySQL 没有同等级 nullability 推断——describe 代码简短。
4. 消息数少 - 省 ~800 行
Postgres message/ 26 个文件共 2314 行。MySQL 的 protocol/ 目录 1552 行。差 ~760 行。
5. 连接/执行状态机稍简
Postgres connection/ 目录(含 describe)2186 行,MySQL connection/ 1314 行——差 ~870 行。
五方面合计 ~6250 + 1430 + 680 + 760 + 870 ≈ 9990 行。
实际的 gap 是多少?sqlx-postgres/src 共 19841 行、sqlx-mysql/src 共 9195 行,相差 10646 行。上面五项解释了其中约 9990 行,剩下六百多行落在 options / 错误码 / 测试等细碎差异上。(注意第 3 项和第 5 项有重叠:describe.rs 已经算在 connection/ 里了,所以这几个数字只能当量级参考。)
这条分析给你的启示:DB 驱动代码量主要由 DB 本身的能力复杂度决定、不是客户端的设计复杂度。Postgres 代码多是因为 Postgres 功能多、sqlx 要一一支持。
17.20 MySQL 驱动演进历史
sqlx-mysql 的几个关键演进:
以下几条都能在仓库根目录的 CHANGELOG.md 里查到:
- 0.1.2(2020-01-03):一次性补齐 MySQL 5+ 的三种认证插件——
mysql_native_password、sha256_password、caching_sha2_password,也就是说连 MySQL 8 默认账号的能力从 0.1.2 就有了。 - 0.7.4(2024-03-11):README 澄清 MariaDB 支持(#3001 "Update README to clarify MariaDB support"),MySQL / MariaDB 加
IpAddr(#3011),补 MariaDB 数据类型文档(#3026)。 - 0.8.0(2024-07-22):CI 覆盖到 MariaDB 11.4 / 11.5(#3266)。
更细的时间线可以直接 grep -i mysql CHANGELOG.md 自己过一遍——比任何二手总结都准。
17.21 测试 sqlx-mysql 的建议
如果你要用 sqlx-mysql、推荐测试策略:
1. Docker 起 MySQL 8——不要用老版本测——新版本的 caching_sha2 / UTF-8 才是生产真实配置。
docker run -d --name mysql-test -e MYSQL_ROOT_PASSWORD=test -p 3306:3306 mysql:8.4
2. 同时测 MariaDB 最新版(如果业务要支持):
docker run -d --name mariadb-test -e MARIADB_ROOT_PASSWORD=test -p 3307:3306 mariadb:11
3. 用 sqlx 的集成测试 feature——sqlx::test 注解管理 per-test 数据库:
#[sqlx::test]
async fn test_create_user(pool: MySqlPool) {
let r = sqlx::query("INSERT INTO users (name) VALUES (?)")
.bind("Alice").execute(&pool).await.unwrap();
assert_eq!(r.last_insert_id(), 1);
}
(示例假设 ./migrations 里建了 users 表:测试函数带参数时,#[sqlx::test] 默认会对新库自动跑 ./migrations,sqlx-macros-core/src/test_attr.rs:149-158。本机无 MySQL,未实跑。)
#[sqlx::test] 的隔离粒度是独立数据库(不是 schema、也不是"跑完回滚事务")。MySQL 这边的具体形态是:库名由测试路径的哈希算出来(sqlx-core/src/testing/mod.rs:53 的 format!("_sqlx_test_{}", hash)),create database 用的就是这个名字(sqlx-mysql/src/testing/mod.rs:160-169);_sqlx_test_database_<id> 是 0.8.4 之前按整数 id 命名的旧格式,只在清理旧库时出现(同文件 :203-237)。登记表是默认库里的一张普通表 _sqlx_test_databases(同文件 :148)——不是 Postgres 那边的 _sqlx_test.databases(Postgres 用的是 schema + 序列,见 sqlx-postgres/src/testing/mod.rs:141-152)。测试跑完(或下次跑测试时)按登记表清理。正因为每次跑的是一个全新的库,上面那个 assert_eq!(r.last_insert_id(), 1) 才敢假设自增从 1 开始。
4. Schema migration 用 sqlx-cli——cargo sqlx migrate 管理 SQL 迁移文件、集成测试启动时自动跑。
5. 性能测试用 sysbench——跑实际负载、对比 sqlx 和其他驱动(比如 mysql_async)。
这五条测试建议让你的 sqlx-mysql 代码在CI 里对 MySQL 和 MariaDB 都验证——避免生产才发现方言差异。
17.22 从 sqlx-mysql 学到的工程技巧
本章虽然表面讲 MySQL、但里面有几条Rust 工程技巧值得单独总结——放在任何 Rust 项目里都有用:
1. Bitflags 用来表达协议状态——Status: u16 带多个 flag。sqlx 用 bitflags! 宏(sqlx-mysql/src/protocol/response/status.rs)表达——比多个 bool 字段紧凑、比 u16 原始值直观。
2. LENENC 编码用 helper 函数统一——sqlx-mysql/src/io/ 目录有通用的 MySqlBufMutExt::put_uint_lenenc、MySqlBufExt::get_uint_lenenc(以及 _str_ / _bytes_ 版本)——所有需要 LENENC 的地方调 helper、不重复实现。
3. 协议包的 Newtype pattern——每个消息类型是独立 struct(Query, StatementExecute 等)、不共用 enum variant。类型安全、独立 encode 逻辑、易扩展。
4. StatementCache<(u32, MySqlStatementMetadata)>——cache value 是 tuple 而不是 struct——简单场景用 tuple 避免引入一层 struct。
5. waiting: VecDeque<Waiting>——stream 内部队列跟踪"正在等什么响应"。run() 开头 push 一个 Waiting::Result,整个响应收完(OK / EOF 且没有更多结果集)或收到 ERR 包时 pop;下次发命令前 wait_until_ready 把没读完的残余响应排空。这是把异步响应模式显式化的好做法。
这五条技巧具体、可迁移——你在自己的 Rust 项目里处理 "有状态 + 异步协议 + 多消息类型" 场景时都用得上。
17.23 MySQL 驱动的一个观察
最后一点可以自己核实的观察——代码体量差距背后是功能面差距,不是投入差距。sqlx-mysql 9195 行里,types/ 占 2638 行、protocol/ 占 1552 行、connection/ 占 1314 行;剩下的是 options、错误码、FromRow 支撑等。没有 COPY、没有 LISTEN,是因为 MySQL 协议里就没有这些东西,而不是 sqlx 少写了;advisory lock 则是 MySQL 有 GET_LOCK() 而 sqlx 没封装。
想判断某个特性 sqlx-mysql 到底支不支持,最可靠的办法有两个:
grep源码——比如 §17.14 里 LOCAL_INFILE 的结论,就是从executor.rs注释里的(unhandled) LocalInfileRequest直接读出来的。- 查
CHANGELOG.md和对应的 PR 编号——sqlx 的 changelog 每条都带 PR 链接,能一路追到实现。
比起听任何人(包括本专栏)转述,这两步都便宜得多。
17.24 三家驱动的系统全景
到本章为止、我们讲了 Postgres(第 16 章)和 MySQL(本章)两家驱动——还剩 SQLite 和 Any。一张表预告四家的整体风格:
| 维度 | sqlx-postgres | sqlx-mysql | sqlx-sqlite | sqlx-core::Any |
|---|---|---|---|---|
| 通信方式 | TCP/TLS | TCP/TLS | C FFI | 上层 trait object |
| 代码量 | 19841 | 9195 | 10061 | 1808(sqlx-core/src/any/) |
| 典型用途 | 生产 web | 生产 web | 本地/测试/嵌入 | 跨 DB 工具 |
| 是否走网络 | 是 | 是 | 否 | 看底层驱动 |
| 有 worker 线程? | 否 | 否 | 是 | 否(代理) |
| 支持 LISTEN | 是 | 否 | 否 | 否 |
| 类型复杂度 | 高 | 中 | 低 | 最低(交集) |
看这张表就能猜到 SQLite 驱动的独特性——worker 线程和 C FFI 两项让它和前两家完全不同。代码量接近 MySQL 但走完全不同的路径。
17.25 Text vs Binary:run() 里的双协议分派
MySQL wire 协议有两套查询方式——sqlx-mysql 的 run() 在同一函数里分派(sqlx-mysql/src/connection/executor.rs:120-156):
let (mut column_names, format, mut needs_metadata) = if let Some(arguments) = arguments {
if persistent && self.inner.cache_statement.is_enabled() {
let (id, metadata) = self.get_or_prepare_statement(sql).await?;
self.inner.stream.send_packet(StatementExecute {
statement: id,
arguments: &arguments,
}).await?;
(metadata.column_names, MySqlValueFormat::Binary, false)
} else {
let (id, metadata) = self.prepare_statement(sql).await?;
self.inner.stream.send_packet(StatementExecute { ... }).await?;
self.inner.stream.send_packet(StmtClose { statement: id }).await?;
(metadata.column_names, MySqlValueFormat::Binary, false)
}
} else {
self.inner.stream.send_packet(Query(sql)).await?;
(Arc::default(), MySqlValueFormat::Text, true)
};
两大分支:
- arguments 为
Some——走 COM_STMT_EXECUTE(binary protocol)—— 先 PREPARE、再 EXECUTE、值以 binary 编码。 - arguments 为
None——走 COM_QUERY(text protocol)—— SQL 作为纯文本发送、结果 text 返回、适合 one-shot DDL/SELECT。
三个子分支:
- 有 arguments + persistent + cache enabled——
get_or_prepare_statement(查缓存或 PREPARE 一次永驻)—— binary 格式。 - 有 arguments + 非 persistent 或 cache disabled——
prepare_statement+ 立刻StmtClose—— binary 格式。 - arguments 为
None——Query(sql)—— text 格式。
needs_metadata 初值只在 text 路径为 true—— COM_QUERY 不事先 PREPARE,列名表只能从结果集的列定义里现建;binary 路径 PREPARE 阶段已拿到列名表。但 COM_STMT_EXECUTE 的结果集照样重发列定义(prepare_statement 里的注释写明 execute 时会再收一次输出列定义,sqlx-mysql/src/connection/executor.rs:53-55)。binary 路径只是用 recv_result_columns 读列定义、不重建列名表,并把 needs_metadata 置回 true 留给后续结果集(sqlx-mysql/src/connection/executor.rs:196-204)——省的是一次 HashMap 构建,不是带宽。
行解码也分派(:230-233):
let row = match format {
MySqlValueFormat::Binary => packet.decode_with::<BinaryRow, _>(&columns)?.0,
MySqlValueFormat::Text => packet.decode_with::<TextRow, _>(&columns)?.0,
};
BinaryRow 和 TextRow 是两个 struct——整数 42 在 text 里是字符串 "42"、binary 里是 4 字节小端——解码完全不同。
Postgres 驱动也按参数有无分成 Extended Query 与简单 Query 两条路(第 16 章);MySQL 这边的特点是两条路的行格式各有一个解码器(BinaryRow / TextRow)——读源码时特别要分清自己在看哪条路径。
17.26 StmtClose:非 persistent 路径的资源回收
非 persistent 路径里有一条容易忽略但关键的语句:
self.inner.stream.send_packet(StmtClose { statement: id }).await?;
含义——每次 query 都 PREPARE 一次、立即 StmtClose 释放 DB 端 statement handle—— 执行完就释放——DB 端不留残骸。
发送时机—— sqlx 并不等 Execute 的响应收完:send_packet(StatementExecute) 之后紧接着就 send_packet(StmtClose)(sqlx-mysql/src/connection/executor.rs:141-148),然后才进入接收循环。这样做成立,是因为 server 按顺序逐条处理命令,读到 COM_STMT_CLOSE 时前一条 EXECUTE 已经执行完,而 COM_STMT_CLOSE 本身没有应答、不会插进结果流里。
persistent 路径不发 StmtClose—— statement 留在客户端的 cache_statement 里、下次复用—— LRU 淘汰时才发 Close(见第 22 章 §22.10)——资源生命周期和 cache 绑定。
query() / query!() 默认 persistent = true—— 日常查询都走缓存路径;手动 .persistent(false) 才走立即释放路径。
17.27 四种认证插件的 enum 承载
MySQL 历史上出过四种密码哈希方案——都需要客户端识别、算对响应。sqlx-mysql 的 AuthPlugin enum(sqlx-mysql/src/protocol/auth.rs:8-13)把它们枚出来:
#[derive(Debug, Copy, Clone)]
pub enum AuthPlugin {
MySqlNativePassword,
CachingSha2Password,
Sha256Password,
MySqlClearPassword,
}
四种含义:
mysql_native_password—— MySQL 5.x 默认、SHA-1 哈希——长期存在、简单但已不推荐。caching_sha2_password—— MySQL 8.0 默认—— SHA-256 + 服务器端缓存—— 更强、兼容性是 MySQL 8.x 的默认坑。sha256_password—— 中间过渡品—— SHA-256 但无缓存——需 SSL/TLS 或 RSA 公钥交换——少见。mysql_clear_password—— 明文发送——只在专门的 PAM 等上下文用—— 不加密通道绝不用。
FromStr 实现(:27-40)—— 从服务器 Handshake 里解析出字符串插件名、映射到 enum——未知插件返回 err_protocol!("unknown authentication plugin: {}", s)——保守失败(MySQL 生态偶尔加新插件、sqlx 宁可报错也不瞎猜)。
生产坑——MySQL 8.0 默认 caching_sha2_password,只认 mysql_native_password 的老客户端连 8.0 会失败;sqlx 从 0.1.2 起就支持 caching_sha2_password(§17.20),不在此列。网上常见的应急办法是把账号改回旧插件:
ALTER USER 'yourapp'@'%' IDENTIFIED WITH mysql_native_password BY 'password';
但官方文档写明 mysql_native_password 8.0.34 起弃用、8.4 默认禁用(此时这条语句报 ERROR 1524)、9.0.0 移除——用 sqlx 0.8.6 时没有理由这么做。
这个 enum 覆盖的是四种常见插件,并非全部——mysql_old_password、MariaDB 的 client_ed25519 等都不在其中,server 要求这类插件时 sqlx 直接报 unknown authentication plugin,只能在 server 侧给账号换一种上面四种之一的插件。
17.28 和《Hyper 与 Tower:工业级 HTTP 栈》专栏第 11 章的呼应
MySQL 的 "packet → body Decoder → row 事件流" 管线、对应《Hyper 与 Tower:工业级 HTTP 栈》专栏第 11 章讲的 body Decoder 与 Http1Transaction——两者同一个模式:
- hyper
Http1Transaction↔ sqlx-mysqlMySqlStream(它用waiting: VecDeque<Waiting>显式记录"接下来该收什么")——都是"以状态机驱动的 wire 协议 transactor"。 - hyper 讲的 Decoder 三种长度语义(Content-Length / chunked / EOF)↔ MySQL 的 "OK / ERR / ResultSet 三类 meta-packet 分派"(
sqlx-mysql/src/connection/executor.rs:162-164,源码注释还提到未处理的 LocalInfileRequest)—— 都是"先看一段前导信息(HTTP 看头部、MySQL 看包的第一字节),再决定后续怎么 parse"。 - hyper 的
httparseparser ↔ sqlx-mysql 的手写Buf::get_*读取—— 一个面向文本协议、一个面向二进制协议——但"解析失败 = bubble 到上层 Error"的机制相同。
关键差异—— HTTP/1 是文本协议、MySQL 是二进制协议;HTTP/1 每个 request/response 一对一、MySQL 一条命令可以返回多个结果集(Status::SERVER_MORE_RESULTS_EXISTS)。从第 11 章的 HTTP transactor 迁移过来理解 MySQL 的 run()——协议复杂度多一层、但心智模型不变。
下一章进入 SQLite 驱动——C FFI + worker 线程、没有网络,和前两家完全不同的工程题。