抢红包案例分析以及代码实现(三)

前文回顾

抢红包案例分析以及代码实现(一)

抢红包案例分析以及代码实现(二)

接下来我们使用乐观锁的方式来修复红包超发的bug

乐观锁

乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁进行实现,它的设计里面由于不阻塞其他线程,所以并不会引发线程频繁挂起和恢复,这样便能够提高并发能力,也称之为为非阻塞锁。 乐观锁使用的是 CAS原理。

CAS 原理

在 CAS 原理中,对于多个线程共同的资源,先保存一个旧(Old Value),比如进入线程后,查询当前存量为 100 个红包,那么先把旧值保存为 100,然后经过一定的逻辑处理。

当需要扣减红包的时候,先比较数据库当前的值和旧值是否一致,如果一致则进行扣减红包的操作,否则就认为它已经被其他线程修改过了,不再进行操作。

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: 更新记录条数

*/

publicintdecreaseRedPacketForVersion(@Param("id") Long id,@Param("version") Integer version);

RedPacket.xml

<!-- 通过版本号扣减抢红包 每更新一次,版本增1, 其次增加对版本号的判断 -->

update

T_RED_PACKET

set stock = stock - 1 ,

version = version + 1

where id = #{id}

and version = #{version}

在扣减红包的时候 , 增加了对版本号的判断,其次每次扣减都会对版本号加一,这样保证每次更新在版本号上有记录 , 从而避免 ABA 问题

对于查询也不使用 for update 语句,避免锁的发生,这样就没有线程阻塞的问题了。然后就可以在类 UserRedPacketServic接口中新增方法 grapRedPacketForVersion,然后在其实现类中完成对应的逻辑即可。

UserRedPacketServic接口及实现类的改造

/**

* 保存抢红包信息. 乐观锁的方式

*

*@paramredPacketId

*            红包编号

*@paramuserId

*            抢红包用户编号

*@return影响记录数.

*/

publicintgrapRedPacketForVersion(Long redPacketId, Long userId);

实现类

/**

* 乐观锁,无重入

* */

@Override

@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)

publicintgrapRedPacketForVersion(Long redPacketId, Long userId){

// 获取红包信息

RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);

// 当前小红包库存大于0

if(redPacket.getStock() >0) {

// 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据

intupdate = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());

// 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺

if(update ==0) {

returnFAILED;

}

// 生成抢红包信息

UserRedPacket userRedPacket =newUserRedPacket();

userRedPacket.setRedPacketId(redPacketId);

userRedPacket.setUserId(userId);

userRedPacket.setAmount(redPacket.getUnitAmount());

userRedPacket.setNote("redpacket- "+ redPacketId);

// 插入抢红包信息

intresult = userRedPacketDao.grapRedPacket(userRedPacket);

returnresult;

}

// 失败返回

returnFAILED;

}

version 值一开始就保存到了对象中,当扣减的时候,再次传递给 SQL ,让 SQL 对数据库的 version 和当前线程的旧值 version 进行比较。如果一致则插入抢红包的数据,否则就不进行操作。

Controller层新增路由方法

为了方便区分测试,在控制器 UserRedPacketController 内新建映射

@RequestMapping(value ="/grapRedPacketForVersion")

@ResponseBody

publicMap grapRedPacketForVersion(Long redPacketId, Long userId) {

// 抢红包

int result = userRedPacketService.grapRedPacketForVersion(redPacketId, userId);

Map retMap =newHashMap();

booleanflag = result >0;

retMap.put("success", flag);

retMap.put("message", flag ?"抢红包成功":"抢红包失败");

returnretMap;

}

View层

为了区分,新建个jsp吧 , 注意POST 请求地址和红包id 。

grapForVersion.jsp

<%@pagelanguage="java"contentType="text/html; charset=UTF-8"

pageEncoding="UTF-8"%>

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">

参数

<!-- 加载Query文件-->

$(document).ready(function(){

//模拟30000个异步请求,进行并发

varmax =30000;

for(vari =1; i <= max; i++) {

//jQuery的post请求,请注意这是异步请求

$.post({

//请求抢id为1的红包

//根据自己请求修改对应的url和大红包编号

url:"./userRedPacket/grapRedPacketForVersion.do?redPacketId=1&userId="+ i,

//成功后的方法

success:function(result){

}

});

}

});

初始化数据,启动应用测试

一致性数据统计:

经过 3 万次的抢夺,一共抢到了7521个红包,剩余12479个红包, 也就是存在大量的因为版本不一致的原因造成抢红包失败的请求。 这失败率太高了。。

有时候会容忍这个失败,这取决于业务的需要,因为允许用户自己再发起抢夺红包。

性能数据统计:

解决因version导致失败问题

为提高成功率,可以考虑使用重入机制 。也就是一旦因为版本原因没有抢到红包,则重新尝试抢红包,但是过多的重入会造成大量的 SQL 执行,所以目前流行的重入会加入两种限制:

一种是按时间戳的重入,也就是在一定时间戳内(比如说 100毫秒),不成功的会循环到成功为止,直至超过时间戳,不成功才会退出,返回失败。

一种是按次数,比如限定 3 次,程序尝试超过 3 次抢红包后,就判定请求失效,这样有助于提高用户抢红包的成功率。

乐观锁重入机制-按时间戳重入

因为乐观锁造成大量更新失败的问题,使用时间戳执行乐观锁重入,是一种提高成功率的方法,比如考虑在 100 毫秒内允许重入,把 UserRedPacketServicelmpl 中的方法grapRedPacketForVersion 修改下

