對不起,網(wǎng)上找的Redis分布式鎖都有漏洞!
基于 Redis 的分布式鎖對大家來說并不陌生,可是你的分布式鎖有失敗的時候嗎?在失敗的時候可曾懷疑過你在用的分布式鎖真的靠譜嗎?以下是結合自己的踩坑經(jīng)驗總結的一些經(jīng)驗之談。

你真的需要分布式鎖嗎?
用到分布式鎖說明遇到了多個進程共同訪問同一個資源的問題。
一般是在兩個場景下會防止對同一個資源的重復訪問:
提高效率。比如多個節(jié)點計算同一批任務,如果某個任務已經(jīng)有節(jié)點在計算了,那其他節(jié)點就不用重復計算了,以免浪費計算資源。不過重復計算也沒事,不會造成其他更大的損失。也就是允許偶爾的失敗。
保證正確性。這種情況對鎖的要求就很高了,如果重復計算,會對正確性造成影響。這種不允許失敗。
這些實現(xiàn)分布式鎖的基礎設施出問題了,也會影響業(yè)務,所以在使用分布式鎖前可以考慮下是否可以不用加鎖的方式實現(xiàn)?
不過這個不在本文的討論范圍內(nèi),本文假設加鎖的需求是合理的,并且偏向于上面的第二種情況,為什么是偏向?因為不存在 100% 靠譜的分布式鎖,看完下面的內(nèi)容就明白了。
從一個簡單的分布式鎖實現(xiàn)說起
分布式鎖的 Redis 實現(xiàn)很常見,自己實現(xiàn)和使用第三方庫都很簡單,至少看上去是這樣的,這里就介紹一個最簡單靠譜的 Redis 實現(xiàn)。
最簡單的實現(xiàn)
實現(xiàn)很經(jīng)典了,這里只提兩個要點:
加鎖和解鎖的鎖必須是同一個,常見的解決方案是給每個鎖一個鑰匙(唯一 ID),加鎖時生成,解鎖時判斷。
不能讓一個資源永久加鎖。常見的解決方案是給一個鎖的過期時間。當然了還有其他方案,后面再說。
加鎖:
public?static?boolean?tryLock(String?key,?String?uniqueId,?int?seconds)?{
????return?"OK".equals(jedis.set(key,?uniqueId,?"NX",?"EX",?seconds));
}
這里調(diào)用了 SET key value PX milliseoncds NX,不明白這個命令的可以參考 SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]:
https://redis.io/commands/set
解鎖:
public?static?boolean?releaseLock(String?key,?String?uniqueId)?{
????String?luaScript?=?"if?redis.call('get',?KEYS[1])?==?ARGV[1]?then?"?+
????????????"return?redis.call('del',?KEYS[1])?else?return?0?end";
????return?jedis.eval(
????????luaScript,?
????????Collections.singletonList(key),?
????????Collections.singletonList(uniqueId)
????).equals(1L);
}
這段實現(xiàn)的精髓在那個簡單的 Lua 腳本上,先判斷唯一 ID 是否相等再操作。
靠譜嗎?
這樣的實現(xiàn)有什么問題呢?
單點問題。上面的實現(xiàn)只要一個 Master 節(jié)點就能搞定,這里的單點指的是單 Master,就算是個集群,如果加鎖成功后,鎖從 Master 復制到 Slave 的時候掛了,也是會出現(xiàn)同一資源被多個 Client 加鎖的。
執(zhí)行時間超過了鎖的過期時間。上面寫到為了不出現(xiàn)一直上鎖的情況,加了一個兜底的過期時間,時間到了鎖自動釋放,但是,如果在這期間任務并沒有做完怎么辦?由于 GC 或者網(wǎng)絡延遲導致的任務時間變長,很難保證任務一定能在鎖的過期時間內(nèi)完成。
Redlock?算法
對于第一個單點問題,順著 Redis 的思路,接下來想到的肯定是 Redlock 了。
Redlock 為了解決單機的問題,需要多個(大于 2)Redis 的 Master 節(jié)點,多個 Master 節(jié)點互相獨立,沒有數(shù)據(jù)同步。
Redlock 的實現(xiàn)如下:
①獲取當前時間。
②依次獲取 N 個節(jié)點的鎖。每個節(jié)點加鎖的實現(xiàn)方式同上。這里有個細節(jié),就是每次獲取鎖的時候的過期時間都不同,需要減去之前獲取鎖的操作的耗時,
比如傳入的鎖的過期時間為 500ms,獲取第一個節(jié)點的鎖花了 1ms,那么第一個節(jié)點的鎖的過期時間就是 499ms;獲取第二個節(jié)點的鎖花了 2ms,那么第二個節(jié)點的鎖的過期時間就是 497ms。
如果鎖的過期時間小于等于 0 了,說明整個獲取鎖的操作超時了,整個操作失敗。
③判斷是否獲取鎖成功。如果 Client 在上述步驟中獲取到了(N/2+1)個節(jié)點鎖,并且每個鎖的過期時間都是大于 0 的,則獲取鎖成功,否則失敗。失敗時釋放鎖。
④釋放鎖。對所有節(jié)點發(fā)送釋放鎖的指令,每個節(jié)點的實現(xiàn)邏輯和上面的簡單實現(xiàn)一樣。
為什么要對所有節(jié)點操作?因為分布式場景下從一個節(jié)點獲取鎖失敗不代表在那個節(jié)點上加速失敗,可能實際上加鎖已經(jīng)成功了,但是返回時因為網(wǎng)絡抖動超時了。
以上就是大家常見的 Redlock 實現(xiàn)的描述了,一眼看上去就是簡單版本的多 Master 版本,如果真是這樣就太簡單了,接下來分析下這個算法在各個場景下是怎樣被玩壞的。
分布式鎖的坑
高并發(fā)場景下的問題
以下問題不是說在并發(fā)不高的場景下不容易出現(xiàn),只是在高并發(fā)場景下出現(xiàn)的概率更高些而已。
性能問題來自于以下兩方面:
①獲取鎖的時間上。如果 Redlock 運用在高并發(fā)的場景下,存在 N 個 Master 節(jié)點,一個一個去請求,耗時會比較長,從而影響性能。
這個好解決,通過上面描述不難發(fā)現(xiàn),從多個節(jié)點獲取鎖的操作并不是一個同步操作,可以是異步操作,這樣可以多個節(jié)點同時獲取。
即使是并行處理的,還是得預估好獲取鎖的時間,保證鎖的 TTL>獲取鎖的時間+任務處理時間。
②被加鎖的資源太大。加鎖的方案本身就是會為了正確性而犧牲并發(fā)的,犧牲和資源大小成正比,這個時候可以考慮對資源做拆分。
拆分的方式有如下兩種:
①從業(yè)務上將鎖住的資源拆分成多段,每段分開加鎖。比如,我要對一個商戶做若干個操作,操作前要鎖住這個商戶,這時我可以將若干個操作拆成多個獨立的步驟分開加鎖,提高并發(fā)。
如果不加處理,很難保證同一時刻兩個線程加鎖的商戶沒有重疊,這時可以按一個維度。
比如某個標簽,對商戶進行分桶,然后一個任務處理一個分桶,處理完這個分桶再處理下一個分桶,減少競爭。
重試的問題:無論是簡單實現(xiàn)還是 Redlock 實現(xiàn),都會有重試的邏輯。
如果直接按上面的算法實現(xiàn),是會存在多個 Client 幾乎在同一時刻獲取同一個鎖,然后每個 Client 都鎖住了部分節(jié)點,但是沒有一個 Client 獲取大多數(shù)節(jié)點的情況。
解決的方案也很常見,在重試的時候讓多個節(jié)點錯開,錯開的方式就是在重試時間中加一個隨機時間。這樣并不能根治這個問題,但是可以有效緩解問題,親試有效。
節(jié)點宕機
對于單 Master 節(jié)點且沒有做持久化的場景,宕機就掛了,這個就必須在實現(xiàn)上支持重復操作,自己做好冪等。
對于多 Master 的場景,比如 Redlock,我們來看這樣一個場景:
假設有 5 個 Redis 的節(jié)點:A、B、C、D、E,沒有做持久化。
Client1 從 A、B、C 這3 個節(jié)點獲取鎖成功,那么 client1 獲取鎖成功。
節(jié)點 C 掛了。
Client2 從 C、D、E 獲取鎖成功,client2 也獲取鎖成功,那么在同一時刻 Client1 和 Client2 同時獲取鎖,Redlock 被玩壞了。
另一個方案是延遲啟動。就是一個節(jié)點掛了修復后,不立即加入,而是等待一段時間再加入,等待時間要大于宕機那一刻所有鎖的最大 TTL。
但這個方案依然不能解決問題,如果在上述步驟 3 中 B 和 C 都掛了呢,那么只剩 A、D、E 三個節(jié)點,從 D 和 E 獲取鎖成功就可以了,還是會出問題。
那么只能增加 Master 節(jié)點的總量,緩解這個問題了。增加 Master 節(jié)點會提高穩(wěn)定性,但是也增加了成本,需要在兩者之間權衡。
任務執(zhí)行時間超過鎖的 TTL
之前產(chǎn)線上出現(xiàn)過因為網(wǎng)絡延遲導致任務的執(zhí)行時間遠超預期,鎖過期,被多個線程執(zhí)行的情況。
這個問題是所有分布式鎖都要面臨的問題,包括基于 Zookeeper 和 DB 實現(xiàn)的分布式鎖,這是鎖過期了和 Client 不知道鎖過期了之間的矛盾。
在加鎖的時候,我們一般都會給一個鎖的 TTL,這是為了防止加鎖后 Client 宕機,鎖無法被釋放的問題。
但是所有這種姿勢的用法都會面臨同一個問題,就是沒發(fā)保證 Client 的執(zhí)行時間一定小于鎖的 TTL。
雖然大多數(shù)程序員都會樂觀的認為這種情況不可能發(fā)生,我也曾經(jīng)這么認為,直到被現(xiàn)實一次又一次的打臉。
Martin Kleppmann 也質疑過這一點,這里直接用他的圖:Client1 獲取到鎖。
Client1 開始任務,然后發(fā)生了 STW 的 GC,時間超過了鎖的過期時間。
Client2 獲取到鎖,開始了任務。
Client1 的 GC 結束,繼續(xù)任務,這個時候 Client1 和 Client2 都認為自己獲取了鎖,都會處理任務,從而發(fā)生錯誤。
如何解決呢?一種解決方案是不設置 TTL,而是在獲取鎖成功后,給鎖加一個 watchdog,watchdog 會起一個定時任務,在鎖沒有被釋放且快要過期的時候會續(xù)期。
這樣說有些抽象,下面結合 Redisson 源碼說下:
?public?class?RedissonLock?extends?RedissonExpirable?implements?RLock?{
????...
????@Override
????public?void?lock()?{
????????try?{
????????????lockInterruptibly();
????????}?catch?(InterruptedException?e)?{
????????????Thread.currentThread().interrupt();
????????}
????}
????@Override
????public?void?lock(long?leaseTime,?TimeUnit?unit)?{
????????try?{
????????????lockInterruptibly(leaseTime,?unit);
????????}?catch?(InterruptedException?e)?{
????????????Thread.currentThread().interrupt();
????????}
????}
????...
?}
Redisson 常用的加鎖 API 是上面兩個,一個是不傳入 TTL,這時是 Redisson 自己維護,會主動續(xù)期。
另外一種是自己傳入 TTL,這種 Redisson 就不會幫我們自動續(xù)期了,或者自己將 leaseTime 的值傳成 -1,但是不建議這種方式,既然已經(jīng)有現(xiàn)成的 API 了,何必還要用這種奇怪的寫法呢。
接下來分析下不傳參的方法的加鎖邏輯:
public?class?RedissonLock?extends?RedissonExpirable?implements?RLock?{
????...
????public?static?final?long?LOCK_EXPIRATION_INTERVAL_SECONDS?=?30;
????protected?long?internalLockLeaseTime?=?TimeUnit.SECONDS.toMillis(LOCK_EXPIRATION_INTERVAL_SECONDS);
????@Override
????public?void?lock()?{
????????try?{
????????????lockInterruptibly();
????????}?catch?(InterruptedException?e)?{
????????????Thread.currentThread().interrupt();
????????}
????}
????@Override
????public?void?lockInterruptibly()?throws?InterruptedException?{
????????lockInterruptibly(-1,?null);
????}
????@Override
????public?void?lockInterruptibly(long?leaseTime,?TimeUnit?unit)?throws?InterruptedException?{
????????long?threadId?=?Thread.currentThread().getId();
????????Long?ttl?=?tryAcquire(leaseTime,?unit,?threadId);
????????//?lock?acquired
????????if?(ttl?==?null)?{
????????????return;
????????}
????????RFuture?future?=?subscribe(threadId);
????????commandExecutor.syncSubscription(future);
????????try?{
????????????while?(true)?{
????????????????ttl?=?tryAcquire(leaseTime,?unit,?threadId);
????????????????//?lock?acquired
????????????????if?(ttl?==?null)?{
????????????????????break;
????????????????}
????????????????//?waiting?for?message
????????????????if?(ttl?>=?0)?{
????????????????????getEntry(threadId).getLatch().tryAcquire(ttl,?TimeUnit.MILLISECONDS);
????????????????}?else?{
????????????????????getEntry(threadId).getLatch().acquire();
????????????????}
????????????}
????????}?finally?{
????????????unsubscribe(future,?threadId);
????????}
//????????get(lockAsync(leaseTime,?unit));
????}
????private?Long?tryAcquire(long?leaseTime,?TimeUnit?unit,?long?threadId)?{
????????return?get(tryAcquireAsync(leaseTime,?unit,?threadId));
????}
????private??RFuture ?tryAcquireAsync(long?leaseTime,?TimeUnit?unit,?final?long?threadId)? {
????????if?(leaseTime?!=?-1)?{
????????????return?tryLockInnerAsync(leaseTime,?unit,?threadId,?RedisCommands.EVAL_LONG);
????????}
????????RFuture?ttlRemainingFuture?=?tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS,?TimeUnit.SECONDS,?threadId,?RedisCommands.EVAL_LONG);
????????ttlRemainingFuture.addListener(new?FutureListener()?{
????????????@Override
????????????public?void?operationComplete(Future?future) ?throws?Exception?{
????????????????if?(!future.isSuccess())?{
????????????????????return;
????????????????}
????????????????Long?ttlRemaining?=?future.getNow();
????????????????//?lock?acquired
????????????????if?(ttlRemaining?==?null)?{
????????????????????scheduleExpirationRenewal(threadId);
????????????????}
????????????}
????????});
????????return?ttlRemainingFuture;
????}
????private?void?scheduleExpirationRenewal(final?long?threadId)?{
????????if?(expirationRenewalMap.containsKey(getEntryName()))?{
????????????return;
????????}
????????Timeout?task?=?commandExecutor.getConnectionManager().newTimeout(new?TimerTask()?{
????????????@Override
????????????public?void?run(Timeout?timeout)?throws?Exception?{
????????????????RFuture?future?=?commandExecutor.evalWriteAsync(getName(),?LongCodec.INSTANCE,?RedisCommands.EVAL_BOOLEAN,
????????????????????????"if?(redis.call('hexists',?KEYS[1],?ARGV[2])?==?1)?then?"?+
????????????????????????????"redis.call('pexpire',?KEYS[1],?ARGV[1]);?"?+
????????????????????????????"return?1;?"?+
????????????????????????"end;?"?+
????????????????????????"return?0;",
??????????????????????????Collections.
可以看到,最后加鎖的邏輯會進入到 org.redisson.RedissonLock#tryAcquireAsync 中,在獲取鎖成功后,會進入 scheduleExpirationRenewal。
這里面初始化了一個定時器,dely 的時間是 internalLockLeaseTime/3。
在 Redisson 中,internalLockLeaseTime 是 30s,也就是每隔 10s 續(xù)期一次,每次 30s。
如果是基于 Zookeeper 實現(xiàn)的分布式鎖,可以利用 Zookeeper 檢查節(jié)點是否存活,從而實現(xiàn)續(xù)期,Zookeeper 分布式鎖沒用過,不詳細說。
不過這種做法也無法百分百做到同一時刻只有一個 Client 獲取到鎖,如果續(xù)期失敗,比如發(fā)生了 Martin Kleppmann 所說的 STW 的 GC,或者 Client 和 Redis 集群失聯(lián)了,只要續(xù)期失敗,就會造成同一時刻有多個 Client 獲得鎖了。
在我的場景下,我將鎖的粒度拆小了,Redisson 的續(xù)期機制已經(jīng)夠用了。如果要做得更嚴格,得加一個續(xù)期失敗終止任務的邏輯。
這種做法在以前 Python 的代碼中實現(xiàn)過,Java 還沒有碰到這么嚴格的情況。
這里也提下 Martin Kleppmann 的解決方案,我自己覺得這個方案并不靠譜,原因后面會提到。
他的方案是讓加鎖的資源自己維護一套保證不會因加鎖失敗而導致多個 Client 在同一時刻訪問同一個資源的情況。
在客戶端獲取鎖的同時,也獲取到一個資源的 Token,這個 Token 是單調(diào)遞增的,每次在寫資源時,都檢查當前的 Token 是否是較老的 Token,如果是就不讓寫。對于上面的場景,Client1 獲取鎖的同時分配一個 33 的 Token,Client2 獲取鎖的時候分配一個 34 的 Token。
在 Client1 GC 期間,Client2 已經(jīng)寫了資源,這時最大的 Token 就是 34 了,Client1 從 GC 中回來,再帶著 33 的 Token 寫資源時,會因為 Token 過期被拒絕。
這種做法需要資源那一邊提供一個 Token 生成器。對于這種 fencing 的方案,我有幾點問題:
①無法保證事務。示意圖中畫的只有 34 訪問了 Storage,但是在實際場景中,可能出現(xiàn)在一個任務內(nèi)多次訪問 Storage 的情況,而且必須是原子的。
如果 Client1 帶著 33 的 Token 在 GC 前訪問過一次 Storage,然后發(fā)生了 GC。
Client2 獲取到鎖,帶著 34 的 Token 也訪問了 Storage,這時兩個 Client 寫入的數(shù)據(jù)是否還能保證數(shù)據(jù)正確?
如果不能,那么這種方案就有缺陷,除非 Storage 自己有其他機制可以保證,比如事務機制;如果能,那么這里的 Token 就是多余的,fencing 的方案就是多此一舉。
②高并發(fā)場景不實用。因為每次只有最大的 Token 能寫,這樣 Storage 的訪問就是線性的,在高并發(fā)場景下,這種方式會極大的限制吞吐量,而分布式鎖也大多是在這種場景下用的,很矛盾的設計。
③這是所有分布式鎖的問題。這個方案是一個通用的方案,可以和 Redlock 用,也可以和其他的 lock 用。所以我理解僅僅是一個和 Redlock 無關的解決方案。
系統(tǒng)時鐘漂移
這個問題只是考慮過,但在實際項目中并沒有碰到過,因為理論上是可能出現(xiàn)的,這里也說下。
Redis 的過期時間是依賴系統(tǒng)時鐘的,如果時鐘漂移過大時會影響到過期時間的計算。
為什么系統(tǒng)時鐘會存在漂移呢?先簡單說下系統(tǒng)時間,Linux 提供了兩個系統(tǒng)時間:clock realtime 和 clock monotonic。
clock realtime 也就是 xtime/wall time,這個時間時可以被用戶改變的,被 NTP 改變,gettimeofday 拿的就是這個時間,Redis 的過期計算用的也是這個時間。
clock monotonic ,直譯過來時單調(diào)時間,不會被用戶改變,但是會被 NTP 改變。
最理想的情況時,所有系統(tǒng)的時鐘都時時刻刻和NTP服務器保持同步,但這顯然時不可能的。
導致系統(tǒng)時鐘漂移的原因有兩個:
系統(tǒng)的時鐘和 NTP 服務器不同步。這個目前沒有特別好的解決方案,只能相信運維同學了。
clock realtime 被人為修改。在實現(xiàn)分布式鎖時,不要使用 clock realtime。
不過很可惜,Redis 使用的就是這個時間,我看了下 Redis 5.0 源碼,使用的還是 clock realtime。
Antirez 說過改成 clock monotonic 的,不過大佬還沒有改。也就是說,人為修改 Redis 服務器的時間,就能讓 Redis 出問題了。
總結
本文從一個簡單的基于 Redis 的分布式鎖出發(fā),到更復雜的 Redlock 的實現(xiàn),介紹了在使用分布式鎖的過程中才踩過的一些坑以及解決方案。
視頻號開通啦,
是關于國外程序員題材的視頻號
長按二維碼關注
