如何防止长耗时请求占用未使用的 DB connection
结论
不要让 Hibernate/GORM 实体或持久化 Session 跨越耗时的 HTTP 调用。建议按以下方式处理:
- 在短事务中完成查询和必要的数据更新。
- 把支付所需字段复制到普通 DTO。
- 结束事务,释放数据库连接。
- 在事务外调用 payment gateway。
- 根据调用结果,再开启一个短事务更新支付状态。
Propagation.NEVER 只能阻止方法在活动事务中执行,无法可靠判断当前线程或 Hibernate Session 是否仍持有连接。测试这类问题时,应结合连接池指标、连接泄漏检测或 DataSource 代理,不要在 requestPayment() 中直接探测底层连接状态。
为什么 transactional = false 仍可能占用连接
static transactional = false 只表示该 Service 方法不会自动在 Spring 事务中运行,不代表数据库连接会在每次查询后立即归还。
如果启用了 Open Session in View(OSIV)或类似机制,一个 Hibernate Session 可能持续整个 Web 请求。Member.get(id) 首次访问数据库时会从连接池借出连接,释放时机则取决于:
- Hibernate 版本及 connection release mode;
- Grails/GORM 版本;
- 是否启用 OSIV;
- Session 是否仍绑定在当前线程;
- 是否有未完成的事务或延迟加载;
- DataSource 和连接池配置。
所以,即使后续 HTTP 请求不再访问数据库,连接仍可能由 Session 保留到请求结束。当并发请求达到连接池上限,其他请求会阻塞在 borrowObject(),最终出现 Timeout waiting for idle object。
扩大 maxActive 只能延后连接池耗尽。如果外部系统持续变慢,增加的连接最终也会被占满,还可能把压力传给数据库。
推荐的代码结构
不要把 Member 实体直接传给耗时的外部调用。先在短事务中取出所需数据:
class PaymentService {
static transactional = false
PaymentRepositoryService paymentRepositoryService
PaymentGateway paymentGateway
void pay(long id) {
PaymentRequest request =
paymentRepositoryService.preparePayment(id)
PaymentResult result =
paymentGateway.requestPayment(request)
paymentRepositoryService.savePaymentResult(id, result)
}
}
把数据库操作集中到事务型 Service:
import org.springframework.transaction.annotation.Transactional
class PaymentRepositoryService {
@Transactional
PaymentRequest preparePayment(long id) {
Member member = Member.get(id)
if (!member) {
throw new IllegalArgumentException("Member not found: ${id}")
}
return new PaymentRequest(
memberId: member.id,
accountNumber: member.accountNumber,
amount: member.pendingAmount
)
}
@Transactional
void savePaymentResult(long id, PaymentResult result) {
Member member = Member.get(id)
if (!member) {
throw new IllegalArgumentException("Member not found: ${id}")
}
member.paymentStatus = result.status
member.gatewayReference = result.reference
member.save(failOnError: true)
}
}
传给网关的是普通对象,不是由 Hibernate 管理的实体:
class PaymentRequest {
long memberId
String accountNumber
BigDecimal amount
}
preparePayment() 提交后,事务使用的连接就可以归还连接池。随后进行的 30 秒 HTTP 调用不需要占用数据库事务。
也可以显式使用 withTransaction
如果暂时不方便拆分 Service,可以显式限定数据库操作的范围:
class PaymentService {
static transactional = false
void pay(long id) {
PaymentRequest paymentRequest = Member.withTransaction {
Member member = Member.get(id)
if (!member) {
throw new IllegalArgumentException("Member not found: ${id}")
}
new PaymentRequest(
memberId: member.id,
accountNumber: member.accountNumber,
amount: member.pendingAmount
)
}
PaymentResult result = requestPayment(paymentRequest)
Member.withTransaction {
Member member = Member.get(id)
member.paymentStatus = result.status
member.gatewayReference = result.reference
member.save(failOnError: true)
}
}
}
重点不在于是否添加了 withTransaction,而在于外部 HTTP 调用必须位于事务闭包之外。
为什么不建议依赖 withNewSession
在某些版本和配置下,withNewSession 可以改善连接释放,因为它缩短了特定 Hibernate Session 的生命周期。但 Session 边界并不等同于明确的事务边界,具体行为还受 Hibernate 和 GORM 版本影响。
数据库写入仍应放在明确的事务中。withNewSession 更适合处理 Session 隔离、一级缓存或批处理的内存占用问题,不适合用作管理连接生命周期的主要手段。
Propagation.NEVER 能检查什么
可以为网关调用添加一个禁止事务存在的边界:
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
class PaymentGateway {
@Transactional(propagation = Propagation.NEVER)
PaymentResult requestPayment(PaymentRequest request) {
// HTTP request
}
}
如果该方法在活动事务中被调用,Spring 会抛出异常。这能防止后续修改时误将 HTTP 调用放进事务,但有两个限制:
- 它检查的是 Spring 事务,无法判断连接池中是否有已借出的连接。
- 即使没有活动事务,Hibernate Session 仍可能因 OSIV 或其他机制持有连接。
Spring 的声明式事务通常通过代理实现。如果在同一个对象内部直接调用带注解的方法,调用可能绕过代理,导致 Propagation.NEVER 不生效。因此,最好把数据库访问和网关调用拆到不同的 Spring Bean 中。
如何在测试阶段发现连接占用
没有一个通用且可靠的 API,能让 requestPayment() 判断“当前请求是否泄漏了数据库连接”。连接可能由 Hibernate、Spring 事务同步机制或 OSIV 管理。直接获取底层连接进行检查,反而可能触发新的连接借用。
更实际的检测方法包括:
- 启用连接池的 abandoned/leak detection,将阈值设为明显短于测试中的 HTTP 延迟。
- 暴露连接池的 active、idle、pending 等指标。
- 使用 DataSource 代理记录连接的借出时间、关闭时间和调用栈。
- 在集成测试中暂停 payment gateway,确认暂停期间连接池的 active 数量不会随请求数持续增加。
- 使用较小的连接池运行并发测试,让问题尽早暴露。
连接泄漏检测的具体属性名取决于连接池实现和版本。旧版 Apache DBCP、DBCP2、Tomcat JDBC Pool 与 HikariCP 使用的配置名称不同,不能直接混用。
测试可以围绕以下条件设计:
when:
int activeBefore = poolMetrics.activeConnections
// 发起请求,并让模拟 payment gateway 阻塞
startPaymentRequestsConcurrently()
then:
// 请求正在等待网关时,不应各自长期持有数据库连接
poolMetrics.activeConnections <= activeBefore + allowedDatabaseWork
poolMetrics 需要根据实际的 DataSource 实现接入,示例中的接口名并不是 Grails 内置 API。
是否应该关闭 OSIV
如果应用不需要在 Controller 或 View 中延迟加载实体关联,可以考虑关闭 OSIV。持久化 Session 不再覆盖整个 Web 请求后,连接和实体的生命周期会更容易控制。
关闭 OSIV 后,需要确保:
- 所需关联都在事务内完成加载;
- Controller 和 View 不再访问尚未初始化的 lazy association;
- Service 返回 DTO,不再依赖延迟加载的实体图;
- 对现有页面和接口进行完整的回归测试。
不同 Grails 版本关闭 OSIV 的配置项可能不同,应以项目实际使用版本的文档为准。
其他必须设置的保护措施
即使数据库连接已经正确释放,外部网关仍可能耗尽 Web 请求线程。还需要采取以下措施:
- 设置 HTTP connect timeout、read timeout 和整体调用时限;
- 限制同时访问 payment gateway 的请求数;
- 为支付接口使用独立线程池或 bulkhead;
- 谨慎设置重试,避免网关恢复时出现重试风暴;
- 使用幂等键,防止超时重试导致重复扣款;
- 对耗时且不需要同步返回结果的流程,考虑改用队列或异步任务。
数据库事务应只覆盖数据库操作,远程调用只接收普通数据对象。这样即使 payment gateway 响应缓慢,也不会同时占满数据库连接池。
备注:内容仅供参考。
