查询已经返回,连接为什么仍可能占着池

采购申请读取一份申请时,会先查申请行,再查商品明细。响应正常并不能说明两次查询使用的语句、结果集和连接都已归还;一旦读取明细中途出错,资源释放路径尤其容易遗漏。读者可以先检查一个具体问题:如果 ResultSet.next() 抛出异常,关闭的是哪个对象,关闭失败会不会掩盖查询失败?这里讨论的是 JDBC 借用和关闭,不是审批状态正确性。示例采用 Java 21、Jakarta EE 11、Open Liberty 26.0.0.5、pgJDBC 42.7.7 与专用 PostgreSQL 16.15 javaee_lab。JDBC 的 java.sql 和 javax.sql 属于 Java SE;不要将 javax.sql.DataSource 改成 jakarta.sql。

本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。

DataSource 是入口,Connection 是一次借用

服务器配置 examples/javaee-enterprise/deploy/server.xml 用 jndiName="jdbc/Procurement" 定义 DataSource,驱动从 ${env.JAVAEE_DRIVER_DIR} 加载,数据库目标为 javaee_lab。examples/javaee-enterprise/webapp/src/main/java/blog/javaee/web/DatabaseCheckResource.java 用 @Resource(lookup = "jdbc/Procurement") 注入 DataSource,调用 getConnection()、准备 SELECT current_database(),读取 ResultSet 后才返回 connected。静态注入成功只能表明入口可解析;真正申请连接发生在调用时,读到指定库名才是这条只读路径的成功判据。

从连接池得到的 Connection 是应用持有的访问句柄。close() 结束这次使用、把管理权交回数据源;不能由此推断数据库服务器上的物理连接必然被关闭。相反,数据库会话仍存在也不证明应用泄漏了句柄。验证泄漏要看有界池里的借用、归还与等待,在受控故障后再借到连接;SELECT current_database() 只证实当时能借用一条。@Resource 注入与 CDI @Inject 对业务对象的选择不同,05 篇的名称匹配也不是池容量测试。

依赖注入拿到的是数据源对象,不应把一个 Connection 缓存在 @ApplicationScoped 字段里供所有请求轮流使用:连接的事务状态、结果集位置和借用时间都属于一次具体操作。在当前实现中 JdbcRequestStore 是 application-scoped,但 insert、find、transition 和订单方法都在方法体内取得连接;字段保存的只是注入的 DataSource。在 find 调用中,申请查询与明细查询都使用同一借用句柄,所以后者能沿用前者刚查得的申请 ID;如果日后将明细加载改成另一个 DAO 方法,必须重新确认它从哪里借连接、是否需要参加同一事务、读到了哪个隔离级别下的状态。两次 getConnection() 返回不同 Java 包装对象既不能证明是两个物理会话,也不自动否定同一受管事务。

服务器侧的配置也只能支持有限推论。server.xml 引入了 JDBC 功能、驱动 jar 和服务器资源,尚未在该文件中写明实验用的 maxPoolSize、借用超时或连接验证策略;不能用产品默认值替代本机部署时读取的有效配置。应用侧 getConnection() 如果等待超时,可能根本没有进入 prepareStatement();数据库 SQL 错误则发生在成功借到连接之后。把请求开始、借用成功、准备语句、执行、结果读取、关闭及事务退出这几段分开计时,才有机会从同为 HTTP 500 的故障里找出等待池还是执行查询失败。第 17 篇会用专门的有界池实验测这条时间线,本章只定义释放责任。

按创建范围关闭,异常时保留主因

真实 examples/javaee-enterprise/adapters-jdbc/src/main/java/blog/javaee/jdbc/JdbcRequestStore.java 的 find 使用外层 try-with-resources 包住 Connection 与申请查询的 PreparedStatement,在读到申请行后以内层 try 管理明细 PreparedStatement,再以更内层 try 管理 ResultSet。关闭顺序与创建顺序相反:先明细结果集,再明细语句、申请结果集、申请语句、连接。DatabaseCheckResource.check() 同样在获取连接与准备语句之后才执行 SQL。源码能证明这些控制流在正常退出与抛出异常时尝试关闭对应对象;它不提供池的物理关闭计数,也不证明强制停止 JVM 仍会逐项执行回调。

