数据库基础体系 · 第 91/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

MySQL 协议与客户端:握手、Prepared Statement、连接池和取消

MySQL 客户端通常不会直接“执行一条 SQL”,而是经过一条由多个状态组成的链路:

  1. TCP 连接建立;
  2. MySQL 协议握手和能力协商;
  3. 身份认证;
  4. 客户端发送命令,例如 COM_QUERYCOM_STMT_EXECUTE
  5. 服务端返回结果集、错误或状态;
  6. 客户端根据事务和连接池策略复用或销毁连接。

因此,驱动程序、连接池和取消操作并不是 SQL 之上的三个独立功能。它们共同受制于一个事实:

经典 MySQL 协议连接通常是一个有序的请求—响应通道;一条物理连接在当前命令的结果没有被完整消费前,不能安全地开始下一个命令。

理解这一点,是理解握手、Prepared Statement、连接池和取消的基础。


一、先区分四个层次

一个数据库访问请求至少涉及四个层次。

1. SQL 层

例如:

SELECT id, name
FROM users
WHERE tenant_id = 42
  AND status = 'active';

SQL 描述的是服务端要执行的语义。优化器、事务隔离、锁和存储引擎属于这一层及其下方。

2. MySQL 命令层

客户端不会把 SQL 字符串直接写入 TCP 流,而是把它封装为 MySQL 命令,例如:

  • COM_QUERY:发送一条文本协议 SQL;
  • COM_STMT_PREPARE:创建服务端 Prepared Statement;
  • COM_STMT_EXECUTE:执行已创建的 Prepared Statement;
  • COM_STMT_CLOSE:关闭 Prepared Statement;
  • COM_INIT_DB:切换默认数据库;
  • COM_PING:检查连接;
  • COM_RESET_CONNECTION:重置连接会话状态;
  • COM_QUIT:请求关闭连接。

3. MySQL 分组协议层

每个命令和响应由一个或多个 MySQL packet 组成。经典协议包通常包含:

3 字节 payload 长度
1 字节 sequence id
payload

其中 payload 长度是小端序整数,表示后续 payload 的字节数;sequence id 用来检查同一命令响应中的包顺序。

长度字段能够表示的单个 payload 上限是 0xFFFFFF 字节。更大的数据需要拆成多个包。客户端不能假设“一次 read 就得到一个 MySQL 包”,也不能假设“一次 write 就完整发送一个命令”。

4. TCP、TLS 和连接池层

TCP 只提供字节流,不提供消息边界。TLS 负责加密和认证,但不改变 MySQL 命令的语义。连接池则管理多条物理连接,并把它们借给多个逻辑请求。

连接池中的“连接”通常有两种含义:

  • 物理连接:一个 TCP/TLS/MySQL 会话;
  • 逻辑连接:应用从池中租借的一段使用权。

二者不应混淆。应用代码看似获得了一个连接对象,实际可能只是暂时占用池中的一条物理连接。


二、握手:从 TCP 连接到可执行 SQL

2.1 TCP 建连不等于 MySQL 登录成功

客户端首先与服务端建立 TCP 连接。TCP 建连成功,只能说明:

  • 目标地址可达;
  • 端口上有程序接受连接;
  • TCP 三次握手完成。

此时还不能说明:

  • MySQL 用户名和密码正确;
  • 认证插件兼容;
  • TLS 已启用;
  • 默认数据库存在;
  • 客户端和服务端能力可以协商成功。

TCP 建连之后,服务端先发送 MySQL 初始握手包。


2.2 初始握手包包含什么

MySQL 8.4 的经典协议握手消息中通常包含以下信息:

  • 协议版本;
  • 服务端版本字符串;
  • 当前连接的 connection_id
  • 一段认证随机数据,通常称为 auth-plugin-data;
  • 服务端支持的能力标志;
  • 默认字符集;
  • 初始服务器状态;
  • 服务端使用的认证插件名称,例如 caching_sha2_password

能力标志是一个位集合。它不是“版本号”,而是告诉客户端:

  • 是否支持协议 4.1;
  • 是否支持长密码;
  • 是否支持多结果集;
  • 是否支持客户端协议级压缩;
  • 是否支持服务端 Prepared Statement;
  • 是否支持连接属性;
  • 是否支持插件认证;
  • 是否要求或支持 TLS;
  • 是否支持 CLIENT_DEPRECATE_EOF 等协议行为。

