目录
超发问题分析
针对上篇文章中,用户抢到红包后,红包总量应-1,当多个用户同时抢红包,此时多个线程同时读得库存为n,相应的逻辑执行后,最后将均执update T_RED_PACKET set stock = stock - 1 where id = #{id} ,很明显这是错误的。
1.悲观锁(for update)
悲观锁是一种利用数据库内部机制提供的锁的方法,对更新的数据加锁, 在并发期间一旦有一个事务持有了数据库记录的锁,其他的线程将不再对数据进行更新了。
1.1 项目使用悲观锁操作
- 线程1查询红包数时使用排他锁
<!--查询红包具体信息--> <select id="getRedPacket" parameterType="long" resultType="cn.whc.pojo.RedPacket"> select id, user_id as userId, amount, send_date as sendDate, total, unit_amount as unitAmount, stock, version, note from T_RED_PACKET where id = #{id} for update </select>
- 然后进行后续操作(redPacketDao.decreaseRedPacket和userRedPacketDao.grapRedPacket)更新红包数量,最后提交事务
- 线程2在查询红包数时,如果线程1还未释放排他锁,线程2将等待
- 线程3同线程2,以此类推
1.2 共享锁(S锁) 和 排他锁(X锁)
共享锁和排他锁是悲观锁不同的实现,都属于悲观锁的范畴
数据库的增删改操作默认都会加排他锁,但查询不会加任何锁
共享锁: 指的是对于多个不同的事务,对同一资源共享同一个锁。对某一资源加共享锁,自身可以读该资源,其他人也可以读该资源,但无法修改。
排他锁: 指对于多个不同的事务,对同一资源只能有一把锁。对某一资源加排他锁,自身可以进行增删改查,其他人无法进行任何操作。
1.3 悲观锁消除超发问题
在RedPacket.xml中查询红包具体信息后面添加for update语句,为查询增加行级锁,这样就不会出现超发现象引发数据不一致性问题了
<!--查询红包具体信息-->
<select id="getRedPacketForUpdate" parameterType="long" resultType="cn.whc.pojo.RedPacket">
select id, user_id as userId, amount, send_date as sendDate, total, unit_amount as unitAmount, stock,
version, note from T_RED_PACKET where id = #{id} for update
</select>
测试结果:
性能问题:
目前只对数据库加了一个锁,显示结果还不太明显,但是如果加得锁比较多的时候,数据库的性能会持续下降,原因如下:
- 对于悲观锁来说,当一条线程抢占了资源后,其他的线程将得不到资源,这个时候,CPU就会将这些得不到资源的线程挂起,挂起的线程也会消耗CPU的资源,尤其是在高并发的请求中
2.乐观锁
乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现。
乐观锁使用的是CAS原理。
2.1 CAS原理
在CAS原理中,对于多个线程共同的资源,先保存一个旧值,比如进入线程后,查询当前存量为100个红包,那么先把旧值保存为100,然后经过一定的逻辑处理。当需要扣减红包的时候,先比较数据库当前的值和旧值是否一致,如果一致则进行扣减红包的操作,否则就认为它已经被其他线程修改过,不再进行操作
CAS可能会存在ABA问题,只要在数据中加入一个版本号的属性,就能解决ABA问题
2.2 乐观锁实现抢红包业务
规避CAS原理产生的ABA问题,需要先在红包表(T_RED_PACKET)加入一个新的列版本号(version),我们已经在一开始创建表的时候就建立了
<!--通过版本号扣减抢红包,每更新一次,版本增1,其次增加对版本号的判断-->
<update id="decreaseRedPacketForVersion">
update T_RED_PACKET
set stock = stock - 1,
version = version + 1
where id = #{id}
and version = #{version}
</update>
在扣减红包的时候,增加了对版本号的判断,在每次扣减都会对版本号 + 1,保证每次更新在版本号上有记录,从而避免ABA问题。对于查询不使用for update语句,避免锁的发生,就没有线程阻塞问题了。
具体业务代码
-
在RedPacketDao接口加入对应的方法:
/** * 通过对保存旧值版本号和数据库版本号匹配,扣减抢红包数 * @param id 红包id * @param version 保存旧值的版本号 * @return */ 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 + 1 where id = #{id} and version = #{version} </update>
-
UserRedPacketService接口加入方法:
/** * 通过保本号保存抢红包信息 * @param redPacketId 红包编号 * @param userId 抢红包用户编号 * @return 影响记录数 */ int grapRedPacketForVersion(Long redPacketId, Long userId);
-
UserRedPacketServiceImpl实现类
@Service("userRedPacketService") public class UserRedPacketServiceImpl implements UserRedPacketService { @Autowired private UserRedPacketDao userRedPacketDao = null; @Autowired private RedPacketDao redPacketDao = null; // 失败 private static final int FAILED = 0; // 乐观锁 @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED) public int grapRedPacketForVersion(Long redPacketId, Long userId) { // 获取红包信息,注意version值 RedPacket redPacket = redPacketDao.getRedPacket(redPacketId); // 当前小红包库存大于0 if (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("抢红包" + redPacketId); // 插入抢红包信息 int result = userRedPacketDao.grapRedPacket(userRedPacket); return result; } else { // 失败返回 return FAILED; } } }
测试结果
从测试结果看出,经过3万次的抢夺,还存在大量红包因为版本并发的原因而没有被抢到,有时候容忍这个失败,取决于业务的需要。不过对于因为版本原因没有抢到红包,我们可以考虑用乐观锁重入机制。
3.乐观锁重入机制的实现
3.1 按时间戳的重入
在一定时间戳内,比如100毫秒,不成功会循环到成功为止,直到超过时间戳,不成功才会退出,返回失败。
业务代码
// 乐观锁重入机制
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
// 记录开始时间
long start = System.currentTimeMillis();
while(true) {
// 获取循环当前时间
long end = System.currentTimeMillis();
// 当前时间已超过100毫秒,返回失败
if(end - start > 100) {
return FAILED;
}
// 获取红包信息,注意version值
RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
// 当前小红包库存大于0
if(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;
}
// 失败返回
return FAILED;
}
}
测试结果
3.2 按次数
比如限定3次,程序尝试3次抢红包后,就判定请求失败
业务代码
// 通过重试次数提高乐观锁抢红包成功率
@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);
// 当前小红包库存大于0
if(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;
}
测试结果
显然3万次请求,所有红包都被抢到了,也没有发生超发现象,这样就可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。