分享一次项目压测导致的并发问题
# 分享一次项目压测导致的并发问题
2023年11月份左右,开发的项目进入压测阶段,压测第一阶段的单据保存,直接40%~60%的线程都提示测试失败,具体失败日志全是数据库层面抛出的主键冲突
异常,时间紧,任务重,因为这一块的业务全是我写的,所以整个排查工作就落在我的身上。
# 1、前言:
排查之前先大致了解一下这个项目结构和业务流程,整体项目以SpringBoot为底层框架、以DDD思想为核心搭建的。DDD主推的领域模型的概念也在项目中有所体现,下图简单展示了保存单据这个业务主要经历的流程
大致分为四个阶段:
1、根据前端参数billType来判断需要当前业务需要执行哪个Service,负责的领域对象是哪个,分别获取其实例
2、把前端参数注入到BO实例对象中,进行对象属性赋值
3、把BO对象当做参数去调用Service的add方法
4、Service出口统一把BO转换成PO进行数据库操作
伪代码如下:
领域服务实例:
@Service("USER_SERVICE")
public class UserServiceImpl implements AbstractService{
public void add(UserBO userBO){
//do something
};
}
2
3
4
5
6
7
领域对象实例:
@Component("USER_BO")
public class UserBO extends AbstractBO {
private String name;
private String id;
}
2
3
4
5
业务伪代码:
public class UserAction{
@Autowire
private Map<String,AbstractService> serviceMap;
@Autowire
private Map<String,AbstractBO> BOMap;
public void add(String billType,Object param){
//1、获取Service实例
AbstractService service = serviceMap.get(billType+"_SERVICE");
// 2、获取BO实例
AbstractBO BO = BOMap.get(billType+"_BO");
// 3、工具类进行属性赋值 param -> BO
BeanUtil.Transform(param,BO);
// 4、执行业务
service.add(BO);
// 5、转换成数据库对象
User po = new User();
BeanUtil.Transform(BO,PO);
// 6、调用入库接口
dao.insert(po);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 2、分析过程
最开始我把目标定位在第三步BeanUtil.Transform(param,BO);我初步是怀疑这个自己写得转换类有线程安全问题,导致了压测失败
然后开始了debug阶段,本地使用ApiFox进行接口压测,在IDEA中进行多线程debug,断点打在了伪代码的17行,程序启动之后,在17行获取的BO对象确实存在问题,其中线程1和线程2的BO对象中的ID属性出现了重复(数据库表的主键是前端生成的)
然后就开始分析BeanUtil工具类,这个转换类中的Transform是静态方法,所有线程共享一个实例,内部可变化的私有属性都是线程不安全的,但是在开发过程中我也都提前预料到了这些问题,这些可以变化的属性我都用ThreadLocal进行的包装,整个工具类分析了一遍,没有发现问题,然后我重新看了一下debug工作台里面的所有变量,哦吼~,发现了问题所在,如下图所示:
所有线程获取的BO实例对象都是同一个,我才忽然想起来,对于Spring来说,每个Bean的作用域默认都是单例的,这就导致了这次的压测事故,因为线程1获取BO实例,把前端传入的ID赋值给实例对象,但是线程2获取的依旧是当前实例就会把线程1已经赋值的属性给覆盖掉~~~
# 3、解决方案
时间紧迫,我选择了最快能解决问题的方案
1、获取到BO实例之后,因为这是Spring代理对象,需要编写一个工具类获取当前代理对象的原始对象实例
2、获取到原始对象实例之后,使用反射重新获取新实例
3、使用新实例对象进行后续的业务操作
伪代码如下:
BO = (AbstractBO)BeanUtil.getTarget(BO);
BO = BO.getClass().newInstance();
//getTarget方法简写
public static Object getTarget(Object proxy) throws Exception {
if (!AopUtils.isAopProxy(proxy)) {
return proxy;
}
// 判断是jdk还是cglib代理
if (AopUtils.isJdkDynamicProxy(proxy)) {
proxy = getJdkDynamicProxyTargetObject(proxy);
} else {
proxy = getCglibProxyTargetObject(proxy);
}
return getTarget(proxy);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 4、关于@Scope扩展
问题解决之后,想了一下,还是需要对Spring的作用域做一个深入的了解,关于作用域的基本概念,其实都能说的上来,但是真实的在项目中使用还是比较少的,那这个@Scope注解是不是存在一些坑呢?上面的问题,我如果直接在BO对象上面加@Scope("prototype")是否能解决问题呢? 我做了一个测试
1、原先代码逻辑不变,在UserBO上增加注解@Scope("prototype")
2、进行本地压测,观察debug控制台参数
测试表明没啥用,那就继续深入了解@Scope的原理,Spring会在当前实例触发构造方法的时候,根据@Scope来判断是返回单例还是新实例,那在当前测试的场景下,虽然BO增加了@Scope注解,但是Controller并没有增加@Scope,多个线程获取的Controller是一个单实例对象,没办法去触发BO的构造方法,所以造成了@Scope的失效
那依据这个原理,如果在Controller也加入一个@Scope("prototype")是不是就可以了? 再做一个测试
1、在Controller上增加注解@Scope("prototype")
2、进行本地压测,观察debug控制台参数
测试表明可以了,是自己想要的效果,BO实例已经是多实例了
那再换个思路,如果只给Controller增加@Scope("prototype"),BO实例不增加,那BO还是单例吗?再做一个测试
测试表明BO依然是单实例
总结:@Scope("prototype")是在构造方法的时候进行触发的,如果当前对象是多实例,但是引用这个对象的对象是单例的,就不会触发构造方法,也就是造成@Scope注解失效
至此,分享结束,与君共勉~