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

数据库协议与驱动:连接握手、预处理、结果流、取消和兼容性

数据库应用通常通过驱动连接数据库。表面上,程序只是调用:

connect()
execute(sql, parameters)
fetch()
close()

实际上,这些调用会跨越多个层次:

应用代码
  ↓
驱动 API、类型转换、连接池
  ↓
数据库线协议
  ↓
TCP/TLS
  ↓
数据库服务端协议实现
  ↓
SQL 执行器、事务管理器、存储引擎

因此,“执行一条 SQL”并不是一个单一动作。它可能包含连接握手、认证、协议消息交换、参数编码、执行计划选择、结果分包、客户端读取、取消请求,以及连接复用前的状态清理。

理解这些边界,有助于解释许多常见问题:

  • 为什么 TCP 已经连通,数据库连接仍然失败?
  • 为什么同一条 SQL,字符串拼接和参数绑定的行为不同?
  • 为什么查询已经在服务端执行完,应用仍然卡住?
  • 为什么取消查询后,连接不能立即复用?
  • 为什么同一个驱动换 PostgreSQL、MySQL 或代理后表现不同?
  • 为什么“支持预处理语句”不一定意味着参数始终在服务端绑定?

本文以 PostgreSQL 和 MySQL 的公开稳定语义为主要例子。具体驱动的默认行为仍可能随语言、驱动版本和连接池配置变化,不能只依据数据库产品名称推断。


一、先区分四个层次:SQL、协议、驱动和连接池

1. SQL 不是数据库协议

SQL 是数据库服务器理解的语言和语义集合,例如:

SELECT id, name
FROM users
WHERE id = 42;

数据库协议则规定客户端和服务器如何传输请求与响应,例如:

  • 客户端如何声明协议版本;
  • 如何协商 TLS;
  • 如何传递用户名和认证数据;
  • 如何发送 SQL 文本或绑定参数;
  • 服务器如何返回列元数据、行数据和错误;
  • 一条消息如何分帧;
  • 客户端如何表示“我已经处理完当前响应”。

同样的 SQL 可以通过不同方式发送:

1. 文本查询协议:直接发送完整 SQL 文本
2. 服务端预处理协议:先准备,再绑定参数,再执行
3. 客户端模拟预处理:客户端先编码参数并拼接请求,再发送完整 SQL

这三种方式的安全性、类型转换、计划缓存和抓包表现并不相同。

2. 驱动不只是“网络客户端”

驱动通常负责:

  • 建立 TCP 或 Unix socket 连接;
  • 进行 TLS 和认证;
  • 把语言对象编码成数据库类型;
  • 把数据库返回值解码成语言对象;
  • 管理协议状态;
  • 暴露同步或异步 API;
  • 实现游标、批量执行、结果读取和取消;
  • 在需要时执行连接重置。

例如,Python 中的整数、时间、字节串和 None,不能直接通过网络发送。驱动必须根据目标数据库的协议和类型规则进行编码。

3. 连接池改变了连接的生命周期

没有连接池时,生命周期通常是:

创建连接 → 使用 → 关闭连接

有连接池时,应用看到的“关闭连接”通常只是:

租借连接 → 执行操作 → 归还连接

真正的物理连接可能继续存在,并被下一个请求使用。因此,归还连接前必须确认:

  • 没有未读取完的结果;
  • 没有打开的事务;
  • 没有遗留的 prepared statement、临时表或 session 参数;
  • 没有正在执行的查询;
  • 没有处于协议错误恢复状态。

连接池复用的不是一个抽象的数据库连接,而是数据库服务器上的会话状态。


二、连接握手:从 TCP 成功到数据库会话就绪

一次数据库连接至少包含以下阶段:

DNS / 地址选择
  ↓
TCP 或 Unix socket 建立
  ↓
TLS 协商(如果启用)
  ↓
数据库协议初始握手
  ↓
认证
  ↓
会话参数和能力确认
  ↓
Ready / 可执行状态

这里有一个重要区别:

