PAYATHON 2026

如何防止长耗时请求占用未使用的 DB connection

支付老李

结论

不要让 Hibernate/GORM 实体或持久化 Session 跨越耗时的 HTTP 调用。建议按以下方式处理:

  1. 在短事务中完成查询和必要的数据更新。
  2. 把支付所需字段复制到普通 DTO。
  3. 结束事务,释放数据库连接。
  4. 在事务外调用 payment gateway。
  5. 根据调用结果,再开启一个短事务更新支付状态。

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 响应缓慢,也不会同时占满数据库连接池。

备注:内容仅供参考。