客户端不能无条件发送自己知道的所有字段,而应当只声明自己真正支持、并且希望启用的能力。


2.3 TLS 握手发生在 MySQL 认证之前

如果客户端和服务端协商使用 TLS,典型流程是:

TCP 建连
  ↓
服务端发送初始握手
  ↓
客户端发送 SSLRequest
  ↓
TLS 握手
  ↓
客户端通过加密连接发送完整认证响应
  ↓
服务端完成 MySQL 认证

SSLRequest 本身不是完整登录请求。它主要携带客户端希望启用的能力和字符集等信息,使双方先切换到 TLS,再发送密码相关数据。

这解释了一个常见现象:

“密码没有明文出现在应用日志中”,不代表密码在网络上传输时一定安全;是否安全取决于 TLS 是否真的启用,以及认证插件使用的传输方式。

生产环境需要确认的是服务端和客户端实际协商出的 TLS 状态,而不是只看连接字符串中是否出现了某个选项。对于 MySQL 账户,还应配合服务端的 REQUIRE SSL 或更严格的 TLS 要求。


2.4 caching_sha2_password 与认证交换

MySQL 8.4 默认认证插件通常是 caching_sha2_password。它的认证过程可分为两类:

  1. 快速认证:服务端已有可用的缓存验证信息,客户端和服务端完成较短的挑战响应交换;
  2. 完整认证:缓存不可用或需要重新完成密码验证。

如果使用 TLS,密码相关交换在加密通道中进行。没有 TLS 时,客户端需要使用服务端 RSA 公钥加密密码,或者通过协议交换取得公钥;具体能否自动完成,取决于驱动的实现和配置。

因此,下面几种情况不能混为一谈:

  • 服务器支持 caching_sha2_password
  • 客户端驱动支持该插件;
  • 客户端允许通过 TLS 认证;
  • 客户端允许在非 TLS 情况下取得 RSA 公钥;
  • 服务器账户的认证插件与密码状态正确。

老旧驱动常见的失败表现是:

Authentication plugin 'caching_sha2_password' cannot be loaded

这不是普通 SQL 错误,而是认证阶段的兼容性错误。升级驱动通常比修改账户认证插件更合理;如果必须修改账户,应明确评估旧客户端兼容性和密码安全边界。


2.5 握手完成后的第一个会话状态

认证成功后,连接还带有一组会话状态,例如:

  • 当前用户和权限;
  • 默认数据库;
  • 当前字符集;
  • sql_mode
  • 时区;
  • autocommit 状态;
  • 当前事务状态;
  • 临时表;
  • 用户变量;
  • Prepared Statement;
  • 连接属性和服务端分配的线程 ID。

连接池复用的正是这个会话状态。因此,连接池不能只把物理 TCP 连接看成“可读写的 socket”。


三、COM_QUERY:文本协议的完整数据流

最简单的 SQL 执行路径是:

客户端:COM_QUERY + SQL 文本
服务端:OK / ERR / Result Set

例如执行:

SELECT id, name
FROM users
WHERE tenant_id = 42;

客户端发送的命令 payload 可以抽象为:

1 字节 command = COM_QUERY
SQL 的 UTF-8 字节

服务端返回结果集时,典型结构是:

列数
列定义 1
列定义 2
...
行 1
行 2
...
结束标志

列定义包含列名、类型、字符集、长度等元数据。文本协议中的每个字段通常以长度编码字符串表示;NULL 有特殊标记。

在 MySQL 8.4 的常见能力协商中,服务端可能使用 OK 包作为结果结束标志,而不是旧式 EOF 包。驱动不能通过“看到 EOF”这一种方式判断结果结束,应该根据协商出的协议能力解析。

查询结果未消费完时会发生什么

假设服务端返回 10 万行,而客户端只读取了前 100 行就把连接归还给池:

请求 A:开始读取结果
请求 A:读取 100 行
请求 A:错误地释放连接
请求 B:从池中借到同一条物理连接
请求 B:发送 COM_QUERY

此时连接的输入流中仍然有请求 A 的剩余行。请求 B 的响应会与请求 A 的剩余响应混在同一个有序协议流中,驱动通常会报错,或者把错误数据交给错误的调用方。