TCP 连接成功,只能说明两个进程之间建立了字节流;它不表示数据库认证成功,也不表示驱动已经可以发送 SQL。

1. TCP 是字节流,不是消息流

TCP 不保留应用层消息边界。一次 send() 可能对应:

  • 一次 recv()
  • 多次 recv()
  • 或与其他发送内容合并后被一次 recv() 读到。

所以数据库协议必须自行设计消息格式,通常包括:

消息类型 + 长度 + 消息正文

驱动必须持续读取,直到获得完整消息。不能假设“一次读取就得到一行结果”或“一次写入就对应一个协议包”。

这也是数据库驱动不能简单用通用 HTTP 客户端代替的原因之一。


三、PostgreSQL 的连接握手

PostgreSQL 前端/后端协议的典型流程如下:

客户端 → 服务器:StartupMessage
服务器 → 客户端:Authentication...
服务器 → 客户端:ParameterStatus
服务器 → 客户端:BackendKeyData
服务器 → 客户端:ReadyForQuery

如果客户端希望使用 TLS,通常先发送一个 SSL 请求。服务器以单字节响应是否支持 TLS;支持时,双方随后执行 TLS 握手,数据库协议消息在加密连接中继续传输。

1. StartupMessage

客户端在启动消息中通常提供:

  • 用户名;
  • 数据库名;
  • 客户端声明的应用名;
  • 其他启动参数。

这不是 SQL。服务器会根据这些内容选择认证方式和初始会话环境。

2. Authentication

服务器可以要求不同认证方式,例如密码认证或 SCRAM。以 SCRAM 为例,认证过程不是简单地把明文密码发给服务器,而是包含客户端随机数、服务器随机数、盐和证明值等步骤。

驱动需要完成整个认证状态机。认证失败时,TCP 可能仍然是正常连接,但数据库会话不会进入可执行状态。

3. ParameterStatus 与 BackendKeyData

服务器可能发送时区、日期样式、服务器版本等会话参数。驱动通常会保存这些信息,用于后续类型转换和错误处理。

BackendKeyData 包含服务器端会话标识和取消密钥。它不是用户密码,主要用于后续发送 PostgreSQL 的取消请求。

4. ReadyForQuery

ReadyForQuery 表示服务器已经完成当前启动阶段,可以接受下一条前端消息。它还会带有事务状态标志,通常表示:

  • 空闲;
  • 事务中;
  • 事务已失败,需要回滚后才能继续。

这个状态对驱动非常重要。数据库连接不是“有响应”和“没响应”两种状态,而是具有协议和事务状态的状态机。


四、MySQL 的连接握手

MySQL 经典协议通常由服务器先发送初始握手包:

服务器 → 客户端:Initial Handshake
客户端 → 服务器:Handshake Response
服务器 ↔ 客户端:TLS 握手(需要时)
服务器 → 客户端:OK 或 ERR

MySQL 初始握手信息通常包含:

  • 协议版本;
  • 服务器版本字符串;
  • 连接标识;
  • 认证插件相关数据;
  • capability flags;
  • 字符集;
  • 状态标志;
  • 最大数据包长度等。

客户端在响应中声明自己支持的能力,例如:

  • 是否支持协议扩展;
  • 是否支持多结果集;
  • 是否支持预处理语句;
  • 是否请求 SSL;
  • 使用什么字符集;
  • 使用哪个认证插件。

TLS 的位置

MySQL 的 TLS 不是“连接成功后再随便打开的一层开关”。客户端会在握手过程中根据服务器能力和客户端配置决定是否转入 TLS。之后,认证凭据和 SQL 数据在 TLS 通道中传输。

PostgreSQL 和 MySQL 都支持 TLS,但具体握手消息、能力协商和配置方式不同。不能把一个产品的连接参数或抓包流程直接套到另一个产品。

连接成功的真正含义

MySQL 服务器返回 OK 后,客户端才可以把连接视为可执行状态。以下情况都可能发生:

TCP 成功,但 TLS 失败
TLS 成功,但认证失败
认证成功,但目标数据库或权限检查失败
认证成功,连接可用