Java 21 的 try-with-resources 规则规定:如果主体与 close() 都抛异常,主体异常保持为主异常,关闭异常进入 getSuppressed()。JdbcRequestStore 捕获 SQLException 后以 IllegalStateException 包装;排错时要沿 getCause() 找原始 SQLState,并查看被抑制的关闭异常,而不是只保留“Load request failed”。RequestMissingException 属于没有读到行的业务结果;既不代表 SQL 连接坏,也不能把此异常吞掉后返回其他租户的申请。要测试关闭失败的抑制链,需要受控的可注入数据源或故障代理;当前没有这种探针。

例如 find 在申请查询没有结果时,在结果集作用域内抛出 RequestMissingException。Java 的自动关闭仍会按嵌套顺序执行;但外层只捕获 SQLException,业务异常本身不会被替换成“Load request failed”。如果恰好在关闭时又产生 SQLException,关闭错误附着到主业务异常的 suppressed 集合,排障不能只在包装为 IllegalStateException 的分支上寻找它。另一方面,ResultSet 已经返回行不等于客户端已经拿到完整采购对象:只要明细结果读取或 ProcurementRequest 的校验失败,该方法仍可能在返回前退出。由此可见一次查询的成功点应放在调用方实际获得完整对象并核对内容之后,而不是第一条 SQL 的 executeQuery() 之后。

错误传播还要遵守隐私边界。服务器记录请求关联 ID、资源名、SQLState 和不含凭证的异常链有助于定位;HTTP 不应把数据库 URL、用户口令或包含另一租户数据的 SQL 参数原样回显。DataSource 连接失败不应被映射为“申请不存在”的 404,后者会让调用方以为业务对象未创建并重复提交。能否安全重试取决于这次业务操作是否已经提交和是否有稳定的去重依据,不取决于 JDBC 句柄是否已经关闭。这与 @Transactional 的回滚判据属于不同问题。

关闭和事务另有一条界线:try 块结束即归还连接,不是“数据库事务已提交”的证据。当前 ProcurementUseCases 的方法标有 @Transactional,JdbcRequestStore.insert 在一条连接内写申请与明细,order 在调用层串起订单插入与状态更新;是否一并回滚必须对受管入口和独立数据库连接核对最终行。已有隔离故障实验在受管 draftThenAbortForLab 插入后人工抛出异常,独立连接观察到该随机租户没有已提交申请行;这只覆盖该受管调用和该库,不能换算为“所有 SQL 路径均不泄漏”。

正常路径已观察,关闭失败仍须单独设计

逐步的输入、命令、故障分流与待收集证据写在JDBC 资源实验卡。卡片中的 NOT_RUN 是尚未实现的注入点,不是已经观察到资源泄漏。

在隔离服务器按 examples/javaee-enterprise/README.md 配置专用库、迁移和 loopback 监听之后,真实入口是 examples/javaee-enterprise/scenarios/05-datasource.sh:设置 JAVAEE_PORT=9085 后执行 bash scenarios/05-datasource.sh。writing-plans/javaee-enterprise/verification/20261004T061900Z-pg16-foundation/ 中 datasource.headers.txt、datasource.body.txt、datasource-exit-code.txt 分别留下 HTTP 200、connected 和退出码 0;探针源码还核对了 javaee_lab。20261004T062900Z-pg16-business/scenario.stdout.txt 记录顺序采购场景读写后的最终行 ORDERED|13.75|1。这是两个不同的运行范围,目录名称不是当前工作树的 WAR 摘要,不能用它们推断连接池借还平衡。

