高并发-【抢红包案例】之三:使用乐观锁方式修复红包超发的bug
文章目录
- 导读
- 乐观锁
- CAS 原理
- ABA问题
- 库表改造
- 代码改造
- RedPacketDao新增接口方法及Mapper映射文件
- UserRedPacketServic接口及实现类的改造
- Controller层新增路由方法
- View层
- 初始化数据,启动应用测试
- 解决因version导致失败问题
- 乐观锁重入机制-按时间戳重入
- 乐观锁重入机制-按次数重入
- 还能更好?
- 代码
导读
高并发-【抢红包案例】之一:SSM环境搭建及复现红包超发问题
高并发-【抢红包案例】之二:使用悲观锁方式修复红包超发的bug
接下来我们使用乐观锁的方式来修复红包超发的bug
乐观锁
乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现,它的设计里面由于不阻塞其他线程,所以并不会引发线程频繁挂起和恢复,这样便能够提高并发能力,也称之为为非阻塞锁。 乐观锁使用的是 CAS原理。
CAS 原理
Redis-11使用 watch 命令监控事务 中也介绍了CAS,这里再重新说下
CAS 原理流程如下:
CAS 原理并不排斥并发,也不独占资源,只是在线程开始阶段就读入线程共享数据,保存为旧值。当处理完逻辑,需要更新数据的时候,会进行一次 比较,即比较各个线程当前共享的数据是否和旧值保持一致。如果一致,就开始更新数据;如果不一致,则认为该前共享的数据是否和旧值保持一致。如果一致,就开始更新数据;如果不一致,则认为该重试,这样就是一个可重入锁,但是 CAS 原理会有一个问题,那就是 ABA 问题,我们先来看下ABA问题
ABA问题
在处理复杂运算的时候,被线程 2 修改的 X 的值有可能导致线程1的运算出错,而最后线程 2 将 X 的值修改为原来的旧值 A,那么到了线程 1运算结束的时间顺序 T6,它将j检测 X 的值是否发生变化,就会拿旧值 A 和 当前的 X 的值 A 比对 , 结果是一致的, 于是提交事务,然后在复杂计算的过程中 X 被线程 2 修改过了,这会导致线程1的运算出错。
在这个过程中,对于线程 2 而言 , X 的值的变化为 A->B->A,所以 CAS 原理的这个设计缺陷被形象地称为“ABA 问题”。
ABA 问题的发生 , 是因为业务逻辑存在回退的可能性 。 如果加入一个非业务逻辑的属性,比如在一个数据中加入版本号( version ),对于版本号有一个约定,就是只要修改 X变量的数据,强制版本号( version )只能递增,而不会回退,即使是其他业务数据回退,它也会递增,那么 ABA 问题就解决了。
只是这个 version 变量并不存在什么业务逻辑,只是为了记录更新次数,只能递增,帮助我们克服 ABA 问题罢了 , 有了这些理论 , 我们就可以开始使用乐观锁来完成抢红包业务了 。
库表改造
为了顺利使用乐观锁 , 需要先在红包表 C T RED PACKET ) 加入一个新的列版本号(version),这个字段在建表的时候已经建了 , 只是我们还没有使用 。 这是第一步
代码改造
既然库表加上了Version字段,那么应用中肯定要用到,自然而言的落到了Dao层上。
RedPacketDao新增接口方法及Mapper映射文件
RedPacketDao.java
/*** @Description: 扣减抢红包数. 乐观锁的实现方式* * @param id* -- 红包id* @param version* -- 版本标记* * @return: 更新记录条数*/public int decreaseRedPacketForVersion(@Param("id") Long id, @Param("version") Integer version);RedPacket.xml
<!-- 通过版本号扣减抢红包 每更新一次,版本增1, 其次增加对版本号的判断 --><update id="decreaseRedPacketForVersion">update T_RED_PACKET set stock = stock - 1 ,version = version + 1where id = #{id} and version = #{version}</update>在扣减红包的时候 , 增加了对版本号的判断,其次每次扣减都会对版本号加一,这样保证每次更新在版本号上有记录 , 从而避免 ABA 问题
对于查询也不使用 for update 语句 , 避免锁的发生 , 这样就没有线程阻塞的问题了。 然后就可 以在类 UserRedPacketServic接口中新增方法 grapRedPacketForVersion,然后在其实现类中完成对应的逻辑即可。
UserRedPacketServic接口及实现类的改造
/*** 保存抢红包信息. 乐观锁的方式* * @param redPacketId* 红包编号* @param userId* 抢红包用户编号* @return 影响记录数.*/public int grapRedPacketForVersion(Long redPacketId, Long userId);实现类
/*** 乐观锁,无重入* */@Override@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)public int grapRedPacketForVersion(Long redPacketId, Long userId) {// 获取红包信息RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);// 当前小红包库存大于0if (redPacket.getStock() > 0) {// 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());// 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺if (update == 0) {return FAILED;}// 生成抢红包信息UserRedPacket userRedPacket = new UserRedPacket();userRedPacket.setRedPacketId(redPacketId);userRedPacket.setUserId(userId);userRedPacket.setAmount(redPacket.getUnitAmount());userRedPacket.setNote("redpacket- " + redPacketId);// 插入抢红包信息int result = userRedPacketDao.grapRedPacket(userRedPacket);return result;}// 失败返回return FAILED;}version 值一开始就保存到了对象中,当扣减的时候,再次传递给 SQL ,让 SQL 对数据库的 version 和当前线程的旧值 version 进行比较。如果一致则插入抢红包的数据,否则就不进行操作。
Controller层新增路由方法
为了方便区分测试,在控制器 UserRedPacketController 内新建映射
@RequestMapping(value = "/grapRedPacketForVersion")@ResponseBodypublic Map<String, Object> grapRedPacketForVersion(Long redPacketId, Long userId) {// 抢红包int result = userRedPacketService.grapRedPacketForVersion(redPacketId, userId);Map<String, Object> retMap = new HashMap<String, Object>();boolean flag = result > 0;retMap.put("success", flag);retMap.put("message", flag ? "抢红包成功" : "抢红包失败");return retMap;}View层
为了区分,新建个jsp吧 , 注意POST 请求地址和红包id 。
grapForVersion.jsp
初始化数据,启动应用测试
一致性数据统计:
经过 3 万次的抢夺,一共抢到了7521个红包,剩余12479个红包, 也就是存在大量的因为版本不一致的原因造成抢红包失败的请求。 这失败率太高了。。
有时候会容忍这个失败,这取决于业务的需要,因为允许用户自己再发起抢夺红包。
性能数据统计:
解决因version导致失败问题
为提高成功率,可以考虑使用重入机制 。 也就是一旦因为版本原因没有抢到红包,则重新尝试抢红包,但是过多的重入会造成大量的 SQL 执行,所以目前流行的重入会加入两种限制
乐观锁重入机制-按时间戳重入
因为乐观锁造成大量更新失败的问题,使用时间戳执行乐观锁重入,是一种提高成功率的方法,比如考虑在 100 毫秒内允许重入,把 UserRedPacketServicelmpl 中的方法grapRedPacketForVersion 修改下
/*** * * 乐观锁,按时间戳重入* * @Description: 乐观锁,按时间戳重入* * @param redPacketId* @param userId* @return* * @return: int*/@Override@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)public int grapRedPacketForVersion(Long redPacketId, Long userId) {// 记录开始时间long start = System.currentTimeMillis();// 无限循环,等待成功或者时间满100毫秒退出while (true) {// 获取循环当前时间long end = System.currentTimeMillis();// 当前时间已经超过100毫秒,返回失败if (end - start > 100) {return FAILED;}// 获取红包信息,注意version值RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);// 当前小红包库存大于0if (redPacket.getStock() > 0) {// 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());// 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺if (update == 0) {continue;}// 生成抢红包信息UserRedPacket userRedPacket = new UserRedPacket();userRedPacket.setRedPacketId(redPacketId);userRedPacket.setUserId(userId);userRedPacket.setAmount(redPacket.getUnitAmount());userRedPacket.setNote("抢红包 " + redPacketId);// 插入抢红包信息int result = userRedPacketDao.grapRedPacket(userRedPacket);return result;} else {// 一旦没有库存,则马上返回return FAILED;}}}当因为版本号原因更新失败后,会重新尝试抢夺红包,但是会实现判断时间戳,如果时间戳在 100 毫秒内,就继续,否则就不再重新尝试,而判定失败,这样可以避免过多的SQL 执行 , 维持系统稳定。
初始化数据后,进行测试
从结果来看,之前大量失败的场景消失了,也没有超发现象 , 3 万次尝试抢光了所有的红包 , 避免了总是失败的结果,但是有时候时间戳并不是那么稳定,也会随着系统的空闲或者繁忙导致重试次数不一。有时候我们也会考虑、限制重试次数,比如 3 次,如下所示
乐观锁重入机制-按次数重入
/*** * * @Title: grapRedPacketForVersion* * @Description: 乐观锁,按次数重入* * @param redPacketId* @param userId* * @return: int*/@Override@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)public int grapRedPacketForVersion(Long redPacketId, Long userId) {for (int i = 0; i < 3; i++) {// 获取红包信息,注意version值RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);// 当前小红包库存大于0if (redPacket.getStock() > 0) {// 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());// 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺if (update == 0) {continue;}// 生成抢红包信息UserRedPacket userRedPacket = new UserRedPacket();userRedPacket.setRedPacketId(redPacketId);userRedPacket.setUserId(userId);userRedPacket.setAmount(redPacket.getUnitAmount());userRedPacket.setNote("抢红包 " + redPacketId);// 插入抢红包信息int result = userRedPacketDao.grapRedPacket(userRedPacket);return result;} else {// 一旦没有库存,则马上返回return FAILED;}}return FAILED;}通过 for 循环限定重试 3 次, 3 次过后无论成败都会判定为失败而退出 , 这样就能避免过多的重试导致过多 SQL 被执行的问题,从而保证数据库的性能.
同样的测试步骤,来看下统计结果
3 万次请求,所有红包都被抢到了 , 也没有发生超发现象,这样就可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。
还能更好?
现在是使用数据库的情况,有时候并不想使用数据库作为抢红包时刻的数据保存载体,而是选择性能优于数据库的 Redis。 之前接触过了Redis的事务,结合lua来实现抢红包的功能
Redis-09Redis的基础事务
Redis-10Redis的事务回滚
Redis-11使用 watch 命令监控事务
先看下理论知识,下篇博文一起来探讨使用Redis + lua 实现抢红包的功能吧。
代码
https://github.com/yangshangwei/ssm_redpacket
总结
以上是生活随笔为你收集整理的高并发-【抢红包案例】之三:使用乐观锁方式修复红包超发的bug的全部内容,希望文章能够帮你解决所遇到的问题。
- 上一篇: 高并发-【抢红包案例】之二:使用悲观锁方
- 下一篇: 并发编程-01并发初窥