应用日志只记录“connect timeout”往往不够。诊断时应区分:

  • 地址解析失败;
  • TCP 建连超时;
  • TLS 证书或协议失败;
  • 认证失败;
  • 服务端拒绝连接;
  • 连接建立后,在首条 SQL 上失败。

五、一次查询的两种基本传输方式

1. 文本查询

文本查询直接发送 SQL 字符串:

SELECT id, name FROM users WHERE id = 42;

服务器解析 SQL 文本,推导参数、执行计划和结果类型,然后返回结果。

优点是简单,适合临时 SQL 和管理工具。缺点是如果把用户输入拼接进字符串,就会产生 SQL 注入;此外,客户端和服务器之间还要共同处理字符串转义、字符集和类型格式。

2. 参数化执行

参数化执行把 SQL 结构和数据分开:

SELECT id, name FROM users WHERE id = $1;

参数值 42 通过协议中的参数字段单独传输,而不是成为 SQL 文本的一部分。

这不仅是“把字符串换成占位符”,还涉及:

  • 占位符语法;
  • 参数类型;
  • 参数二进制或文本编码;
  • 服务器是否真正执行了 prepare;
  • 计划何时生成和缓存;
  • 结果列如何解码。

参数化可以防止参数值改变 SQL 语法结构,但它不能自动安全地替换表名、列名或 SQL 关键字。例如下面通常不能把表名作为普通值参数绑定:

SELECT * FROM $1;       -- 通常不是合法的标识符参数用法

动态标识符必须使用驱动提供的标识符引用功能,或使用受控白名单映射。


六、服务端预处理:PostgreSQL 的 Parse、Bind、Execute

PostgreSQL 扩展查询协议把一次执行拆成多个阶段:

Parse → Bind → Describe(可选)→ Execute → Sync

1. Parse:解析并创建 prepared statement

客户端发送 SQL 文本和参数类型信息,服务器解析 SQL 并创建一个 prepared statement。

可以把它抽象为:

SQL 文本 + 参数类型
        ↓
解析后的语句对象

如果 statement 名为空,它通常表示 unnamed statement;如果有名称,则可以在当前会话中复用。

2. Bind:绑定参数并创建 portal

Bind 把参数值绑定到 prepared statement,形成一个可执行的 portal。可以理解为:

prepared statement + 参数值
              ↓
portal

prepared statement 更接近“已解析的语句模板”,portal 更接近“一次绑定后的执行上下文”。

3. Execute:执行 portal

Execute 执行 portal,并可以指定返回行数。配合游标或合适的驱动 API,可以逐步取得结果,而不必一次把全部结果载入客户端内存。

4. Sync:恢复消息边界

Sync 要求服务器在处理完当前扩展查询序列后返回 ReadyForQuery。如果中间发生错误,服务器通常会丢弃当前事务或当前同步点之前的后续消息,直到完成错误恢复。

这说明协议错误处理不能简单理解为:

收到错误 → 继续发送下一条 SQL

在 PostgreSQL 中,驱动必须知道服务器是否已经回到可安全发送下一条命令的同步状态。

SQL PREPARE 与协议级 prepare 的区别

PostgreSQL 也支持 SQL 命令:

PREPARE find_user (integer) AS
SELECT id, name
FROM users
WHERE id = $1;

EXECUTE find_user(42);

DEALLOCATE find_user;

这里创建的是数据库会话中的 prepared statement。

驱动使用扩展协议的 Parse/Bind/Execute 时,也可以创建服务端 prepared statement,但驱动可能:

  • 每次使用 unnamed statement;
  • 执行若干次后才切换为 named statement;
  • 在连接断开时丢失服务端对象;
  • 根据参数和执行次数选择不同计划策略。

所以“代码调用了 prepared API”和“数据库会话中存在一个可见的命名 prepared statement”不是完全等价的命题。


七、MySQL 的预处理协议

MySQL 二进制协议的服务端预处理通常是:

COM_STMT_PREPARE
  ↓
服务器返回 statement_id、参数数量、列信息
  ↓
