• Spring 长事务导致connection closed,又熬了一个大夜!


    大家好,我是不才陈某~

    是的,今早一到公司就收到了机器人的告警,从异常日志来看是数据库连接已关闭,然后我在解决这个问题的过程中发现了几个问题,不急,听我一一道来

    异常被try后没有继续抛出,导致继续执行后续操作

    我们看到前文示例代码会发现我们在 try 之后只是 rollback 了,对于异常也只是打印一下并没有继续抛出。

    那么就会导致一种情况:假设你在 Service 层中调用多个调用数据库的修改方法,那么第一个操作失败后异常没有抛出,Service 层不知道,就会继续向后面执行,修复很简单,只需要将异常抛出即可:

    1. // 案例1:参考MybatisPlus的com.baomidou.mybatisplus.extension.toolkit.SqlHelper##executeBatch()实现
    2. batchSqlSession.rollback();
    3. Throwable unwrapped = ExceptionUtil.unwrapThrowable(e);
    4. if (unwrapped instanceof RuntimeException) {
    5.     MyBatisExceptionTranslator myBatisExceptionTranslator
    6.             = new MyBatisExceptionTranslator(sqlSessionFactory.getConfiguration().getEnvironment().getDataSource(), true);
    7.     throw Objects.requireNonNull(myBatisExceptionTranslator.translateExceptionIfPossible((RuntimeException) unwrapped));
    8. }
    9. throw new CommonException(unwrapped);
    10. // 案例2:简单来说,只要能把异常抛出去即可,并不定需要像上面这么复杂
    11. batchSqlSession.rollback();
    12. throw new CustomException(e);

    大事务/长事务导致 connection closed

    代码场景

    我们来看一段业务功能的伪代码,大致如下:

    1. @Transactional(rollbackFor = Exception.class)
    2. @Override
    3. public Integer billCheck() {
    4.     // 获取对应的策略
    5.     策略 = getStrategy();
    6.     // 前置参数校验
    7.     if (必要参数是否存在){
    8.         return false;
    9.     }
    10.     try {
    11.         // 解析文件
    12.         文件里的数据集合 = 策略.parseFile(file);
    13.         // 将文件里的数据插入数据库表
    14.         影响的行数 = 策略.handleFileData(文件里的数据);
    15.         if (影响的行数 > 0) {
    16.             // 将文件里的数据和本地的数据进行对比操作
    17.             对比后的数据 = 策略.doBillCheck(参数);
    18.             // 将对比的结果分开插入到数据库中
    19.             batchUtils.batchUpdateOrInsert(成功的数据,
    20.                     某Mapper.class,
    21.                     (billErr, mapper) -> mapper.insert(data));
    22.             batchUtils.batchUpdateOrInsert(失败的数据,
    23.                     某Mapper.class,
    24.                     (billErr, mapper) -> mapper.insert(data));
    25.             batchUtils.batchUpdateOrInsert(需要更新的数据,
    26.                     某Mapper.class,
    27.                     (billErr, mapper) -> mapper.update(data));
    28.         }
    29.         // 发送企业微信机器人通知
    30.         策略.sendRobotMessage();
    31.         log.info("耗时:{}毫秒", 耗时);
    32.     } catch (Exception e) {
    33.         log.error("对账出错", e);
    34.         throw new CommonException("对账出错");
    35.     }
    36.     return 影响的行数;
    37. }

    我们梳理一下,这是一个普通的模板方法 + 策略模式的应用,因为业务场景中不管是哪个通道的文件都会必经如下几个步骤,所以就将其抽象了。我们可以发现这个方法里面做了很多数据库操作,并且使用了声明式事务注解,然后里面大致有如下几个步骤:

    1. 解析文件

    2. 将文件里的数据插入数据库表

    3. 将文件里的数据和本地的数据进行对比操作

    4. 将对比的结果分开插入到数据库中

    然后我们再来看一段配置,它来自 druid 连接池框架,如下:

    1. spring:
    2.   datasource:
    3.     druid:
    4.       remove-abandoned: true
    5.       ## 单位:秒
    6.       remove-abandoned-timeout: 60
    7.       log-abandoned: true

    以上三条属性一般是用来防止连接泄露的,说明如下:

    • removeAbandoned:要求获取到连接后,如果空闲时间超过 removeAbandonedTimeoutMillis 秒后没有 close,druid 会强制回收,默认false;

    • logAbandoned:如果回收了连接,是否要打印一条 log,默认 false;

    • removeAbandonedTimeoutMillis:连接回收的超时时间,默认5分钟;

    看到这里我想大部分同学可能已经知道是什么问题了,没错,肯定是因为拿到了连接,但拿的时间超过了这个限制,导致 druid 直接强制回收了该连接,但是知根知底方能百战百胜,这么好的机会怎么能不深入了解一下?关注公号:码猿技术专栏,回复关键词:1111 获取阿里内部性能调优手册~

    什么时候获取的连接?

    是的,既然是连接超时被关闭,那我们肯定要先找到是什么时候拿到的连接,是方法中第一次操作数据库【将文件里的数据插入数据库表】的时候?那当然不是,我们知道 Mybatis 有一个 Executor_ _接口,感兴趣的可以自行了解,它定义了数据库操作的基本方法,它才是SQL语句幕后的执行者,我们直接来看获取连接的地方 org.apache.ibatis.executor.BaseExecutor##getConnection

    1. protected Connection getConnection(Log statementLog) throws SQLException {
    2.     Connection connection = transaction.getConnection();
    3.     if (statementLog.isDebugEnabled()) {
    4.         return ConnectionLogger.newInstance(connection, statementLog, queryStack);
    5.     } else {
    6.         return connection;
    7.     }
    8.   }

    我们可以看出来,我们是通过 Transaction 去获取连接的,但如果我们是第一次操作的时候才去获取的连接,那怎么会连接超时呢?所以我初步推断是开启事务的时候可能就已经获取连接了,那我们来求证一下,来到 Spring 的事务管理器 PlatformTransactionManager,Mybatis 用的是它的实现类 DataSourceTransactionManager, 然后我们一路跟 getTransaction 方法来到 AbstractPlatformTransactionManager##getTransaction,再到 DataSourceTransactionManager##doBegin

    1. public final TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException {
    2.     // 省略无关代码 ...
    3.     doBegin(transaction, definition);
    4.     // 省略无关代码 ...
    5. }
    6. @Override
    7. protected void doBegin(Object transaction, TransactionDefinition definition) {
    8.     DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
    9.     Connection con = null;
    10.     try {
    11.         // 如果数据源事务对象的ConnectionHolder为null或者是事务同步的  
    12.         if (!txObject.hasConnectionHolder() ||
    13.                 txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
    14.             // 获取当前数据源的数据库连接  
    15.             Connection newCon = obtainDataSource().getConnection();
    16.             if (logger.isDebugEnabled()) {
    17.                 logger.debug("Acquired Connection [" + newCon + "] for JDBC transaction");
    18.             }
    19.             txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
    20.         }
    21. }

    就是这!它其实在进入方法的最开始,开启事务的时候就已经获取了连接,然后由于【解析文件】耗时过长,导致整个方法的执行时间超过了 60s 被强制回收连接,但你以为这就结束了?没错,当时出现这个问题的时候,我还手动触发了一次,结果第二次通过了,你说诡异不诡异?两次执行的时间都是 90s。

    druid removeAbandoned 背后的秘密

    所以我们继续看一下 druid 是怎么强制回收连接的,Druid每隔 timeBetweenEvictionRunsMillis(默认1分钟)会调用DestroyTask,在这里会判断是否可以回收泄露的连接,就是因为它是1分钟执行一次,所以可能第二次正好它执行的时候还没超过 60s,所以这次简直就是玄学了啊。

    1. public class DestroyTask implements Runnable {
    2.     public DestroyTask() {
    3.     }
    4.     @Override
    5.     public void run() {
    6.         shrink(true, keepAlive);
    7.         // 判断removeAbandoned是否为true,默认是false
    8.         if (isRemoveAbandoned()) {
    9.             removeAbandoned();
    10.         }
    11.     }
    12. }

    然后我们看到 removeAbandoned 方法,这里面有一段代码如下:

    1. for (; iter.hasNext();) {
    2.     DruidPooledConnection pooledConnection = iter.next();
    3.     // 判断该连接是否还在运行,只回收不运行的连接
    4.     // Druid会在连接执行query,update的时候设置为正在运行,
    5.     // 并在回收后设置为不运行
    6.     if (pooledConnection.isRunning()) {
    7.         continue;
    8.     }
    9.     long timeMillis = (currrentNanos - pooledConnection.getConnectedTimeNano()) / (1000 * 1000);
    10.     //判断连接借出去的时间大小
    11.     if (timeMillis >= removeAbandonedTimeoutMillis) {
    12.         iter.remove();
    13.         pooledConnection.setTraceEnable(false);
    14.         abandonedList.add(pooledConnection);
    15.     }
    16. }
    17. //判断是否要记录连接回收日志,这个很重要,可以及时发现项目中是否有连接泄露
    18. if (isLogAbandoned()) {
    19.     StringBuilder buf = new StringBuilder();
    20.     buf.append("abandon connection, owner thread: ");
    21.     buf.append(pooledConnection.getOwnerThread().getName());
    22.     buf.append(", connected at : ");
    23.     buf.append(pooledConnection.getConnectedTimeMillis());
    24.     buf.append(", open stackTrace\n");
    25. }

    是的,如果你的连接被强制回收了的话,你只需要将 LogAbandoned 设置为 true,就可以通过日志看到相关信息了

    解决方案

    到这,问题就基本都发现了,那么我最后是怎么解决的呢?原本我是想的把不需要事务的动作抽离出来新建一个方法,后面我发现这样子好像模板方法并不好使了,我就采用了编程式事务,感兴趣的可以自己在了解一下,最后伪代码如下:

    1. @Autowired
    2. private TransactionTemplate transactionTemplate;
    3. @Transactional(rollbackFor = Exception.class)
    4. @Override
    5. public Integer billCheck() {
    6.     // 获取对应的策略
    7.     策略 = getStrategy();
    8.     // 前置参数校验
    9.     if (必要参数是否存在){
    10.         return false;
    11.     }
    12.     try {
    13.         // 解析文件
    14.         文件里的数据集合 = 策略.parseFile(file);
    15.         // 编程式事务
    16.         影响的行数 = transactionTemplate.execute(transactionStatus -> {
    17.             // 将文件里的数据插入数据库表
    18.             return 策略.handleFileData(文件里的数据);
    19.         });
    20.         if (影响的行数 > 0) {
    21.             // 将文件里的数据和本地的数据进行对比操作
    22.             对比后的数据 = 策略.doBillCheck(参数);
    23.             // 编程式事务
    24.             transactionTemplate.execute(transactionStatus -> {
    25.                 // 将对比的结果分开插入到数据库中
    26.                 batchUtils.batchUpdateOrInsert(成功的数据,
    27.                         某Mapper.class,
    28.                         (billErr, mapper) -> mapper.insert(data));
    29.                 batchUtils.batchUpdateOrInsert(失败的数据,
    30.                         某Mapper.class,
    31.                         (billErr, mapper) -> mapper.insert(data));
    32.                 batchUtils.batchUpdateOrInsert(需要更新的数据,
    33.                         某Mapper.class,
    34.                         (billErr, mapper) -> mapper.update(data));
    35.                 return Boolean.TRUE;
    36.             });
    37.         }
    38.         // 发送企业微信机器人通知
    39.         策略.sendRobotMessage();
    40.         log.info("耗时:{}毫秒", 耗时);
    41.     } catch (Exception e) {
    42.         log.error("对账出错", e);
    43.         throw new CommonException("对账出错");
    44.     }
    45.     return 影响的行数;
    46. }

    这样子,我们将解析文件和对比数据(只是查询)这种耗时操作放在了事务外,并且将原本一个事务里的操作拆成了两个小事务,这样子基本就避免了大事务的问题了,完结撒花~

    大事务/长事务可能造成的影响

    • 并发情况下,数据库连接池容易被撑爆

    • 锁定太多的数据,造成大量的阻塞和锁超时

    • 执行时间长,容易造成主从延迟

    • 回滚所需要的时间比较长

    • undo log膨胀

    所以在业务涉及中,你一定要对大事务特别对待,比如业务设计时,把大事务拆成小事务。

    总结

    声明式事务有一个局限,那就是他的最小粒度要作用在方法上!所以大家在用的时候要格外格外注意大事务的问题,尽量避免在事务中做一些无关数据库的操作,比如RPC远程调用、文件解析等,都是血泪的教训啊!!

    来源:https://juejin.cn/post/7089346387925696520

    最后说一句(别白嫖,求关注)

  • 相关阅读:
    数据结构(9)树形结构——大顶堆、小顶堆
    大数据与人工智能的未来已来
    es中的聚合查找
    了解前端知识
    Java架构师缓存架构设计
    螺丝扭断力试验机SJ-12
    [Spring Cloud] Open Feign---扩展
    Linux系统编程·进程状态
    EasyExcel导入/导出Excel文件
    在 Solidity 中 ++i 为什么比 i++ 更省 Gas?
  • 原文地址:https://blog.csdn.net/BASK2312/article/details/128198722