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

数据库连接与连接池:容量、超时、排队、泄漏和故障恢复

数据库连接通常被当作一个简单对象:打开连接,执行 SQL,关闭连接。但在生产系统中,一条连接同时占用数据库进程或线程、内存、网络套接字、事务状态、锁和临时资源。应用实例增多、查询变慢或数据库发生切换时,连接问题会表现为:

  • 数据库连接数达到上限;
  • 请求大量等待连接池;
  • 应用线程耗尽,但数据库看起来并不繁忙;
  • 事务长期保持打开,阻塞清理或持有锁;
  • 数据库已恢复,应用仍不断使用失效连接;
  • 重试和连接重建形成“重连风暴”。

理解这些现象,不能只记住“设置一个连接池大小”。需要先区分连接、连接池、数据库容量、排队、超时和事务生命周期,再沿着正常路径和故障路径分析。


一、连接到底是什么

1. 逻辑连接与物理连接

应用看到的“连接”通常是一个客户端对象。它至少包含:

  1. 到数据库服务器的 TCP 或 Unix socket;
  2. 数据库协议会话;
  3. 身份认证和权限上下文;
  4. 服务端保存的会话状态;
  5. 客户端驱动保存的读写缓冲区和协议状态。

连接池中的一个对象通常对应一个物理数据库连接。应用请求从池中借出的则是一个租约借用关系

请求
  └─ 从连接池借出物理连接
       └─ 开始或复用事务
            └─ 执行 SQL
                 └─ 提交/回滚
                      └─ 将连接归还连接池

归还连接不等于关闭物理连接。连接池通常会保留它,供下一个请求复用。

因此必须区分:

  • 关闭连接:释放数据库会话和网络资源;
  • 归还连接:结束本次借用,物理连接可能仍保留;
  • 提交事务:结束当前事务;
  • 回滚事务:撤销当前事务并结束事务;
  • 释放锁:通常随事务提交或回滚发生,但具体资源还可能受语句和会话状态影响。

如果应用只把连接放回池中,却没有结束事务,下一次请求可能拿到一个仍处于事务中的连接。这是连接池中最危险的状态污染之一。

2. 数据库连接不是“执行 SQL 的瞬时句柄”

数据库服务器通常需要为每个会话保存:

  • 当前用户、数据库和权限上下文;
  • 当前事务和快照;
  • 预处理语句;
  • 会话变量;
  • 临时表;
  • 游标;
  • 未读结果;
  • 锁和其他事务资源;
  • 日志、统计或复制相关状态。

PostgreSQL 的服务端连接通常由一个后端进程处理;MySQL 的连接模型和线程处理方式由其服务器实现与线程配置决定。这里不应把“一个连接等于固定一个操作系统线程”当作跨产品规范,但可以确定的是:连接数增加会消耗数据库的并发处理能力和内存。

一个连接池不能把数据库能承受的并发查询凭空变大。它主要解决的是:

  • 复用认证和连接建立成本;
  • 限制应用侧同时进入数据库的请求数;
  • 在短暂请求高峰期间提供有限排队;
  • 统一连接生命周期管理。

它不能解决:

  • SQL 本身太慢;
  • 锁竞争;
  • 索引缺失;
  • 数据库内存不足;
  • 数据库无法处理所配置的总连接数。

二、连接的生命周期与连接池状态

一个连接池至少需要处理以下状态:

创建中 ──成功──> 空闲
创建中 ──失败──> 失效

空闲 ──借出──> 使用中
使用中 ──归还且健康──> 空闲
使用中 ──归还但污染/失效──> 关闭

空闲 ──空闲超时──> 关闭
空闲 ──健康检查失败──> 关闭

应用请求还存在一个独立状态:

等待借连接 ──获得连接──> 执行 SQL
等待借连接 ──超时──────> 请求失败
执行 SQL ───超时──────> 查询取消或连接失效

“等待借连接”和“等待数据库执行”不是同一件事。它们必须分别测量和设置超时。

1. 借出、使用和归还

伪代码可以表示一个正确的最小生命周期:

conn = pool.acquire(timeout=200ms)
if conn == TIMEOUT:
    return 503

try:
    begin transaction
    execute SQL
    commit
catch error:
    rollback if transaction state is known
    mark conn invalid if protocol state is uncertain
    raise
finally:
    pool.release(conn)