所以,以下操作必须在归还连接前完成:

  • 读取完所有结果行;
  • 读取多结果集;
  • 处理结果结束包;
  • 处理错误;
  • 或明确关闭并丢弃该物理连接。

四、Prepared Statement:参数绑定不是字符串拼接

“Prepared Statement”在 MySQL 客户端语境中可能指两种不同机制。

4.1 客户端模拟预处理

驱动先把参数编码成 SQL 文本,例如:

SELECT *
FROM users
WHERE id = 42
  AND name = 'Alice';

然后仍然发送 COM_QUERY

这种模式不使用服务端的 COM_STMT_PREPARECOM_STMT_EXECUTE。它的优点是兼容性较好,缺点是驱动必须正确实现每种类型的 SQL 字面量转义和编码。

4.2 服务端 Prepared Statement

真正的服务端预处理使用以下协议:

COM_STMT_PREPARE
  ↓
statement_id、参数数量、结果列数量、元数据
  ↓
COM_STMT_EXECUTE + 参数类型和值
  ↓
二进制协议结果
  ↓
COM_STMT_CLOSE

这里的 statement_id 通常只在创建它的那条物理连接上有效。它不是全局对象,也不能从连接 A 拿到连接 B 上执行。


4.1 服务端 Prepared Statement 的生命周期

以如下 SQL 为例:

SELECT id, name
FROM users
WHERE tenant_id = ?
  AND status = ?;

第一步:准备

客户端发送:

COM_STMT_PREPARE
"SELECT id, name FROM users WHERE tenant_id = ? AND status = ?"

服务端解析 SQL,并返回:

  • statement ID;
  • 参数数量:2;
  • 结果列数量:2;
  • 警告数量;
  • 参数和列的元数据。

准备阶段通常完成语法解析和语句对象创建,但不等于执行了查询,也不等于已经读取数据。

第二步:执行

客户端发送:

COM_STMT_EXECUTE
statement_id = 123
parameter[0] = 42
parameter[1] = "active"

执行包中会包含:

  • statement ID;
  • 执行标志;
  • 参数是否为长数据;
  • NULL bitmap;
  • 每个参数的类型编码;
  • 每个参数的实际值。

类型编码很重要。例如,整数、浮点数、字符串、日期时间和二进制数据不应被全部当作同一种文本处理。

第三步:读取二进制结果

服务端 Prepared Statement 的结果行使用二进制协议,而 COM_QUERY 通常使用文本协议。二进制行通过类型元数据解释字段值,NULL 字段由 bitmap 标记。

这并不意味着 Prepared Statement 一定更快。解析、优化、网络传输、磁盘访问和锁等待的成本仍然存在;是否复用执行计划还受到 MySQL 版本、语句类型和优化器行为影响。Prepared Statement 的确定性优势首先是参数作为值传递,而不是把值拼进 SQL 语法。

第四步:关闭

客户端发送:

COM_STMT_CLOSE

服务端释放该连接上的 statement 对象。关闭连接时,连接上的 Prepared Statement 也会随会话结束而失效。


4.2 参数能替代什么,不能替代什么

参数占位符代表值,不代表 SQL 语法结构。

正确:

SELECT *
FROM users
WHERE id = ?;

错误或不具备预期语义:

SELECT *
FROM ?
ORDER BY ?;

表名、列名、排序方向等属于 SQL 语法结构,不能简单作为值绑定。动态标识符应使用白名单映射:

用户输入 "created_at"
  → 映射为固定 SQL 片段 "created_at"

用户输入 "name"
  → 映射为固定 SQL 片段 "name"

而不是直接把用户字符串拼接进 SQL。

还要区分以下两件事:

  • 参数绑定可以避免把字符串值当作 SQL 语法解析;
  • 参数绑定不能自动修复错误的字符集配置、错误的类型转换、错误的权限设计或动态标识符注入。

4.3 一个可运行的 SQL 示例

以下 SQL 可在 MySQL 8.4、InnoDB 表上执行:

CREATE DATABASE IF NOT EXISTS demo
  CHARACTER SET utf8mb4;

USE demo;

CREATE TABLE IF NOT EXISTS users (
    id BIGINT PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    name VARCHAR(100) NOT NULL,
    status VARCHAR(20) NOT NULL,
    KEY ix_users_tenant_status (tenant_id, status)
) ENGINE = InnoDB;