COM_STMT_EXECUTE
  ↓
客户端传递参数类型和值
  ↓
服务器返回二进制结果集

关闭时可以发送:

COM_STMT_CLOSE

与普通文本查询相比,预处理执行返回的行通常采用二进制协议格式,驱动据此把值解码为语言对象。

MySQL 还支持 SQL 级别的预处理语句,例如:

PREPARE stmt FROM
  'SELECT id, name FROM users WHERE id = ?';

SET @user_id = 42;

EXECUTE stmt USING @user_id;

DEALLOCATE PREPARE stmt;

这里的参数变量和作用域属于 MySQL SQL 语义;驱动直接调用 COM_STMT_PREPARE 时,走的是客户端/服务器协议能力。两者都叫 prepared statement,但 API 层次不同。

客户端模拟预处理

某些驱动或配置会选择“模拟预处理”:

应用传入 SQL + 参数
  ↓
驱动在本地转义和编码
  ↓
生成完整 SQL
  ↓
发送普通文本查询

这不等于服务端预处理。它可能仍然避免了直接手写拼接,但其正确性依赖驱动的转义、字符集和类型处理,也不具备服务端协议级 prepared statement 的全部行为。

判断方式包括:

  • 查看驱动文档和配置;
  • 抓取协议或启用数据库日志;
  • 观察服务端 prepared statement 统计;
  • 使用数据库审计或代理确认请求类型。

不能仅凭 API 名称推断实际传输方式。


八、参数类型、计划缓存和“预处理一定更快”的误解

预处理的核心收益首先是结构与数据分离,而不是必然提升性能。

一次执行可能经历:

解析 SQL
→ 绑定参数
→ 生成自定义计划或使用通用计划
→ 执行

服务器是否复用计划、何时复用、通用计划是否优于针对当前参数的自定义计划,都与数据库实现、语句复杂度和数据分布有关。

一个简单反例

假设表中存在严重倾斜:

tenant_id = 1       有数亿行
tenant_id = 999999  只有几行

同一条参数化查询:

SELECT *
FROM events
WHERE tenant_id = $1;

1999999,最优计划可能不同。固定复用一个通用计划,可能对其中一类参数明显不理想。

因此:

  • 参数化主要解决安全和语义正确性;
  • 预处理可能减少重复解析;
  • 计划缓存是否改善性能,需要通过执行计划和实际指标验证;
  • 不能把“使用 prepared statement”直接等同于“更快”。

参数类型也会影响运算符选择、隐式转换和索引使用。驱动应尽量明确传递类型,应用不能假设所有数据库都会用相同规则推断字符串、整数、日期和二进制值。


九、结果集不是一个对象,而是一段需要消费的协议数据

服务器返回结果时,通常包含:

列元数据
  ↓
多批行数据
  ↓
结束标志或命令完成消息
  ↓
事务/会话就绪消息

一个结果集可能很大。驱动可以采用两种模式。

1. 缓冲读取

驱动在 execute() 或首次读取时,把全部结果读入客户端内存:

服务器 → 驱动内存:全部结果
应用 → 驱动:逐行读取

优点:

  • API 简单;
  • 查询完成后连接通常已经消费完结果;
  • 连接池更容易复用连接。

缺点:

  • 大结果集占用大量内存;
  • 查询结果全部传输完之前,执行调用可能不返回;
  • 应用开始处理时,服务器可能已经生成了大量数据。

2. 流式读取

驱动只读取一部分结果:

服务器 → 驱动缓冲区 → 应用
             ↑
        按需继续读取

当应用处理速度较慢时,驱动读取速度下降,TCP 接收窗口可能缩小,最终形成反压:

应用慢
→ 驱动少读
→ TCP 窗口收缩
→ 服务器发送受阻
→ 服务端执行/产生结果的速度下降

这不是严格意义上的“服务器主动暂停执行”保证,而是网络流控和执行器行为共同产生的效果。某些算子仍可能先在服务器内存或临时空间中物化结果。


十、为什么“查询已经执行完”但连接仍不能复用

