昨天,同事告诉我生产系统上竟然出现了两个一样的申请号,我表示十分震惊,申请号是系统中唯一标示(不是表主键),怎么会重复呢???
我去找了一下生成申请号的代码,第一眼就觉得没问题,明明是加了synchronized的方法,怎么会生成一样的申请号呢,我甚至开始怀疑synchronized到底是不是真的能保证线程同步了。并且我们也不是集群,就一个tomcat,完全没道理。
我看到数据库里这两条申请的创建时间是一模一样的,那还真是无巧不成书,也就是说这两个也业务员正好在同一时间点击了按钮。
然后找了两个同事,他们在测试环境模拟试试,然后一起喊着“3 2 1 点”,就复现了这个bug。(尴尬...)之前没出现这个bug是因为没人会闲着没事一起同时点按钮。
再去找找资料吧:
https://blog.csdn.net/qq_45173404/article/details/106616406
https://www.jb51.net/article/74566.htm
当synchronized用来修饰一个方法或者一个代码块的时候,能够保证在同一时刻最多只有一个线程执行该段代码。方法声明时使用,放在范围操作符(public等)之后,返回类型声明(void等)之前.这时,线程获得的是成员锁,即一次只能有一个线程进入该方法,其他线程要想在此时调用该方法,只能排队等候,当前线程(就是在synchronized方法内部的线程)执行完该方法后,别的线程才能进入。
看着没问题,贴一下我们生成申请号的逻辑和代码:
申请类型固定值开头+年月日+从1开始按天自增的3位序列号,如:TEST20210401001 ;
@Service("appNoImpl")
public class AppNoImpl implements IAppNo {
@Resource
private AppNoMapper appNoMapper;
public synchronized String getOneAppNo(String appType) throws Exception {
AppNo appNo = appNoMapper.selectByPrimaryKey(this.newSqlParam("ruleKey", appType));
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd");
if(appNo == null){
AppNo no = new AppNo();
no.setMaxNo(2);
String date = sdf.format(new Date());
no.setRuleDate(date);
no.setRuleKey(appType);
appNoMapper.insert(this.newSqlParam("appNo", no));
return appType+date+String.format("%03d", 1);
}else{
if(sdf.format(new Date()).equals(appNo.getRuleDate())){
String appNum = appNo.getRuleKey()+appNo.getRuleDate()+String.format("%03d", appNo.getMaxNo());
appNo.setMaxNo(appNo.getMaxNo()+1);
appNoMapper.updateByPrimaryKeySelective(this.newSqlParam("appNo", appNo));
return appNum;
}else{
appNo.setMaxNo(2);
appNo.setRuleDate(sdf.format(new Date()));
appNoMapper.updateByPrimaryKeySelective(this.newSqlParam("appNo", appNo));
return appNo.getRuleKey()+appNo.getRuleDate()+String.format("%03d", 1);
}
}
}
}
数据库大致这样:
我用测试类开启了20个线程同时调用这个方法,居然没有重复的申请号,当时还是很郁闷的。
也许是测试类调用不会产生bug,于是我在页面写个按钮,按钮里for循环发5次ajax异步请求,这时就出现bug了,5个请求生成了3个申请号。
我还跟同事讨论这个事,他说是不是事务的问题,我现在才想到,这可能是脏读了。两个业务员同时点击按钮,开启两个线程,都是不同的事务,同时来到这个方法,有synchronized的存在,假如A先执行本方法,B等着,A读到数据库存的是45,然后+1变成46,本方法执行完毕,锁释放,B开始执行,但是由于A后续还有代码在执行,所以A事务没有提交,这时B读到数据库的值仍然是45,接下来+1变成46,于是A和B最后的申请号都是TEST202104010046了。
所以说,synchronized其实是起作用的,但是这种场景,它根本锁不住。
于是,我想到用数据库锁。就是本方法体的第一行,查询的selectByPrimaryKey方法sql里直接加for update。
--第一行查询原sql 我们用的mybatis后边是传进去的参数 这里我直接写出来了
select * from APP_NO t WHERE t.rule_key='TEST'
--改成这样
select * from APP_NO t WHERE t.rule_key='TEST' FOR UPDATE
然后,我试了试,好用!
但是,有问题啊!这相当于使用数据库锁了,此时方法的synchronized完全就是多余的,没它一样能锁住。
并且这样的方式还有一个bug,就是如果将来rule_key字段是首次传进来的,数据库还没有这条记录,那FOR UPDATE根本就锁不住了,这时候仍然会出现脏读,插入两条相同rule_key的数据。我试验了一下,果然主键冲突了(rule_key是表主键)。
那么,此时直接锁整个表应该可以解决此问题,不过这种方式也太损了吧。
想了想,又有办法了!让这个生成申请号的方法使用新事务,本方法运行完毕直接提交事务,由于有synchronized修饰,所以只会有一个线程运行到这里。(我们就一个tomcat,不是集群)
我们系统不是用注解控制事务的,而是用spring-mybatis配置文件配置文件名以特定字符开头来控制事务,像add/update/modify之类开头的方法就开启事务propagation="REQUIRED",
那么我就添加一个方法以getOneAppNo开头的就开启新事务propagation="REQUIRES_NEW"。(getOneAppNo就是上边创建申请号的方法)看起来注解方式控制事务才是真好用,我们这个就挺不人性化的。所以,也有类似情况的朋友得看看你的项目是如何开启新事务的。
这样的话,数据库锁就不用了,前边的那个加FOR UPDATE就没用了。
经过验证,效率很好,首次并发创建的申请也没问题。
综上,此问题解决办法就只改一行代码:给这个创建申请号的方法开启新的事务。
synchronized还是没毛病的~~
不过由于我们开启了新的事务,如果调用者的代码出现了异常,我们这里生成的申请号也不会回滚了,会导致号码不连续。听说有的公司的业务要求号码必须连续(好奇葩的要求),那就得想想别的办法了。