INSERT INTO users (id, tenant_id, name, status)
VALUES
    (1, 42, 'Alice', 'active'),
    (2, 42, 'Bob',   'disabled')
ON DUPLICATE KEY UPDATE
    name = VALUES(name),
    status = VALUES(status);

使用 Prepared Statement 语义执行:

PREPARE find_user FROM
'SELECT id, name
 FROM users
 WHERE tenant_id = ?
   AND status = ?';

SET @tenant_id = 42;
SET @status = 'active';

EXECUTE find_user USING @tenant_id, @status;

DEALLOCATE PREPARE find_user;

预期结果:

+----+-------+
| id | name  |
+----+-------+
|  1 | Alice |
+----+-------+

这里的 PREPARE 是 SQL 层命令,但它体现了服务端 Prepared Statement 的核心生命周期:创建、绑定会话变量执行、释放。

需要注意:

  • PREPARE 创建的 statement 属于当前会话;
  • 换一条连接后,EXECUTE find_user 不可见;
  • 在应用驱动中,通常由驱动直接使用 COM_STMT_* 协议,而不是发送这些 SQL 语句;
  • 如果 PREPAREEXECUTE 发生在事务中,它仍然受当前会话事务状态影响。

五、事务与 Prepared Statement 的关系

Prepared Statement 和事务是两个正交概念。

例如:

START TRANSACTION;

UPDATE users
SET status = 'disabled'
WHERE id = 1;

SELECT status
FROM users
WHERE id = 1;

COMMIT;

Prepared Statement 只描述 SQL 解析和参数传递方式;事务决定:

  • 读写是否在同一事务中;
  • 修改何时可见;
  • 锁何时释放;
  • 回滚时哪些修改被撤销。

InnoDB 下,如果应用从连接池借连接执行事务,事务期间必须保持同一条物理连接:

借连接
  ↓
START TRANSACTION
  ↓
多条 SQL
  ↓
COMMIT 或 ROLLBACK
  ↓
重置会话状态
  ↓
归还连接

不能把事务的第一条语句发到连接 A,第二条语句发到连接 B。事务上下文是会话级状态,不是由 SQL 字符串携带的。


六、连接池:把物理会话变成受控资源

6.1 连接池解决什么问题

建立一条 MySQL 连接包含:

  • TCP 建连;
  • TLS 握手;
  • MySQL 握手;
  • 用户认证;
  • 会话初始化。

连接池通过复用物理连接,减少这些固定成本,并限制并发连接数量。

典型池状态可以表示为:

空闲连接
   ↓ 借出
使用中连接
   ↓ 归还
空闲连接

使用中连接
   ↓ 协议错误、网络断开、取消后状态不确定
废弃连接

当池已达到最大物理连接数时,新请求进入等待队列:

请求到达
  ├─ 有空闲连接 → 立即借出
  ├─ 可创建新连接 → 创建后借出
  └─ 已达上限 → 排队等待或超时

因此,数据库连接池的等待时间不等于数据库执行时间。一个请求的总延迟可以近似拆成:

总延迟 =
  排队等待
+ 建连/认证时间(若新建)
+ 服务端执行时间
+ 结果传输时间
+ 客户端解码时间

只监控 SQL 执行时间而不监控池等待时间,会遗漏容量瓶颈。


6.2 一个简单容量推导

设:

  • C:池允许的最大物理连接数;
  • S:单个请求占用连接的平均时间;
  • λ:请求到达率;
  • U = λ × S:平均所需并发连接数。

例如:

λ = 400 请求/秒
S = 20 ms = 0.020 秒
U = 400 × 0.020 = 8

这说明平均需要约 8 个并发连接,但不是说设置 max_connections = 8 就足够。实际还要考虑:

  • P99 执行时间;
  • 事务和结果流占用时间;
  • 突发流量;
  • 慢查询;
  • 连接建立失败后的重试;
  • 其他应用实例共享的服务端连接预算。

如果有 N 个应用实例,每个实例池上限为 C_i,理论最大连接数约为:

C_total = Σ C_i + 其他客户端连接数

这比只看单个实例更重要。服务端 max_connections 是全局上限,连接池上限之和不能无条件超过它。


6.3 为什么池中的连接必须重置

连接使用过程中可能留下以下状态:

SET autocommit = 0;
SET time_zone = '+08:00';
SET sql_mode = '...';
USE another_db;
SET @tenant_id = 42;
START TRANSACTION;
CREATE TEMPORARY TABLE t (...);

如果这些状态直接泄漏给下一个请求,就会出现隐蔽错误:

  • 下一个请求误以为自己处于 autocommit;
  • 查询使用了错误时区;
  • 未提交事务长期持有锁;
  • 临时表名称冲突;
  • 用户变量影响后续逻辑;
  • 默认数据库发生改变。

连接归还前至少应保证:

事务已 COMMIT 或 ROLLBACK
结果集已消费或连接已关闭
会话状态已重置,或连接被丢弃

MySQL 提供 COM_RESET_CONNECTION 用于重置连接会话状态。它与断开再重连相比可以减少建连成本,但驱动和连接池是否使用它属于实现细节。池不能假设所有驱动都自动恢复应用设置。

一个重要边界是:重置连接不是回滚的替代品。应用仍然应在事务异常路径显式 ROLLBACK。如果事务状态、结果流或协议状态已经不确定,直接关闭物理连接通常比尝试修复更安全。


6.4 连接池超时不是 SQL 超时

至少要区分四种时间:

  1. 池等待超时:等待空闲连接的最长时间;
  2. 建连超时:TCP、TLS 和认证阶段的最长时间;
  3. 读写超时:等待网络读写的最长时间;
  4. SQL 执行超时:服务端执行语句的最长时间。

例如:

池等待 2 秒超时

只能说明应用没有及时获得连接,不能说明 MySQL 已经执行了 2 秒 SQL。

相反:

网络 read timeout = 2 秒

也不能严格等价为“服务端在 2 秒后停止执行”。客户端可能只是停止等待响应,服务端仍可能继续执行,直到完成、被杀死、遇到锁等待超时或检测到连接断开。


七、取消:为什么没有一个通用的“取消当前包”按钮

7.1 经典协议的基本限制

客户端发送一个查询后,服务端可能正在:

  • 解析 SQL;
  • 优化;
  • 访问 InnoDB;
  • 等待行锁;
  • 读取磁盘;
  • 发送大量结果;
  • 执行存储过程;
  • 处理多个结果集。

经典 MySQL 协议没有一个可在同一条忙碌连接上安全插入的通用 CANCEL 命令。原因是该连接当前已经处于某个命令的响应流中。如果客户端同时发送另一个命令:

请求 A 尚未结束
客户端又发送请求 B

服务端和客户端都可能无法判断 B 属于哪个状态,协议流会被破坏。

因此,经典 MySQL 客户端一般有三种取消策略:

  1. 等待服务端正常结束;
  2. 关闭当前连接;
  3. 使用另一条连接执行 KILL QUERY

7.2 关闭连接并不等于立即停止服务端执行

如果客户端关闭 socket:

客户端关闭连接
  ↓
服务端最终发现连接断开
  ↓
服务端结束或清理当前命令

“最终发现”可能发生在服务端下一次网络写入、读取或其他检查点,而不是客户端调用 close() 的瞬间。

因此,关闭连接的语义通常是:

  • 客户端不再等待结果;
  • 当前物理连接不能再放回连接池;
  • 服务端可能稍后清理正在执行的命令;
  • 对已经提交的外部副作用不能自动撤销。

如果语句已经执行了 INSERTUPDATE 或调用了外部存储过程,客户端超时后不能根据“没有收到响应”断言操作一定没有发生。


7.3 使用 KILL QUERY

MySQL 为管理员提供了:

KILL QUERY <connection_id>;

它用于终止指定连接当前正在执行的语句,但通常保留连接本身。与之相对:

KILL CONNECTION <connection_id>;

会终止整个连接,连接上的事务也会随会话终止而处理。

connection_id 可以通过:

SELECT CONNECTION_ID();

在当前会话中取得。诊断时也可以查看:

SHOW FULL PROCESSLIST;

典型取消流程是:

工作连接 A:执行长查询
  ↓
监控或控制逻辑发现超时
  ↓
控制连接 B:KILL QUERY connection_id_of_A
  ↓
工作连接 A:收到错误或中断结果

取消需要相应权限。杀死其他账户的线程通常需要更高权限,例如 CONNECTION_ADMIN;具体权限应以当前版本的账户授权和动态权限规则为准。不要为了实现取消而给业务账户授予过宽的管理权限。