考虑以下伪代码:

cursor.execute("SELECT id FROM large_table")
row = cursor.fetchone()
pool.release(connection)

如果结果集还有未读取的数据,连接上的服务器响应仍然没有消费完。此时直接把连接归还连接池,下一位请求发送 SQL,可能造成:

  • 新请求先读到旧查询的剩余行;
  • 驱动报告协议错乱;
  • 连接阻塞;
  • 连接被池标记为损坏并关闭。

正确做法取决于驱动:

  1. 完整读取结果;
  2. 显式关闭游标,让驱动丢弃剩余结果;
  3. 取消查询并继续读取到协议结束;
  4. 如果无法恢复,关闭物理连接而不是放回池中。

“释放游标”和“释放连接”也不是同一件事。游标释放可能只释放服务器对象;连接上的剩余协议数据仍必须处理。

PostgreSQL 游标示例

在 PostgreSQL 中,服务端游标通常需要事务边界。使用 psql 可以观察一个基本例子:

BEGIN;

DECLARE user_cursor CURSOR FOR
    SELECT id, name
    FROM users
    ORDER BY id;

FETCH FORWARD 2 FROM user_cursor;
FETCH FORWARD 2 FROM user_cursor;

CLOSE user_cursor;

COMMIT;

每个 FETCH 只请求一部分逻辑结果,但底层驱动仍需正确消费每次响应中的协议消息。事务没有提交前,游标的生命周期和锁、快照行为也会受到事务影响。

不要把服务端游标误解成“数据库只执行两行”。查询计划中的排序、聚合或其他算子,可能需要先处理更多数据。

MySQL 的结果读取差异

MySQL 驱动常见两种模式:

  • buffered:驱动先读取完整结果;
  • unbuffered:驱动边读边返回。

名称和 API 随驱动变化,但原则相同:未消费完的结果会占用连接,且通常不能在同一连接上安全执行下一条命令。


十一、结果流中的多结果集和协议边界

某些数据库能力允许一条请求产生多个结果集,例如:

  • 存储过程返回多个结果;
  • 批量语句;
  • 多语句执行;
  • 服务端返回状态结果后还有后续结果。

驱动必须提供类似以下的消费逻辑:

execute()
while result_exists:
    consume_current_result()
    if has_next_result:
        move_to_next_result()

如果应用只读取第一个结果集便归还连接,剩余结果仍可能留在网络流中。

多语句能力还会扩大攻击和兼容性边界。一个驱动关闭多语句支持,不只是 API 限制,也可能是为了减少语句边界、错误处理和注入风险。


十二、取消:停止执行、停止传输和关闭连接不是一回事

“取消查询”至少有三种不同动作:

  1. 请求服务器停止执行当前语句;
  2. 客户端停止等待或停止读取;
  3. 直接关闭网络连接。

它们的结果不同。

1. PostgreSQL 的取消请求

PostgreSQL 使用独立连接发送取消请求。该请求包含:

  • 目标后端进程标识;
  • 后端密钥。

取消连接本身不执行普通 SQL,而是通知目标后端取消当前操作。

典型流程:

主连接:正在执行查询
取消连接:发送 CancelRequest
数据库后端:尝试取消查询
主连接:收到 query canceled 错误

取消请求存在竞态:

查询刚好完成
→ 取消请求随后到达
→ 取消无效或目标已不再执行原查询

因此取消只能视为“尽力请求”,不能作为严格的事务控制原语。

如果查询在事务中被取消,PostgreSQL 通常会使当前事务进入失败状态。之后发送普通 SQL 会得到类似“当前事务已中止”的错误,必须执行:

ROLLBACK;

然后才能继续使用该连接。

2. MySQL 的取消

MySQL 常见的服务端取消方式是从另一个已认证连接执行:

KILL QUERY <connection_id>;

它针对目标连接当前正在执行的语句,而不是必然关闭整个会话。

也可以:

KILL CONNECTION <connection_id>;

这会终止连接本身,影响更大。

