高併發場景解決--搶紅包
前言
高併發場景越來越多的應用在網際網路業務上。
本文將重點介紹悲觀鎖、樂觀鎖、Redis分散式鎖在高併發環境下的如何使用以及優缺點分析。
本文相關的學習專案–搶紅包,歡迎Star.
三種方式介紹
悲觀鎖
悲觀鎖,假定會發生併發衝突,在你開始改變此物件之前就將該物件給鎖住,直到更改之後再釋放鎖。
其實,悲觀鎖是一種利用資料庫內部機制提供的鎖的方法,也就是對更新的資料進行加鎖。這樣在併發期間一旦有一個事務持有了資料庫記錄的鎖,其他的執行緒將不能再對資料進行更新了,這就是悲觀鎖的實現方式。
悲觀鎖的實現方式: SQL + FOR UPDATE
<!--悲觀鎖-->
<select id="getRedPacketForUpdate" parameterType="int" resultType="com.demo.entity.RedPacket">
select id, user_id as userId, amount, send_date as sendDate, total, unit_amount as unitAmount,
stock, version, note
from t_red_packet
where
id = #{id} for update
</select>
根據加鎖的粒度,當對主鍵查詢進行加鎖時,意味著將持有對資料庫記錄的行更新鎖(因為這裡使用主鍵查詢,所以只會對行加鎖。如果使用的是非主鍵查詢,要考慮是否對全表加鎖的問題,加鎖後可能引發其他查詢的阻塞〉,那就意味著在高併發的場景下,當一條事務持有了這個更新鎖才能往下操作,其他的執行緒如果要更新這條記錄,都需要等待,這樣就不會出現超發現象引發的資料一致性問題了。
對於悲觀鎖來說,當一條執行緒搶佔了資源後,其他的執行緒將得不到資源,那麼這個時候, CPU 就會將這些得不到資源的執行緒掛起,掛起的執行緒也會消耗CPU 的資源,尤其是在高井發的請求中。
一旦執行緒l 提交了事務,那麼鎖就會被釋放,這個時候被掛起的執行緒就會開始競爭資源,那麼競爭到的執行緒就會被CPU 恢復到執行狀態,繼續執行。
於是頻繁掛起,等待持有鎖執行緒釋放資源,一旦釋放資源後,就開始搶奪,恢復執行緒,周而復始直至所有紅包資源搶完。試想在高併發的過程中,使用悲觀鎖就會造成大量的執行緒被掛起和恢復,這將十分消耗資源,這就是為什麼使用悲觀鎖效能不佳的原因。有些時候,我們也會把悲觀鎖稱為獨佔鎖,畢竟只有一個執行緒可以獨佔這個資源,或者稱為阻塞鎖,因為它會造成其他執行緒的阻塞。無論如何它都會造成併發能力的下降,從而導致CPU頻繁切換執行緒上下文,造成效能低下。為了克服這個問題,提高併發的能力,避免大量執行緒因為阻塞導致CPU進行大量的上下文切換,程式設計大師們提出了樂觀鎖機制,樂觀鎖已經在企業中被大量應用了。
樂觀鎖。
樂觀鎖是一種不會阻塞其他執行緒併發的機制,它不會使用資料庫的鎖進行實現,它的設計裡面由於不阻塞其他執行緒,所以並不會引發執行緒頻繁掛起和恢復,這樣便能夠提高井發能力,所以也有人把它稱為非阻塞鎖。
它的實現思路是,在更新時會判斷其他執行緒在這之前有沒有對資料進行修改,一般用版本號機制。
讀取出資料時,將此版本號一同讀出,之後更新時,對此版本號加一。此時,將提 交資料的版本資料與資料庫表對應記錄的當前版本資訊進行比對,如果提交的資料 版本號大於資料庫表當前版本號,則予以更新,否則認為是過期資料。
<!--樂觀鎖-->
<update id="decreaseRedPacketByVersion">
update t_red_packet
set
stock = stock - 1,
version = version + 1
where
id = #{id}
and version = #{version}
</update>
但是,僅僅這樣是不行的,在高併發的情景下,由於版本不一致的問題,存在大量紅包爭搶失敗的問題。為了提高搶紅包的成功率,我們加入重入機制。
重入機制
- 按時間戳重入(比如100ms時間內)
示例程式碼:
// 記錄開始的時間
long start = System.currentTimeMillis();
// 無限迴圈,當搶包時間超過100ms或者成功時退出
while(true) {
// 迴圈當前時間
long end = System.currentTimeMillis();
// 如果搶紅包的時間已經超過了100ms,就直接返回失敗
if(end - start > 100) {
return FAILED;
}
....
}
- 按次數重入(比如3次機會之內)
示例程式碼:
// 允許使用者重試搶三次紅包
for(int i = 0; i < 3; i++) {
// 獲取紅包資訊, 注意version資訊
RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
// 如果當前的紅包大於0
if(redPacket.getStock() > 0) {
// 再次傳入執行緒儲存的version舊值給SQL判斷,是否有其他執行緒修改過資料
int update = redPacketDao.decreaseRedPacketByVersion(redPacketId, redPacket.getVersion());
// 如果沒有資料更新,說明已經有其他執行緒修改過資料,則繼續搶紅包
if(update == 0) {
continue;
}
....
}
...
}
這樣就可以消除大量的請求失敗,避免非重入的時候大量請求失敗的場景。
Redis
我們知道當資料量非常大時,頻繁的存取資料庫,對於資料庫的壓力是非常大的。這時我們可以採用快取技術,利用Redis的輕量級、便捷、快速的機制解決高併發問題。
通過流程圖,我們看到整個流程與資料庫互動只有兩次,使用者搶紅包操作的過程其實都是在Redis中完成的,這顯然提高了效率。
但是如何解決資料不一致帶來的超發問題呢?
分散式鎖
通俗的講,分散式鎖就是說,快取中存入一個值(key-value),誰拿到這個值誰就可以執行程式碼。
在併發環境下,我們通過鎖住當前的庫存,來確保資料的一致性。知道資訊存入快取、庫存-1之後,我們再重新釋放鎖。
為了防止死鎖的發生,可以設定鎖的過期時間來解決。
- 加鎖
// 先判斷快取中是否存在值,沒有返回true,並儲存value,已經有值就不儲存,返回false
if(stringRedisTemplate.opsForValue().setIfAbsent(key, value)) {
return true;
}
String curentValue = stringRedisTemplate.opsForValue().get(key);
// 如果鎖過期
if(!StringUtils.isEmpty(curentValue) && Long.parseLong(curentValue) < System.currentTimeMillis()) {
// getAndSet設定新值,並返回舊值
// 獲取上一個鎖的時間
String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, value);
if(!StringUtils.isEmpty(curentValue) && oldValue.equals(curentValue)) {
return true;
}
}
return false;
- 解鎖
try {
String currentValue = stringRedisTemplate.opsForValue().get(key);
if(!StringUtils.isEmpty(currentValue) && currentValue.equals(value)) {
stringRedisTemplate.opsForValue().getOperations().delete(key);
}
} catch (Exception e) {
logger.error("RedisLock 解鎖異常:" + e.getMessage());
}
總結
悲觀鎖使用了資料庫的鎖機制,可以消除資料不一致性,對於開發者而言會十分簡單,但是,使用悲觀鎖後,資料庫的效能有所下降,因為大量的執行緒都會被阻塞,而且需要有大量的恢復過程,需要進一步改變演算法以提高系統的井發能力。
使用樂觀鎖有助於提高併發效能,但是由於版本號衝突,樂觀鎖導致多次請求服務失敗的概率大大提高,而我們通過重入(按時間戳或者按次數限定)來提高成功的概率,這樣對於樂觀鎖而言實現的方式就相對複雜了,其效能也會隨著版本號衝突的概率提升而提升,並不穩定。使用樂觀鎖的弊端在於, 導致大量的SQL被執行,對於資料庫的效能要求較高,容易引起資料庫效能的瓶頸,而且對於開發還要考慮重入機制,從而導致開發難度加大。
使用Redis去實現高併發,消除了資料不一致性,並且在整個過程中儘量少的涉及資料庫。但是這樣使用的風險在於Redis的不穩定性,因為其事務和儲存都存在不穩定的因素,所以更多的時候,建議使用獨立Redis伺服器做高併發業務,一方面可以提高Redis的效能,另一方面即使在高併發的場合,Redis伺服器巖機也不會影響現有的其他業務,同時也可以使用備機等裝置提高系統的高可用,保證網站的安全穩定。
以上討論了3 種方式實現高併發業務技術的利弊,妥善規避風險,同時保證系統的高可用和高效是值得每一位開發者思考的問題。