KILL QUERY 的竞态

假设连接池把连接 A 的线程 ID 记录为 100

t1:请求 R1 使用 connection_id=100
t2:R1 超时,准备执行 KILL QUERY 100
t3:R1 已经释放连接,连接池把它借给 R2
t4:控制连接执行 KILL QUERY 100

如果线程 ID 仍对应同一条连接,R2 可能被错误终止。更危险的是,如果连接已经关闭,后来某条新连接复用了相同 ID,控制逻辑可能杀死完全无关的请求。

所以,基于 KILL QUERY 的取消必须同时解决:

  • 请求与物理连接的绑定;
  • 连接 ID 的准确取得;
  • 超时检测与连接释放的竞态;
  • 连接归还后不得继续异步杀死它;
  • 控制连接的权限;
  • kill 命令本身的失败和延迟。

这也是许多通用连接池只选择“取消后关闭并丢弃当前物理连接”的原因:它牺牲了连接复用,却减少了错误杀死其他请求的风险。


7.4 KILL QUERY 与锁等待超时不是一回事

InnoDB 相关的等待至少包括:

  • 行锁等待;
  • 元数据锁等待;
  • 磁盘或 I/O 等待;
  • 其他内部资源等待。

例如:

SET SESSION innodb_lock_wait_timeout = 5;

主要针对 InnoDB 行锁等待,不是所有 SQL 执行时间的总上限,也不等价于客户端取消。

max_execution_time 可以限制部分语句的执行时间,常见用法是对 SELECT 使用优化器提示:

SELECT /*+ MAX_EXECUTION_TIME(2000) */
       id, name
FROM users
WHERE tenant_id = 42;

这里 2000 的单位是毫秒。它并不意味着所有语句、所有等待类型和所有存储过程都能被统一限制。DDL、锁等待、网络传输和客户端读取慢也不能简单归入这个参数的语义。

因此,超时控制应按目标区分:

限制排队       → 连接池等待超时
限制建连       → TCP/TLS/认证超时
限制服务端等待 → 锁等待参数、语句级限制、KILL QUERY
限制客户端等待 → context / socket read deadline

八、驱动 API 中的取消与生命周期

以下示例使用 Go 标准库 database/sql 的接口说明生命周期;具体取消行为取决于所使用的 MySQL 驱动。不要把 database/sql 的抽象保证误认为每个驱动都用同一种协议实现。

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, `
    SELECT id, name
    FROM users
    WHERE tenant_id = ?
      AND status = ?`,
    42, "active",
)
if err != nil {
    return err
}
defer rows.Close()

for rows.Next() {
    var id int64
    var name string

    if err := rows.Scan(&id, &name); err != nil {
        return err
    }
    fmt.Println(id, name)
}

if err := rows.Err(); err != nil {
    return err
}

每一步的意义是:

  1. db.QueryContext 从池中租借一条物理连接;
  2. 驱动可能使用客户端模拟参数,也可能使用服务端 Prepared Statement;
  3. SQL 执行并开始返回结果;
  4. rows.Next() 消费结果流;
  5. rows.Close() 确保结果集结束,或让驱动采取清理策略;
  6. rows.Err() 检查迭代过程中的网络、服务端和解码错误;
  7. 函数结束后,驱动把连接归还池,或者因状态不可恢复而关闭它。

当 context 超时后,驱动可能采取以下实现之一:

  • 关闭底层连接;
  • 使用另一个控制连接发出 KILL QUERY
  • 在驱动可识别的协议边界处返回错误;
  • 仍需等待读取或清理完成。

因此,应用应把取消后的连接视为“不可信资源”,除非驱动明确保证连接已恢复到协议边界。

取消错误也不等于事务已回滚。对于事务代码,必须写出明确的错误路径:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}

committed := false
defer func() {
    if !committed {
        _ = tx.Rollback()
    }
}()

if _, err = tx.ExecContext(ctx, `
    UPDATE users
    SET status = ?
    WHERE id = ?`, "disabled", 1); err != nil {
    return err
}

if err = tx.Commit(); err != nil {
    return err
}

committed = true
return nil

这段代码表达了一个重要事实:

