SQLx 源码深度解析

第17章 MySQL 驱动:COM_QUERY 与 COM_STMT 的二元选择

作者 杨艺韬 · 10,340 字 · 发布于 · 更新于

MySQL 驱动的体量只有 Postgres 驱动的一半:协议扩展少、类型系统简单。COM_QUERY 与 COM_STMT 的二选一,对应的正是 Postgres 那边 Simple 与 Extended 的分野。

本章要点

  • sqlx-mysql(约 9195 行)是 sqlx-postgres 的一半体量——协议细节少(无 COPY / LISTEN / advisory lock 等扩展) + 类型系统简单(MySQL 内建类型比 Postgres 少很多)。
  • MySQL 协议两大命令:COM_QUERY(0x03,文本查询)vs COM_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 握手(新):

  1. 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)相反——服务端的快速校验因此永远对不上。
  2. Server 检查缓存(就是"caching"的来源)——如果密码在 server 的 caching_sha2_password 缓存里、验证通过。
  3. 缓存 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,官方文档说明这会让内建函数名都成为保留字。

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

两条路径:

  1. Fast auth(server 已缓存密码 hash)——Handshake、HandshakeResponse、AuthMoreData(0x03)、OK 四个包完成。
  2. 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 到底支不支持,最可靠的办法有两个:

  1. grep 源码——比如 §17.14 里 LOCAL_INFILE 的结论,就是从 executor.rs 注释里的 (unhandled) LocalInfileRequest 直接读出来的。
  2. 查 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。

三个子分支:

  1. 有 arguments + persistent + cache enabled—— get_or_prepare_statement(查缓存或 PREPARE 一次永驻)—— binary 格式。
  2. 有 arguments + 非 persistent 或 cache disabled—— prepare_statement + 立刻 StmtClose—— binary 格式。
  3. 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-mysql MySqlStream(它用 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 的 httparse parser ↔ 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 线程、没有网络,和前两家完全不同的工程题。