继上次的SSM+Redis高并发抢红包之-悲观锁之后,现在我来写下如何使用乐观锁了解决抢红包的并发问题
首先我们先了解下什么是乐观锁(也叫非阻塞锁)
乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现,由于它不阻塞其他线程,所以不会引发线程频繁的挂起和恢复,这样就能够提高并发能力。乐观锁使用的是CAS原理
关于什么是CAS原理,这里简单讲下: 在一个多线程的环境中,有个共同的资源,那么首先我们先保存一个旧值,然后经过一定的逻辑处理,当需要进行某个操作的时候(这里可以理解为当扣减红包的时候),先比较数据库当前的值和旧值是否一致,如果一致就进行红包扣减,否则就不执行这个操作,大概内容就是这样,详细的可以看看这篇博客CAS原理及所导致的ABA问题,关于其底层实现可以看看这篇:CAS底层实现
由于CAS原理的使用会产生一个问题,那就是ABA问题,关于啥是ABA,上面那个链接博客,可以去看下。
那我们如何解决这个问题呢,一般都设一个专属字段,大多数情况下都设一个version 版本号的字段,这个字段是只能自增的,通过控制version的值,然后每次更新数据是进行比较version值,就可以解决ABA问题了
继上次的代码,这里只需在RedPacketDao 接口中添加
/*
* 扣减抢红包数 --乐观锁使用版本号来控制
* @param id 红包id
* @param version 版本号
* @return 更新记录条数
*/
public int decreaseRedPacketForVersion(@Param("id") Long id, @Param("version") Integer version);
其次在 RedPacket中添加一条sql语句
<!-- 扣减抢红包库存 ,没更新一次,版本加1,其次增加对版本号的判断-->
<update id="decreaseRedPacketForVersion">
update T_RED_PACKET set stock = stock - 1,version = version + 1
where id = #{id} and version = #{version}
</update>
为了方便测试,我们在UserRedPacketService中新增一个接口方法
/*
* 保存抢红包信息-保存version
* @param redPacketId 红包编号
* @param userId 抢红包用户编号
* @return 影响记录数
*/
public int grapRedPacketForVersion(Long redPacketId, Long userId);
当然,也需要在impl中进行实现
@Override
@Transactional(isolation=Isolation.READ_COMMITTED, propagation=Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
// TODO Auto-generated method stub
//获取红包信息,这里注意下version值
RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
//当小红包库存大于0
if(redPacket.getStock() > 0) {
//再次传入线程保存的version 旧值给SQL判断,是否有其他线程修改过数据
int updateFlag = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
//如果没有数据更新,则说明其他线程已经修改过数据,本次抢红包失败
if(updateFlag == 0) {
return FAILED;
}
//生成抢红包信息
UserRedPacket userRedPacket = new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(redPacket.getUnitAmount());
userRedPacket.setNote("抢红包 "+ redPacketId);
//插入抢红包信息
int result = userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}
return FAILED;
}
最后在UserRedPacketController中新开一个方法
@RequestMapping("/grapRedPacketForVersion")
@ResponseBody
public Map<String, Object> grapRedPacketForVersion(Long redPacketId, Long userId){
//抢红包
int result = userRedPacketService.grapRedPacketForVersion(redPacketId, userId);
Map<String,Object> res = new HashMap<String,Object>();
boolean flag = result > 0;
res.put("success", flag);
res.put("message", flag?"抢红包成功":"抢红包失败");
return res;
}
然后就可以进行测试了
我们继续在t_red_packet中插入200000元, 20000个红包
stock还剩7297个,也就是存在大量因为版本不一致的原因造成抢红包失败,主要是因为版本的并发而产生的。
一般为了提高成功率,会考虑采取重入机制,重入机制有两种,一种是按时间戳重入,一种是按次数重入
1.按时间戳重入
按时间戳重入,只需在业务层UserRedPacketServiceImpl里写一个循环语句即可,这里我们设定时间戳为100ms,超过这个时间就退出循环
@Override
@Transactional(isolation=Isolation.READ_COMMITTED, propagation=Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
// TODO Auto-generated method stub
//记录开始时间
long start = System.currentTimeMillis();
//无限循环,等待成功或时间满100ms退出
while(true) {
//获取循环时间
long end = System.currentTimeMillis();
//当超过100ms就返回失败
if(end - start > 100) {
return FAILED;
}
//获取红包信息,这里注意下version值
RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
//当小红包库存大于0
if(redPacket.getStock() > 0) {
//再次传入线程保存的version 旧值给SQL判断,是否有其他线程修改过数据
int updateFlag = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
//如果没有数据更新,则说明其他线程已经修改过数据,本次抢红包失败
if(updateFlag == 0) {
continue;
}
//生成抢红包信息
UserRedPacket userRedPacket = new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(redPacket.getUnitAmount());
userRedPacket.setNote("抢红包 "+ redPacketId);
//插入抢红包信息
int result = userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}
else {
return FAILED;
}
}
}
2、按次数也就是将while 改成for 限定i大小,就行了。
这两种都可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。但是当数据量不断增大,直接使用数据库将变得很慢,这时候就可以选择性能更好的,通过Redis+Lua语言来处理高并发。关于Redis+Lua语言实现抢红包,可以看下我下篇博客SSM+Redis高并发抢红包之-Lua+Redis
详细代码可以在我GitHub上进行查看 ssm-redis