数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上

1 分钟阅读
·

系列目录

  1. 从单线程到线程池:云盘转 Java 后的第一堂并发课
  2. 线程池不是 new 出来就完事:参数、队列与快慢接口隔离
  3. 数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上(本篇)

从 Node.js 的事务写法开始准备

做 Java 版云盘前,我已有 Node.js、C# 等后端开发经验。Node 服务里的事务代码让我预判,Java 项目也需要尽早确认两个问题:多条 SQL 怎样使用同一连接,以及慢操作怎样影响有限的数据库连接。于是我先回看 Node.js 的实现,再学习 Spring 的声明式事务和连接池配置,准备在项目中按这些约束实现和检查。

2015 年的转型复盘记录了 Node 版服务端的两个事务问题:

  • 事务对象需要逐层传递。 Node 的 MySQL 驱动中,事务关联到具体连接。多条 SQL 要使用同一事务,service 层需要取得 transaction(这里实际是那条连接),再传给每个 dao 方法。后来不少 dao 方法的签名都带有 transaction 参数,不带该参数的方法也难以复用于事务代码。
  • 并发时出现过难以定位的异常。 当时使用的基础 mysql 包在并发压力下曾在事务中抛出无法复现的异常。后来我推测连接可能被错误复用,但当时没有结论。Node 的回调模型依赖回调闭包关联连接的使用关系,排查时不容易还原调用过程。

当时的代码大致如下(示意,脱敏简写):

// service 层:开启事务,然后一路往下传
db.beginTransaction(function(err, tx) {
  if (err) return callback(err);
  fileDao.insertFile(tx, fileInfo, function(err) {
    if (err) return tx.rollback(function() { callback(err); });
    quotaDao.incrUsed(tx, uid, size, function(err) {
      if (err) return tx.rollback(function() { callback(err); });
      tx.commit(function(err) {
        callback(err);
      });
    });
  });
});

每个 dao 方法都需要 tx 参数,每个回调都需要处理回滚。

Spring 可以通过注解管理事务,适合减少这部分样板代码。但注解是否生效取决于代理调用,事务持续时间也决定连接占用时间。Java 版实现和后续排查都围绕这两个条件展开。

声明式事务如何生效

Java 版的事务代码如下:

@Service
public class FileService {

    @Autowired
    private FileDao fileDao;
    @Autowired
    private QuotaDao quotaDao;

    @Transactional(rollbackFor = Exception.class)
    public void saveFile(FileInfo fileInfo) {
        fileDao.insertFile(fileInfo);          // 不需要传任何事务对象
        quotaDao.incrUsed(fileInfo.getUid(), fileInfo.getSize());
    }
}

这里没有显式调用 beginTransaction、commit 或 rollback,也不传递 tx 参数。@Transactional 与 Spring 的事务拦截器确定事务边界。

在 Spring 默认的代理模式下,容器会为符合条件的 bean 创建代理。外部代码通过代理调用 saveFile 时,事务拦截器才会执行。以 DataSourceTransactionManager 管理 JDBC 数据源为例,调用过程通常是:

  1. 事务拦截器在方法执行前从数据源取得连接,关闭该连接的自动提交,并将与数据源关联的连接资源绑定到当前线程。Spring 通过 TransactionSynchronizationManager 的 ThreadLocal 状态保存这类资源。
  2. 方法内通过 Spring 管理的 JdbcTemplateDataSourceUtils 或等价集成方式获取该数据源的连接时,会复用线程已绑定的连接。因此这些 SQL 可以处于同一个本地事务中。直接绕过 Spring 的资源获取方式自行调用数据源,未必会加入这个事务。
  3. 方法正常返回时,拦截器提交事务;方法以符合回滚规则的异常退出时,拦截器回滚事务;最后释放连接资源并归还连接池。

连接资源绑定到线程是 JDBC 本地事务的实现细节。“一个请求对应一个线程”只适用于常见的同步 Servlet 请求处理。异步执行、线程切换和跨线程任务不会自动携带事务上下文。

这里的前提是代理实际拦截了调用。方法自调用会绕过这个前提,需要在编码和 review 时单独检查。

连接池如何分配连接

事务管理负责多条 SQL 的原子提交或回滚,连接池负责复用数据库连接。

建立 MySQL 连接需要完成网络握手和认证,数据库可接受的连接总数也有限。每次请求都新建并关闭连接会增加数据库和网络开销。连接池按配置维护可复用连接,请求取得连接后使用,再归还给池。当时使用的是 commons-dbcp,公司在其上有一层封装。以下参数名称对应 DBCP 1.x:

  • maxActive:池中最多允许同时处于活动状态的连接数。达到上限后,新的借用请求需要等待可用连接或失败。
  • maxWait:等待可用连接的最长时间。超时后借用会失败并抛出异常。DBCP 1.x 中,负值表示无限等待;它只限制获取连接的等待时间,不限制 SQL 执行时间或事务持续时间。
  • testOnBorrow / validationQuery:启用借出校验后,池会使用 validationQuery 验证连接。连接可能被数据库或网络设备关闭,校验可以减少业务拿到失效连接后在首条 SQL 失败的概率。校验会增加借用时的开销,是否启用需要结合连接失效频率和延迟要求决定。

连接池不会在启动时固定创建 maxActive 条连接。实际创建数量还受 initialSizeminIdle、空闲连接回收和借用负载等配置影响;maxActive 是活动连接数上限。

事务、线程、连接三者的关系如下:

请求线程、连接池与事务边界的关系