这里有三个关键条件:

  1. finally 必须覆盖所有异常路径,包括超时、取消和序列化异常;
  2. 事务结束后才能归还健康连接;
  3. 如果客户端不知道服务端是否已经执行了请求,不能仅凭“回滚”就断言连接仍然可复用。

实际代码还需要处理结果集关闭、游标关闭、批处理对象释放以及连接重置。不同语言和驱动的 API 名称不同,但生命周期原则相同。

2. 为什么“归还前重置”很重要

请求可能修改会话状态。例如:

  • PostgreSQL 的 SET search_pathSET ROLE、临时表、预处理语句;
  • MySQL 的会话变量、字符集、SQL mode、临时表;
  • 当前事务隔离级别;
  • 当前数据库或 schema 上下文;
  • 未完成的结果读取。

如果这些状态被下一个用户请求继承,就可能造成数据访问错误,甚至权限边界失效。

连接池可以采用两种方式:

  • 每次借出前显式设置所需状态;
  • 归还时执行驱动或协议提供的重置操作。

重置操作不是万能的。它本身可能产生网络往返,也不一定覆盖应用自定义资源。发生协议错误、连接中断、未读结果或事务状态未知时,通常应该销毁连接,而不是强行重置后复用。


三、容量:连接池大小不是越大越好

1. 三个不同的容量上限

至少要区分:

应用实例并发请求数
连接池最大物理连接数
数据库允许的最大连接数

假设:

  • 应用有 8 个实例;
  • 每个实例池最大 20 个物理连接;
  • 数据库允许的总连接数为 150;
  • 其中需要为管理员、监控或其他服务保留 10 个连接。

应用理论上可能建立:

8×20=1608 \times 20 = 160

个连接,已经超过数据库可供业务使用的近似上限:

15010=140150 - 10 = 140

即使每个实例平时只使用 5 个连接,扩容、流量突增或连接预热也可能使总数达到 160。连接池配置应按所有实例的最坏总和检查,而不是只看单个实例。

在 PostgreSQL 中,max_connections 控制可接受的连接规模,并且还涉及为特定用途保留连接的配置。MySQL 中,max_connections 控制服务器允许的并发客户端连接数。具体可用连接数还会受到管理连接、复制、监控以及其他服务的影响,不能简单把配置值全部分给业务请求。

可以用以下 SQL 检查基础配置。

PostgreSQL:

SHOW max_connections;
SELECT count(*) AS current_connections
FROM pg_stat_activity;

示例输出可能是:

 max_connections
-----------------
 200

 current_connections
---------------------
 137

pg_stat_activity 中的行表示当前可见的服务器活动或会话记录。它不是“还可以安全建立多少个连接”的直接答案,因为并发创建、保留连接和权限边界仍然需要考虑。

MySQL:

SHOW VARIABLES LIKE 'max_connections';
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Threads_running';

含义不同:

  • Threads_connected:当前连接数;
  • Threads_running:当前处于运行状态的线程数,不等于连接数;
  • max_connections:服务器连接上限。

Threads_connected 很高而 Threads_running 很低,可能表示大量空闲连接;Threads_running 持续接近连接上限,则更可能是查询、锁或 CPU 造成的并发压力。

2. 数据库连接容量与查询并发容量不是一回事

连接数为 100,不代表数据库能同时高效执行 100 条复杂查询。

设:

  • CC:连接池允许的并发执行数;
  • SS:一次数据库操作的平均服务时间;
  • λ\lambda:每秒到达数据库的请求数。

在稳定状态下,排队论中的基本关系为:

L=λWL = \lambda W

其中:

  • LL:系统中平均存在的请求数;
  • WW:请求从进入到完成的平均时间;
  • λ\lambda:吞吐率。

如果 100 个连接都执行平均 200 ms 的查询,那么在理想情况下单池吞吐上限近似为:

1000.2=500 次/秒\frac{100}{0.2} = 500 \text{ 次/秒}

但这是非常理想化的估算。CPU、磁盘、锁、缓存命中率、网络和 WAL 等资源可能更早成为瓶颈。

更重要的是,增加连接数可能降低吞吐:

  1. 更多查询同时竞争 CPU;
  2. 更多事务持有锁;
  3. 更多工作内存被分配;
  4. 缓存局部性变差;
  5. 上下文切换增加;
  6. 大量查询同时访问磁盘,形成 I/O 队列。