客户端取消发生在 COMMIT 前后,语义完全不同。

  • COMMIT 尚未成功发送或服务端尚未处理:可能仍未提交,也可能请求已到达但响应丢失;
  • COMMIT 已在服务端成功执行但响应丢失:客户端可能收到超时,却不能安全重试非幂等操作;
  • ROLLBACK 只能撤销当前事务中尚未提交的修改,不能撤销已经提交的事务。

这就是数据库操作中的“结果未知”状态。网络超时后,是否重试必须结合业务幂等键、唯一约束和提交确认策略,而不能仅凭客户端错误类型判断。


九、连接池中的并发模型:为什么不能在一条连接上复用多个请求

经典 MySQL 协议连接通常遵循:

发送命令 A
读取 A 的完整响应
发送命令 B
读取 B 的完整响应

它不是天然的多路复用协议。即使客户端有多个 goroutine、线程或异步任务,驱动通常也必须在一条物理连接上串行化命令。

错误模型如下:

线程 1:发送查询 A
线程 2:发送查询 B
线程 1:读取响应
线程 2:读取响应

如果驱动没有严格锁住连接,线程 1 可能读取到 B 的响应头,线程 2 可能读取到 A 的行。成熟驱动通常会禁止这种用法,或者在连接对象层面串行化。

连接池提供并发能力的方式是:

请求 1 → 物理连接 A
请求 2 → 物理连接 B
请求 3 → 物理连接 C

而不是让多个请求共享同一条正在执行命令的连接。

事务进一步要求请求内部固定连接:

事务 R
  ├─ UPDATE → 连接 A
  ├─ SELECT → 连接 A
  └─ COMMIT → 连接 A

不能把 tx 对象中的操作交给不同连接。


十、故障表现与诊断路径

10.1 握手阶段失败

常见错误及定位方向:

connection refused

通常发生在 TCP 层:端口未监听、地址错误或防火墙拒绝。

timeout during connect

可能发生在网络、TLS、认证或连接池等待阶段,需要结合客户端日志区分。

Access denied for user

通常已经到达 MySQL 认证阶段,需检查用户名、密码、来源主机、账户认证插件和权限。

Unknown database

认证可能已成功,但初始化默认数据库失败。

Authentication plugin ... cannot be loaded

重点检查驱动版本、认证插件支持和 TLS/RSA 配置,而不是 SQL 本身。


10.2 查询后协议错误

如果出现类似:

Commands out of sync

常见原因包括:

  • 结果集没有读取完;
  • 多结果集没有继续消费;
  • 连接在错误状态下被复用;
  • 同一连接被并发使用;
  • 驱动和服务端对协议能力协商理解不一致。

诊断时应记录:

  • 连接是否来自池;
  • 请求是否有未关闭的 rows;
  • 是否启用了多语句或多结果集;
  • 错误发生在发送、读取列定义还是读取行;
  • 出错后连接是否被销毁。

10.3 连接池耗尽

典型现象:

数据库本身 CPU 不高,但应用请求大量超时

可能是池等待,而不是 MySQL 执行慢。

应分别观测:

  • 当前打开连接数;
  • 空闲连接数;
  • 等待池连接的请求数;
  • 池等待时长;
  • 单连接持有时长;
  • 事务持续时间;
  • 结果集读取时长;
  • 新建连接失败数;
  • 被关闭和被回收的连接数。

一个常见泄漏路径是:

rows, err := db.QueryContext(ctx, query)
if err != nil {
    return err
}
// 中途 return,但没有 rows.Close()

另一个路径是事务没有提交或回滚:

tx, _ := db.Begin()
// 发生错误后直接 return

这类错误会长期占用池中的物理连接,并可能持有 InnoDB 锁。


10.4 取消后出现“连接坏了”或“结果仍然产生”

这两种现象都可能是正常边界:

  • 客户端关闭 socket 后,服务端需要一段时间才能发现;
  • 服务端可能已经完成部分工作;
  • 驱动为保护协议状态而主动丢弃连接;
  • KILL QUERY 可能在查询即将完成时到达,最终返回结果或返回中断错误;
  • 客户端取消只停止等待,不会撤销已经提交的副作用。

因此,取消后的处理通常应包括:

返回上层超时/取消错误
确认当前请求不再使用 rows、tx、conn
不要把状态未知的物理连接放回池
对写操作评估是否需要幂等重试或人工核对

十一、部署边界:单实例、代理和读写分离

