深入 Play 22:JDBC 与连接池,异步接口为何仍会等连接
连接池最多提供两个连接,四个线程同时执行 JDBC:两个执行 SQL,另外两个停在借连接的位置。把执行器缩为两个线程后,连接池等待者变成零,执行器队列里多出两个任务。请求总数没有改变,等待发生的位置变了。
这种区别决定了排障时应检查什么。返回 CompletionStage<Result> 只能说明结果稍后交付;借连接和执行 SQL 仍会占用执行它们的线程。只记录 HTTP 总耗时,无法区分排队、借连接、数据库执行和响应调度。
从提交到归还,分别记录时间
累计工程 examples/play-lab/app/labdb/PoolLab.java 为每个任务记录提交、开始执行、开始借连接、借到连接、SQL 开始、SQL 结束和归还连接的相对微秒数。所有时间来自同一次实验的单调时钟起点,不用不同机器的墙上时间相减。
flowchart LR
Submit[提交任务] --> Queue[执行器队列]
Queue --> Start[工作线程开始]
Start --> Borrow[等待连接池]
Borrow --> SQL[执行 JDBC SQL]
SQL --> Close[关闭句柄 归还连接]
Close --> Reply[完成结果与响应]
started - submitted 包含任务调度等待,borrowed - borrowStarted 是借连接耗时。SQL 的计时只覆盖调用执行查询的区间;归还时间则在 try-with-resources 离开后记录。响应完成还有框架调度和网络过程,不能把 SQL 结束直接记成 HTTP 结束。
实验先提交两个执行 pg_sleep(1.5) 的任务,再通过独立 PostgreSQL 连接检查 pg_stat_activity,确认两个 SQL 都进入 PgSleep。之后才提交另外两个任务并读取池和执行器状态,因此快照不依赖“睡一小段时间应该够了”的猜测。
| 执行器线程数 | 池最大连接数 | 数据库正在执行 | 连接池等待者 | 执行器队列 |
|---|---|---|---|---|
| 4 | 2 | 2 | 2 | 0 |
| 2 | 2 | 2 | 0 | 2 |
两组的四个任务最终都返回 ok,activeAfterTasks=0。这组观测没有证明线程池应永久等于连接池大小:一个工作线程可能执行多个阶段,应用也可能还有其他使用同一池的入口。它证明的是,在固定容量和工作负载下,增加工作线程不能制造更多数据库连接。
排查同类问题时,可先写出 提交 → 开始 → 借到 → 执行完 → 归还 的事件,再把每段等待归入它所属的资源。这个方法也适用于 HTTP 客户端连接池和文件处理执行器;具体容量不同,时间边界相同。
Database 负责借还,调用者决定执行位置
本系列固定 Play 3.0.6,源码提交为 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。Databases.scala 的 withConnection 实现 先获取连接,调用 block,最后在 finally 中关闭连接。该方法没有把 block 自动提交到另一个线程。
Hikari 返回的是池管理的连接句柄。应用关闭这个句柄后,池才能重新分配底层连接;关闭一次借用与关闭整个池是两个层级。实验还读取 Hikari 的 active、idle 和等待者指标,并在场景结束后调用 Database.shutdown()。
下面的完整 Java 类把一次 JDBC 借用放进调用者提供的执行器,返回已经脱离连接的整数结果。类使用 JDK 21 编译;它不创建执行器,也不拥有注入的 Database,因此不在每次查询后关闭整个池。
1 | |
如果执行器在提交时拒绝任务,supplyAsync 可以同步抛出拒绝异常;应用仍需在提交边界映射错误,不能只依赖 Stage 的异常回调。第 13 篇的有界执行器实验覆盖了这一边界。
连接、Statement 和 ResultSet 都在同一个任务内创建、使用和关闭。把 ResultSet 或 Connection 放进结果对象返回,会把资源作用域交给后续代码,原有 finally 无法继续保证消费阶段的有效性。第 23 篇用待完成 Stage 展示这一反例。
轻量接口是否与 JDBC 争用线程
/db/load?dedicated=false 和 dedicated=true 分别把两个真实 JDBC 查询提交到默认执行上下文和 lab-blocking-dispatcher。测试进程将默认 dispatcher 的并行度最小值、最大值都设为 2,并从应用配置读回确认;每个查询执行有限的 pg_sleep(2) 后再读取 SELECT 1。
驱动程序在 PostgreSQL 中观察到两个带实验标记的 PgSleep 后,才请求 /health。数据库活动与 HTTP 请求来自不同观察通道,这避免了仅凭 Java 线程名判断 SQL 当时仍在执行。
| 执行位置 | 第一、第二、第三次 health 耗时,秒 | HTTP 状态 |
|---|---|---|
| 默认 dispatcher | 0.881692、0.005473、0.005100 | 全部 200 |
| 专用 dispatcher | 0.010628、0.005715、0.004915 | 全部 200 |
默认组记录到 application-pekko.actor.default-dispatcher-*,专用组记录到 application-lab-blocking-dispatcher-*。第一组首个 health 请求等待较长,后两次已经接近 SQL 释放后的时段;不能把三个数当成同一饱和区间的独立样本。
这些数值只描述本次受控功能实验。它没有预热、稳定到达率、长时间采样或延迟分位数,不能推出吞吐能力,也不能声称专用池普遍快多少倍。可迁移的结论是把阻塞工作放到明确的执行资源,并同时观察该资源和轻量请求的竞争关系。
失败之后,连接是否还能借出
借连接失败、SQL 失败和应用代码失败发生在不同位置。实验先等池内两个物理连接初始化完成,再持有两次借用,以 300 ms 的连接预算申请第三个连接。这样测到的超时才对应池耗尽,避免把建立首批连接的耗时误当成排队。
| 注入位置 | 实际结果 | 随后检查 |
|---|---|---|
| 第三次借连接 | SQLTransientConnectionException |
已有借用关闭后可继续查询 |
SELECT 1/0 |
SQLSTATE 22012 |
active 回到 0 |
| 借到连接后抛应用异常 | IllegalStateException |
active 回到 0 |
| 三类失败之后 | SELECT 1 返回 1 |
activeAfterFailures=0 |
异常类型只能说明故障被观察到了。连接是否归还必须另查池状态和后续借用;一条 error 日志不提供资源终态。
清理代码还要处理清理自身失败。finally { close(executor); database.shutdown(); } 中前一条抛出异常,会跳过后一条。PoolLab.closeExperiment 使用嵌套 finally 继续关闭数据库,把清理错误加入主异常的 suppressed,并保留线程原有中断状态。
/db/interrupted-cleanup 先执行真实 SELECT 1,再对清理驱动线程设置中断。该次结果为主异常 synthetic primary failure、suppressed=1、interrupted=true,并且 poolClosed=true、executorTerminated=true。这证明了本次中断路径继续尝试两项清理;它没有模拟磁盘或驱动关闭过程中所有可能的故障。
复现入口与证据分层
依赖只增加 javaJdbc、evolutions 和 org.postgresql:postgresql:42.7.5。实际 stage 中的 jar 确认为 HikariCP 5.0.1 与 PostgreSQL JDBC 42.7.5,服务器报告 PostgreSQL 17.6;版本与 SHA-256 记录在 examples/play-lab/evidence/batch22-25/isolated/resolved-jdbc-artifacts.json,镜像摘要保存在同目录的 image-inspect.json。
数据库默认禁用。先为实验创建唯一命名的回环容器;以下 5432 端口必须空闲,名字已存在时换一个新名字。lab-only-password 是公开的合成实验口令。镜像固定为 PostgreSQL 17.6,复测还应保存 inspect 输出中的 RepoDigests 和服务器版本。
1 | |
等待 readiness 命令成功后,从仓库根目录执行以下单行命令。macOS 可使用下方 java_home 选择已安装的 JDK 21;其他系统将 JAVA_HOME 设置为本地 JDK 21 安装目录。
1 | |
play-db-local 和端口 5432 必须对应本次自建容器。脚本只负责启动、验证和停止自己的应用进程;不要把已有业务数据库作为实验目标。实验为请求创建小型池以隔离计数,生产应用应把池的创建与关闭放到应用资源生命周期。
历史隔离证据的38项JUnit及stage、37次HTTP观察保留在isolated/。共享累计工程数据库启用后46项JUnit及stage通过,shared-http/summary.json与HTTP/SQL原始记录有37次真实HTTP观察;9个新增class与生产jar字节一致。共享health样本与隔离时间分开保留,默认位置本轮首个请求约1.582秒、专用位置约0.012秒,不能要求每轮复现历史的同一个数字。
隔离DB禁用回归执行原有31项、另7个数据库方法跳过;共享DB禁用则实际执行39项、跳过7项。接口为每个跳过方法产生两条XML通知,原始共53条,归并后只有46个不同方法,不能把Passed46解读为46个无数据库场景。shared-db-free.json保存原始条数、实际执行数与跳过数。
四篇的完整 Java 类也保存在 writing/examples,可在 stage 构建后单独进行编译检查。该命令只编译示例,不启动数据库或应用;JDK 21 的编译输出和实际数据库实验应分别保存。
1 | |
两个改动练习
- 保持执行器四线程,把池从 2 改为 1,再记录每个任务的七个时点。验收只检查快照中的数据库活动、借连接等待和最终 active=0,不预写总耗时必须增加几倍。
- 在借到连接之后、创建 Statement 之前抛异常,再把清理驱动线程中断。验收主异常保留、suppressed 可见、中断位恢复,以及池关闭和执行器终止;删掉其中任何一项,说明少观察了哪个资源。