因此,连接池大小应接近数据库在目标查询类型、目标延迟和目标并发下能够稳定处理的并发数,而不是盲目追随 CPU 核数或应用线程数。

3. 一个完整算例

假设:

  • 4 个应用实例;
  • 每个实例池上限为 12;
  • 数据库连接总上限为 80;
  • 需要保留 8 个管理和故障处理连接;
  • 业务目标是最多允许 40 个并发数据库操作。

总池容量为:

4×12=484 \times 12 = 48

数据库业务可用连接近似为:

808=7280 - 8 = 72

所以从数据库连接上限角度看,48 没有超出 72。但业务目标只有 40 个并发数据库操作,48 个连接仍可能让过多查询同时进入数据库。

一种更保守的分配是每个实例最多 10 个:

4×10=404 \times 10 = 40

但这只在实例数量稳定时成立。若自动扩容到 8 个实例,容量会变成:

8×10=808 \times 10 = 80

又超过业务可用的 72。于是容量规划必须把扩容上限也纳入:

实例最大数×每实例池上限数据库业务可用连接\text{实例最大数} \times \text{每实例池上限} \leq \text{数据库业务可用连接}

如果无法满足这个不等式,就必须使用集中式连接代理、降低每实例池上限、限制实例扩容,或改变数据库部署和分片方案。代理可以减少数据库看到的物理连接数,但不会自动消除后端查询并发和事务占用;事务级复用还要求客户端不能依赖跨请求的会话状态。


四、排队:连接池把压力放在哪里

当所有连接都在使用时,新请求有两种处理方式:

  1. 立即失败;
  2. 进入等待队列。

等待队列相当于把压力从数据库转移到应用进程。它可以吸收短暂尖峰,但不能消除超额负载。

1. 排队时间的组成

一个请求的总耗时可以粗略拆成:

Ttotal=Tapp+Tpool-wait+Tconnect+Tqueue-db+Texecute+TresultT_{\text{total}} = T_{\text{app}} + T_{\text{pool-wait}} + T_{\text{connect}} + T_{\text{queue-db}} + T_{\text{execute}} + T_{\text{result}}

其中:

  • Tpool-waitT_{\text{pool-wait}}:等待连接池租约;
  • TconnectT_{\text{connect}}:建立物理连接;
  • Tqueue-dbT_{\text{queue-db}}:数据库内部等待 CPU、锁或 I/O;
  • TexecuteT_{\text{execute}}:真正执行 SQL 的时间;
  • TresultT_{\text{result}}:传输和消费结果集的时间。

如果只记录“SQL 执行耗时”,可能看不到连接池等待;如果只记录请求总耗时,也无法知道慢在应用、连接池还是数据库。

2. 为什么排队会自我放大

假设连接池有 10 个连接,每条 SQL 平均执行 100 ms。理想吞吐约为:

10/0.1=100 次/秒10 / 0.1 = 100 \text{ 次/秒}

如果请求到达率长期为 120 次/秒,系统的利用率为:

ρ=120×0.110=1.2\rho = \frac{120 \times 0.1}{10} = 1.2

ρ>1\rho > 1 时,队列会不断增长,直到:

  • 等待超时;
  • 请求被上游取消;
  • 应用线程或协程耗尽;
  • 连接归还速度下降,进一步恶化。

这不是“池太小”这一句话就能解释的问题。池增大到 12,可能暂时使理论容量达到 120,但数据库也会承受更高的并发。正确做法是先确认瓶颈是连接数限制、查询服务时间、锁等待、CPU 还是 I/O。

3. 等待队列的工程语义

连接池等待应当有上限。无限等待会造成线程堆积,最终使连接池之外的 HTTP、RPC、消息消费线程也被占满。

一个请求从池中借连接时,至少应该定义:

  • 最大池大小;
  • 最小空闲连接数;
  • 创建新连接的并发上限;
  • 借连接超时;
  • 等待队列是否有上限;
  • 池关闭时如何唤醒等待者。

等待超时的含义是“本次请求没有及时获得连接”,不等于数据库查询失败。返回错误时,监控应区分:

pool_acquire_timeout
connect_timeout
db_lock_timeout
db_statement_timeout
network_error

否则运维人员可能错误地增加数据库连接数,而实际问题是某条 SQL 持有事务和锁太久。


五、超时:不同计时器解决不同问题

“设置超时”不是一个动作,而是一组作用对象不同的计时器。

1. 连接建立超时

