场景引入
今天的正文开始,我们先引入一个简单的业务场景
保费代扣(金融公司固定日从用户账户划扣保费)
看图说话
定时任务开始跑批的时候,我们会去查询这一笔单子相关的代扣信息,然后在我们的保费申请表中新增一笔数据,紧接着就异步发起一个流程编排,进入真正的代扣逻辑处理。
计划非常完美,但是,在压测过程中却频频发现异常,主要问题体现在两点 - 间断性的提示保费申请不存在(保费代扣的流程编排第一个节点就是校验保费申请信息是否存在),我们通过保费申请编号去数据库中的确存在数据,但是翻看日志,sql 的 query 结果的确为 0 - 经常性的日志提示 Caused by: java.sql.SQLException: connection holder is null,导致后续跑批失败
小伙伴们可以开始思考,这两个问题有可能是因为什么导致的
数据库连接为何丢失
在测试反馈定时任务执行失败的时候,刚看到这个异常提示的时候,connection holder is null,第一反应就是我的数据库连接还没执行完任务去哪儿了
项目中使用的是 druid 连接池,遇事不决,github 上看下
https://github.com/alibaba/druid/issues?q=connection+holder+is+null
在github上,看到了也有不少小伙伴提了issue
大致看下来,明白这个问题大概率是两个原因 - 连接数超上限 - 单个连接超时
带着这两个问题,我去查看了阿波罗的相关配置
目前系统中使用的是阿里的 druid 连接池,配置参数为
JAVA // 最大连接数 spring.datasource.druid.max-active = 128 // 配置获取连接等待超时的时间 spring.datasource.druid.max-wait = 60000 // 超时自动动回收连接配置 spring.datasource.druid.remove-abandoned = true // 连接超时回收时间 spring.datasource.druid.remove-abandoned-timeout-millis = 30000
带着这些参数,我去翻看了一下这个代扣数据查询的逻辑
在看到长达 100 多行的一个方法被 @Trancation 注解覆盖的时候,我就知道这个事不妙
接下来到了@Trancation的科普时间,虽然 @Trancation 用起来很爽,但是使用不当极易被拉进火葬场,我们知道声明式事务是基于 AOP 来管理的,在业务方法前后进行拦截,针对我们的业务代码没有入侵性,但是声明式事务的局限性就在于它的最低粒度要作用与方法上。
仔细翻看这个逻辑,因为代扣所需数据较多,进行了多次跨模块查询,并且代码还经过多人之手,大家每次都缝缝补补一些逻辑,并且都心照不宣的没有发现这个事务过大的问题
很显然,我们没办法找到第一个写上这个 @Trancation 的人,对他说,小伙子,你这写的有问题呀,事务过大了🌶️🐔
宽以律己,严以待人,呸,说反了,严以律己,宽以待人,不是所有人都在第五层,所以,在平时代码中,针对这种事务范围的控制,我们可以有选择性的去使用编程性事务去处理,阿里巴巴规范中针对这一点其实是对我们有说明的
尽管编程式事务看起来不是那么“优雅”,但是起码可以尽可能去提醒或者规避其他的小伙伴这个大事务的出现,并且相对于声明式事务没有那么容易失效
那么回来这个正题,其实分析到这里大家应该已经有思路,为何会出现连接丢失,就是因为事务过大,长时间占着这一个数据库链接,迟迟不舍得释放,导致连接池资源不足,或者单个连接超时,解决方案也很简单,为了不阻塞测试进度,我们可以暂时性关闭连接超时回收的机制,将连接数调大,然后治本的方法还是去动手优化这个屎山
你以为这里就结束了吗?当然不是。
在排查的时候我思考了一个问题
@Trancational 在什么时侯开启开启事务的?
给你三秒的思考时间。
ok,如果你很清楚的知道可以跳过这部分,如果你不是非常清楚,或者你认为是一进入方法就开启了事务,那么我们来看下这个 @Trancational 开启事务的源码
我们先写个 demo 将断点打到方法刚开始的地方
观察堆栈,这个地方非常可疑
org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction
接下来我们断点到 #createTransactionIfNecessary 这个方法
这个方法是用来创建事务,将当前事务状态和信息保存到TransactionInfo对象中,包括设置传播行为、隔离级别、手动提交、开启事务等,绑定事务txInfo到当前线程,这里面用了使用许多缓存和threadLocal对象,方便获取连接信息等,并且着里有一点,我们先继续往下走
往下逐步追踪,观察堆栈即可找到真正开始处理事务的逻辑
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
贴心的我已经帮你注释好了,不用谢
我们可以看到在这段代码里这一段将事务切换到手动提交,将 autoCommit 设置为 false
Java // Switch to manual commit if necessary. This is very expensive in some JDBC drivers, // so we don't want to do it unnecessarily (for example if we've explicitly // configured the connection pool to set it already). // 必须时切换手动提交事务。在一些JDBC驱动中是很昂贵的,非必须的话不要用。(比如我们已经把连接池设置冲突) // 如果是自动提交,事务对象的必须恢复自动提交为true,连接的自动提交关闭。 if (con.getAutoCommit()) { // doCleanupAfterCompletion方法中 事务结束时会判断这个值为true时,连接才会自动提交。 txObject.setMustRestoreAutoCommit(true); if (logger.isDebugEnabled()) { logger.debug("Switching JDBC Connection [" + con + "] to manual commit"); } con.setAutoCommit(false); }
那么,此时开启了事务了么,看起来不像
此处就是将我们的自动提交关闭了,那么我们先来写个小demo实验一下
接下来利用这个 sql 查询一下当前事务,又是一个知识点,拿本子记起来
SELECT * FROM informationschema.INNODBTRX
接下来我们将断点走下去,在看下,是否事务开启了
真相大白了,原来 @Trancational 是在执行第一个sql 的时候开启的事务,具体的源码跟踪交给小伙伴们自己动手实践一下
数据库中有数据,为何就查询不到呢?
接下来回顾一下我们的第一个问题,为何数据库中明明有数据,但是在执行过程中却查不到呢?
接下来上一波伪代码
眼尖的小伙伴,可能一下子就发现了,是不是这个异步发起流程编排的锅
关于 Spring 中事务的传播这边就不给小伙伴赘述了
那么,如何控制在事务提交之后,在去发起我们的流程编排呢
这边就需要用到我们今天的主角
Java // 注册事务后置处理 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { log.info("事务提交完成,发起流程!"); // 异步发起流程编排 this.asyncExecute(insurancePremiumApplyDo, flowId); }); }
这样就很好解决了我们出现的问题,你还以为这样就结束了么,当然不是,知其然还需要知其所以然,我们来看下 TransactionSynchronizationManager 这个事务管理器是如何帮我做到这个事务提交后才进行执行我们的代码呢。
一样 demo 走起,看下这一个 afterCommit 方法是否真的起作用了
果然非常的完美。接下来我们去看下这个 afterCommit 是如何起作用的。
正式揭开源码的面纱之前,我们想一想,如果是我们去实现这个 afterCommit 功能,我们会怎么做?
第一想法是不是判断当前线程绑定的事务是否处理完,如果处理完了就去调用这个 afterCommit 的方法。
那么在哪里去判断这个事务处理完了呢?对,就是我们前文提到的 #invokeWithinTransaction 中的 commitTransactionAfterReturning 这个方法。这个方法名已经很清楚告诉了我们,我这个方法执行的就是提交了事务后的方法,看来我们的理论知识非常的充沛,接下来,我们只需要扒开源码看看,去验证一下我们的猜想。验证我们猜想之前
我们先按顺序看一下 demo 代码中 #registerSynchronization 这个方法里做了什么事
这个方法看起来还是蛮简单易懂的,将我们 new 出来的 TransactionSynchronization 对象,放入一个 synchronizations 我们暂且称为事务同步器这样的 ThreadLocal 里,为何是装在 ThreadLocal 里的,并且是一个 Set 集合,我们其实想一下,一个线程是否可以被多个事务绑定,如何存储被多个事务绑定的线程 我们就可以理解为何使用了 ThreadLocal 和 Set 集合了,我们暂时留个心眼,接着往下看
我们直接将断点打在 afterCommit 上,debug 分析一波,是谁在调用 afterCommit,和我们的猜想是否一致。出发进入 afterCommit 内部
xiu,代码 debug 进到了这里
我们关注到图里的第二个方法,这边注释也写的非常清楚,actually 表示实际,就是实际调用我们 afterCommit 的地方
并且这边是一个循环,也符合我们上文看到的为何把我们 new 出来的对象 装进了 synchronizations 中,至于为何是 list,而不是 set ,感兴趣的小伙伴可以去研究一下,不是我们分析的重点
我们接着看 谁调用了 #invokeAfterCommit 方法呢
哦,就是它上面的 #triggerAfterCommit 方法,想必英文过了四级的同学,都能理解注释的意思
Trigger {@code afterCommit} callbacks on all currently registered synchronizations.
在所有的 synchronizations 上触发 afterCommit 这个方法
ok,接下来就是揭晓谜底的时刻,是谁去触发了这个 afterCommit 方法,我们的猜测是否正确呢,是否是这个 commitTransactionAfterReturning 方法呢?
打开我们的堆栈看一看
在距离第 6 行的地方找到了 #invokeWithinTransaction 中的 commitTransactionAfterReturning 这个方法,猜想正确。
接下来我们就可以正向看一下,这个 #afterCommit 是如何实现的
长图预警,慢慢看,可以收藏点赞在看
总结
看到这里我们已经可以清楚了
@Trancation 的使用小细节,不建议过大的事务,建议如果可能的话手动开启事务
@Trancation 事务开启的实现,是方法进来的时候只是将自动提交关闭,事务就绪,在代码真正调用 sql 的时候去开启事务
事务后置提交的底层实现 #afterCommit 的源码
关于 @Trancation 的事务源码还是非常易懂,建议小伙伴们可以去读一读,也给小伙伴留一个作业,看一自己动手去翻阅一下 org.springframework.transaction.support.TransactionSynchronization#beforeCommit 的实现,也是对本文的巩固学习
一不小心,又进步了,卷死你的同事从这一步开始