一、事故場景
有次,運營和商家做了個限量搶購活動,限量100件,但活動當天卻超賣了,最終賣出的數(shù)量是160多件。這種超賣是比較嚴重的事故,出現(xiàn)了的話基本上和分布式鎖有關(guān)系。
二、問題分析
項目中的搶購訂單使用了分布式鎖,而分布式鎖的是基于Redis實現(xiàn)的,下面是訂單搶購核心代碼(使用偽代碼講解):
String key = "key:" + request.getSeckillId;
Boolean lockFlag = null;
try {
// 獲取分布式鎖
Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, "val", 10, TimeUnit.SECONDS);
if (lockFlag) {
// HTTP請求調(diào)用其他服務接口
......
// 庫存校驗
Object stock = redisTemplate.opsForHash().get(key+":info", "stock");
assert stock != null;
if (Integer.parseInt(stock.toString()) <= 0) {
// 異常
} else {
// 扣減庫存
redisTemplate.opsForHash().increment(key+":info", "stock", -1);
// 發(fā)送事件,異步生成訂單
}
}
} finally {
// 釋放鎖
if (lockFlag) {
stringRedisTemplate.delete("key");
}
}
代碼中給分布式鎖設(shè)置10秒超時時間來保障業(yè)務邏輯有足夠的執(zhí)行時間,并且也對庫存進行了校驗,整塊邏輯采用 try-finally
語句塊來保證鎖一定會及時釋放,代碼看起來很安全,平時也沒有出現(xiàn)問題。
但問題就在于,中間有調(diào)用其他服務接口,并且在搶購活動開始的一瞬間,因為流量過大,導致調(diào)用所依賴的服務超時而鎖失效。這個時候就會發(fā)生一連串的連鎖反應:一開始獲得鎖的線程還沒有執(zhí)行完畢,鎖就被另一個線程獲取了,而第一個線程執(zhí)行業(yè)務邏輯完畢后執(zhí)行釋放鎖的操作時就會把第二個線程的鎖給釋放了,然后第三個線程再次獲取鎖,就這樣陷入了惡性循環(huán)。
當然,雖然鎖失去了作用,但還有個庫存校驗邏輯,但是偏偏庫存校驗邏輯不是非原子性的,代碼中庫存校驗方式是先從 Redis 中 get 出庫存數(shù)量,然后判斷庫存是否還有,最后再進行庫存的扣減。這種庫存校驗的方式在鎖正常的情況下也是可以的,但一旦鎖失效就是不安全了。
所以,問題的根本原因在于庫存校驗嚴重依賴了分布式鎖最終才導致超賣。
三、解決問題
從上面的分析可以知道,問題就出現(xiàn)在分布式鎖和庫存校驗那里,所以,我們可以對癥下藥。
1、使用相對安全的分布式鎖
相對安全的定義就是:加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。但即使是這樣也無法保障業(yè)務的絕對安全,因為鎖的過期時間始終是有界的,除非不設(shè)置過期時間或者把過期時間設(shè)置的很長,但這樣做也會帶來其他問題,沒有意義。
而 redisTemplate.opsForValue().setIfAbsent()
就是對應 redis 的命令 set key value [EX seconds] [PX milliseconds] [NX|XX]
,這是安全的同時也是原子性的。所以我們只需要實現(xiàn)安全的釋放鎖即可。
要想實現(xiàn)相對安全的釋放分布式鎖,必須依賴 key 的 value 值。在釋放鎖的時候,通過 value 值的唯一性來保證不會勿刪。我們基于 LUA 腳本實現(xiàn)原子性的安全解鎖,封裝方法如下:
public void safedUnLock(String key, String val) {
String luaScript = "local in = ARGV[1] local curr=redis.call('get', KEYS[1]) if in==curr then redis.call('del', KEYS[1]) end return 'OK'";
RedisScript<String> redisScript = RedisScript.of(luaScript);
redisTemplate.execute(redisScript, Collections.singletonList(key), Collections.singleton(val));
}
2、實現(xiàn)安全的庫存校驗
如果我們對于并發(fā)有比較深入的了解的話,會發(fā)現(xiàn)想 Redis 的 get and compare/ read and save 等操作,都是非原子性的。如果要實現(xiàn)原子性,我們可以借助 LUA 腳本來實現(xiàn)。但就我們這個例子中,由于搶購活動一次只能購買一件,所以可以不用基于LUA腳本實現(xiàn)而是基于 redis 本身的原子性:
// redis 操作完數(shù)據(jù)并返回操作結(jié)果的整個過程是原子性的
Long currStock = redisTemplate.opsForHash().increment("key", "stock", -1);
所以,代碼中的庫存校驗是多余的,下面是優(yōu)化后結(jié)果:
String key = "key:" + request.getSeckillId();
String val = UUID.randomUUID().toString();
try {
// 獲取分布式鎖
Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, val, 10, TimeUnit.SECONDS);
if (!lockFlag) {
// 業(yè)務異常
}
// HTTP請求調(diào)用其他服務接口
......
// 庫存校驗,基于redis本身的原子性來保證
Long currStock = stringRedisTemplate.opsForHash().increment(key + ":info", "stock", -1);
if (currStock < 0) { // 說明庫存已經(jīng)扣減完了。
// 業(yè)務異常。
log.error("[搶購下單] 無庫存");
} else {
// 發(fā)送事件,異步生成訂單
}
} finally {
safedUnLock(key, val);
}
四、方案優(yōu)化
1、是否需要分布式鎖
其實可以發(fā)現(xiàn),我們借助于redis本身的原子性扣減庫存,也是可以保證不會超賣的。對的。但是如果沒有這一層鎖的話,那么所有請求進來都會走一遍業(yè)務邏輯,由于依賴了其他系統(tǒng),此時就會造成對其他系統(tǒng)的壓力增大。這會增加的性能損耗和服務不穩(wěn)定性,得不償失。
2、分布式鎖的選型
- 可以使用 Redission 來解決鎖的存續(xù)問題;
- 也可以用 RedLock 來實現(xiàn)分布式鎖。RedLock的可靠性更高,但其代價是犧牲一定的性能。在本場景,這點可靠性的提升遠不如性能的提升帶來的性價比高。如果對于可靠性極高要求的場景,則可以采用 RedLock 來實現(xiàn)。
3、能否用數(shù)據(jù)庫做最終的防護
如果在 Redis 中扣減庫存成功后進行數(shù)據(jù)庫的同步操作,比如使用 set stock = stock - 1 where stock - 1
來保證不會超賣,將這做為最后的保障手段。但在高并發(fā)場景下操作數(shù)據(jù)庫更新的話會有性能損耗,也會給數(shù)據(jù)庫帶來很大壓力,但要論證多大才算大,以我的經(jīng)驗 mysql 簡單字段的并發(fā)寫 1000~
2000 qps是完全扛得住的,這需要壓測來論證,當然如果并發(fā)太高也可以只使用緩存操作,異步機制同步文章來源:http://www.zghlxwxcb.cn/news/detail-481403.html
在性能要求極高的場景下,一般數(shù)據(jù)以緩存為準,支付交易等也是如此,分布式場景下,大多數(shù)場景都是最終一致性。文章來源地址http://www.zghlxwxcb.cn/news/detail-481403.html
到了這里,關(guān)于【開發(fā)經(jīng)驗】之記一次Redis分布式鎖造成的事故的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!