连接建立超时覆盖 DNS、TCP、TLS、认证或协议握手中的一个或多个阶段,具体边界取决于客户端驱动。

它解决的是:

数据库地址不可达
网络丢包
服务端未接受连接
认证握手长期无响应

它不解决已经建立连接上的慢查询。

PostgreSQL 客户端通常可以通过连接参数设置 connect_timeout。MySQL 的 connect_timeout 主要与服务器等待客户端在连接握手阶段完成响应有关;客户端库也可能提供自己的连接超时参数。使用时必须查看所用驱动的语义,不能把同名参数当作跨产品、跨驱动的统一标准。

2. 连接池获取超时

这是应用等待空闲连接的时间:

请求 -> pool.acquire() -> 等待 -> 获得连接或失败

它不会取消已经在数据库执行的 SQL。如果池中 10 个连接都被慢查询占用,设置 200 ms 的获取超时只会让第 11 个请求快速失败,并不会让前 10 个查询停止。

3. SQL 执行超时

SQL 执行超时作用于数据库语句或客户端等待结果的过程。

PostgreSQL 的 statement_timeout 可以限制语句执行时间,超时时服务器取消当前语句;lock_timeout 用于等待锁的时间。lock_timeout 只限制锁等待,不限制 CPU 执行或磁盘读取。deadlock_timeout 不是“死锁最大允许时间”,而是服务器等待一段时间后检查死锁的配置,死锁检测和处理具有不同语义。

例如,PostgreSQL 可以在事务内临时设置:

BEGIN;

SET LOCAL statement_timeout = '2s';
SET LOCAL lock_timeout = '500ms';

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

COMMIT;

逐步解释:

  1. BEGIN 开启事务;
  2. SET LOCAL 只在当前事务内生效,事务结束后恢复;
  3. 如果等待锁超过 500 ms,锁请求失败;
  4. 如果语句总执行时间超过 2 s,语句被取消;
  5. 发生错误后,事务会进入失败状态,必须 ROLLBACK,不能直接继续执行后续 SQL;
  6. 成功则提交。

如果 UPDATE 超时,正确恢复路径是:

ROLLBACK;

然后才能把连接归还连接池。若客户端在超时后无法确定服务端协议状态,销毁连接通常比复用更安全。

MySQL 的 InnoDB innodb_lock_wait_timeout 主要控制等待 InnoDB 行锁的时间。它不是所有 SQL 执行时间的通用上限,且锁等待、死锁、元数据锁等待和普通计算耗时可能由不同机制控制。MySQL 还支持针对部分语句类型的执行时间限制,但其作用范围和版本、语句类型有关,不能替代应用层总超时。

4. 事务空闲超时

“连接空闲”与“事务空闲”不是一回事:

连接空闲:
  没有事务,等待下一条命令

事务空闲:
  事务已经 BEGIN,但当前没有执行 SQL

第二种更危险,因为它可能保留快照和锁,阻止数据库回收旧版本。

PostgreSQL 提供 idle_in_transaction_session_timeout,用于终止长时间处于事务内空闲状态的会话。它能防止某些事务长期占用资源,但也会使应用收到连接断开或事务错误,因此应用必须正确处理。普通连接空闲超时和事务内空闲超时不能混为一谈。

MySQL 的 wait_timeout 主要针对非交互连接在空闲状态下的等待时间;它不是查询执行超时,也不能可靠替代应用层事务超时。服务器关闭空闲连接后,连接池中的对象可能变成陈旧连接,池必须在借出或使用失败时将其剔除并重建。

5. 客户端总超时与数据库超时的关系

设:

  • TaT_a:应用请求剩余时间;
  • TpT_p:连接池获取超时;
  • TcT_c:建立连接超时;
  • TqT_q:数据库语句超时;
  • TrT_r:结果读取超时。

合理关系通常是:

Tp+Tc+Tq+Tr<TaT_p + T_c + T_q + T_r < T_a

但这不是简单相加就能完全保证,因为各阶段可能重叠,驱动取消也可能需要额外时间。关键原则是:数据库内部超时不应晚于上游请求已经放弃的时间,否则数据库可能继续执行一个客户端已经不再等待的查询。

反例:

HTTP 超时:2 秒
数据库 statement_timeout:10 秒

2 秒后客户端断开,并不自动保证数据库立即停止执行。数据库可能继续运行剩余 8 秒,连接仍被占用,进而造成连接池耗尽。


