Redis分布式鎖及安全問題解決
一、為什么需要分布式鎖
單機(jī)鎖: 多個線程同時改變一個變量時,需要對變量或者代碼塊做同步從而保證串行修改變量.
多機(jī)系統(tǒng): 存在多機(jī)器多請求同時對同一個共享資源進(jìn)行修改,如果不加以限制,將導(dǎo)致數(shù)據(jù)錯亂和數(shù)據(jù)不一致性. 比如: 庫存超賣、抽獎多發(fā)、券多發(fā)放、訂單重復(fù)提交...
二、常見的分布式鎖
實現(xiàn)方式 | 優(yōu)點 | 缺點 | 應(yīng)用場景 |
MySQL數(shù)據(jù)庫表 | 易于理解/易于實現(xiàn) | 容易出現(xiàn)單點故障、死鎖性能低/可靠性低 | 適用于并發(fā)量低、 性能要求低的場景 |
Redis分布式鎖 | 性能高/易于實現(xiàn)可跨集群部署,無單點故障 | 鎖失效時間的控制不穩(wěn)定穩(wěn)定性低于 ZooKeeper | 適用于高并發(fā)、高性能場景 |
ZooKeeper 分布式鎖 | 無單點故障/可靠性高不可重入/無死鎖問題 | 實現(xiàn)復(fù)雜性能低于緩存分布式鎖 | 適用于大部分分布式場景, 除對性能要求極高的場景 |
三、 用Redis實現(xiàn)一個分布式鎖
3.1 SETNX
SET lock 1 NX
String buyTicket() { Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1"); if (lock) { try { int stockNum = byTicketMapper.selectStockNum(); if (stockNum > 0) { //TODO by ticket process.... byTicketMapper.reduceStock(); return "SUCCESS"; } return "FAILED"; }finally { redisTemplate.delete("lock"); } } return "OOPS...PLEASE TRY LATTER"; }
Java代碼很容易看出, 假如執(zhí)行了加鎖后程序出現(xiàn)宕機(jī), 執(zhí)行不到finally語句塊里的解鎖, 就出會有死鎖問題. 為了解決死鎖, 很容易就想到給鎖設(shè)置一個過期時間.
3.2 設(shè)置鎖過期時間和唯一ID
設(shè)置key時同時設(shè)置過期時間:
SET lock 1 NX EX 30
Java代碼:
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "1", Duration.ofSeconds(10L));
但這會導(dǎo)致更嚴(yán)重的錯刪鎖問題, 比如某個線程1加鎖后, 執(zhí)行業(yè)務(wù)邏輯比較慢, 鎖過期自動釋放了, 此時線程2競爭加鎖成功, 而線程1執(zhí)行了刪除鎖, 以此類推, 相當(dāng)于鎖失效.
改進(jìn): 設(shè)置線程UUID, 并且用lua腳本保證GET和DEL原子性操作, 防止刪錯key
String buyTicket() { String uuid = UUID.randomUUID().toString(); Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, Duration.ofSeconds(10L)); if (lock) { try { int stockNum = byTicketMapper.selectStockNum(); if (stockNum > 0) { //TODO by ticket process.... byTicketMapper.reduceStock(); return "SUCCESS"; } return "FAILED"; } finally { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; this.redisTemplate.execute(new DefaultRedisScript<>(script), Arrays.asList("lock"), uuid); } } return "OOPS...PLEASE TRY LATTER"; }
看起來好像還不錯, 但是依然有過期時間無法完全匹配實際需求的問題:
太短 -> 鎖失效無法保證程序正確處理業(yè)務(wù)
太長 -> 異常流程過度占有鎖導(dǎo)致資源浪費
有更好的解決方案嗎? 比如開啟一個后臺線程, 定時檢查主線程是否持有鎖(即未完成操作資源), 給它自動延長鎖過期時間. 幸運的javaer 已經(jīng)Redisson庫封裝好了這些操作.
3.3 Redisson
看門狗機(jī)制: 加一個后臺線程定時檢查鎖,自動續(xù)過期時間
Java代碼
String buyTicket() { RLock lock = redissonClient.getLock("lock"); try { if (lock.tryLock(30,TimeUnit.SECONDS)) { int stockNum = byTicketMapper.selectStockNum(); if (stockNum > 0) { //TODO by ticket process.... byTicketMapper.reduceStock(); return "SUCCESS"; } return "FAILED"; } }catch (InterruptedException e){ log.error("Try Lock Error:{}",e.getMessage()); }finally { lock.unlock(); } return "OOPS...PLEASE TRY LATTER"; }
對于單機(jī)版的redis至此已經(jīng)是很好的方案了, 然而現(xiàn)實中大多數(shù)使用的是集群redis...
四、 主從同步對分布式鎖的影響
高并發(fā)場景主從切換鎖失效: 試想一下這樣的場景, 主節(jié)點加鎖成功, 沒有同步到從節(jié)點時主節(jié)點宕機(jī), 此時從節(jié)點選舉出新的主節(jié)點, 它就丟失了還沒同步的鎖, 此時其他客戶端向新的主節(jié)點請求加鎖會成功, 導(dǎo)致沖突.
4.1 Redlock
Redlock 的方案官網(wǎng)解釋: 既然主從架構(gòu)有問題, 那就部署多個主庫實例.
Redlock整體流程:
- 客戶端在多個 Redis 實例上申請加鎖
- 必須保證大多數(shù)節(jié)點(超過半數(shù))加鎖成功
- 大多數(shù)節(jié)點加鎖的總耗時,要小于鎖設(shè)置的過期時間
- 釋放鎖,要向全部節(jié)點發(fā)起釋放鎖請求
疑問:
1 ) 假如有3個客戶端競爭同一資源, 向5個Redis請求獲取鎖, 容易出現(xiàn)沒有獲勝者的情況.
-> redis官方: 多路復(fù)用 以及 沒有獲得過半數(shù)鎖的客戶端盡快釋放鎖
2) 某個主節(jié)點宕機(jī)時可能出現(xiàn)鎖安全性問題. 比如: 當(dāng)Redis持久化策略為AOF使用appendfsync=everysec即每秒fsync一次, 故障時會丟失1秒的數(shù)據(jù), 也就是丟鎖. 當(dāng)該節(jié)點恢復(fù)時, 其他客戶端來獲取鎖成功
-> redis官方: 在崩潰后使實例不可用, 至少比最大 TTL多一點, 保證崩潰時的鎖在所有節(jié)點都自動失效. [損失了可用性]
RedLock的爭論:
針對RedLock的方案, 業(yè)界大佬Martin Kleppmann專門寫過一篇文章分析它的效率, 正確性和NPC問題 , redis的作者也一一反駁, 有興趣可以看文章末尾參考資料.
NPC問題:
Clock Drift時鐘漂移
-> redis作者: 與鎖的自動釋放時間相比,誤差幅度很小
Network Delay網(wǎng)絡(luò)延遲
Process Pause進(jìn)程暫停(GC)
-> redis作者: 第3步已經(jīng)考慮了以上問題, 當(dāng)出現(xiàn) 加鎖總耗時 > 鎖過期時間 就會認(rèn)為加鎖失敗, 而在步驟3之后出現(xiàn)GC或ND問題, 其他鎖服務(wù)比如zookeeper也這樣.
通過以上爭論, 我們看到redlock確實存在一些缺點:
1) 性能折損, 且無法做到100%安全的分布式鎖
2) 不能橫向擴(kuò)容: 如果要提升高可用, 只能增加更多單節(jié)點, 每個單節(jié)點不能再加從節(jié)點
4.2 Fencing Token
針對主從架構(gòu)下的分布式鎖, 前面提到的Martin Kleppmann, 在它的文章里提出了"fencing token"的解決方案:
客戶端在獲取鎖時,鎖服務(wù)可以提供一個「遞增」的 token
客戶端拿著這個 token 去操作共享資源
共享資源可以根據(jù) token 拒絕「后來者」的請求
這個方案要求共享資源具備"互斥"能力, 而且在分布式環(huán)境下做嚴(yán)格自增的token無疑也是個難題.
有沒有其他方案呢, 在找資料的過程中, 我發(fā)現(xiàn)Redisson較新的版本(我用的是3.25.0)提供了FencedLock.
4.3 FencedLock
它的底層獲取鎖的同時, 使用 incr 命令從redis獲取自增的token:
但是在redis集群環(huán)境下, 這樣使用incr會有可靠性問題. 當(dāng)多個客戶端同時調(diào)用incr命令時,可能會出現(xiàn)并發(fā)沖突,導(dǎo)致數(shù)據(jù)不一致.
雖然redisson的官方文檔說RedLock已棄用,推薦使用Lock or FencedLock, 但如前述我覺得上述FencedLock會有可靠性問題. (如果大佬們有其他見解, 請賜教, 感激~)
4.4 兜底鎖
對安全性要求比較高的場景, 也許我們可以參考fencing token的思路在資源層再做一個兜底鎖, 比如MySQL:
在操作資源前先標(biāo)記token, 再(檢查+修改)共享資源
UPDATE table T SET val = $new_val WHERE id = $id AND current_token = $token_value;
兩種思路結(jié)合我們就擁有了一個更安全可靠的分布式鎖體系:
- redis分布式鎖: 作用于上層, 完成了大多數(shù)"互斥", 把大部分請求擋在上層, 減輕了操作資源層的壓力.
- MySQL兜底鎖: 通過版本號或者插入鎖的方式實現(xiàn)"互斥", 避免極端情況下的并發(fā)沖突, 由于上層已經(jīng)擋住了大部分請求, MySQL鎖也能很好的避開它本身的缺點.
五、總結(jié)
1) 沒有一把完美的分布式鎖, 在設(shè)計分布式鎖的時候, 需要多角度考慮它是否滿足了以下特性:
- 獨占排他互斥
- 防死鎖
- 保證原子性
- 正確性
- 可重入
- 容錯分布式
2) 如果是要求數(shù)據(jù)絕對正確的業(yè)務(wù), 資源層要做好兜底。
到此這篇關(guān)于Redis分布式鎖及安全問題解決的文章就介紹到這了,更多相關(guān)Redis分布式鎖內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
利用redisson快速實現(xiàn)自定義限流注解(接口防刷)
利用redis的有序集合即Sorted?Set數(shù)據(jù)結(jié)構(gòu),構(gòu)造一個令牌桶來實施限流,而redisson已經(jīng)幫我們封裝成了RRateLimiter,通過redisson,即可快速實現(xiàn)我們的目標(biāo),這篇文章主要介紹了利用redisson快速實現(xiàn)自定義限流注解,需要的朋友可以參考下2024-07-07Redis不是一直號稱單線程效率也很高嗎,為什么又采用多線程了?
這篇文章主要介紹了Redis不是一直號稱單線程效率也很高嗎,為什么又采用多線程了的相關(guān)資料,本文給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2021-03-03redis由于目標(biāo)計算機(jī)積極拒絕,無法連接的解決
這篇文章主要介紹了redis由于目標(biāo)計算機(jī)積極拒絕,無法連接的解決方案,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2023-07-07