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