六、事务、取消和连接复用的边界

1. 语句超时后为什么不能直接复用连接

以 PostgreSQL 为例,事务中的语句失败后,事务进入 aborted 状态:

BEGIN;
SELECT 1 / 0;
SELECT 2;

第二条语句不会正常执行,服务器会报告当前事务已失败,必须先:

ROLLBACK;

连接池若在第一条语句失败后直接归还连接,下一个请求执行任何 SQL 都可能遇到这个状态。

MySQL 的错误和事务状态规则与 PostgreSQL 不完全相同,但同样不能假设“任何 SQL 错误都不会改变连接可用性”。死锁回滚范围、锁等待超时行为、自动提交状态、未读结果等都应以具体引擎和驱动语义为准。

2. 客户端取消不是可靠的回滚证明

客户端超时后可能尝试发送取消请求,但存在以下情况:

  • 取消请求没有到达服务器;
  • 原查询已经完成,取消晚到;
  • 网络中断导致客户端不知道服务器状态;
  • 驱动取消后连接协议流中仍有未消费数据;
  • 事务是否已提交变得不确定。

因此要区分两类失败:

明确未执行

例如在连接建立前失败,通常可以安全重试。

执行结果未知

例如请求已发送,随后网络断开。此时数据库可能:

  • 尚未执行;
  • 已执行但未提交;
  • 已提交,但确认响应丢失。

对未知结果自动重试可能造成重复扣款、重复插入或重复发货。解决方法不是简单“重试三次”,而是使用幂等键、业务状态表、唯一约束或可查询的操作记录确认最终状态。


七、连接泄漏:不是只有忘记 close

1. 泄漏的定义

连接泄漏是指连接已经不再被业务使用,却没有回到池中,也没有被关闭。常见原因包括:

  • 异常分支跳过归还;
  • 超时取消后清理逻辑未执行;
  • 流式结果集未关闭;
  • 事务开启后请求被中途取消;
  • 协程或线程持有连接跨越异步边界;
  • 将连接放进全局对象或缓存;
  • 连接池关闭时仍有借出连接;
  • 归还连接时检测到协议状态异常,但没有销毁;
  • 连接归还了,但事务或会话状态污染,表现为“逻辑泄漏”。

2. 如何区分泄漏和慢查询

假设连接池上限为 50:

  • active=50 持续上升;
  • 数据库 Threads_running 或 PostgreSQL 活动查询也持续增加;
  • 每个借用持续时间很长;
  • 慢查询、锁等待明显。

这更像查询变慢或事务占用。

另一种情况:

  • active=50 持续不降;
  • 数据库只有少量活动查询;
  • 大多数连接在应用内部没有对应 SQL;
  • 借用持续时间不断增加;
  • 连接池等待超时增加。

这更像应用侧连接泄漏、忘记消费结果或忘记结束事务。

3. 泄漏检测应记录借用关系

连接池监控至少应记录:

pool_size
idle
active
pending_acquire
acquire_timeout_total
borrow_duration
connection_creation_failures
validation_failures
destroyed_connections

开发和排障阶段还可以记录:

  • 借出连接的请求 ID;
  • 借出时间;
  • 调用栈;
  • SQL 操作类别;
  • 事务开始时间;
  • 归还原因;
  • 是否发生过超时或取消。

不要默认记录完整 SQL 和参数。参数可能包含密码、令牌、个人信息或业务机密,应进行脱敏,并遵循最小权限和审计要求。

一种简单的诊断思路是定义:

Tborrow=TreturnTborrowT_{\text{borrow}} = T_{\text{return}} - T_{\text{borrow}}

然后按分位数观察 borrow_duration。如果 P99 持续超过请求预算,连接池等待会快速积累。若某些调用栈的借用时间无限增长,则优先检查异常处理和结果集关闭。


八、数据库侧如何观察连接状态

1. PostgreSQL

查看会话状态:

SELECT
    pid,
    usename,
    datname,
    client_addr,
    state,
    wait_event_type,
    wait_event,
    xact_start,
    query_start,
    state_change,
    now() - xact_start AS transaction_age,
    now() - state_change AS state_age,
    left(query, 200) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST;

重点观察:

  • state = 'active':当前有查询活动;
  • state = 'idle':连接空闲;
  • state = 'idle in transaction':事务打开但当前没有查询;
  • wait_event_typewait_event:是否在锁、I/O 或其他等待;
  • xact_start 很早但 state 长期为空闲:可能存在长事务。