直接关闭客户端 socket 不是等价的精确取消。服务器通常需要检测到连接断开,随后清理相关执行;检测和清理存在延迟,而且网络中断并不总能立即被对端观察到。

3. 查询超时和取消请求的关系

客户端超时通常是:

客户端等待超过期限
→ 驱动发起取消或关闭连接
→ 应用返回超时错误

服务端超时则是:

服务器内部计时器到期
→ 服务器中止语句
→ 客户端收到数据库错误

二者不能互相替代。客户端超时返回,并不证明数据库已经停止执行;服务端收到取消,也不代表客户端已经读完错误响应。

生产系统通常需要定义:

  • 客户端请求截止时间;
  • 数据库语句超时;
  • 锁等待超时;
  • 连接建立超时;
  • 结果读取超时;
  • 取消失败后的连接处置策略。

超时之后,连接能否复用必须根据驱动和协议状态判断,而不能无条件放回连接池。


十三、错误处理:连接可能“还能用”,也可能已经被污染

数据库错误有多个层次。

1. SQL 语义错误

例如列名不存在:

SELECT nonexistent_column FROM users;

服务器会返回错误消息。若协议已经完成错误响应和同步,连接通常仍可继续使用。

2. 事务错误

PostgreSQL 中:

BEGIN;

SELECT 1 / 0;
-- 当前事务进入 failed 状态

SELECT 2;
-- 仍然失败

ROLLBACK;
SELECT 2;
-- 才能继续

这里不是连接断了,而是事务状态不允许继续执行普通 SQL。

3. 协议或传输错误

例如:

  • 驱动没有消费完整结果;
  • 消息长度异常;
  • 服务器意外关闭连接;
  • TLS 连接中断;
  • 解码器发现不符合预期的消息。

这类错误可能意味着驱动已经无法确定消息边界。最安全的处理通常是关闭物理连接,不能依赖 ROLLBACK 修复未知协议状态。

4. 连接池归还规则

连接池可以把连接分为:

可复用
不可复用,关闭并重建

常见应关闭而不是归还的情况包括:

  • 未消费完结果且驱动无法清理;
  • 取消后无法确认已回到就绪状态;
  • 协议解析错误;
  • 读写超时导致消息边界未知;
  • 服务端断开;
  • 驱动报告连接状态损坏。

十四、兼容性不是“SQL 能执行”这么简单

数据库兼容性至少分为五层。

1. 线协议兼容性

包括:

  • 握手版本;
  • capability flags;
  • TLS 行为;
  • 认证插件;
  • 消息格式;
  • 扩展消息;
  • 大包和压缩能力。

代理、连接池代理和数据库兼容实现可能只支持其中一部分。

2. SQL 语法兼容性

占位符就有明显差异:

-- PostgreSQL
SELECT * FROM users WHERE id = $1;

-- MySQL 驱动协议通常使用
SELECT * FROM users WHERE id = ?;

标识符引用也不同:

-- PostgreSQL
SELECT "order" FROM "user";

-- MySQL
SELECT `order` FROM `user`;

把一个数据库的 SQL 模板直接复制到另一个数据库,可能在解析阶段就失败。

3. SQL 语义兼容性

即使语法相似,语义也可能不同:

  • NULL 与空字符串;
  • 布尔类型;
  • 隐式类型转换;
  • 字符集和排序规则;
  • 时间戳及时区;
  • 自增值获取;
  • RETURNING 等语句能力;
  • DDL 是否参与事务;
  • 默认隔离级别;
  • 锁行为。

例如,应用若依赖“执行 DDL 后一定可以回滚”,就不能只根据 SQL 文本判断,必须确认目标引擎的事务语义。

4. 驱动 API 兼容性

不同驱动可能在这些方面不同:

  • ? 是否被转换为 $1
  • 是否默认开启客户端模拟预处理;
  • 查询结果是否默认缓冲;
  • close() 是否自动回收剩余结果;
  • 时间值返回为本地时间、带时区时间还是字符串;
  • 发生超时后连接是否自动丢弃;
  • 自动提交模式和事务开始时机。

同名 API 不代表同样的生命周期语义。

