高并发-【抢红包案例】之三:使用乐观锁方式修复红包超发的bug
文章目錄
- 導(dǎo)讀
- 樂(lè)觀鎖
- CAS 原理
- ABA問(wèn)題
- 庫(kù)表改造
- 代碼改造
- RedPacketDao新增接口方法及Mapper映射文件
- UserRedPacketServic接口及實(shí)現(xiàn)類的改造
- Controller層新增路由方法
- View層
- 初始化數(shù)據(jù),啟動(dòng)應(yīng)用測(cè)試
- 解決因version導(dǎo)致失敗問(wèn)題
- 樂(lè)觀鎖重入機(jī)制-按時(shí)間戳重入
- 樂(lè)觀鎖重入機(jī)制-按次數(shù)重入
- 還能更好?
- 代碼
導(dǎo)讀
高并發(fā)-【搶紅包案例】之一:SSM環(huán)境搭建及復(fù)現(xiàn)紅包超發(fā)問(wèn)題
高并發(fā)-【搶紅包案例】之二:使用悲觀鎖方式修復(fù)紅包超發(fā)的bug
接下來(lái)我們使用樂(lè)觀鎖的方式來(lái)修復(fù)紅包超發(fā)的bug
樂(lè)觀鎖
樂(lè)觀鎖是一種不會(huì)阻塞其他線程并發(fā)的機(jī)制,它不會(huì)使用數(shù)據(jù)庫(kù)的鎖進(jìn)行實(shí)現(xiàn),它的設(shè)計(jì)里面由于不阻塞其他線程,所以并不會(huì)引發(fā)線程頻繁掛起和恢復(fù),這樣便能夠提高并發(fā)能力,也稱之為為非阻塞鎖。 樂(lè)觀鎖使用的是 CAS原理。
CAS 原理
Redis-11使用 watch 命令監(jiān)控事務(wù) 中也介紹了CAS,這里再重新說(shuō)下
CAS 原理流程如下:
CAS 原理并不排斥并發(fā),也不獨(dú)占資源,只是在線程開(kāi)始階段就讀入線程共享數(shù)據(jù),保存為舊值。當(dāng)處理完邏輯,需要更新數(shù)據(jù)的時(shí)候,會(huì)進(jìn)行一次 比較,即比較各個(gè)線程當(dāng)前共享的數(shù)據(jù)是否和舊值保持一致。如果一致,就開(kāi)始更新數(shù)據(jù);如果不一致,則認(rèn)為該前共享的數(shù)據(jù)是否和舊值保持一致。如果一致,就開(kāi)始更新數(shù)據(jù);如果不一致,則認(rèn)為該重試,這樣就是一個(gè)可重入鎖,但是 CAS 原理會(huì)有一個(gè)問(wèn)題,那就是 ABA 問(wèn)題,我們先來(lái)看下ABA問(wèn)題
ABA問(wèn)題
在處理復(fù)雜運(yùn)算的時(shí)候,被線程 2 修改的 X 的值有可能導(dǎo)致線程1的運(yùn)算出錯(cuò),而最后線程 2 將 X 的值修改為原來(lái)的舊值 A,那么到了線程 1運(yùn)算結(jié)束的時(shí)間順序 T6,它將j檢測(cè) X 的值是否發(fā)生變化,就會(huì)拿舊值 A 和 當(dāng)前的 X 的值 A 比對(duì) , 結(jié)果是一致的, 于是提交事務(wù),然后在復(fù)雜計(jì)算的過(guò)程中 X 被線程 2 修改過(guò)了,這會(huì)導(dǎo)致線程1的運(yùn)算出錯(cuò)。
在這個(gè)過(guò)程中,對(duì)于線程 2 而言 , X 的值的變化為 A->B->A,所以 CAS 原理的這個(gè)設(shè)計(jì)缺陷被形象地稱為“ABA 問(wèn)題”。
ABA 問(wèn)題的發(fā)生 , 是因?yàn)闃I(yè)務(wù)邏輯存在回退的可能性 。 如果加入一個(gè)非業(yè)務(wù)邏輯的屬性,比如在一個(gè)數(shù)據(jù)中加入版本號(hào)( version ),對(duì)于版本號(hào)有一個(gè)約定,就是只要修改 X變量的數(shù)據(jù),強(qiáng)制版本號(hào)( version )只能遞增,而不會(huì)回退,即使是其他業(yè)務(wù)數(shù)據(jù)回退,它也會(huì)遞增,那么 ABA 問(wèn)題就解決了。
只是這個(gè) version 變量并不存在什么業(yè)務(wù)邏輯,只是為了記錄更新次數(shù),只能遞增,幫助我們克服 ABA 問(wèn)題罷了 , 有了這些理論 , 我們就可以開(kāi)始使用樂(lè)觀鎖來(lái)完成搶紅包業(yè)務(wù)了 。
庫(kù)表改造
為了順利使用樂(lè)觀鎖 , 需要先在紅包表 C T RED PACKET ) 加入一個(gè)新的列版本號(hào)(version),這個(gè)字段在建表的時(shí)候已經(jīng)建了 , 只是我們還沒(méi)有使用 。 這是第一步
代碼改造
既然庫(kù)表加上了Version字段,那么應(yīng)用中肯定要用到,自然而言的落到了Dao層上。
RedPacketDao新增接口方法及Mapper映射文件
RedPacketDao.java
/*** @Description: 扣減搶紅包數(shù). 樂(lè)觀鎖的實(shí)現(xiàn)方式* * @param id* -- 紅包id* @param version* -- 版本標(biāo)記* * @return: 更新記錄條數(shù)*/public int decreaseRedPacketForVersion(@Param("id") Long id, @Param("version") Integer version);RedPacket.xml
<!-- 通過(guò)版本號(hào)扣減搶紅包 每更新一次,版本增1, 其次增加對(duì)版本號(hào)的判斷 --><update id="decreaseRedPacketForVersion">update T_RED_PACKET set stock = stock - 1 ,version = version + 1where id = #{id} and version = #{version}</update>在扣減紅包的時(shí)候 , 增加了對(duì)版本號(hào)的判斷,其次每次扣減都會(huì)對(duì)版本號(hào)加一,這樣保證每次更新在版本號(hào)上有記錄 , 從而避免 ABA 問(wèn)題
對(duì)于查詢也不使用 for update 語(yǔ)句 , 避免鎖的發(fā)生 , 這樣就沒(méi)有線程阻塞的問(wèn)題了。 然后就可 以在類 UserRedPacketServic接口中新增方法 grapRedPacketForVersion,然后在其實(shí)現(xiàn)類中完成對(duì)應(yīng)的邏輯即可。
UserRedPacketServic接口及實(shí)現(xiàn)類的改造
/*** 保存搶紅包信息. 樂(lè)觀鎖的方式* * @param redPacketId* 紅包編號(hào)* @param userId* 搶紅包用戶編號(hào)* @return 影響記錄數(shù).*/public int grapRedPacketForVersion(Long redPacketId, Long userId);實(shí)現(xiàn)類
/*** 樂(lè)觀鎖,無(wú)重入* */@Override@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)public int grapRedPacketForVersion(Long redPacketId, Long userId) {// 獲取紅包信息RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);// 當(dāng)前小紅包庫(kù)存大于0if (redPacket.getStock() > 0) {// 再次傳入線程保存的version舊值給SQL判斷,是否有其他線程修改過(guò)數(shù)據(jù)int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());// 如果沒(méi)有數(shù)據(jù)更新,則說(shuō)明其他線程已經(jīng)修改過(guò)數(shù)據(jù),則重新?lián)寠Zif (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 值一開(kāi)始就保存到了對(duì)象中,當(dāng)扣減的時(shí)候,再次傳遞給 SQL ,讓 SQL 對(duì)數(shù)據(jù)庫(kù)的 version 和當(dāng)前線程的舊值 version 進(jìn)行比較。如果一致則插入搶紅包的數(shù)據(jù),否則就不進(jìn)行操作。
Controller層新增路由方法
為了方便區(qū)分測(cè)試,在控制器 UserRedPacketController 內(nèi)新建映射
@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層
為了區(qū)分,新建個(gè)jsp吧 , 注意POST 請(qǐng)求地址和紅包id 。
grapForVersion.jsp
初始化數(shù)據(jù),啟動(dòng)應(yīng)用測(cè)試
一致性數(shù)據(jù)統(tǒng)計(jì):
經(jīng)過(guò) 3 萬(wàn)次的搶奪,一共搶到了7521個(gè)紅包,剩余12479個(gè)紅包, 也就是存在大量的因?yàn)榘姹静灰恢碌脑蛟斐蓳尲t包失敗的請(qǐng)求。 這失敗率太高了。。
有時(shí)候會(huì)容忍這個(gè)失敗,這取決于業(yè)務(wù)的需要,因?yàn)樵试S用戶自己再發(fā)起搶奪紅包。
性能數(shù)據(jù)統(tǒng)計(jì):
解決因version導(dǎo)致失敗問(wèn)題
為提高成功率,可以考慮使用重入機(jī)制 。 也就是一旦因?yàn)榘姹驹驔](méi)有搶到紅包,則重新嘗試搶紅包,但是過(guò)多的重入會(huì)造成大量的 SQL 執(zhí)行,所以目前流行的重入會(huì)加入兩種限制
樂(lè)觀鎖重入機(jī)制-按時(shí)間戳重入
因?yàn)闃?lè)觀鎖造成大量更新失敗的問(wèn)題,使用時(shí)間戳執(zhí)行樂(lè)觀鎖重入,是一種提高成功率的方法,比如考慮在 100 毫秒內(nèi)允許重入,把 UserRedPacketServicelmpl 中的方法grapRedPacketForVersion 修改下
/*** * * 樂(lè)觀鎖,按時(shí)間戳重入* * @Description: 樂(lè)觀鎖,按時(shí)間戳重入* * @param redPacketId* @param userId* @return* * @return: int*/@Override@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)public int grapRedPacketForVersion(Long redPacketId, Long userId) {// 記錄開(kāi)始時(shí)間long start = System.currentTimeMillis();// 無(wú)限循環(huán),等待成功或者時(shí)間滿100毫秒退出while (true) {// 獲取循環(huán)當(dāng)前時(shí)間long end = System.currentTimeMillis();// 當(dāng)前時(shí)間已經(jīng)超過(guò)100毫秒,返回失敗if (end - start > 100) {return FAILED;}// 獲取紅包信息,注意version值RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);// 當(dāng)前小紅包庫(kù)存大于0if (redPacket.getStock() > 0) {// 再次傳入線程保存的version舊值給SQL判斷,是否有其他線程修改過(guò)數(shù)據(jù)int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());// 如果沒(méi)有數(shù)據(jù)更新,則說(shuō)明其他線程已經(jīng)修改過(guò)數(shù)據(jù),則重新?lián)寠Zif (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 {// 一旦沒(méi)有庫(kù)存,則馬上返回return FAILED;}}}當(dāng)因?yàn)榘姹咎?hào)原因更新失敗后,會(huì)重新嘗試搶奪紅包,但是會(huì)實(shí)現(xiàn)判斷時(shí)間戳,如果時(shí)間戳在 100 毫秒內(nèi),就繼續(xù),否則就不再重新嘗試,而判定失敗,這樣可以避免過(guò)多的SQL 執(zhí)行 , 維持系統(tǒng)穩(wěn)定。
初始化數(shù)據(jù)后,進(jìn)行測(cè)試
從結(jié)果來(lái)看,之前大量失敗的場(chǎng)景消失了,也沒(méi)有超發(fā)現(xiàn)象 , 3 萬(wàn)次嘗試搶光了所有的紅包 , 避免了總是失敗的結(jié)果,但是有時(shí)候時(shí)間戳并不是那么穩(wěn)定,也會(huì)隨著系統(tǒng)的空閑或者繁忙導(dǎo)致重試次數(shù)不一。有時(shí)候我們也會(huì)考慮、限制重試次數(shù),比如 3 次,如下所示
樂(lè)觀鎖重入機(jī)制-按次數(shù)重入
/*** * * @Title: grapRedPacketForVersion* * @Description: 樂(lè)觀鎖,按次數(shù)重入* * @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);// 當(dāng)前小紅包庫(kù)存大于0if (redPacket.getStock() > 0) {// 再次傳入線程保存的version舊值給SQL判斷,是否有其他線程修改過(guò)數(shù)據(jù)int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());// 如果沒(méi)有數(shù)據(jù)更新,則說(shuō)明其他線程已經(jīng)修改過(guò)數(shù)據(jù),則重新?lián)寠Zif (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 {// 一旦沒(méi)有庫(kù)存,則馬上返回return FAILED;}}return FAILED;}通過(guò) for 循環(huán)限定重試 3 次, 3 次過(guò)后無(wú)論成敗都會(huì)判定為失敗而退出 , 這樣就能避免過(guò)多的重試導(dǎo)致過(guò)多 SQL 被執(zhí)行的問(wèn)題,從而保證數(shù)據(jù)庫(kù)的性能.
同樣的測(cè)試步驟,來(lái)看下統(tǒng)計(jì)結(jié)果
3 萬(wàn)次請(qǐng)求,所有紅包都被搶到了 , 也沒(méi)有發(fā)生超發(fā)現(xiàn)象,這樣就可以消除大量的請(qǐng)求失敗,避免非重入的時(shí)候大量請(qǐng)求失敗的場(chǎng)景。
還能更好?
現(xiàn)在是使用數(shù)據(jù)庫(kù)的情況,有時(shí)候并不想使用數(shù)據(jù)庫(kù)作為搶紅包時(shí)刻的數(shù)據(jù)保存載體,而是選擇性能優(yōu)于數(shù)據(jù)庫(kù)的 Redis。 之前接觸過(guò)了Redis的事務(wù),結(jié)合lua來(lái)實(shí)現(xiàn)搶紅包的功能
Redis-09Redis的基礎(chǔ)事務(wù)
Redis-10Redis的事務(wù)回滾
Redis-11使用 watch 命令監(jiān)控事務(wù)
先看下理論知識(shí),下篇博文一起來(lái)探討使用Redis + lua 實(shí)現(xiàn)搶紅包的功能吧。
代碼
https://github.com/yangshangwei/ssm_redpacket
總結(jié)
以上是生活随笔為你收集整理的高并发-【抢红包案例】之三:使用乐观锁方式修复红包超发的bug的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 高并发-【抢红包案例】之二:使用悲观锁方
- 下一篇: 高并发-【抢红包案例】之四:使用Redi