后端系统工程 12:有界连接池与等待取消
系列导航:上一篇 11 撤回、历史保留与消息系统集成验收 | 本篇 12 | 下一篇 13 RPC 客户端的请求生命周期
数据库连接的数量不由应用决定。PostgreSQL 为每个客户端会话分配一个后端进程,max_connections 规定服务器同时接受的连接上限,并且只能在启动时设置(PostgreSQL 16 连接设置)。应用侧连接池是这份服务端预算在单个进程里的记账员:每借出一条连接,它就替调用方占用了服务端的一个槽位。记账错一次,可能表现为请求永远等不到连接,也可能表现为若干实例同时扩容后,整个数据库拒绝新会话。
本篇实现一个有界连接池,并用真实 PostgreSQL 检验四类结果:容量不超预算;等待超时、等待取消与归还交错时没有槽位丢失;连接在服务端被终止或会话状态被污染后不会交给下一个调用方;多实例的总连接数受数据库侧上限约束。代码在 examples/12-connection-pool/,全部输出在 evidence/12/output.txt。
需求契约
调用方只看到四个操作:在截止时间内获取一条连接;使用后归还;发现连接损坏时声明它不可复用;服务关闭时让池停止发放连接并回收已有连接。池对调用方作出以下承诺,也只作出这些承诺:
- 任何时刻,池持有的连接总数(空闲、借出、正在创建三者之和)不超过
maxTotal。 acquire在截止时间之前返回一条可用连接,或者抛出超时、中断、关闭、创建失败四种异常之一;不会在截止时间之后无限阻塞。- 获取失败的调用方不占用任何槽位;获取成功的调用方占用一个槽位,直到归还。
- 一条连接回到池中后,上一位借用者留下的未提交事务和会话参数不会被下一位借用者看到。
- 同一次借用只能归还一次;重复归还被拒绝,且不改变池状态。
池不承诺查询一定成功,不承诺连接在借出后的整个使用期间都保持有效,也不负责跨进程协调。多个应用实例之间的总量,要靠配置推导与数据库侧硬上限共同约束。
规模假设只用于推导参数,全部是教学取值:数据库 max_connections = 200,超级用户保留 3 个槽位;运维、监控与迁移工具预留 10 个;同库的其他服务固定占用 40 个;本服务平时 6 个实例,滚动发布时最多同时存在 2 个新实例。
连接预算从数据库往回推
PostgreSQL 的保留槽位分两层:当空闲槽位不超过 superuser_reserved_connections 时只接受超级用户;PostgreSQL 16 新增的 reserved_connections 再为具有 pg_use_reserved_connections 权限的角色留出一层(同一文档)。应用角色不应指望使用这两层。按上面的假设:
1 | |
分母取发布期间的峰值实例数,而不是平时的 6 个,因为旧实例的池在退出前仍持有连接。若按 6 计算得到 24,8 个实例同时存在时需求为 192,超过 147。
推导值只是配置约束,任何一个实例的配置写错,算式就失效。PostgreSQL 提供了一个可以落到服务端的硬约束:角色级连接上限。CREATE ROLE 文档说明,CONNECTION LIMIT 规定该角色可建立的并发连接数,只计普通连接,不计预备事务和后台工作进程(PostgreSQL 16 CREATE ROLE)。实验把应用角色限制为 12:
1 | |
三组多进程实验检验预算(每个进程 8 个线程、各执行 30 次 pg_sleep(0.01)):
| 配置 | 结果(evidence/12/output.txt) |
|---|---|
| 3 进程 × 每池 4 | 三个进程各 240 次成功,err53300=0 |
| 3 进程 × 每池 6 | 两个进程各创建 6 条连接、240 次成功;第三个进程 0 次成功,240 次 53300 |
| 3 进程 × 不用池、每次直连 | 三个进程分别 152、181、200 次 53300 |
53300 是 too_many_connections(PostgreSQL 16 错误码)。第二组结果的形状值得注意:超出预算并没有让三个实例都慢一点,而是让启动最晚的实例一条连接都拿不到。对外看,就是部分实例全部请求失败,另一部分实例正常。哪一个实例被饿死取决于启动先后,实验中是 b-1,下一次运行可能换成别的进程。第三组说明,没有池时,连接数跟着并发请求数走,与配置无关。
状态与计数
池内每条连接处于空闲、借出、校验中三种位置之一;此外还有尚未存在、正在创建的连接,它们已经占用了容量。实现用四个计数与两个队列表达全部状态,并在持锁时维持一个等式:
1 | |
图 12-1:池内一条连接的状态迁移(重复归还的拒绝路径见右侧注释)
stateDiagram-v2
[*] --> Creating: acquire 且 total < max(先 total++ 预留)
Creating --> Borrowed: create 成功
Creating --> [*]: create 失败(total--,把容量交给队首等待者)
Borrowed --> Resetting: Lease.close()(序号匹配)
Resetting --> Idle: 复位成功且无等待者
Resetting --> Borrowed: 复位成功且有等待者(直接交接)
Resetting --> Destroyed: 复位失败 / markBroken / 池已关闭
Idle --> Validating: acquire 取到空闲连接
Validating --> Borrowed: 未超寿命且校验通过
Validating --> Destroyed: 超寿命或 isValid 失败
Destroyed --> [*]: total--,必要时发放创建许可
note right of Borrowed
重复或过期的 close() 被拒绝:
序号不匹配 → IllegalStateException,状态不变
end note
每次借出都分配一个递增的租借序号,写在连接条目上,也写在交给调用方的 Lease 对象里。归还时比较两者:不一致就说明这个 Lease 已经归还过,或者连接已经被别人借走。double_release_rejected 用例中,第二次 close() 和归还后的 get() 都被拒绝,随后两次借出得到的是两条不同的连接,total=2。如果没有序号,同一条连接可能被放回空闲队列两次,再被两个调用方同时持有。
三个设计选择
容量先预留,再创建
最直接的写法是先检查 total < max,释放锁去创建连接,创建成功后再加计数。两个线程同时看到 total = max − 1 时,都会去创建,结果超出上限。正确做法是在锁内先执行 total++、creating++,这时其他线程已经看不到这个空位;创建失败时再在锁内撤回预留,并把空出的容量交给队首等待者。这一条按代码结构推出,本篇没有为“先检查后创建”的写法单独做对照实验;它的后果与第三组直连实验同类,都是本地计数之外多出服务端会话。
create_failure_releases_slot 把连接地址指向一个关闭的端口,连续 5 次获取都得到 CreateFailedException,之后 total=0;把地址改回正确值后,两次获取都成功。若失败路径漏掉撤回,total 会停在 2,池永久认为自己已满。
等待者:信号量还是显式队列
一种常见结构是信号量加空闲队列:tryAcquire(timeout) 拿到许可后再去空闲队列取连接,归还时先放回队列再释放许可。这样的结构简单,但被唤醒的等待者和刚到达的调用方会争夺同一条空闲连接,Semaphore 的公平模式只约束许可,不约束空闲队列。
本实现采用显式 FIFO 等待队列和直接交接:归还时如果有等待者,连接不进入空闲队列,而是写入队首等待者的 grantedEntry,borrowed 计数保持不变,然后唤醒它;容量空出时(连接被销毁或创建失败),则给队首等待者一个“创建许可”,total 已替它预留。新到达的调用方只能看到空闲队列和剩余容量,无法插队。代价是要自己处理等待者在被分配后、醒来前发生超时或中断的情况,这正是下一节的问题。
何时校验连接
连接在池中闲置期间可能已被服务端终止(管理员执行 pg_terminate_backend、服务端重启、中间网络设备回收空闲 TCP 连接)。可选的时机有三种:
| 方案 | 发现问题的位置 | 代价 |
|---|---|---|
| 借出前校验 | 调用方拿到连接之前 | 每次借出可能多一次往返;用空闲时长阈值减少次数 |
| 后台定期校验 | 校验线程 | 校验间隔内仍可能借出失效连接;要和借出线程协调 |
| 不校验,靠调用方遇错重试 | 第一次使用时 | 调用方要能判断哪些错误可安全重试;写操作的失败语义变复杂 |
JDBC 规定 Connection.isValid(timeout) 必须通过实际查询或等效机制确认连接有效,并且该查询“在当前事务的上下文中执行”(Java SE 21 java.sql.Connection)。后半句决定了次序:归还时必须先复位、结束事务,借出前的校验才不会混进上一位调用方的事务。实现中 validateAfterIdle 控制闲置多久才需要校验;连接还受 maxLifetime 约束,超过寿命直接销毁。
terminated_backend_replaced 用例借出 3 条连接再全部归还,管理员终止这 3 个后端,随后 3 次借出全部成功,池销毁了 3 条旧连接并创建新连接。负向对照把校验阈值设为一天,同样终止后端后,调用方第一次执行 select 1 就收到 SQLSTATE 57P01(admin_shutdown)。
超时与归还的交错
显式交接引入了一个窗口:归还线程在锁内把连接写给等待者 W 时,W 可能已经到了截止时间,只是还没重新拿到锁。W 醒来后,若先判断“已超时”并直接抛出 TimeoutException,这条连接就记在 borrowed 里,却没有任何调用方持有它。这个槽位永久丢失,没有错误日志,也没有异常栈。
图 12-2:分配与超时同时发生时的两种处理(W 为等待者,R 为归还线程)
sequenceDiagram
participant W as 等待者 W
participant L as 池锁
participant R as 归还线程 R
W->>L: awaitNanos(剩余时间),释放锁
Note over W: 截止时间到,准备重新取锁
R->>L: 取得锁
R->>R: waiters.pollFirst() == W
R->>W: W.grantedEntry = 连接,borrowed 不变,signal
R->>L: 释放锁
W->>L: 重新取得锁
alt 正确实现:先看是否已分配
W-->>W: granted() 为真,返回连接(分配优先于超时)
else 错误实现:先看截止时间
W-->>W: 抛出 TimeoutException
Note over W,R: 连接计入 borrowed,却无人持有 → 槽位泄漏
end
正确实现的等待循环把两个判断的次序固定下来:
1 | |
分配写入与这里的判断都在同一把锁下进行,因此“检查未分配”与“离开队列”之间不会插入新的分配。Condition 文档要求在循环中等待,并检查真正等待的状态谓词,因为实现允许虚假唤醒(Java SE 21 Condition)。这里的谓词就是 granted(),而不是“被唤醒”。
中断路径不能沿用“分配优先”。调用方中断等待,通常是因为上层请求已经放弃,把连接交给它只会让连接停在一个不再执行的任务里。实现在捕获 InterruptedException 后调用 returnGrantLocked:已分配的连接转交给下一个等待者或放回空闲队列,已分配的创建许可则撤回预留。
实验在进程内用假资源重复这个交错,因为它检验的是池的状态机,而不是数据库(输出中标注 model=in-process-fake)。池容量为 2,两个持有者占满;每轮一个等待者以 1ms 超时获取,另一个线程在 0.9 到 1.1ms 之间归还,使归还时刻落在超时附近:
1 | |
正确实现运行 5000 轮后,静止状态下 borrowed=0,仍能立即借满 2 个。错误实现 acquireBuggy 在第 2 轮就泄漏了一个槽位,此后只能借出 1 个。每次运行的 grants 与 timeouts 比例随调度变化,判定依据是静止状态的计数和可借数量。同类竞争又在真实连接上运行了 900 次获取(6 个线程、3ms 超时、容量 2),结束时数据库侧该应用的会话数等于池的 total,等于 2。
两种“取消”
“取消”一词在连接池附近有两个含义,处理方式不同。
第一种是取消等待:调用方还没拿到连接。interrupt_while_waiting 用例中,持有者占满容量为 1 的池,另一个任务以 30 秒超时等待,主线程对该任务调用 Future.cancel(true)。中断前等待队列长度为 1,中断后为 0,interrupts=1;持有者归还后,下一次获取在 200ms 内成功,连接回到空闲队列。
第二种是取消正在执行的语句:调用方已经拿着连接,查询执行太久。PostgreSQL 的取消请求通过另一条新连接发送 CancelRequest,服务端不对其回复;文档明确说取消信号“可能有效也可能无效”,前端无法直接知道取消是否成功,只能继续等待原查询的响应(PostgreSQL 16 协议流程,取消进行中的请求)。因此,取消之后不能假定连接已经空闲,必须等原语句返回,再按正常路径归还和复位。
cancel_running_query 让一条连接执行 pg_sleep(10),300ms 后从另一个线程调用 Statement.cancel()。语句在 316ms 时以 SQLSTATE 57014(query_canceled)返回,连接归还后再次借出执行 select 42,返回 42。整个过程只创建了 1 条连接,说明取消语句不需要销毁连接。如果取消发生在事务中,事务会进入失败状态,这要靠归还时的回滚复位来清理。
归还时的复位
连接是有状态的会话。上一位借用者可能关闭了自动提交却没有提交,也可能修改了 statement_timeout。PgFactory.reset 在归还时执行三步:若不在自动提交模式则回滚并恢复自动提交;执行 RESET ALL 恢复会话参数;任何一步抛出异常就把连接标记为不可复用并销毁。
reset_session_state 模拟一位“忘记提交”的借用者:关闭自动提交,插入一行,执行 set statement_timeout = '1234ms',然后直接归还。下一次借出得到同一条连接,其 autoCommit=true、statement_timeout=0;从管理员连接查询探针表,行数为 0。复位覆盖不到的会话状态(临时表、LISTEN、会话级咨询锁、服务端预备语句)在本实现中没有处理,README 把它们列为排除项。需要彻底清理时可以选择 DISCARD ALL,但它会释放驱动缓存的预备语句,需要和驱动行为一起验证,本篇没有做这个实验。
关闭
关闭分三步:状态切到 CLOSING,此后的 acquire 立即抛出 PoolClosedException;唤醒全部等待者,它们同样收到关闭异常;销毁全部空闲连接,再在宽限期内等待借出的连接归还,归还的连接直接销毁,宽限期结束时返回仍未归还的数量。返回值让上层决定是继续等待、记录泄漏,还是对仍在使用的连接调用 Connection.abort。
close_with_waiters 中,两名等待者都收到 closed,持有者在 200ms 后归还,close 返回未归还数 0,新的获取被拒绝;200ms 后数据库侧该应用的会话数为 0。
用例与证据
| 用例 | 类型 | 观察值(摘自 evidence/12/output.txt) |
|---|---|---|
normal_concurrent |
正常 | 16 线程 × 40 次全部成功,sampledPeakBackends=4,关闭后会话数 0 |
boundary_timeout |
边界 | 池耗尽时 200ms 超时在 200ms 返回;0 超时在 0ms 返回;等待队列为空 |
race_timeout_vs_release |
竞争(模型) | 5000 轮,borrowedAtRest=0,obtainable=2/2 |
negative_control_buggy_timeout |
负向对照 | 第 2 轮泄漏,obtainable=1/2 |
race_real_connections |
竞争(真实连接) | 843 次成功、57 次超时,会话数 2 = total |
interrupt_while_waiting |
取消 | 等待队列 1 → 0,归还后可再借出 |
cancel_running_query |
取消 | 57014,316ms,连接复用成功 |
reset_session_state |
恢复 | 未提交行不可见,参数恢复默认 |
terminated_backend_replaced / 对照 |
失败恢复 | 校验后 3 次成功;不校验时首用 57P01 |
create_failure_releases_slot |
失败恢复 | 5 次失败后 total=0,修复后成功 |
double_release_rejected |
边界 | 二次归还与过期 get() 被拒绝 |
close_with_waiters |
关闭 | 等待者收到关闭异常,会话数归零 |
budget_* / 直连对照 |
多进程预算 | 见“连接预算”一节的表 |
运行入口为 examples/12-connection-pool/run.sh,环境记录在 evidence/12/env.txt(PostgreSQL 16.15、OpenJDK 21.0.12.1、pgJDBC 42.7.4、4 核)。normal_concurrent 中的会话峰值来自约 2ms 一次的 pg_stat_activity 采样,可能漏掉瞬时峰值。容量上限的硬性证据来自角色连接上限:越界时服务端返回 53300,3 × 4 配置下这一错误为 0 次。
结论边界
这些实验支持以下结论:在单进程内,该池的容量计数在创建失败、校验失败、超时、中断、重复归还和关闭路径下保持一致;真实 PostgreSQL 连接在服务端终止、语句取消和会话污染后,不会以失效或带状态的形式交给下一位调用方;多实例总量只有配合数据库侧上限才能成为硬约束,而超出预算时的失败集中在部分实例上。
它们不支持关于吞吐、延迟分位或最优池大小的任何结论,本篇没有测量这些指标。实验也没有覆盖半开 TCP 连接、网络分区、主备切换下的行为。等待时间预算只讨论了获取超时本身。获取超时应小于请求的总截止时间,并为后续查询留出余量;一次远程调用从发出到结果确定的完整生命周期,包括截止时间如何传给对端、迟到的响应如何处理、哪些操作允许自动重试,是下一篇 13 RPC 客户端的请求生命周期 的主题。





