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