/**

*

*

* 乐观锁,按时间戳重入

*

*@Description: 乐观锁,按时间戳重入

*

*@paramredPacketId

*@paramuserId

*@return

*

*@return: int

*/

@Override

@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)

publicintgrapRedPacketForVersion(Long redPacketId, Long userId){

// 记录开始时间

longstart = System.currentTimeMillis();

// 无限循环,等待成功或者时间满100毫秒退出

while(true) {

// 获取循环当前时间

longend = System.currentTimeMillis();

// 当前时间已经超过100毫秒,返回失败

if(end - start >100) {

returnFAILED;

}

// 获取红包信息,注意version值

RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);

// 当前小红包库存大于0

if(redPacket.getStock() >0) {

// 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据

intupdate = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());

// 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺

if(update ==0) {

continue;

}

// 生成抢红包信息

UserRedPacket userRedPacket =newUserRedPacket();

userRedPacket.setRedPacketId(redPacketId);

userRedPacket.setUserId(userId);

userRedPacket.setAmount(redPacket.getUnitAmount());

userRedPacket.setNote("抢红包 "+ redPacketId);

// 插入抢红包信息

intresult = userRedPacketDao.grapRedPacket(userRedPacket);

returnresult;

}else{

// 一旦没有库存,则马上返回

returnFAILED;

}

}

}

当因为版本号原因更新失败后,会重新尝试抢夺红包,但是会实现判断时间戳,如果时间戳在 100 毫秒内,就继续,否则就不再重新尝试,而判定失败,这样可以避免过多的SQL 执行,维持系统稳定。

初始化数据后,进行测试

从结果来看,之前大量失败的场景消失了,也没有超发现象,3 万次尝试抢光了所有的红包,避免了总是失败的结果,但是有时候时间戳并不是那么稳定,也会随着系统的空闲或者繁忙导致重试次数不一。有时候我们也会考虑、限制重试次数,比如 3 次,如下所示:

乐观锁重入机制-按次数重入

/**

*

*

*@Title: grapRedPacketForVersion

*

*@Description: 乐观锁,按次数重入

*

*@paramredPacketId

*@paramuserId

*

*@return: int

*/

@Override

@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)

publicintgrapRedPacketForVersion(Long redPacketId, Long userId){

for(inti =0; i <3; i++) {

// 获取红包信息,注意version值

RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);

// 当前小红包库存大于0

if(redPacket.getStock() >0) {

// 再次传入线程保存的version旧值给SQL判断,是否有其他线程修改过数据

intupdate = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());

// 如果没有数据更新,则说明其他线程已经修改过数据,则重新抢夺

if(update ==0) {

continue;

}

// 生成抢红包信息

UserRedPacket userRedPacket =newUserRedPacket();

userRedPacket.setRedPacketId(redPacketId);

userRedPacket.setUserId(userId);

userRedPacket.setAmount(redPacket.getUnitAmount());

userRedPacket.setNote("抢红包 "+ redPacketId);

// 插入抢红包信息

intresult = userRedPacketDao.grapRedPacket(userRedPacket);

returnresult;

}else{

// 一旦没有库存,则马上返回

returnFAILED;

}

}

returnFAILED;

}

通过 for 循环限定重试 3 次,3 次过后无论成败都会判定为失败而退出,这样就能避免过多的重试导致过多 SQL 被执行的问题,从而保证数据库的性能。

同样的测试步骤,来看下统计结果

3 万次请求,所有红包都被抢到了,也没有发生超发现象,这样就可以消除大量的请求失败,避免非重入的时候大量请求失败的场景。

还能更好?

现在是使用数据库的情况,有时候并不想使用数据库作为抢红包时刻的数据保存载体,而是选择性能优于数据库的 Redis。之前接触过了Redis的事务,结合lua来实现抢红包的功能。

Redis-09Redis的基础事务:https://blog.csdn.net/yangshangwei/article/details/82863772

Redis-10Redis的事务回滚:https://blog.csdn.net/yangshangwei/article/details/82866216

Redis-11使用 watch 命令监控事务:https://blog.csdn.net/yangshangwei/article/details/82867200

先看下理论知识,下篇博文一起来探讨使用Redis + lua 实现抢红包的功能吧。

代码

https://github.com/yangshangwei/ssm_redpacket

扩展阅读

对高并发流量控制的一点思考

高并发系统的设计及秒杀实践

从构建分布式秒杀系统聊聊限流特技

来源:https://blog.csdn.net/yangshangwei/article/details/82982796

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容

  • Java8张图 11、字符串不变性 12、equals()方法、hashCode()方法的区别 13、...
    Miley_MOJIE阅读 3,696评论 0 11
  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,217评论 11 349
  • 孩子第二个30天目标: 1、每日运动 2、每日练习古筝 3、清单视觉化,打卡(第三个是附加) 一、检视收获 继续延...
    Jewel_dacf阅读 285评论 0 0
  • 余姚北,杭州东, 四更天寒雨伴风,征人顶孤星。 嘉兴南,金山北, 倦马忙蹄雪满鬃,决不枉此生! 2018.1.9 ...
    燕赵悲歌阅读 226评论 0 1
  • 今天和小j闹翻了,原因是因为领导让她去做一件事,其实那件事一开始领导已经和我说过,就是促进已购买家居的业主自发向他...
    国器阅读 231评论 0 0