本文讨论的是经典 MySQL 协议直连或由兼容代理转发的语义。部署拓扑会改变故障表现,但不会改变核心边界。

直连单实例

应用 → MySQL

connection_id、事务和 Prepared Statement 都直接对应 MySQL 会话。

通过代理

应用 → 代理 → MySQL

需要确认代理是否:

  • 终止并重新建立 MySQL 会话;
  • 支持 COM_STMT_*
  • 支持 COM_RESET_CONNECTION
  • 支持多结果集;
  • 支持 TLS 透传或重新加密;
  • 在后端之间路由同一连接;
  • 正确转发 KILL QUERY 的目标 ID。

应用看到的连接 ID 不一定可以直接用于后端 MySQL,尤其是在代理修改协议或维护后端连接池时。

读写分离

事务和 Prepared Statement 都要求路由保持一致:

  • 事务开始后不能随意切换到另一台后端;
  • COM_STMT_PREPARECOM_STMT_EXECUTE 必须到达同一会话;
  • 取消命令必须到达真正执行查询的后端;
  • 读写切换可能造成刚提交数据的可见性延迟。

所以,使用代理时不能只验证“普通 SELECT 能运行”,还应验证 Prepared Statement、事务、取消、连接重置和异常恢复路径。


十二、一个完整的请求生命周期

把前面的机制合在一起,一次带参数、可取消、使用连接池的查询大致是:

应用创建 context
  ↓
连接池排队
  ↓
借出物理连接
  ↓
检查或重置会话状态
  ↓
必要时完成 TCP/TLS/MySQL 握手
  ↓
发送 COM_QUERY
  或
发送 COM_STMT_PREPARE + COM_STMT_EXECUTE
  ↓
读取列定义和结果行
  ↓
消费完整结果集
  ↓
检查 rows.Err
  ↓
成功:归还连接
失败但协议状态明确:清理后归还
失败且协议状态不明确:关闭并丢弃

如果发生取消:

context 超时
  ↓
驱动尝试取消或关闭连接
  ↓
应用停止读取并处理错误
  ↓
当前物理连接不再盲目复用
  ↓
评估服务端是否仍在执行
  ↓
评估写操作结果是否未知

如果发生事务错误:

SQL 错误或取消
  ↓
尝试 ROLLBACK
  ↓
若回滚也无法确认协议状态
  → 关闭物理连接
  ↓
事务对象结束

十三、容易混淆的结论

“用了 Prepared Statement 就一定更快”

不成立。它的主要确定性收益是参数与 SQL 语法分离,以及二进制参数传输。解析、优化、锁等待、磁盘访问和结果读取仍然存在。

“参数绑定可以防止所有 SQL 注入”

不成立。它主要保护值参数。表名、列名、排序方向等动态结构仍然需要白名单。

“连接池越大越好”

不成立。更大的池可能让更多请求同时进入 MySQL,增加 CPU、内存、锁竞争和上下文切换,并使慢查询互相放大。

“客户端超时后 SQL 一定停止”

不成立。客户端停止等待与服务端停止执行是两个事件。

“关闭事务连接就一定回滚了业务操作”

不成立。未提交事务通常会由服务端回滚,但如果提交结果已经发生而响应丢失,客户端可能无法知道最终状态。

“连接归还池后仍可异步执行 KILL QUERY”

风险很高。连接 ID 与逻辑请求的绑定已经失效,可能杀错后续请求。

“TCP 连接还活着,所以 MySQL 会话状态正常”

不成立。服务端可能已关闭连接,协议流可能已损坏,事务和结果集也可能处于未完成状态。连接池应依赖驱动的协议状态,而不是只依赖 TCP keepalive。


MySQL 客户端的可靠性最终取决于是否正确管理三个边界:

  1. 协议边界:一个命令的响应必须完整、有序地消费;
  2. 会话边界:事务、字符集、变量和 Prepared Statement 都属于物理连接;
  3. 结果边界:超时和取消只能说明客户端不再等待,不能自动证明服务端没有执行。

握手决定连接能否安全建立,Prepared Statement 决定参数如何进入协议,连接池决定物理会话如何被复用,取消机制则决定请求失控时如何处理协议、服务端执行和业务结果的不确定性。把这四者放在同一条生命周期中理解,才能正确诊断“偶发协议错误”“连接池耗尽”和“超时后数据到底有没有写入”等生产问题。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。