5. 运行环境兼容性

还要考虑:

  • 直连数据库还是经过代理;
  • 连接池是否在应用进程内;
  • 是否经过负载均衡;
  • 读写分离是否改变连接目标;
  • TLS 是否在代理终止;
  • DNS 是否返回多个地址;
  • 服务端升级后认证插件或协议能力是否变化。

因此,兼容性测试应覆盖完整路径:

应用
→ 驱动版本
→ 连接池
→ TLS / 代理
→ 数据库版本
→ 存储引擎和配置

十五、一个可运行的 PostgreSQL 预处理与事务示例

以下示例使用 PostgreSQL 的 SQL 级预处理,便于直接在 psql 中运行。

前置条件:

  • 已连接到 PostgreSQL;
  • 当前用户有访问 users 表的权限;
  • users.id 为整数,且存在 name 列。
BEGIN;

PREPARE find_user (integer) AS
    SELECT id, name
    FROM users
    WHERE id = $1;

EXECUTE find_user(42);
EXECUTE find_user(43);

DEALLOCATE find_user;

COMMIT;

每一步的含义是:

  1. BEGIN 开始事务。prepared statement 的会话生命周期和事务生命周期不是同一概念,但这里用事务展示明确的执行边界。
  2. PREPARE 解析 SQL,并声明参数类型为 integer
  3. EXECUTE 每次提供一个参数值。
  4. DEALLOCATE 删除当前会话中的 prepared statement。
  5. COMMIT 提交事务。

预期输出取决于表中数据,可能是零行、一行或多行。EXECUTE 没有匹配行不是协议错误,而是正常的空结果集。

如果执行期间出现错误:

BEGIN;

PREPARE bad_stmt (integer) AS
    SELECT missing_column
    FROM users
    WHERE id = $1;

解析阶段就会失败。若错误发生在事务中,后续操作是否可继续取决于数据库事务状态;在 PostgreSQL 中,应使用 ROLLBACK 清理失败事务。

注意,这个例子不等于所有 PostgreSQL 驱动都会用 SQL PREPARE。驱动也可能通过扩展协议的 Parse/Bind/Execute 完成服务端参数化。


十六、一个可运行的 MySQL 预处理示例

前置条件:

  • 已连接到 MySQL 8.4 或兼容版本;
  • 当前用户有访问 users 表的权限;
  • users.id 可与整数参数比较。
PREPARE find_user FROM
    'SELECT id, name FROM users WHERE id = ?';

SET @user_id = 42;

EXECUTE find_user USING @user_id;

SET @user_id = 43;

EXECUTE find_user USING @user_id;

DEALLOCATE PREPARE find_user;

流程是:

PREPARE:服务器解析语句
SET:准备会话变量
EXECUTE:把会话变量绑定到 ?
DEALLOCATE:释放 prepared statement

@user_id 是 MySQL 会话变量,不是 PostgreSQL 风格的 $1 参数。MySQL 驱动直接使用二进制预处理协议时,应用通常不需要显式执行这些 SQL 命令,但参数占位符、类型编码和 statement 生命周期仍然受相同的产品语义约束。


十七、诊断一个“查询卡住”的完整路径

假设应用报告:

execute() 超时

不要立即得出“数据库没有执行”或“数据库执行太慢”的结论。可以沿着下面的状态路径检查。

阶段一:连接阶段

确认:

DNS 是否完成?
TCP 是否建立?
TLS 是否完成?
认证是否完成?
是否收到 Ready / OK?

如果连接池中已有连接,新的请求可能根本没有经历连接握手,而是在等待池中可用连接。

阶段二:发送阶段

确认驱动是否已经写完:

  • SQL;
  • 参数;
  • 执行消息;
  • 必要的同步消息。

发送阻塞可能意味着服务端接收缓慢、网络反压或请求包过大。

阶段三:服务端执行阶段

区分:

  • CPU 执行;
  • 等待锁;
  • 等待读取磁盘;
  • 等待其他资源;
  • 已生成结果但客户端没有及时读取。

