连接池最多提供两个连接,四个线程同时执行 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.Executor;
import play.db.Database;

public final class JdbcLeaseProbe {
private final Database database;
private final Executor executor;

public JdbcLeaseProbe(Database database, Executor executor) {
this.database = database;
this.executor = executor;
}

public CompletionStage<Integer> selectOne() {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = database.getConnection();
Statement statement = connection.createStatement()) {
statement.setQueryTimeout(5);
try (ResultSet rows = statement.executeQuery("SELECT 1")) {
if (!rows.next()) {
throw new SQLException("SELECT 1 returned no row");
}
return rows.getInt(1);
}
} catch (SQLException failure) {
throw new CompletionException(failure);
}
}, executor);
}
}

如果执行器在提交时拒绝任务,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
2
podman run --name play-db-local -e POSTGRES_PASSWORD=lab-only-password -e POSTGRES_DB=play_lab -p 127.0.0.1:5432:5432 -d docker.io/library/postgres:17.6
podman exec play-db-local pg_isready -U postgres -d play_lab

等待 readiness 命令成功后,从仓库根目录执行以下单行命令。macOS 可使用下方 java_home 选择已安装的 JDK 21;其他系统将 JAVA_HOME 设置为本地 JDK 21 安装目录。

1
2
3
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
PLAY_LAB_DB_ENABLED=true PLAY_LAB_JDBC_URL='jdbc:postgresql://127.0.0.1:5432/play_lab?connectTimeout=3&socketTimeout=5' bash examples/play-lab/sbtw test stage
python3 examples/play-lab/lab/database_checks.py --container play-db-local --jdbc-url 'jdbc:postgresql://127.0.0.1:5432/play_lab?connectTimeout=3&socketTimeout=5' --evidence examples/play-lab/evidence/local-db-run

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
"$JAVA_HOME/bin/javac" -Xlint:all -Werror -cp 'examples/play-lab/target/universal/stage/lib/*' -d examples/play-lab/target/article-examples examples/play-lab/evidence/batch22-25/writing/examples/*.java

两个改动练习

  1. 保持执行器四线程,把池从 2 改为 1,再记录每个任务的七个时点。验收只检查快照中的数据库活动、借连接等待和最终 active=0,不预写总耗时必须增加几倍。
  2. 在借到连接之后、创建 Statement 之前抛异常,再把清理驱动线程中断。验收主异常保留、suppressed 可见、中断位恢复,以及池关闭和执行器终止;删掉其中任何一项,说明少观察了哪个资源。

上一篇:上传下载与临时文件 · 下一篇:事务与幂等 · 系列起点:最小应用