乐企数电发票分布式发票号码生成重复的问题修复思路分享
文章目录
- 1.前言
- 2.解决思路
- 2.1错误姿势
- 2.2歪打正着
- 2.3正确姿势
- 3.总结
1.前言
由于之前接了乐企数电开票,服务上线之后,使用的公司少没有啥问题,后面切换了两家日开票量大的公司上线之后,就发现发票号码生成重复了,后面下班紧急修复了,修复到凌晨3点发了一个版本上去,之后才没有重复的,这次其实是歪打正着的搞好了,后面我思考分析了下之后重新优化了之后上了几个版本后观察,使用数据库表的方式还是会有一定的概率会重复,之前歪打正着是因为使用了@Transactional注解,事务传播级别设置为READ_COMMITTED,后面揭晓为啥是歪打正着。在这个项目中使用了biz-ratelimiter-redissonlock-manualctrltrans-spring-boot-start启动器,在使用它的时候有一些注意事项,下面分享是啥注意事项。在那两家公司切换上线之后发票号码生成重复了之后,导致开票数据有问题,后面处理修复了好几天的数据,也是搞的蛋疼的很,所以才分享一下这个问题的解决思路。
2.解决思路
参考文章
https://mp.weixin.qq.com/s/-1Xh_g3b58hA765uLqDs6A
https://www.cnblogs.com/qwg-/p/18080431
2.1错误姿势
使用了这个
@BizIdempotentManualCtrlTransLimiterAnno(isOpenManualCtrlTrans = true, isOpenRedissonLock = true)
一般情况下在一个方法中单用分布式锁即可,不用手动去控制事务的提交,各个表的数据操作错了就错了,只要不影响业务就行,一个微服务的操作多张表不用做到数据的特别的强一致性,在多个微服务之间都没有使用seata这种分布式事务框架的,都是使用最终一致性的MQ来达到最终一致性,更何况这种一个微服务的多表数据之间就不需要这种强的数据一致性操作了,如果这种搞一个大方法来手动提交事务就会是一个大事务提交,这种处理实际上也是比较坑的,所以不推荐这种使用。
@BizIdempotentManualCtrlTransLimiterAnno(isOpenManualCtrlTrans = true)
如果在一个方法中使用了分布式锁+手动控制事务,这个方法的切面中涉及到多张表的操作,这种情况下使用姿势就有很大的问题,一是:分布式锁的粒度太大,二是:在一个大的方法中,涉及操作多张表的增删改查的时候,不用去手动控制事务的,使用默认的机制就可以了,springBoot默认会自动取提交事务的,事务的提交也是一个异步的的过程,如果手动控制一个事务的提交,在一个大方法执行完最后提交的这个事务会是一个大事务,也就是说,这个事务里面操作的表数据太多了对应一次需要执行的sql也会有很多,所以使用分布式锁 + 手动控制事务 + mybatisPlus的乐观锁 这种情况下使用数据了的表来存储乐企下载的赋码段之后在来计算一个自增的发票号码,在高并发的情况下就会生成重复,之前使用的AtomicLong或LongAdder(这两个的性能差不多),使用这两个来自增Long类型,只使用于单节点的服务上,如果在多节点上,就会重复,假设两个请求都读取到库中表里当前发票号加入内存还是上一个事务没有提交之后的值,这种加入计算就会重复了,所以正确的Long的原子自增需要使用RedisTemplate的increment来自增一个Long,因为RedisTemplate的increment是原子的。
使用这种方法的弊端在哪里?
1.分布式锁粒度大,操作了一个大事务
2.默认springBoot的mysql的事务隔离基本是REPEATABLE_READ可重复读,所以每个请求线程操作的数据都是相互隔离不可见的,只有当事务提交之后读取出来的才是最新的值,这里就存在一个空隙,操作不是原子的,事务提交是异步的,每次读取的值都有可能是上次(或是上上次,不晓得是哪一次的)的久的值,这种就导致本次计算重复。
3.使用了AtomicLong或LongAdder来内存中原子加,是适用于单节点,如果在分布式环境下,多节点每个节点上的内存数据都有可能是久的值,这种就会导致计算之后发票号产生大量的重复。
2.2歪打正着
在最最最最最最外层的public方法上加了@Transactional注解,事务传播级别设置为READ_COMMITTED和将Long的自增操作由AtomicLong改为了RedisTemplate的increment这个,还有就是使用了:
@BizIdempotentManualCtrlTransLimiterAnno(isOpenManualCtrlTrans = true, isOpenRedissonLock = true)
只使用分布式锁,不手动控制事务避免了大事务的提交,这个是可以解决,但是如果内部方法有任何的异常抛出,最终抛出返回的异常是事务只读的一个异常,所以这种方式会有这么一个问题,这个问题是由于@Transactional注解的切面执行顺序比@BizIdempotentManualCtrlTransLimiterAnno的注解切面顺序优先级高,就相当于@BizIdempotentManualCtrlTransLimiterAnno的注解切面抛出的任何异常最后都会被@Transactional注解的切面捕捉到,最终抛出了一个事务只读的一个异常,上面参考文章第一个链接的那篇文章上说的是自定义分布式锁注解+@Transactional注解会有问题,跟我遇到的这个大致相似,但是我这个歪打正着是在最最最最最最外层的public方法上加了@Transactional注解,事务传播级别设置为READ_COMMITTED(这种搞暂时是没有重复了,是因为可以读已经提交的事务数据,也就是线程一计算最新的发票号码事务自动提交之后,其它线程可以读到最新当前发票号码提交的事务的数据),这种搞了之后,虽然歪打正着的解决了,但是运行时间较短,并发不高,很不会在重复,如果在并发较高的情况下,这个事务自动提交是异步的,等待提交的事务数据库那边还处理不过来导致事务堆积,还有就是读已提交只是对于单个节点的服务可见。这种两个情况下都是会有问题的,只不过并发小不会出现重复。
2.3正确姿势
使用redis来存在从乐企下载的发票赋码段相关信息(起始号码、终止号码、当前号码,触发下载号码值:终止号码减 50)、发票赋码下载结果(下载status、ywlsh业务流水号)这个需要存redis、还有一个就是发票计算需要使用RedisTemplate的increment来计算,所以这里使用了三个缓存加分布式锁才解决了这个发票重复的问题,redis中的发票当前发票号码必须以RedisTemplate的increment中计算存储的为准,如果以redis中存储那个下载实体对象来的话就会导致重复,因为这个发票赋码段的存储redis更新会有一个延迟,所以跟数据库那种方式一样会有一个更新延迟,所以只能以RedisTemplate的increment计算存储redis中的为准,就不会重复了。
3.总结
这个问题也是搞的非常的蛋疼,经过了生产的实践之后的经验总结,如果上面使用存redis的方式还不能解决,估计怕是要写lua脚本来搞了,但是最终使用存redis的方式来计算解决了这个问题,不然就更加的麻烦了,希望我的分享对你有所启发和帮助,请一键三连,么么么哒!