查找阻塞关系:

SELECT
    blocked.pid AS blocked_pid,
    blocked.query AS blocked_query,
    blocker.pid AS blocker_pid,
    blocker.query AS blocker_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocker
  ON blocker.pid = ANY(pg_blocking_pids(blocked.pid));

这类查询只能说明当前观察时刻的阻塞关系。处理阻塞前应确认业务影响和事务身份,不能看到一条长查询就直接终止。终止会话可能触发回滚,回滚本身也需要时间,并且可能影响正在进行的业务操作。

2. MySQL

查看连接和线程:

SHOW FULL PROCESSLIST;
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Threads_running';

或者:

SELECT
    ID,
    USER,
    HOST,
    DB,
    COMMAND,
    TIME,
    STATE,
    INFO
FROM information_schema.PROCESSLIST
ORDER BY TIME DESC;

可以重点查看:

  • Sleep:空闲连接,不代表泄漏;
  • Query:正在执行查询;
  • Waiting for ... lock 等状态:等待锁或其他资源;
  • TIME:当前状态持续时间,不应单独当作 SQL 执行时间;
  • INFO:当前 SQL,可能为空或被截断。

InnoDB 锁等待可以结合 performance_schema 的锁表和事务表分析。不同 MySQL 版本的监控字段和可见性可能有所变化,生产诊断应以目标版本的 performance_schema 文档为准。


九、连接失效与故障恢复

1. 连接为什么会变成陈旧连接

连接池中的连接可能在应用不知情时失效:

  • 数据库重启;
  • 主备切换;
  • 防火墙清除空闲连接;
  • NAT 或负载均衡器回收连接;
  • 数据库主动终止会话;
  • 网络分区;
  • TLS 或认证配置变化;
  • 服务端空闲连接超时。

TCP 连接在本地看起来仍然存在,不代表远端会话有效。真正使用连接时,应用可能收到连接重置、EOF、broken pipe 或协议错误。

2. 健康检查的取舍

池可以在借出前验证连接,例如执行轻量探测或使用驱动提供的 ping。它能减少把明显失效的连接交给业务,但有代价:

  • 每次借出都增加网络往返;
  • 检查成功到真正执行之间仍可能发生故障;
  • ping 成功不证明事务、锁和会话状态正确;
  • 大量连接同时健康检查可能形成额外压力。

因此健康检查不能替代执行时错误处理。实际通常结合:

  • 空闲超过某时间后再验证;
  • 建立连接时验证;
  • SQL 执行遇到连接级错误时立即销毁;
  • 连接回收前检查借用和事务状态。

3. 失效连接的处理路径

发生连接级错误时,池应把连接从可复用集合中移除:

业务执行 SQL
    │
    ├─ 成功 ──> 提交/结束事务 ──> 归还空闲
    │
    ├─ SQL 语义错误 ──> 按事务规则回滚 ──> 可能复用
    │
    ├─ 锁等待超时 ──> 回滚/结束事务 ──> 按驱动状态决定复用
    │
    └─ 网络/协议错误 ──> 标记失效 ──> 关闭并重建

网络错误后不要执行“再发一次同样的写请求”作为通用恢复措施,因为结果可能已经提交。

4. 数据库重启后的重连风暴

数据库恢复后,所有应用实例可能同时发现连接失效,并同时执行:

关闭旧连接
-> 建立新连接
-> 认证
-> 预热
-> 重试业务请求

如果有 NN 个实例、每个实例需要重建 PP 条连接,则可能产生:

N×PN \times P

个近乎同时的连接请求。数据库刚恢复时还要执行恢复、回放日志、建立缓存,重连风暴会再次压垮它。

恢复策略应包含:

  • 连接创建速率限制;
  • 指数退避和随机抖动;
  • 连接池预热上限;
  • 熔断或暂时拒绝上游请求;
  • 区分连接错误与业务错误;
  • 限制重试次数;
  • 只对幂等操作重试;
  • 故障恢复后逐步放量。

退避时间可以采用:

dk=min(dmax,d0×2k)+random(0,j)d_k = \min(d_{\max}, d_0 \times 2^k) + \text{random}(0,j)

其中:

  • kk:第几次重试;
  • d0d_0:初始等待;
  • dmaxd_{\max}:最大等待;
  • jj:随机抖动范围。