正常复跑从现有接口入手,不需要先添加诊断代码。按工程 README 配好本地专用库、JAVAEE_WAR、驱动目录、独立口令并启动 loopback 上的实验服务器后,在仓库根目录执行:

1
2
3
cd examples/javaee-enterprise
JAVAEE_PORT=9085 bash scenarios/05-datasource.sh
curl -sS -i --max-time 10 http://127.0.0.1:9085/procurement/api/db-check

脚本退出 0 且正文为 connected 是一个判据;独立 curl 还应记录 HTTP 200、响应正文和时间,并以服务器日志确认对应部署件。若连续执行多次,要为每次保存退出码和响应,不能把一次的日志拼成多次均成功。脚本本身没有保存运行时 WAR 的 SHA、池借还计数,也没有在查询内部观察 ResultSet.close(),所以即便以上命令都成功,本章的连接归还实验仍不能标成 PASS。

针对本章的失败实验应在隔离配置副本中提供有界连接池及可控的读取或关闭故障:先保存 WAR 与配置摘要、连接池借用/等待计数和请求 ID;在查询成功后与查询执行中分别注入一次故障,记录主异常、suppressed 异常、池中借出数与随后可否再次借用。恢复原配置复测只读探针。不能用 psql SELECT 1/0 的失败冒充 Java 侧资源关闭测试,也不能仅凭 HTTP 500 判断是否归还了连接。这个故障实验、池计数和恢复记录目前均为 NOT_RUN;已有受管回滚场景不填这组空白。

现成的故障入口 examples/javaee-enterprise/scenarios/18-lab-rollback.sh 可以在 README 指定的隔离库、演示开关、专用角色和密码都满足时运行:JAVAEE_PORT=9085 JAVAEE_DEMO_MODE=true JAVAEE_LAB_USER=javaee_lab JAVAEE_LAB_PASSWORD='<本地口令>' bash scenarios/18-lab-rollback.sh。它会在申请和明细插入后抛错,以另一连接核对申请行数为零;已归档的 20261004T063400Z-pg16-jta-rollback/RUN.md 限定了这次结论。这是一条业务事务失败路径,不是关闭异常注入。若要在不改工程的情况下单独核对 Java 抑制异常的语言规则,可以在 Java 21 的 jshell 中运行下列完整片段;它没有 JDBC,不能据此推断服务器池状态,且此处未将该命令记为已实测:

1
2
3
4
5
6
7
8
9
class FailingHandle implements AutoCloseable {
public void close() throws Exception { throw new Exception("close"); }
}
try (FailingHandle handle = new FailingHandle()) {
throw new Exception("query");
} catch (Exception error) {
System.out.println(error.getMessage());
System.out.println(error.getSuppressed()[0].getMessage());
}

要把语言规则升级成 JDBC 结论,需在隔离测试副本引入可控的 ResultSet/Connection 故障包装,在受管入口请求时使执行抛错、关闭也抛错,随后重放 /api/db-check 并核对借出计数恢复;代码、测试探针与原始日志当前都未提供。具体失败时机也必须单独记录:连接借用失败,没有待关闭的 JDBC 句柄;执行时失败,有待关闭资源;关闭时失败则需要保留 suppressed 链。恢复后要再测同样的请求,而不是只看到服务器进程仍存活。

采集池指标时应先固定观测口径:借出的逻辑连接数、空闲物理连接数、请求等待时间和数据库后端会话数不是同一个计数。基线测量先暂停其它测试流量,记一次请求前后的借出数和响应时间,随后再注入异常;如果同时执行迁移脚本或打开 psql 控制台,数据库会话数变化便无法专门归因于探针。失败后的再次借用也应附上最大等待时间和退出码;单靠最终借到了连接,不能排除连接曾被长期占用后由池强制回收。只有故障期间、恢复期间连续采样,加上关闭异常完整链,才可以描述本版本池如何处理这类故障。

