📢 大家好,我是 【战神刘玉栋】,有10多年的研发经验,致力于前后端技术栈的知识沉淀和传播。 💗
🌻 CSDN入驻不久,希望大家多多支持,后续会继续提升文章质量,绝不滥竽充数,如需交流,欢迎留言评论。👍
写在前面的话
博主所在公司的产品线,部署上线了多家客户,遇到的线上故障的场景也较多,这里新开一个故障复盘
系列,记录并分享一下这些故障的的定位、分析、解决过程。
首先分享的这篇,是排查定位耗时较久的一个由于事务用法错误,导致的大量锁表,进而引发大范围故障的问题。
故障描述
某客户线上环境的死锁问题持续了一个多月了,隔一天会出现一次,业务侧的代码,陆续调整过几次,但都治标不治本。
现象大概是,业务高峰期(9点-9点30),会出现大量锁表,DBA介入杀锁有时候仍无法正常,需要搭配重启相关服务才可以。
相关报错截图如下所示:
由于影响越来越大,公司领导组织研发骨干,针对DBA提供的数据库锁表语法.xslt
,以此为突破口进行技术攻关,最终发现了相关问题代码,修正后故障得到恢复,这里针对本次故障进行复盘总结,交流分享排查经验和事务用法。
排查过程
排查前期
【DBA发出锁表Excel】
2024-03-20下午,DBA发出了锁表语法,如下图所示,发现单个事务就执行了大量的语法,9点10分开始事务,9点25失败回滚,持续了15分钟的事务(该事务包含了 976 个语法)。
【分析锁表业务语法特征】
通过分析上面的数据库锁表语法发现里面涉及到的语法并不是来源于同一个接口,且业务范围也各不相同,说明各个不同业务的数据库操作都被嵌入到了同一个事务当中,且定位到锁表语法来自于收费服务,后续开始重点排查收费服务项目。
【组织人员排查】
Excel 暴露出一些问题:
- 这些语法背后的数据确实没入库,但为何用户没有反馈问题
- 语法涉及的接口看日志是操作成功了,但是为何数据没入库
- 为什么这么多语句会在同一个事务中一起 rollback
疑问点比较多,但可以先从“为什么接口成功了,但数据确没有入库”这一点来分析。
排查过程 1
通过Excel
提供的信息,结合应用日志
分析,定位到收费服务的encounterCardInfoAdd
是属于有问题的接口。
从应用日志看,该接口基本都是INFO,即操作成功的。
接口完整地址:[HTTP] /open/thirdParty/physical/encounterCardInfoAdd
但是比对了SkyWalking
,确实DBA反馈的Excel
里面合并到一个事务的SQL,所在的接口在SkyWaling
看就是没有 commit。
然后该接口的正常的情况,看日志是有commit的,如下图,89ba3310ce46ffa2 没提交,ef65b1e6ea8a2280有提交
查看代码,除了一些规范问题,并没有看到什么不妥之处,也没有手动控制事务。
但没有定位到,为什么会有这种接口成功,但是没有commit或rollback的情况。
排查过程 2
上面接口上没看出来问题,那就继续观察现场环境,发现下面两个异常点。
1、锁的高峰期,收费控制台日志有大量异常:
Closed Connection org.springframework.dao.RecoverableDataAccessException: ### Error querying database. Cause: java.sql.SQLRecoverableException: Closed Connection
30分钟2000个,其中9点28分,1分钟有500个。
其他服务和其他时间段都没有。
2、锁的高峰期,从prometheus看,收费的数据库连接数比其他服务高很多,同时段其他服务,或非该时段,收费的连接数正常。
从上面两个点,可以得出的结论是,出现锁问题的时间段,确实收费服务的数据库连接是有问题的。
结合之前排查来看,感觉大概率和手动开启事务但没操作回滚提交有关系,应该和TransactionSynchronizationManager.registerSynchronization关系不大。
继续测试了一下,只要有手动开启事务,但没有提交。
这次事务里面又有for update语句,那数据就会一直处于锁状态,这时候其他也锁同样数据的接口,将被阻塞。
如果请求量又比较大,就会出现杀完锁,又马上出现锁的情况。
之前现场有反馈,每次出现问题,必须重启收费,才可以正常,应该也是重启后才真正释放了链接。
这种手动开启事务未提交,比较贴合昨天Skywaling没提交但是接口没报错,以及现场的日志耗时情况。
至于小孙发的Excel,目前还不好判断。怀疑是程序重启或者触发了回收机制,一次性rollbabck了,但目前貌似druid没配置回收。
排查过程 3
继续通过Excel
,结合应用日志
分析,收费服务的savePrescriptionToHospital
方法也可能存在问题。
接口完整地址:[HTTP] /open/internetHospital/savePrescriptionToHospital
接口链路ID:ce07a0a7f900548a
接口触发时间:2024-03-20 09:09:52.292
接口现象如下:
1、该接口发生在问题产生的时间段,接口显示成功,但是可以看到相关语法在上面的 Excel 却显示了回滚;
2、查看SkyWaling,也看到了前面类似的情况(有的有commit、有的没有);
3、查看该接口的关联日志,该接口请求了医嘱的saveSelectedRecords
,该接口存在异常;
通过查看代码,发现了问题,如下图所示:该接口的子方法中,手动开启了事务,但在调用医嘱接口,报错的时候,没有做任何处理,导致是否没有提交或回滚。
上面的代码并未使用 try-catch 来保证事务开启后一定会被提交或者回滚,结合该接口对应的链路追踪信息,可以发现在调用 this.saveSelectedRecords 时抛出了异常。于是该事务一直处于开启状态,后续复用该连接的请求,对数据库进行操作时会默认被该事务管理。
【另一种思路】
当没有发现问题接口的情况下,出现上述没有commit的情况。
另外一个思路就是,如果正常通过框架提供的 @Transactional 来进行数据库事务操作,是不可能出现不同接口的数据库操作被关联到同一个事务的情况,所以上述的情况只能是开发者使用了 Spring 事务管理器DataSourceTransactionManager
来手动管理各个事务阶段提交的场景,且开启后未正确处理相关异常等逻辑,没有及时关闭,导致数据库事务一致处于开启状态。
排查结论
故障的原因是因为收费服务下的某个接口,没有正确使用Spring事务管理器,手动开启事务后,当程序发生异常,没有做出相应的处理,导致事务一直处于开启状态。
同时,这些开启的事务被绑定到了 Tomcat
的请求连接池中,当其他请求进来复用了这些请求连接时,会自动持有之前未关闭的事务,这导致不同的业务接口对数据库的操作一直处于同一个事务中,没有被正确提交。
知识拓展
Tips:问题是基本排查出来也解决了,但是没有知识总结,那就是不是一个完整的技术复盘。
Tips:很明显,这次故障是由于程序猿对事务用法不熟悉、或者说不严谨导致的了,因此针对事务相关知识补充。
事务前置知识
Spring 支持两种事务方式,分别是声明式事务和编程式事务,两者各有优缺点。
声明式事务,其代表就是使用@Transactional,优点是简化配置、降低代码侵入性、易于理解。
缺点主要有:
- 不够灵活,仅支持方法级别的事务控制,无法实现细粒度的事务控制;
- 有较多导致注解失效的场景需要考虑,例如自调用问题、非public、异常不匹配、被标记回滚等;
编程式事务,典型代表有手动控制DataSourceTransactionManager
和直接使用TransactionTemplate
。
优点和@Transactional是相对的,灵活性高,细粒度的控制。
缺点主要有:
- 代码冗长,编程式事务管理需要在业务代码中显式地编写事务管理逻辑,可能会导致代码变得冗长和复杂;
- 可读性差,由于事务管理逻辑与业务逻辑交织在一起,可能会降低代码的可读性和可维护性;
- 由于是手动控制,容易产生未关闭事务的情况出现;
综上所述,@Transactional 注解和编程式事务管理各有优缺点,可以根据项目的需求、复杂度和团队的技术水平来选择合适的事务管理方式。在简单的业务场景中,@Transactional 注解可能更加适用,而在复杂的事务管理需求下,编程式事务管理可能更加灵活。
回到上述问题
前面介绍完声明式事务和编程式事务的优缺点,公司框架鉴于安全性考虑,大部分简单场景推荐直接使用Spring声明式事务即@Transactional注解完成,但由于某产品线业务场景较为复杂,需要借助编程式事务写法,更精细的手动控制事务的提交和回滚动作。
全局搜该产品线的项目代码,可以看到如下图的大量事务控制代码。
不管是这次的遇到的锁问题,还是事务错乱的问题。追其根源,还是研发人员对手动控制事务的把控不到位,事务开启后如果没正常关闭,会导致后续各种问题的出现。
收费服务的接口手动开启了事务,期间Feign调用了医嘱服务,但没有对这部分代码块进行异常捕获,当医嘱服务异常,此方法直接结束,事务未关闭,一直处于活动状态,本事务里面涉及的锁表语法都会阻塞,同时造成后续其他影响。
关于手动控制
1、尽量使用try-catch
代码块控制手动事务代码,保障异常发生时,可以正常回滚事务;
2、当又遇到if-else
分支时,要充分考虑每个代码段都有提交或回滚动作,不要有疏漏;
3、如果怕疏漏,可以在finally
添加兜底操作,或者封装一个公用事务操作方法;
4、可以尝试使用Spring自带的TransactionTemplate来尝试操作事务;
5、研发主管审核手动控制事务的代码要特别注意,减少错误几率;
【某编程事务示例代码】
public class TransactionService {
@Autowired
private DataSourceTransactionManager transactionManager;
public void someTransactionalMethod() {
// 定义事务属性
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
// 设置事务隔离级别,默认是 ISOLATION_DEFAULT
def.setIsolationLevel(DefaultTransactionDefinition.ISOLATION_DEFAULT);
// 设置事务传播行为,默认是 PROPAGATION_REQUIRED
def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED);
// 开启事务
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 执行事务操作,例如执行数据库操作
// ...
// 提交事务
transactionManager.commit(status);
} catch (Exception e) {
// 如果发生异常,则回滚事务
transactionManager.rollback(status);
throw e; // 可以选择抛出异常或者处理异常
} finally {
// 确保在方法结束时关闭事务
if (!status.isCompleted()) {
if (status.isRollbackOnly()) {
transactionManager.rollback(status);
} else {
transactionManager.commit(status);
}
}
}
}
}
关于事务封装
【问题说明】
手动控制事务的代码,存在大量冗余,下面的这几句代码,全局可以搜到多处,应该是研发人员拷贝了其他人的代码。
无独有偶,类似的还有下面的用法,应该是拷贝其他代码直接过来。
【改进说明】
涉及重复的代码,应该考虑抽离公用部分封装一个方法,这样后续的好处如下:
- 研发可以直接试用公用方法,而不是选择拷贝大量代码;
- 后续发现代码问题,对代码的修改也集中在公用部分;
- 可以在公用方法上做一些扩展和封装,例如事务正常关闭;
Tips:程序猿允许拷贝代码,但在拷贝之前要先熟悉用法背后的原理,同时考虑一下复用性。
【封装手动控制事务示例】
public class TransactionUtils {
@Autowired
private DataSourceTransactionManager transactionManager;
/**
* 执行事务操作
*
* @param transactionalCode 执行事务操作的逻辑,可以是一个 Lambda 表达式或者方法引用
* @param isolationLevel 事务隔离级别
* @param <T> 返回值类型
* @return 事务操作的返回值
*/
public <T> T doInTransaction(TransactionCode<T> transactionalCode, int isolationLevel) {
// 定义事务属性
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setIsolationLevel(isolationLevel);
def.setPropagationBehavior(DefaultTransactionDefinition.PROPAGATION_REQUIRED);
// 开启事务
TransactionStatus status = transactionManager.getTransaction(def);
try {
// 执行事务操作
T result = transactionalCode.execute();
// 提交事务
transactionManager.commit(status);
return result;
} catch (Exception e) {
// 发生异常,则回滚事务
transactionManager.rollback(status);
throw e; // 抛出异常
}
}
/**
* 定义一个函数式接口,用于执行事务操作的逻辑
*
* @param <T> 返回值类型
*/
@FunctionalInterface
public interface TransactionCode<T> {
T execute();
}
}
public class TransactionalService {
private TransactionUtils transactionUtils;
public TransactionalService(TransactionUtils transactionUtils) {
this.transactionUtils = transactionUtils;
}
public void someTransactionalMethod() {
transactionUtils.doInTransaction(() -> {
// 在这里执行你的事务逻辑
// 返回值可以是任何类型,根据需要自行定义
return null;
}, DefaultTransactionDefinition.ISOLATION_DEFAULT);
}
}
问题排查经验
这次出现问题后,除了代码写法的注意事项,还有就是遇到问题时排查定位的经验分享。
回过头来,可以总结一下哪些是我们可以快速定位问题的宝贵经验。
1、出现死锁问题,第一时间要找DBA积极配合,无论是导致死锁的语法,还是事务错乱的回滚情况,DBA这边的情况是很重要的,尤其是每天卡顿的第一时间产生死锁的语法;
2、遇到线上问题,灵活借用Prometheus、Grafana、Skywalking、Zipkin、Kibana、SpringBootAdmin、Nacos、Rancher、应用日志等工具,知道每个工具能做什么。例如使用Prometheus定位程序线程数,工程反馈每次需要重启恢复,死锁杀不完,其实可以往这方面验证;利用Kibana可以查应用日志看不到的更多信息;利用Skywalking和Zipkin可以定位到全链路更多信息;当然,最重要的还是应用日志的使用,这是基础功能,研发人员必备;
3、代码提交记录很关键,优先定位出现卡顿问题开始的那天,之前一段时间的代码提交和发布记录,越拖越久,越难定位;
事务使用经验
Tips:问题排查的手段,毕竟是基于问题已经产生情况下的兜底操作,需要耗费较大的代价,程序猿还是尽量把问题消灭在编码阶段。
上面说了一大堆,关于声明式事务和编程式事务,没必要去纠结好坏,也不能一捆子打死必须都用@Transactional
。
像复杂的业务必须使用手动控制事务的方式,更加灵活,技术没有好坏,还是用法问题。
目前来看,部分业务经常使用for update
行锁机制,在事务控制下,编码不科学出现锁表的可能性较大,如何做好复杂事务的控制,减低锁表概率?
避免长时间的事务阻塞
- 合理设计事务边界是避免锁表的关键,事务应该尽可能地短小,仅在必要时才持有数据库锁,长时间的事务可能导致数据库锁的持有时间过长,增加了锁表的风险。因此,将事务的范围限制在必要的最小范围内是非常重要的。
- 避免长时间的查询操作,可以的话,查询可以放在事务外,只有必要动作才在事务中完成。
- 避免在事务中使用远程调用,这部分耗时是不可控的。
- 尽量少用嵌套或挂起事务,使用也要保障子事务耗时。
- 适当选择@Transactional(timeout = 30)指定事务超时时间。
- 如果使用@Transactional,事务所在方法也不宜过大,不适合在复杂方法上、甚至整个接口逻辑上,直接加事务。
- 。。。
尽量保障锁表的顺序
在编写事务代码时,注意控制加锁的顺序,尽量按照固定的顺序对数据进行加锁。确保在事务中对数据进行操作时,按照相同的顺序加锁和解锁,避免出现死锁或锁表的顺序问题。
避免跨事务的查询操作
尽量避免在一个事务中执行长时间的查询操作,而在另一个事务中更新相同的数据,这样容易导致锁表的情况发生。如果需要跨事务操作,可以考虑使用乐观锁或者分布式锁来控制并发访问。
其他事务措施
- 不要局限于数据库锁,可以灵活搭配使用分布式锁等其他机制;
- 更精准的行锁,减少锁的数据范围;
- 。。。
总结陈词
上文分享了某次事务用法导致的故障,针对故障做了排查总结和知识沉淀,关于事务更详细的用法,后面再专栏补充。
💗 后续会逐步分享企业实际开发中的实战经验,有需要交流的可以联系博主。