随机抖动的目的,是避免所有实例按相同时间表再次连接。

5. 连接关闭与优雅停机

应用发布或缩容时,不能立即杀死所有进程而不处理连接。合理顺序是:

停止接收新请求
-> 等待或拒绝新的连接池借用
-> 让进行中的事务完成到截止时间
-> 超时事务回滚
-> 关闭池中的空闲连接
-> 关闭仍未完成的借用连接

如果应用使用消息队列,还要先暂停拉取或正确处理消息确认,否则数据库事务回滚后消息可能已被确认,形成业务不一致。


十、重试、幂等与事务边界

1. 哪些错误适合重试

通常更容易安全重试的是:

  • 连接建立前失败;
  • 明确的临时网络不可达,且业务操作尚未发送;
  • 明确的锁等待超时,且事务已回滚;
  • 数据库明确返回可重试的临时错误。

不应仅凭异常名称重试:

  • 连接断开发生在写请求之后;
  • 提交响应丢失;
  • 事务状态未知;
  • 违反唯一约束;
  • 权限错误;
  • SQL 语法错误;
  • 数据校验错误。

2. 幂等写操作示例

一个业务请求可以携带唯一操作号 request_id,并由数据库约束保证去重:

CREATE TABLE payment_request (
    request_id  varchar(64) PRIMARY KEY,
    account_id  bigint NOT NULL,
    amount      decimal(18, 2) NOT NULL,
    created_at  timestamp NOT NULL
);

处理逻辑:

BEGIN;

INSERT INTO payment_request(request_id, account_id, amount, created_at)
VALUES ('req-20250101-0001', 42, 100.00, CURRENT_TIMESTAMP)
ON CONFLICT (request_id) DO NOTHING;

-- 根据插入结果决定是否执行后续一次性业务变更

COMMIT;

这是 PostgreSQL 语法。MySQL 需要使用相应的唯一键和冲突处理语法,不能直接照搬 ON CONFLICT

该模式的核心不是“重试一定安全”,而是把重试转换为同一个业务操作的再次确认。唯一约束、事务和状态记录共同提供了可验证的幂等边界。


十一、容量、查询优化和安全边界是连在一起的

1. 慢查询会伪装成连接池容量不足

如果一条查询原本执行 50 ms,后来因为基数估计错误选择了低效 Join 算法,执行时间变成 2 s,那么相同连接数下每个连接能完成的请求数会显著下降:

吞吐并发连接数平均服务时间\text{吞吐} \approx \frac{\text{并发连接数}}{\text{平均服务时间}}

连接池等待增加并不一定说明池太小,可能是:

  • 统计信息过期;
  • 估算行数与实际行数差异巨大;
  • Join 顺序错误;
  • Nested Loop 在大结果集上退化;
  • 缺少适合过滤条件的索引;
  • 锁等待或磁盘 I/O 增加。

因此连接池指标应和查询计划、数据库等待事件、CPU、I/O、锁等待一起分析。单独增加池大小,可能只是把慢查询数量增加。

2. 安全配置也会影响连接和池

连接池复用的是带有身份和会话状态的数据库连接,因此安全问题可能跨请求传播:

  • 连接使用高权限账号;
  • 租户上下文通过会话变量设置,却没有归还前清理;
  • 日志打印包含密码或敏感参数;
  • 未启用 TLS,连接认证和数据可能暴露;
  • 连接串包含明文凭据;
  • 审计记录无法关联请求 ID;
  • 动态 SQL 拼接造成注入。

应使用最小权限账号、参数化查询、加密传输和必要的审计。连接池并不能降低 SQL 注入风险;反而因为连接长期复用,会让错误的会话状态持续更久。


十二、常见误解与对应的失败表现

误解一:连接池越大,吞吐越高

失败表现:数据库 CPU、锁等待、上下文切换上升,P99 延迟变差。

原因:连接池只是允许更多并发进入数据库,不能增加 CPU、内存、磁盘或锁资源。

误解二:空闲连接很多就是泄漏

失败表现:看到数据库有大量 idle 或 MySQL Sleep 连接,立即杀连接。

原因:空闲连接可能是连接池的正常缓存。应查看池的 activepending、借用时长和数据库事务状态。真正危险的是 idle in transaction 或长期持有事务的连接。

误解三:客户端请求超时后,数据库查询一定停止

失败表现:HTTP 请求已经失败,但数据库活动查询和连接占用仍持续增加。

