日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問(wèn) 生活随笔!

生活随笔

當(dāng)前位置: 首頁(yè) > 编程资源 > 编程问答 >内容正文

编程问答

高并发-【抢红包案例】之三:使用乐观锁方式修复红包超发的bug

發(fā)布時(shí)間:2025/3/21 编程问答 26 豆豆
生活随笔 收集整理的這篇文章主要介紹了 高并发-【抢红包案例】之三:使用乐观锁方式修复红包超发的bug 小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,幫大家做個(gè)參考.

文章目錄

  • 導(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 原理中,對(duì)于多個(gè)線程共同的資源,先保存一個(gè)舊(Old Value),比如進(jìn)入線程后,查詢當(dāng)前存量為 100 個(gè)紅包,那么先把舊值保存為 100,然后經(jīng)過(guò)一定的邏輯處理。
  • 當(dāng)需要扣減紅包的時(shí)候,先比較數(shù)據(jù)庫(kù)當(dāng)前的值和舊值是否一致,如果一致則進(jìn)行扣減紅包的操作,否則就認(rèn)為它已經(jīng)被其他線程修改過(guò)了,不再進(jìn)行操作。
  • 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

    <%@ page language="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"> <html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><title>參數(shù)</title><!-- 加載Query文件--><script type="text/javascript" src="https://code.jquery.com/jquery-3.2.0.js"></script><script type="text/javascript">$(document).ready(function () {//模擬30000個(gè)異步請(qǐng)求,進(jìn)行并發(fā)var max = 30000;for (var i = 1; i <= max; i++) {//jQuery的post請(qǐng)求,請(qǐng)注意這是異步請(qǐng)求$.post({//請(qǐng)求搶id為1的紅包//根據(jù)自己請(qǐng)求修改對(duì)應(yīng)的url和大紅包編號(hào)url: "./userRedPacket/grapRedPacketForVersion.do?redPacketId=1&userId=" + i,//成功后的方法success: function (result) {}});}});</script></head><body></body> </html>

    初始化數(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ì)加入兩種限制

  • 一種是按時(shí)間戳的重入,也就是在一定時(shí)間戳內(nèi)(比如說(shuō) 100毫秒),不成功的會(huì)循環(huán)到成功為止,直至超過(guò)時(shí)間戳,不成功才會(huì)退出,返回失敗。
  • 一種是按次數(shù),比如限定 3 次,程序嘗試超過(guò) 3 次搶紅包后,就判定請(qǐng)求失效,這樣有助于提高用戶搶紅包的成功率。

  • 樂(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)題。

    如果覺(jué)得生活随笔網(wǎng)站內(nèi)容還不錯(cuò),歡迎將生活随笔推薦給好友。