资源获取顺序本身还决定谁负责清理。若 source.getConnection() 抛错,尚无连接句柄,自然不能要求应用调用它的 close();若 connection.prepareStatement() 失败,已经取得的连接仍必须退出外层 try-with-resources;若查询执行后读取结果集失败,则已创建的结果集、语句和连接都在各自嵌套作用域内退出。一个失败用例至少选定其中一个精确注入点,记录已成功创建哪些资源,再核对对应关闭尝试与借出数。把“异常被捕获了”当成所有资源都已释放,会漏掉在 try 资源声明之前取得、却从未纳入 try 作用域的对象;当前实现之所以值得阅读,正是因为连接在资源声明内取得,生命周期边界可从源码追踪。

归还之后还要避免继续使用失效句柄:将 Connection、PreparedStatement 或 ResultSet 从 DAO 方法返回给上层,会让上层拿到一个作用域已经结束的资源,调用时可能报“已关闭”。当前 find 返回的是重新构造的不可变采购记录,而非向 Web 层暴露结果集;上层可以用它计算业务状态,不需要持有数据库连接。若未来改成流式导出或游标式读取,方法签名须明确谁消费、谁关闭以及客户端提前断开时怎样回收资源,不能简单复制目前一次性加载的 try 块。该流式接口尚不存在,本文不宣称导出泄漏测试已经运行。

还有数据库连接真正失效的情况:服务器可能把已失效的物理会话留在池里,下一位借用者首先看到连接错误;驱动与服务器若配置了连接验证或失效清理,后续借用的结果可能不同。应在专用数据库维护窗口制造一次可控断连,分别记录第一位失败调用与下一位调用的异常及状态,再恢复数据库并核对只读查询。该实验也属于第 17 篇的故障恢复范围,目前没有实际结果,不能在这里填“池会自动重试几次”。关闭权只要求应用在作用域结束时归还句柄;断连后的重建策略则属于产品配置和部署观测。

两道练习与答案

练习一:查询明细时 executeQuery() 抛 SQLException,随后 Connection.close() 也失败。对读者最有用的异常链是什么?可从 HTTP 500 推出池已经归还连接吗?

答案:查询异常应作为主异常,关闭异常由 try-with-resources 放进 suppressed 列表;外层包装后保留 cause,诊断时同时检查两者。HTTP 500 只说明请求失败;是否归还须看受管池借还指标和后续借用。关闭操作失败时尤其不能假定归还成功。这是 Java 控制流的推导,注入该故障的容器实验 NOT_RUN。

若测试时观察到 getSuppressed() 为空,首先检查是否真的让关闭动作抛了异常、抛出点是否位于同一个 try-with-resources 主异常作用域,而不是立刻改写 Java 的规则;如果只有借用失败,根本不存在已经取得的句柄待关闭。主体异常和关闭异常的抑制关系是语言保证,服务器的池归还与异常包装仍需分别测量。

练习二:运行 /procurement/api/db-check 得到 200 connected,数据库侧仍能看到会话。能否据此断定 DatabaseCheckResource 泄漏了连接?怎样区分物理会话与借用句柄?

答案:不能。池可能在应用归还逻辑连接后保留物理会话以复用。应在冻结池容量、无其它负载的隔离实例中重复借用、记录借出与空闲数,并核对后续请求是否能及时获得连接;同时区分探针成功、池计数和数据库会话。单次 connected 只为只读 SQL 正常路径留证。

边界与资料

本章只以现有源码和两次限定范围的记录解释资源归属,未测查询失败时的关闭异常、池借还指标或池耗尽。完整调用与测试入口是上述仓库相对路径;工程未推送到目标分支之前,不用可能不存在的 GitLab master blob 代替本地源码。规范依据:JLS 21 §14.20.3 try-with-resources、Java SE 21 DataSource、Connection、ResultSet、Open Liberty 26.0.0.5 DataSource 配置。规范规定调用责任,池内部行为以实际版本的指标和实验为准。