Java虚拟线程场景下JDBC连接池耗尽问题排查与解决咨询
问题背景
我们有一个基于Java 17的服务,执行本地逻辑后向第三方系统发送请求,第三方响应时间约800-1400ms。此前使用大小为12的ThreadPoolExecutor(基础设施无法扩容),请求发送速率为5-8请求/秒。升级到Java 21并使用虚拟线程后,请求速率提升至约50请求/秒,但出现JDBC连接耗尽的异常。
数据库连接池上限约100:服务启动时占用10个连接;使用ThreadPoolExecutor时新增13个,总计23个;使用newVirtualThreadPerTaskExecutor时连接数飙升至94个,触发以下错误:
FATAL: remaining connection slots are reserved for non-replication superuser connections
我们用以下SQL监控PostgreSQL 13.9的连接情况:
SELECT pid, datname, usename, application_name, client_addr, client_port, backend_start, query_start, state_change, query, state FROM pg_stat_activity where application_name ='PostgreSQL JDBC Driver';
典型异常栈示例:
org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction at org.springframework.orm.jpa.JpaTransactionManager.doBegin(JpaTransactionManager.java:466) ~[spring-orm-6.1.2.jar:6.1.2] at org.springframework.transaction.support.AbstractPlatformTransactionManager.startTransaction(AbstractPlatformTransactionManager.java:531) ~[spring-tx-6.1.2.jar:6.1.2] at org.springframework.transaction.support.AbstractPlatformTransactionManager.getTransaction(AbstractPlatformTransactionManager.java:405) ~[spring-tx-6.1.2.jar:6.1.2] at org.springframework.transaction.interceptor.TransactionAspectSupport.createTransactionIfNecessary(TransactionAspectSupport.java:610) ~[spring-tx-6.1.2.jar:6.1.2] ... at java.base/java.util.concurrent.ThreadPerTaskExecutor$TaskRunner.run(ThreadPerTaskExecutor.java:314) ~[na:na] at java.base/java.lang.VirtualThread.run(VirtualThread.java:309) ~[na:na] Caused by: org.hibernate.exception.GenericJDBCException: Unable to acquire JDBC Connection [FATAL: remaining connection slots are reserved for non-replication superuser connections] [n/a]
我们的观察:
- ThreadPoolExecutor的12个固定线程会复用连接,连接数稳定;
- 虚拟线程在等待第三方响应时会释放载体线程,导致大量新虚拟线程被创建,每个线程都申请新连接,最终耗尽连接池。
尝试过的无效方案:
- 设置VM参数
-Djdk.virtualThreadScheduler.maxPoolSize=5甚至1,无法限制虚拟线程数量; - 用计数器/信号量限制虚拟线程,虽降低吞吐量,但连接数仍远高于ThreadPoolExecutor时期。
疑问:为什么虚拟线程会频繁创建新连接,而ThreadPoolExecutor能复用线程绑定的连接?如何限制并发任务数和数据库连接数,实现连接复用?
相关代码:
虚拟线程初始化:
this.executorService = Executors.newSingleThreadScheduledExecutor(); this.taskExecutorService = Executors.newVirtualThreadPerTaskExecutor(); this.executorService.scheduleAtFixedRate(this, 0L, this.pollingTime.toMillis(), TimeUnit.MILLISECONDS); //服务启动时调用一次 public void run() { this.taskExecutorService.execute(//我的可运行任务); }
JDBC连接代码:
public Optional<Job> getNextJob(String queue) { Optional<Job> job = Optional.empty(); try { Connection connection = this.dataSource.getConnection(); try { String sql = "DELETE FROM scheduler_task WHERE id = (SELECT id FROM scheduler_task WHERE queue = ? and trigger_date < now() LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING id, queue, reference_id, trigger_date"; PreparedStatement stmt = connection.prepareStatement(sql); try { ResultSet resultSet = stmt.executeQuery(); try { if (resultSet.next()) { job = Optional.of(this.mapToJob(resultSet)); } } catch (Throwable var14) { if (resultSet != null) { try { resultSet.close(); } catch (Throwable var13) { var14.addSuppressed(var13); } } throw var14; } if (resultSet != null) { resultSet.close(); } } catch (Throwable var15) { if (stmt != null) { try { stmt.close(); } catch (Throwable var12) { var15.addSuppressed(var12); } } throw var15; } if (stmt != null) { stmt.close(); } } catch (Throwable var16) { if (connection != null) { try { connection.close(); } catch (Throwable var11) { var16.addSuppressed(var11); } } throw var16; } if (connection != null) { connection.close(); } return job; } catch (SQLException var17) { log.error("error", var17); throw new Exception(var17); } }
原因分析
- 虚拟线程的无限制并发特性:
newVirtualThreadPerTaskExecutor会为每个任务创建新虚拟线程,而虚拟线程在IO阻塞(比如等待第三方响应)时会释放载体线程,让载体线程处理其他虚拟线程。这导致短时间内可以同时运行大量虚拟线程,每个线程都去申请数据库连接,远超原线程池的并发量。 - 连接复用逻辑的差异:原ThreadPoolExecutor的固定线程可以通过ThreadLocal绑定连接,实现线程级的连接复用;而虚拟线程是一次性的,每个虚拟线程都会从连接池申请新连接,用完后归还,但并发量过大时连接池被快速占满。
- 对
jdk.virtualThreadScheduler.maxPoolSize的误解:这个参数限制的是载体线程(平台线程)的数量,不是虚拟线程的数量。即使设置为5,仍然可以创建大量虚拟线程,只是它们会在5个载体线程上调度,无法限制虚拟线程的并发数。
解决方案
1. 限制虚拟线程的并发执行数
不要直接使用无限制的newVirtualThreadPerTaskExecutor,而是创建带并发上限的虚拟线程池,控制同时运行的虚拟线程数量:
// 根据数据库可用连接数设置并发上限,比如20 int maxConcurrency = 20; ExecutorService taskExecutorService = new ThreadPoolExecutor( maxConcurrency, maxConcurrency, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>(), Thread.ofVirtual().factory() );
这样可以避免过多虚拟线程同时申请连接,将连接数控制在合理范围内。
2. 优化数据库连接池配置
- 设置合理的最大连接数:根据数据库分配给该服务的连接配额(比如30个),调整连接池的
maximumPoolSize(以HikariCP为例)为30,确保不会超过数据库的连接上限。 - 配置连接回收策略:设置
idleTimeout(比如30秒)和maxLifetime(比如1小时),及时回收空闲连接,避免连接泄漏。 - 开启连接有效性验证:配置
connectionTestQuery为SELECT 1,确保获取的连接是可用的,避免无效连接占用资源。
3. 简化JDBC连接的使用逻辑
确保连接被正确关闭,避免泄漏。使用try-with-resources语法自动管理资源,简化代码:
public Optional<Job> getNextJob(String queue) { String sql = "DELETE FROM scheduler_task WHERE id = (SELECT id FROM scheduler_task WHERE queue = ? and trigger_date < now() LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING id, queue, reference_id, trigger_date"; try (Connection connection = this.dataSource.getConnection(); PreparedStatement stmt = connection.prepareStatement(sql)) { stmt.setString(1, queue); try (ResultSet resultSet = stmt.executeQuery()) { if (resultSet.next()) { return Optional.of(this.mapToJob(resultSet)); } } } catch (SQLException e) { log.error("获取任务失败", e); throw new RuntimeException(e); } return Optional.empty(); }
try-with-resources会自动关闭Connection、PreparedStatement和ResultSet,避免手动关闭时出现的泄漏问题。
4. 调整任务调度逻辑
- 避免任务堆积:将
scheduleAtFixedRate改为scheduleWithFixedDelay,确保上一个任务执行完成后再调度下一个任务,避免短时间内提交大量任务。 - 批量获取任务:修改
getNextJob为批量获取多个任务(比如一次获取10个),减少数据库连接的申请次数,提升处理效率。
5. 控制事务并发数(Spring环境)
如果使用Spring事务管理,确保同时执行的事务数不超过连接池容量。可以通过配置Spring的TaskExecutor带并发限制,或者在事务方法上添加限流逻辑,避免过多事务同时占用连接。
内容的提问来源于stack exchange,提问作者Shoaib