数据库监控和锁诊断比客户端异常文本更能说明服务端状态。

阶段四:结果读取阶段

查询可能已经开始返回结果,但应用卡在:

  • 等待第一行;
  • 等待完整结果集;
  • 客户端解码;
  • 下游网络发送;
  • 应用线程处理。

如果启用了流式读取,fetch() 的耗时可能主要来自结果传输,而不是 SQL 执行计划。

阶段五:取消和清理阶段

超时后确认:

服务器是否收到取消?
服务器是否停止执行?
连接是否收到完整错误响应?
事务是否处于失败状态?
连接是否被正确关闭或重置?

只记录“应用返回超时”无法回答这些问题。


十八、几个典型反例

反例一:把参数值直接拼接进 SQL

sql = "SELECT * FROM users WHERE name = '" + user_input + "'"

输入中只要包含引号、注释符或其他 SQL 结构,就可能改变语句语义。即使应用做了简单替换,也容易在字符集、编码或边界情况下出错。

应使用驱动参数绑定:

SQL:SELECT * FROM users WHERE name = ?
参数:user_input

具体占位符由目标数据库和驱动决定。

反例二:只取一行就归还连接

execute()
fetch_one()
release_to_pool()

如果结果未消费完,连接可能被污染。应完整消费、显式清理,或关闭物理连接。

反例三:客户端超时后继续复用原连接

timeout
→ 捕获异常
→ 把连接放回池

如果取消尚未完成,或者协议中仍有结果数据,下一次请求可能读到旧响应。超时后的连接必须由驱动和连接池根据可验证状态处理。

反例四:把连接断开当成精确取消

断开 socket 可能最终终止服务器工作,但它不提供与服务端取消命令相同的时序和语义。断开还可能触发事务回滚、锁释放和连接重建,代价通常更大。

反例五:把数据库名相同的驱动当成协议相同

PostgreSQL 的扩展协议和 MySQL 的命令/包协议是不同体系。即便两个驱动都提供:

execute(sql, params)

其占位符、类型编码、预处理、结果读取和取消机制仍可能完全不同。


十九、实践中的边界和取舍

参数化优先于字符串拼接

参数化的首要价值是保持 SQL 结构与数据分离,并减少注入风险。对于动态表名、列名和排序方向,应使用白名单映射,而不是把任意输入当作普通参数。

流式读取适合大结果,但增加生命周期复杂度

流式读取可降低客户端峰值内存,但要求:

  • 明确游标和连接的生命周期;
  • 确保异常路径也会消费或关闭结果;
  • 避免把长时间流式读取的连接无限期占满连接池;
  • 处理客户端慢、网络慢和取消失败。

连接池不应掩盖数据库容量

一个连接池可以快速创建大量客户端并发,但数据库服务端仍受:

  • 最大连接数;
  • CPU;
  • 内存;
  • 锁;
  • 磁盘;
  • 工作线程或进程;
  • 中间代理容量

限制。池的排队超时与数据库语句超时是两个不同指标。前者表示拿不到连接,后者表示拿到连接后执行或等待过久。

预处理需要观察实际行为

若关心性能或计划复用,应结合:

  • 数据库执行计划;
  • 服务端日志;
  • 驱动配置;
  • prepared statement 统计;
  • 参数分布;
  • 实际延迟和资源消耗。

不要只根据“prepared”这个词判断请求一定经过服务端预处理,也不要假设计划缓存必然带来收益。


数据库驱动的核心职责,是把应用层的调用转换成一条有严格状态、边界和生命周期要求的协议数据流。连接握手决定会话能否成立;预处理决定 SQL 结构和参数如何分离;结果流决定连接何时真正空闲;取消决定超时后如何结束服务端工作;兼容性则要求同时检查线协议、SQL 语义、驱动行为和部署拓扑。

当这些层次被分开观察,许多“数据库偶尔卡住”“连接池随机报错”“换数据库后参数行为变化”的问题,就可以从抽象的 API 异常还原为具体的状态转换和数据流问题。


系列导航与关联阅读

官方资料

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