对同一数据源使用 DataSourceTransactionManager 的普通 JDBC 事务,事务通常会在其生命周期内占用一条连接。事务持续时间越长,连接占用越久。PROPAGATION_REQUIRES_NEW 等传播行为可能再获取连接,因此嵌套调用不一定只使用一条连接。连接池被长事务占满后,其他请求会等待或在 maxWait 到期后失败。

方法自调用会绕过事务拦截

在实现批量操作前,我把代理调用列入检查项。一次 review 中,这项检查发现了下面的调用链:方法标了 @Transactional,批量插入中途失败后,之前插入的记录仍然存在。

@Service
public class TransferService {

    // 外部入口:没有事务
    public void batchTransfer(List<TransferTask> tasks) {
        for (TransferTask task : tasks) {
            this.doTransfer(task);   // 注意:this 调用
        }
    }

    @Transactional(rollbackFor = Exception.class)
    public void doTransfer(TransferTask task) {
        // 若干 dao 操作……
    }
}

this.doTransfer(task) 直接调用目标对象的方法。外部代码持有的 transferService 通常是代理对象,调用代理方法才会进入事务拦截器。默认代理模式不会拦截类内部的 this 调用,因此 doTransfer 上的 @Transactional 不会生效。

如果调用前后没有其他事务,且连接保持默认自动提交,SQL 会分别提交,后续失败无法回滚已经提交的 SQL。具体行为还取决于数据源和调用链中是否已经存在事务。

mock 掉 dao 的单测通常无法发现这个问题,需要使用真实数据库检查失败后的提交和回滚结果。当时将 doTransfer 移到另一个 Service,使调用通过 bean 注入的代理完成;后续将这类调用纳入约定。

我起初将它归为传播级别问题,例如 Propagation.REQUIRED 的嵌套行为。后来确认,传播级别只有在事务拦截器实际执行后,才会决定加入现有事务或创建新事务。自调用绕过代理时,传播属性不会被处理。

共享连接池仍会影响快接口

线程池拆分上线后,目录递归查询再次被频繁触发。慢接口线程池已满并开始快速失败,快接口线程仍在运行,但监控中的快接口 RT 仍然上升。

jstack 显示,快接口线程在获取数据库连接时等待。

慢 SQL 数秒后才返回时,包围该查询的事务会在这段时间内占用连接。慢接口并发增加后,连接池中的可用连接减少;快接口即使使用独立线程池,获取连接时仍会与慢接口竞争同一个连接池,并在 maxWait 内等待或失败。线程池隔离不能消除共享连接池的竞争。

那次通过终止慢查询让连接逐步归还,服务随后恢复。这个结果要求将同一数据库共享的连接池纳入容量规划和故障隔离设计。拆分连接池可以减少业务间的直接竞争,但所有池的连接上限之和仍必须受数据库连接容量约束。

事务约定与连接池监控

这些检查和问题形成了以下约定。

事务使用约定:

  1. 团队约定将 @Transactional 主要声明在 service 层的外部调用入口,避免在仅由同类内部调用的方法上标注事务。它不是 Spring 的强制要求,但能减少代理自调用带来的误解。
  2. 需要对受检异常回滚时,显式配置 rollbackFor = Exception.class 或更精确的异常类型。Spring 默认对 RuntimeExceptionError 回滚,对受检异常默认不回滚。
  3. 同类内部需要独立事务边界的逻辑拆分到另一个 bean,通过注入调用。是否需要独立事务仍应按传播行为和业务一致性要求判断。
  4. 事务内避免远程调用和不必要的大循环逐条 SQL。缩短事务可以减少锁持有时间和连接占用时间,但操作能否移出事务取决于一致性要求。

连接池侧的改造:

  1. 接入监控。 将连接池的活动连接数、空闲连接数和等待情况上报到公司监控平台。活动连接数长期接近 maxActive 时告警。具体可获得的指标取决于 DBCP 版本和公司封装。
  2. 配置 maxWait。 为借用连接设置有限等待时间,使连接池耗尽以受控失败的形式暴露。超时时间需要根据接口时延目标和调用方的重试策略设置。
  3. 评估借出校验。 对存在失效连接问题的场景,启用 testOnBorrow 并配置合适的 validationQuery。高频借用且延迟敏感的场景还需要评估校验开销和其他连接保活策略。
  4. 治理慢 SQL。 连接池参数只能控制耗尽时的等待和失败方式,不能缩短慢 SQL。递归查询仍需要单独优化。

容量数字按惯例未记录。改动后的一个现象是,慢查询再次出现时,监控会先显示活动连接接近上限并触发告警,慢接口出现局部错误;其他接口的 RT 基本保持稳定。这个结果依赖当时的流量、线程池和连接池配置,不能视为所有场景下的保证。

单库事务的边界

这套方案使用的是单个数据源上的本地事务。云盘的元数据和配额当时在同一个库中,因此可以通过单库事务保证相关 SQL 的原子性。IM 融合后,“写库 + 发消息通知多端” 这类跨资源操作增加,本地数据库事务不能同时提交数据库变更和消息发送。

当时我查过分布式事务资料,对 XA 和两阶段提交只了解基本概念,没有在生产中使用。云盘后来对可接受短暂不一致的操作使用 Kafka 异步处理,并以本地状态标记和消息重试处理失败情况。它依赖的前提、重复消息处理和一致性边界在第 6 篇展开。

参考资料


407 字 · 63 段落
ximing

Written by ximingFollow onGitHub

相关文章