搶紅包案例分析以及程式碼實現(三)

Java團長_發表於2018-11-11

前文回顧

接下來我們使用樂觀鎖的方式來修復紅包超發的bug


樂觀鎖

樂觀鎖是一種不會阻塞其他執行緒併發的機制,它不會使用資料庫的鎖進行實現,它的設計裡面由於不阻塞其他執行緒,所以並不會引發執行緒頻繁掛起和恢復,這樣便能夠提高併發能力,也稱之為為非阻塞鎖。 樂觀鎖使用的是 CAS原理。


CAS 原理

  1. 在 CAS 原理中,對於多個執行緒共同的資源,先儲存一箇舊(Old Value),比如進入執行緒後,查詢當前存量為 100 個紅包,那麼先把舊值儲存為 100,然後經過一定的邏輯處理。

  2. 當需要扣減紅包的時候,先比較資料庫當前的值和舊值是否一致,如果一致則進行扣減紅包的操作,否則就認為它已經被其他執行緒修改過了,不再進行操作。


CAS 原理流程如下:

640

CAS 原理並不排斥併發,也不獨佔資源,只是線上程開始階段就讀入執行緒共享資料,儲存為舊值。當處理完邏輯,需要更新資料的時候,會進行一次 比較,即比較各個執行緒當前共享的資料是否和舊值保持一致。如果一致,就開始更新資料;如果不一致,則認為該前共享的資料是否和舊值保持一致。如果一致,就開始更新資料;如果不一致,則認為該重試,這樣就是一個可重入鎖,但是 CAS 原理會有一個問題,那就是 ABA 問題,我們先來看下ABA問題。


ABA問題

640

在處理複雜運算的時候,被執行緒 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: 更新記錄條數
  */

 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 + 1
   where id = #{id}
   and version = #{version}
 </update>


在扣減紅包的時候 , 增加了對版本號的判斷,其次每次扣減都會對版本號加一,這樣保證每次更新在版本號上有記錄 , 從而避免 ABA 問題

對於查詢也不使用 for update 語句,避免鎖的發生,這樣就沒有執行緒阻塞的問題了。然後就可以在類 UserRedPacketServic介面中新增方法 grapRedPacketForVersion,然後在其實現類中完成對應的邏輯即可。


UserRedPacketServic介面及實現類的改造

/**
  * 儲存搶紅包資訊. 樂觀鎖的方式
  *
  * @param redPacketId
  *            紅包編號
  * @param userId
  *            搶紅包使用者編號
  * @return 影響記錄數.
  */

 public int grapRedPacketForVersion(Long redPacketId, Long userId);


實現類

/**
  * 樂觀鎖,無重入
  * */

 @Override
 @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
 public int grapRedPacketForVersion(Long redPacketId, Long userId) {
   // 獲取紅包資訊
   RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
   // 當前小紅包庫存大於0
   if (redPacket.getStock() > 0) {
     // 再次傳入執行緒儲存的version舊值給SQL判斷,是否有其他執行緒修改過資料
     int update = redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
     // 如果沒有資料更新,則說明其他執行緒已經修改過資料,則重新搶奪
     if (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 對資料庫的 version 和當前執行緒的舊值 version 進行比較。如果一致則插入搶紅包的資料,否則就不進行操作。


Controller層新增路由方法

為了方便區分測試,在控制器 UserRedPacketController 內新建對映

@RequestMapping(value = "/grapRedPacketForVersion")
 @ResponseBody
 public 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層

為了區分,新建個jsp吧 , 注意POST 請求地址和紅包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>引數</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個非同步請求,進行併發
             var max = 30000;
             for (var i = 1; i <= max; i++) {
                 //jQuery的post請求,請注意這是非同步請求
                 $.post({
                     //請求搶id為1的紅包
                     //根據自己請求修改對應的url和大紅包編號
                     url: "./userRedPacket/grapRedPacketForVersion.do?redPacketId=1&userId=" + i,
                     //成功後的方法
                     success: function (result) {
                     }
                 });
             }
         });
       
</script>
   </head>
   <body>
   </body>
</html>




初始化資料,啟動應用測試

一致性資料統計:

640

經過 3 萬次的搶奪,一共搶到了7521個紅包,剩餘12479個紅包, 也就是存在大量的因為版本不一致的原因造成搶紅包失敗的請求。 這失敗率太高了。。

有時候會容忍這個失敗,這取決於業務的需要,因為允許使用者自己再發起搶奪紅包。


效能資料統計:
640


解決因version導致失敗問題

為提高成功率,可以考慮使用重入機制 。也就是一旦因為版本原因沒有搶到紅包,則重新嘗試搶紅包,但是過多的重入會造成大量的 SQL 執行,所以目前流行的重入會加入兩種限制:

  1. 一種是按時間戳的重入,也就是在一定時間戳內(比如說 100毫秒),不成功的會迴圈到成功為止,直至超過時間戳,不成功才會退出,返回失敗。

  2. 一種是按次數,比如限定 3 次,程式嘗試超過 3 次搶紅包後,就判定請求失效,這樣有助於提高使用者搶紅包的成功率。


樂觀鎖重入機制-按時間戳重入

因為樂觀鎖造成大量更新失敗的問題,使用時間戳執行樂觀鎖重入,是一種提高成功率的方法,比如考慮在 100 毫秒內允許重入,把 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();
   // 無限迴圈,等待成功或者時間滿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 執行,維持系統穩定。

初始化資料後,進行測試

640

640

從結果來看,之前大量失敗的場景消失了,也沒有超發現象,3 萬次嘗試搶光了所有的紅包,避免了總是失敗的結果,但是有時候時間戳並不是那麼穩定,也會隨著系統的空閒或者繁忙導致重試次數不一。有時候我們也會考慮、限制重試次數,比如 3 次,如下所示:


樂觀鎖重入機制-按次數重入

/**
  *
  *
  * @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 被執行的問題,從而保證資料庫的效能。

同樣的測試步驟,來看下統計結果

640

640

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

PS:如果覺得我的分享不錯,歡迎大家隨手點贊、轉發。


Java團長

專注於Java乾貨分享

640

掃描上方二維碼獲取更多Java乾貨

相關文章