目录
背景
项目上有一个业务场景,处理的流程非常长。 对于前端的操作来说,就是一个提交按钮,但是对于后端的逻辑来说,链路比较多,简单梳理下如下:
① 根据入参做相关数据查询
② 更新相关表的数据
③ 更新ES中对应记录的数据
④ 调用第三方接口回传数据
⑤ 数据回传成功后更新相关表数据
其中④和⑤是一个整体,要么都成功,要么都失败,并且它们的失败不会影响①②③的数据状态。所以操作上,对于①②③是一个事务,对于④⑤是一个事务,两者之间不关联。
现状
目前的代码组织方式如下:
@Autowired
private TransactionService transactionService;
@Override
@Transaction(rollbackFor = Exception.class)
public RspBo funMain(ReqBo req){
//① 根据入参做相关数据查询
//② 更新相关表的数据
//③ 更新ES中对应记录的数据
transactionService.funcA();
}
@Service
public class TransactionServiceImpl {
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW)
public void funcA() {
// ④ 请求第三方接口
// ⑤ 相关表更新操作
}
}
上游的Controller中调用funMain。 从这个代码看的话,funMain中的前三步操作,独享事务,funcA方法独享事务,因为事务的传播机制是REQUIRES_NEW。
看上去,代码也没啥毛病,也符合预期了。
问题描述
实际做测试的时候,发现偶发会出现整个系统不可用,全部加载等待的情况。 跟踪后发现是所有数据库连接都是用完了,新的请求进来,拿不到数据库连接,一直等待,所有页面都是加载等待。
直观上想,这不就是数据库连接太少了吗,加,加多点,直接加成2000。 好,问题暂时解决,但是压测的时候,还是会出现同样的问题,也就是说,高并发情况下,会出现这一批进来的请求全部失败,然后后续的也一直失败。 按常理说,如果是数据库连接不够,总会有先处理完成的释放了,后来的继续用,也不应该出现全部失败啊?
问题定位
尝试将数据库最大连接数改成4,然后一次并发请求进来4个。
# 最大活动可用连接数
spring.datasource.druid.max-active=4
# 获取连接最大等待时长,单位秒
spring.datasource.druid.max-wait=5000
想当然的认为,最大4个,请求进来4个,那这4个应该是可以成功的吧,但是实际情况却是,没有一个成功,所有请求报错都是如下:
org.springframework.transaction.CannotCreateTransactionException: Could not open JDBC Connection for transaction; nested exception is com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 5001, active 4, maxActive 4, creating 0
at org.springframework.jdbc.datasource.DataSourceTransactionManager.doBegin(DataSourceTransactionManager.java:305)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.startTransaction(AbstractPlatformTransactionManager.java:400)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.handleExistingTransaction(AbstractPlatformTransactionManager.java:434)
为什么?明明最大4个,请求也是4个,而且没有其他请求,这怎么全部都失败了?感觉不科学啊!
问题的主要原因在于:
funMain这个方法有事务注解,进入这个方法的时候,就会申请使用一个新的数据库连接,而funA的事务传播是开启新事物,对应的也会申请一个新的数据库连接。 因为同时进来4个请求,这四个请求上去就申请了4个链接,每个线程中再往后走funcA的时候,funcA获取不到数据库连接了,所以全部报异常。
就这个场景而言,数据库连接的最大数设置为5,最大并发为4的时候,就可以全部正常。
总结
对于这个问题,改造优化的方式有以下几种:
① tomcat的最大工作线程数一定要小于连接池的最大连接数,即:server.tomcat.threads.max值要小于spring.datasource.druid.max-active 。
② 从代码方面优化,可以再往上抽一层,因为funMain本身是独立的,完全可以将funMain中前三步的内容也抽出来放到一个子方法中加上事务,这样funMain就不用带事务注解了,逻辑顺序执行,前三步方法执行完提交事务,继续走funcA方法,这样始终使用的都是一个数据库连接。
相对而言,②是更合理的方案,主要还是需要从代码层面注意,写代码的时候多思考。
之前一直怀疑是不是mysql连接不释放的问题,最后发现并不是,对于连接释放这块:
(1)事务对应方法执行完毕,连接就会放回连接池。如果方法没有加事务,正常的查询结束后,连接也会直接放回连接池。
(2)如果子方法没有设置事务传播机制,默认会开启一个新事物,如果父级有事务,就和父级使用同一个事物,如果父级没有,就新开一个事务。对于数据库的连接而言,只要在一个事务内,就只会使用一个链接。 但是对于PROPAGATION_NESTED嵌套事务,则会和父级公用一个连接,在一个事务内。
(3)就这个场景而言,前三步涉及到ES的变更,后两步涉及到http请求以及数据库数据变更,这看似简单,但是实际都是比较棘手的场景,因为都涉及到了分布式事务的问题。 对于①②③而言,相对容易些,讲ES的更新放到最后,并且加上try/catch,如果es更新失败,则抛异常,让事务回滚,这时候也不会再走funcA。 而对于funcA中的④⑤就要复杂些,因为业务要求必须是调用http请求成功后,才能做表数据的更新,如果调用http请求失败,比较简单,直接不往后走了,不涉及事务;如果调用http请求成功,但是⑤相关的mysql操作失败了,如何回退?这种也是一种典型的分布式事务场景。 一般来说,我个人建议有两种解决方式:
一、http接口提供回退的接口,⑤如果失败了,在catch中调用http接口的回退接口,将http接口业务回退,并且这里要设置自动重试。然后抛出异常,事务回滚。
二、加事务补偿机制,可以是人工,可以是自动。在⑤的catch中,将异常记录,将相关数据记录记录到事务补偿表或者文件中,定期重新提交或者提供运维界面,支持手工提交和数据修复。至少在数据层面实现功能闭环。