原因:应用层断开、驱动取消和数据库取消是不同动作,网络故障时还可能无法确认取消结果。

误解四:数据库重启后重试几次就够了

失败表现:数据库恢复瞬间出现大量认证、连接和查询请求,随后再次过载。

原因:所有实例同时重连和重试,形成同步风暴;并且写操作结果可能处于未知状态。

误解五:回滚后任何连接都能复用

失败表现:协议错误、未读结果、会话变量污染或驱动状态异常在后续请求中出现。

原因:回滚只解决一部分事务状态问题,不能证明连接协议流和会话状态已经健康。


十三、建立可验证的故障排查路径

遇到“获取数据库连接超时”时,可以按下面顺序缩小范围:

第一步:看应用池状态

确认:

总连接数
空闲连接数
使用中连接数
等待借连接数
借连接超时数
连接创建失败数
借用时长分布
  • active 接近上限,pending 增加:先看慢 SQL、锁和事务;
  • active 不高但获取仍失败:检查池实现、连接状态计数或线程/协程调度;
  • 创建失败多:检查数据库上限、网络、认证和 TLS;
  • 借用时长无限增长:检查泄漏和未结束事务。

第二步:看数据库连接与活动

PostgreSQL 看 pg_stat_activity 的状态、事务年龄和等待事件;MySQL 看 PROCESSLISTThreads_connectedThreads_running 以及锁等待信息。

第三步:区分等待位置

记录时间线:

请求进入
-> 开始等待连接
-> 获得连接
-> 开始发送 SQL
-> 收到首字节
-> 结果读取完成
-> 事务提交
-> 归还连接

只有这样才能区分:

  • 应用排队;
  • 建连慢;
  • 数据库锁等待;
  • SQL 执行慢;
  • 结果集读取慢;
  • 提交或网络响应慢;
  • 归还逻辑未执行。

第四步:确认是否发生扩容或故障

检查:

  • 应用实例数是否增加;
  • 每实例池是否重新初始化;
  • 数据库是否刚重启或主备切换;
  • 网络设备是否回收空闲连接;
  • 是否发生批量重试;
  • 是否有单个租户或接口产生异常流量。

第五步:恢复时先保护数据库

在原因尚未确认时,不要直接把所有池上限调大。更安全的临时动作可能是:

  • 限制入口并发;
  • 暂停高成本任务;
  • 减少重试;
  • 清理明确的长事务;
  • 隔离异常实例;
  • 限制重连速率;
  • 恢复后逐步放量。

终止数据库会话、调整连接上限和修改超时都应先确认版本语义、权限影响和回滚方式。


十四、一个可操作的配置推导框架

可以从业务预算反推连接池,而不是从“线程数乘以某个倍数”开始。

已知:

  • 目标数据库请求吞吐为 λ\lambda
  • 目标平均数据库服务时间为 SS
  • 允许的并发余量系数为 ff,例如用于吸收波动;
  • 应用实例最大数为 NN

所需并发连接的初步估算为:

Cλ×S×fC \geq \lambda \times S \times f

每个实例池上限约为:

Cinstance=CNC_{\text{instance}} = \left\lceil \frac{C}{N} \right\rceil

然后检查:

N×CinstanceCdatabase-businessN \times C_{\text{instance}} \leq C_{\text{database-business}}

这只是容量起点,不是性能保证。验证时还要使用实际查询分布和压力测试观察:

  • 数据库 CPU;
  • I/O 延迟;
  • 锁等待;
  • 事务年龄;
  • 查询 P95/P99;
  • 连接池等待 P95/P99;
  • 错误率和超时率;
  • 扩容后的总连接数。

如果服务时间 SS 因慢查询从 100 ms 增加到 500 ms,连接需求和排队风险都会变化。此时优先修复执行计划、索引、事务范围或锁竞争,通常比继续增加连接池更合理。


连接池的本质不是“多开一些数据库连接”,而是把有限的数据库会话资源交给应用统一调度。容量决定最多允许多少工作进入数据库,排队决定超额工作暂时堆在哪里,超时决定系统何时放弃,事务生命周期决定连接能否安全复用,泄漏决定资源是否永久脱离调度,故障恢复则决定一次数据库中断会不会演变成全系统重试风暴。

只有把这些状态和边界分别测量、分别处理,连接池才真正承担起隔离突发流量和控制并发的作用。


系列导航与关联阅读

官方资料

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