搶紅包案例分析以及程式碼實現(三)
前文回顧
接下來我們使用樂觀鎖的方式來修復紅包超發的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: 更新記錄條數
*/
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>
初始化資料,啟動應用測試
一致性資料統計:
經過 3 萬次的搶奪,一共搶到了7521個紅包,剩餘12479個紅包, 也就是存在大量的因為版本不一致的原因造成搶紅包失敗的請求。 這失敗率太高了。。
有時候會容忍這個失敗,這取決於業務的需要,因為允許使用者自己再發起搶奪紅包。
效能資料統計:
解決因version導致失敗問題
為提高成功率,可以考慮使用重入機制 。也就是一旦因為版本原因沒有搶到紅包,則重新嘗試搶紅包,但是過多的重入會造成大量的 SQL 執行,所以目前流行的重入會加入兩種限制:
一種是按時間戳的重入,也就是在一定時間戳內(比如說 100毫秒),不成功的會迴圈到成功為止,直至超過時間戳,不成功才會退出,返回失敗。
一種是按次數,比如限定 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 執行,維持系統穩定。
初始化資料後,進行測試
從結果來看,之前大量失敗的場景消失了,也沒有超發現象,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 被執行的問題,從而保證資料庫的效能。
同樣的測試步驟,來看下統計結果
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乾貨分享
掃描上方二維碼獲取更多Java乾貨
相關文章
- 搶紅包案例分析以及程式碼實現
- 搶紅包案例分析以及程式碼實現(二)
- 搶紅包案例分析以及程式碼實現(四)
- C#實現搶紅包演算法C#演算法
- Android 輔助功能 -搶紅包(三)Android
- Jmeter5.0 搶紅包併發操作案例JMeter
- 高併發-「搶紅包案例」之一:SSM環境搭建及復現紅包超發問題SSM
- 基於 Redis 實現基本搶紅包演算法Redis演算法
- Redis秒殺實戰-微信搶紅包-秒殺庫存,附案例原始碼(Jmeter壓測)Redis原始碼JMeter
- golang實現二倍均值演算法和搶紅包Golang演算法
- 層次分析法模型原理以及程式碼實現模型
- redis實現分散式鎖(包含程式碼以及分析利弊)Redis分散式
- Canvas實現放大鏡效果完整案例分析(附程式碼)Canvas
- Android 輔助功能 -搶紅包Android
- 2020-12-28 搶紅包
- 微信小程式搶紅包高併發設計微信小程式
- Linklist程式碼實現以及程式碼解讀
- 拼多多在哪裡可以搶紅包?紅包如何使用呢?
- Android 輔助功能 -搶紅包(二)Android
- Android通過輔助功能實現搶微信紅包原理簡單介紹Android
- app直播原始碼如何實現直播間紅包功能APP原始碼
- 【演算法】手撕紅黑樹(上)—— 基本性質以及插入實現(附帶程式碼實現)演算法
- L2-009 搶紅包【模擬】
- 2018 新年活動系統 — 搶紅包
- 紅黑樹的原理以及實現
- OpenMP task construct 實現原理以及原始碼分析Struct原始碼
- OpenMP Sections Construct 實現原理以及原始碼分析Struct原始碼
- Python教你全自動搶微信紅包Python
- 如何設計一個搶紅包系統
- 【SpringMVC】RESTFul簡介以及案例實現SpringMVCREST
- 從【抓包分析】到【程式碼實戰】,實現下載某破站影片(附原始碼)原始碼
- 用js模擬實現微信搶紅包的演算法,並說明你的思路JS演算法
- 吸粉紅包驚現-會吸粉的紅包,才是好紅包!
- 紅黑樹核心程式碼分析(JAVA)Java
- Kafka ACL實現架構以及實操案例剖析Kafka架構
- 【整合學習】:Stacking原理以及Python程式碼實現Python
- 微信小程式實現商城案例(賦原始碼)微信小程式原始碼
- Java實現網路爬蟲 案例程式碼Java爬蟲