樂觀鎖是一種不會阻塞其他線程并發的機制,它不會使用資料庫的鎖進行實作,它的設計裡面由于不阻塞其他線程,是以并不會引發線程頻繁挂起和恢複,這樣便能夠提高并發能力,是以也有人把它稱為非阻塞鎖,那麼它的機制是怎麼樣的呢?
樂觀鎖使用的是 CAS 原理,是以我們先來讨論 CAS 原理的内容。
CAS 原理概述
在 CAS 原理中,對于多個線程共同的資源,先儲存一個舊值(Old Value),比如進入線程後,查詢目前存量為 100 個紅包,那麼先把舊值儲存為 100,然後經過一定的邏輯處理。
當需要扣減紅包的時候,先比較資料庫目前的值和舊值是否一緻,如果一緻則進行扣減紅包的操作,否則就認為它已經被其他線程修改過了,不再進行操作,CAS 原理流程如圖 1 所示。

圖 1 CAS原理
CAS 原理并不排斥并發,也不獨占資源,隻是線上程開始階段就讀入線程共享資料,儲存為舊值。當處理完邏輯,需要更新資料的時候,會進行一次比較,即比較各個線程目前共享的資料是否和舊值保持一緻。
如果一緻,就開始更新資料;如果不一緻,則認為該資料已經被其他線程修改了,那麼就不再更新資料,可以考慮重試或者放棄。有時候可以重試,這樣就是一個可重入鎖,但是 CAS 原理會有一個問題,那就是 ABA 問題,下面先來讨論一下 ABA 問題。
ABA 問題
對于樂觀鎖而言,我們之前讨論了存在 ABA 的問題,那麼什麼是 ABA 問題呢?下面看看表 1 的兩個線程發生的場景。
表 1 ABA問題
時 刻
線程1
線程2
備 注
T0
——
——
初始化 X=A
T1
讀入X=A
——
——
T2
——
讀入X=A
——
T3
處理線程 1 的業務邏輯
X=B
修改共享變量為 B
T4
處理線程 2 業務邏輯第一段
此時線程1在 X=B 的情況下運作邏輯
T5
X=A
還原變量為 A
T6
因為判斷 X=A,是以更新資料
處理線程 2 業務邏輯第二段
此時線程 1 無法知道線程 2 是否修改過 X,引發業務邏輯錯誤
T7
——
更新資料
——
在 T3 時刻,由于線程 2 修改了 X=B,此時線程 1 的業務邏輯依舊執行,但是到了 T5 時刻,線程 2 又把 X 還原為 A,那麼到了 T6 時刻,使用 CAS 原理的舊值判斷,線程 1 就會認為 X 值沒有被修改過,于是執行了更新。
我們難以判定的是在 T4 時刻,線程 1 在 X=B 的時候,對于線程 1 的業務邏輯是否正确的問題。由于 X 線上程 2 中的值改變的過程為 A->B->A,才引發這樣的問題,是以人們形象地把這類問題稱為 ABA 問題。
ABA 問題的發生,是因為業務邏輯存在回退的可能性。如果加入一個非業務邏輯的屬性,比如在一個資料中加入版本号(version),對于版本号有一個約定,就是隻要修改 X 變量的資料,強制版本号(version)隻能遞增,而不會回退,即使是其他業務資料回退,它也會遞增,那麼 ABA 問題就解決了,如表 2 所示。
表 2 用版本号消除 ABA 問題
時刻
線程1
線程2
備 注
T0
——
——
初始化 X=A,version=0
T1
讀入X=A
——
線程1舊值:version=0
T2
——
讀入X=A
線程2舊值:version=0
T3
處理線程1的業務邏輯
X=B
修改共享變量為 B,version=1
T4
處理線程 2 業務邏輯第一段
——
T5
——
X=A
還原變量為A,version=2
T6
判斷 version == 0,由于線程 2 兩次更新資料,導緻資料 version=2,是以不再更新資料
處理線程 2 業務邏輯第二段
此時線程 1 知道舊值 version 和目前 version 不一緻,将不更新資料
T7
——
更新資料
——
隻是這個 version 變量并不存在什麼業務邏輯,隻是為了記錄更新次數,隻能遞增,幫助我們克服 ABA 問題罷了,有了這些理論,我們就可以開始使用樂觀鎖來完成搶紅包業務了。
樂觀鎖實作搶紅包業務
通過上述的讨論,我們清楚了 CAS 原理如何避免資料的不一緻,如何規避 CAS 原理産生的 ABA 問題,在高并發的應用中使用 CAS 原理,我們稱之為樂觀鎖。
為了順利使用樂觀鎖,需要先在紅包表(T_RED_PACKET)加入一個新的列版本号(version),這個字段在建表的時候已經建了,隻是我們還沒有使用,那麼在 UserRedPacket.xml 的代碼中加入新的方法,代碼如下所示。
update T_RED_PACKET
set stock = stock - 1,
version = version + 1
where id = #{id}
and version = #{version}
注意加粗的代碼,在扣減紅包的時候,增加了對版本号的判斷,其次每次扣減都會對版本号加一,這樣保證每次更新在版本号上有記錄,進而避免 ABA 問題。
對于查詢也不使用 for update 語句,避免鎖的發生,這樣就沒有線程阻塞的問題了,這裡在對應的 UserRedPacketDao 接口上加入對應方法,然後就可以在類 UserRedPacketServiceImpl 中新增方法 grapRedPacketForVersion(需要在其接口 UserRedPacketService 加上同樣的方法),完成對應的邏輯即可,代碼如下所示。
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId, Long userId) {
// 擷取紅包資訊,注意version值
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 ("搶紅包"+ redPacketId);
//插入搶紅包資訊
int result = userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}
//失敗傳回
return FAILED;
}
version 值一開始就儲存到了對象中,當扣減的時候,再次傳遞給 SQL,讓 SQL 對資料庫的 version 和目前線程的舊值 version 進行比較。如果一緻則插入搶紅包的資料,否則就不進行操作。
為了進行測試,在控制器 UserRedPacketController 内建立方法,代碼如下所示。
@RequestMapping(value = "/grapRedPacketForVersion")
@ResponseBody
public Map grapRedPacketForVersion(Long redPacketId, Long userId) {
// 搶紅包
int result = UserRedPacketService.grapRedPacketForVersion(redPacketId, userId);
Map retMap = new HashMap();
boolean flag = result > 0;
retMap.put("success", flag);
retMap.put("message", flag ? "搶紅包成功" : "搶紅包失敗");
return retMap;
}
修改 test.jsp 的 JSP 檔案中 JavaScript 的 POST 請求位址和紅包,id 對應上新增控制器的方法,便可以進行測試,結果如圖 2 所示。
圖 2 樂觀鎖測試結果
從圖 2 中我們看到,經過 3 萬次的搶奪,還會存在大量的紅包,也就是存在大量的因為版本不一緻的原因造成搶紅包失敗的請求,不過這個失敗率太高了一點。有時候會容忍這個失敗,這取決于業務的需要,因為允許使用者自己再發起搶奪紅包,比如微信搶紅包也常常發生錯誤傳回。再次對性能進行測試,如圖 3 所示。
圖 3 測試樂觀鎖性能
33 秒完成所有搶紅包的功能,這個結果和不使用任何鎖一樣,但是還存在大量的紅包因為版本并發的原因而沒有被搶到,而且這個機率比較高,下面解決這個問題。
為了克服這個問題,提高成功率,還會考慮使用重入機制。也就是一旦因為版本原因沒有搶到紅包,則重新嘗試搶紅包,但是過多的重入會造成大量的 SQL 執行,是以目前流行的重入會加入兩種限制,一種是按時間戳的重入,也就是在一定時間戳内(比如說 100 毫秒),不成功的會循環到成功為止,直至超過時間戳,不成功才會退出,傳回失敗。
另外一種是按次數,比如限定 3 次,程式嘗試超過 3 次搶紅包後,就判定請求失效,這樣有助于提高使用者搶紅包的成功率,下面讨論如何重入。
樂觀鎖重入機制
因為樂觀鎖造成大量更新失敗的問題,使用時間戳執行樂觀鎖重入,是一種提高成功率的方法,比如考慮在 100 毫秒内允許重入,把 UserRedPacketServiceImpl 中的方法 grapRedPacketForVersion 修改為以下代碼。
@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 執行,維持系統穩定。樂觀鎖按時間戳重入,如圖 4 所示。
圖 4 樂觀鎖按時間戳重入
從結果來看,之前大量失敗的場景消失了,也沒有超發現象,3 萬次嘗試搶光了所有的紅包,避免了總是失敗的結果,但是有時候時間戳并不是那麼穩定,也會随着系統的空閑或者繁忙導緻重試次數不一。
有時候我們也會考慮限制重試次數,比如 3 次。下面再次改寫 UserRedPacketServiceImpl 中的方法 grapRedPacketForVersion,代碼如下所示。
@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 被執行的問題,進而保證資料庫的性能。為此筆者也進行了測試,如圖 5 所示。
圖 5 樂觀鎖按重試次數重入
顯然效果很好,3 萬次請求,所有紅包都被搶到了,也沒有發生超發現象,這樣就可以消除大量的請求失敗,避免非重入的時候大量請求失敗的場景。
但是現在是使用資料庫的情況,有時候并不想使用資料庫作為搶紅包時刻的資料儲存載體,而是選擇性能優于資料庫的 Redis。
之前我們學習過 Redis 事務,也學習過 Redis 的 Lua 語言,它是一種原子性的操作,為此我們将繞開資料庫用 Redis 處理高并發的請求。