学习了大佬的博客,跑了下demo,但是失败了。
MVC那里也不知道为啥拦截不到我的请求,大佬原文地址
https://blog.csdn.net/yangshangwei/article/details/82975845。
模拟 20 万元的红包,共分为 2 万个可抢的小红包,有 3 万人同时抢夺的场景 ,模拟出现超发和如何保证数据一致性的问题。
案例关注点:
数据一致性和系统的性能
使用 SQL 去查询红包的库存、发放红包的总个数、总金额,我们发现了错误,红包总额为 20 万元,两万个小红包,结果发放了 200020元的红包, 20002 个红包。现有库存为-2,超出了之前的限定,这就是高并发的超发现象,这是一个错误的逻辑 。
针对这个案例,用户抢到红包后,红包总量应-1,当多个用户同时抢红包,此时多个线程同时读得库存为n,相应的逻辑执行后,最后将均执update T_RED_PACKET set stock = stock - 1 where id = #{id}
,很明显这是错误的。
1.线程1在查询红包数时使用排他锁 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
2.然后进行后续的操作(redPacketDao.decreaseRedPacket 和 userRedPacketDao.grapRedPacket),更新红包数量,最后提交事务。
3.线程2在查询红包数时,如果线程1还未释放排他锁,它将等待
4.线程3同线程2,依次类推
一致性数据统计:
性能数据统计:
1. 在红包表添加version版本字段或者timestamp时间戳字段,这里我们使用version
2. 线程1查询后,执行更新变成了update T_RED_PACKET set stock = stock - 1, version = version + 1 where id = #{id} and version = #{version}
这样,保证了修改的数据是和它查询出来的数据是一致的,而其他线程并未进行修改。当然,如果更新失败,表示在更新操作之前有其他线程已经更新了该红包数,那么就可以尝试重入机制来保证更新成功。
在扣减红包的时候 , 增加了对版本号的判断,其次每次扣减都会对版本号加一,这样保证每次更新在版本号上有记录 , 从而避免 ABA 问题
一致性数据统计:
性能数据统计:
解决因version导致失败问题
为提高成功率,可以考虑使用重入机制 。 也就是一旦因为版本原因没有抢到红包,则重新尝试抢红包,但是过多的重入会造成大量的 SQL 执行,所以目前流行的重入会加入两种限制
1.一种是按时间戳的重入,也就是在一定时间戳内(比如说 100毫秒),不成功的会循环到成功为止,直至超过时间戳,不成功才会退出,返回失败。
2.一种是按次数,比如限定 3 次,程序尝试超过 3 次抢红包后,就判定请求失效,这样有助于提高用户抢红包的成功率。
/**
*
*
* 乐观锁,按时间戳重入
*
* @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);
// 当前小红包库存大于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;
}
}
}
当因为版本号原因更新失败后,会重新尝试抢夺红包,但是会实现判断时间戳,如果时间戳在 100 毫秒内,就继续,否则就不再重新尝试,而判定失败,这样可以避免过多的SQL 执行 , 维持系统稳定。
一致性测试:
性能测试:
/**
*
*
* @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);
// 当前小红包库存大于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;
}
通过 for 循环限定重试 3 次, 3 次过后无论成败都会判定为失败而退出 , 这样就能避免过多的重试导致过多 SQL 被执行的问题,从而保证数据库的性能.
数据一致性和性能截图