互聯(lián)網(wǎng)/程序員/技術(shù)/資料共享?
基于 Redis 的分布式鎖對大家來說并不陌生,可是你的分布式鎖有失敗的時候嗎?在失敗的時候可曾懷疑過你在用的分布式鎖真的靠譜嗎?以下是結(jié)合自己的踩坑經(jīng)驗(yàn)總結(jié)的一些經(jīng)驗(yàn)之談。

用到分布式鎖說明遇到了多個進(jìn)程共同訪問同一個資源的問題。一般是在兩個場景下會防止對同一個資源的重復(fù)訪問:提高效率。比如多個節(jié)點(diǎn)計算同一批任務(wù),如果某個任務(wù)已經(jīng)有節(jié)點(diǎn)在計算了,那其他節(jié)點(diǎn)就不用重復(fù)計算了,以免浪費(fèi)計算資源。不過重復(fù)計算也沒事,不會造成其他更大的損失。也就是允許偶爾的失敗。
保證正確性。這種情況對鎖的要求就很高了,如果重復(fù)計算,會對正確性造成影響。這種不允許失敗。
引入分布式鎖勢必要引入一個第三方的基礎(chǔ)設(shè)施,比如 MySQL,Redis,Zookeeper 等。這些實(shí)現(xiàn)分布式鎖的基礎(chǔ)設(shè)施出問題了,也會影響業(yè)務(wù),所以在使用分布式鎖前可以考慮下是否可以不用加鎖的方式實(shí)現(xiàn)?不過這個不在本文的討論范圍內(nèi),本文假設(shè)加鎖的需求是合理的,并且偏向于上面的第二種情況,為什么是偏向?因?yàn)椴淮嬖?100% 靠譜的分布式鎖,看完下面的內(nèi)容就明白了。從一個簡單的分布式鎖實(shí)現(xiàn)說起
分布式鎖的 Redis 實(shí)現(xiàn)很常見,自己實(shí)現(xiàn)和使用第三方庫都很簡單,至少看上去是這樣的,這里就介紹一個最簡單靠譜的 Redis 實(shí)現(xiàn)。實(shí)現(xiàn)很經(jīng)典了,這里只提兩個要點(diǎn):一個可復(fù)制粘貼的實(shí)現(xiàn)方式如下: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);
}
這段實(shí)現(xiàn)的精髓在那個簡單的 Lua 腳本上,先判斷唯一 ID 是否相等再操作。單點(diǎn)問題。上面的實(shí)現(xiàn)只要一個 Master 節(jié)點(diǎn)就能搞定,這里的單點(diǎn)指的是單 Master,就算是個集群,如果加鎖成功后,鎖從 Master 復(fù)制到 Slave 的時候掛了,也是會出現(xiàn)同一資源被多個 Client 加鎖的。
執(zhí)行時間超過了鎖的過期時間。上面寫到為了不出現(xiàn)一直上鎖的情況,加了一個兜底的過期時間,時間到了鎖自動釋放,但是,如果在這期間任務(wù)并沒有做完怎么辦?由于 GC 或者網(wǎng)絡(luò)延遲導(dǎo)致的任務(wù)時間變長,很難保證任務(wù)一定能在鎖的過期時間內(nèi)完成。
如何解決這兩個問題呢?試試看更復(fù)雜的實(shí)現(xiàn)吧。對于第一個單點(diǎn)問題,順著 Redis 的思路,接下來想到的肯定是 Redlock 了。Redlock 為了解決單機(jī)的問題,需要多個(大于 2)Redis 的 Master 節(jié)點(diǎn),多個 Master 節(jié)點(diǎn)互相獨(dú)立,沒有數(shù)據(jù)同步。Redlock 的實(shí)現(xiàn)如下:②依次獲取 N 個節(jié)點(diǎn)的鎖。每個節(jié)點(diǎn)加鎖的實(shí)現(xiàn)方式同上。這里有個細(xì)節(jié),就是每次獲取鎖的時候的過期時間都不同,需要減去之前獲取鎖的操作的耗時,比如傳入的鎖的過期時間為 500ms,獲取第一個節(jié)點(diǎn)的鎖花了 1ms,那么第一個節(jié)點(diǎn)的鎖的過期時間就是 499ms;獲取第二個節(jié)點(diǎn)的鎖花了 2ms,那么第二個節(jié)點(diǎn)的鎖的過期時間就是 497ms。如果鎖的過期時間小于等于 0 了,說明整個獲取鎖的操作超時了,整個操作失敗。③判斷是否獲取鎖成功。如果 Client 在上述步驟中獲取到了(N/2+1)個節(jié)點(diǎn)鎖,并且每個鎖的過期時間都是大于 0 的,則獲取鎖成功,否則失敗。失敗時釋放鎖。④釋放鎖。對所有節(jié)點(diǎn)發(fā)送釋放鎖的指令,每個節(jié)點(diǎn)的實(shí)現(xiàn)邏輯和上面的簡單實(shí)現(xiàn)一樣。為什么要對所有節(jié)點(diǎn)操作?因?yàn)榉植际綀鼍跋聫囊粋€節(jié)點(diǎn)獲取鎖失敗不代表在那個節(jié)點(diǎn)上加速失敗,可能實(shí)際上加鎖已經(jīng)成功了,但是返回時因?yàn)榫W(wǎng)絡(luò)抖動超時了。以上就是大家常見的 Redlock 實(shí)現(xiàn)的描述了,一眼看上去就是簡單版本的多 Master 版本,如果真是這樣就太簡單了,接下來分析下這個算法在各個場景下是怎樣被玩壞的。以下問題不是說在并發(fā)不高的場景下不容易出現(xiàn),只是在高并發(fā)場景下出現(xiàn)的概率更高些而已。①獲取鎖的時間上。如果 Redlock 運(yùn)用在高并發(fā)的場景下,存在 N 個 Master 節(jié)點(diǎn),一個一個去請求,耗時會比較長,從而影響性能。這個好解決,通過上面描述不難發(fā)現(xiàn),從多個節(jié)點(diǎn)獲取鎖的操作并不是一個同步操作,可以是異步操作,這樣可以多個節(jié)點(diǎn)同時獲取。即使是并行處理的,還是得預(yù)估好獲取鎖的時間,保證鎖的 TTL>獲取鎖的時間+任務(wù)處理時間。②被加鎖的資源太大。加鎖的方案本身就是會為了正確性而犧牲并發(fā)的,犧牲和資源大小成正比,這個時候可以考慮對資源做拆分。①從業(yè)務(wù)上將鎖住的資源拆分成多段,每段分開加鎖。比如,我要對一個商戶做若干個操作,操作前要鎖住這個商戶,這時我可以將若干個操作拆成多個獨(dú)立的步驟分開加鎖,提高并發(fā)。
②用分桶的思想,將一個資源拆分成多個桶,一個加鎖失敗立即嘗試下一個。比如批量任務(wù)處理的場景,要處理 200w 個商戶的任務(wù),為了提高處理速度,用多個線程,每個線程取 100 個商戶處理,就得給這 100 個商戶加鎖。如果不加處理,很難保證同一時刻兩個線程加鎖的商戶沒有重疊,這時可以按一個維度。比如某個標(biāo)簽,對商戶進(jìn)行分桶,然后一個任務(wù)處理一個分桶,處理完這個分桶再處理下一個分桶,減少競爭。重試的問題:無論是簡單實(shí)現(xiàn)還是 Redlock 實(shí)現(xiàn),都會有重試的邏輯。如果直接按上面的算法實(shí)現(xiàn),是會存在多個 Client 幾乎在同一時刻獲取同一個鎖,然后每個 Client 都鎖住了部分節(jié)點(diǎn),但是沒有一個 Client 獲取大多數(shù)節(jié)點(diǎn)的情況。解決的方案也很常見,在重試的時候讓多個節(jié)點(diǎn)錯開,錯開的方式就是在重試時間中加一個隨機(jī)時間。這樣并不能根治這個問題,但是可以有效緩解問題,親試有效。對于單 Master 節(jié)點(diǎn)且沒有做持久化的場景,宕機(jī)就掛了,這個就必須在實(shí)現(xiàn)上支持重復(fù)操作,自己做好冪等。對于多 Master 的場景,比如 Redlock,我們來看這樣一個場景:假設(shè)有 5 個 Redis 的節(jié)點(diǎn):A、B、C、D、E,沒有做持久化。
Client1 從 A、B、C 這3 個節(jié)點(diǎn)獲取鎖成功,那么 client1 獲取鎖成功。
節(jié)點(diǎn) C 掛了。
Client2 從 C、D、E 獲取鎖成功,client2 也獲取鎖成功,那么在同一時刻 Client1 和 Client2 同時獲取鎖,Redlock 被玩壞了。
怎么解決呢?最容易想到的方案是打開持久化。持久化可以做到持久化每一條 Redis 命令,但這對性能影響會很大,一般不會采用,如果不采用這種方式,在節(jié)點(diǎn)掛的時候肯定會損失小部分的數(shù)據(jù),可能我們的鎖就在其中。另一個方案是延遲啟動。就是一個節(jié)點(diǎn)掛了修復(fù)后,不立即加入,而是等待一段時間再加入,等待時間要大于宕機(jī)那一刻所有鎖的最大 TTL。但這個方案依然不能解決問題,如果在上述步驟 3 中 B 和 C 都掛了呢,那么只剩 A、D、E 三個節(jié)點(diǎn),從 D 和 E 獲取鎖成功就可以了,還是會出問題。那么只能增加 Master 節(jié)點(diǎn)的總量,緩解這個問題了。增加 Master 節(jié)點(diǎn)會提高穩(wěn)定性,但是也增加了成本,需要在兩者之間權(quán)衡。之前產(chǎn)線上出現(xiàn)過因?yàn)榫W(wǎng)絡(luò)延遲導(dǎo)致任務(wù)的執(zhí)行時間遠(yuǎn)超預(yù)期,鎖過期,被多個線程執(zhí)行的情況。這個問題是所有分布式鎖都要面臨的問題,包括基于 Zookeeper 和 DB 實(shí)現(xiàn)的分布式鎖,這是鎖過期了和 Client 不知道鎖過期了之間的矛盾。在加鎖的時候,我們一般都會給一個鎖的 TTL,這是為了防止加鎖后 Client 宕機(jī),鎖無法被釋放的問題。但是所有這種姿勢的用法都會面臨同一個問題,就是沒發(fā)保證 Client 的執(zhí)行時間一定小于鎖的 TTL。雖然大多數(shù)程序員都會樂觀的認(rèn)為這種情況不可能發(fā)生,我也曾經(jīng)這么認(rèn)為,直到被現(xiàn)實(shí)一次又一次的打臉。Martin Kleppmann 也質(zhì)疑過這一點(diǎn),這里直接用他的圖:Martin Kleppmann 舉的是 GC 的例子,我碰到的是網(wǎng)絡(luò)延遲的情況。不管是哪種情況,不可否認(rèn)的是這種情況無法避免,一旦出現(xiàn)很容易懵逼。如何解決呢?一種解決方案是不設(shè)置 TTL,而是在獲取鎖成功后,給鎖加一個 watchdog,watchdog 會起一個定時任務(wù),在鎖沒有被釋放且快要過期的時候會續(xù)期。這樣說有些抽象,下面結(jié)合 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 自己維護(hù),會主動續(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.
可以看到,最后加鎖的邏輯會進(jìn)入到 org.redisson.RedissonLock#tryAcquireAsync 中,在獲取鎖成功后,會進(jìn)入 scheduleExpirationRenewal。這里面初始化了一個定時器,dely 的時間是 internalLockLeaseTime/3。在 Redisson 中,internalLockLeaseTime 是 30s,也就是每隔 10s 續(xù)期一次,每次 30s。
如果是基于 Zookeeper 實(shí)現(xiàn)的分布式鎖,可以利用 Zookeeper 檢查節(jié)點(diǎn)是否存活,從而實(shí)現(xiàn)續(xù)期,Zookeeper 分布式鎖沒用過,不詳細(xì)說。不過這種做法也無法百分百做到同一時刻只有一個 Client 獲取到鎖,如果續(xù)期失敗,比如發(fā)生了 Martin Kleppmann 所說的 STW 的 GC,或者 Client 和 Redis 集群失聯(lián)了,只要續(xù)期失敗,就會造成同一時刻有多個 Client 獲得鎖了。在我的場景下,我將鎖的粒度拆小了,Redisson 的續(xù)期機(jī)制已經(jīng)夠用了。如果要做得更嚴(yán)格,得加一個續(xù)期失敗終止任務(wù)的邏輯。這種做法在以前 Python 的代碼中實(shí)現(xiàn)過,Java 還沒有碰到這么嚴(yán)格的情況。這里也提下 Martin Kleppmann 的解決方案,我自己覺得這個方案并不靠譜,原因后面會提到。他的方案是讓加鎖的資源自己維護(hù)一套保證不會因加鎖失敗而導(dǎo)致多個 Client 在同一時刻訪問同一個資源的情況。在客戶端獲取鎖的同時,也獲取到一個資源的 Token,這個 Token 是單調(diào)遞增的,每次在寫資源時,都檢查當(dāng)前的 Token 是否是較老的 Token,如果是就不讓寫。對于上面的場景,Client1 獲取鎖的同時分配一個 33 的 Token,Client2 獲取鎖的時候分配一個 34 的 Token。在 Client1 GC 期間,Client2 已經(jīng)寫了資源,這時最大的 Token 就是 34 了,Client1 從 GC 中回來,再帶著 33 的 Token 寫資源時,會因?yàn)?Token 過期被拒絕。這種做法需要資源那一邊提供一個 Token 生成器。對于這種 fencing 的方案,我有幾點(diǎn)問題:①無法保證事務(wù)。示意圖中畫的只有 34 訪問了 Storage,但是在實(shí)際場景中,可能出現(xiàn)在一個任務(wù)內(nèi)多次訪問 Storage 的情況,而且必須是原子的。如果 Client1 帶著 33 的 Token 在 GC 前訪問過一次 Storage,然后發(fā)生了 GC。Client2 獲取到鎖,帶著 34 的 Token 也訪問了 Storage,這時兩個 Client 寫入的數(shù)據(jù)是否還能保證數(shù)據(jù)正確?如果不能,那么這種方案就有缺陷,除非 Storage 自己有其他機(jī)制可以保證,比如事務(wù)機(jī)制;如果能,那么這里的 Token 就是多余的,fencing 的方案就是多此一舉。②高并發(fā)場景不實(shí)用。因?yàn)槊看沃挥凶畲蟮?Token 能寫,這樣 Storage 的訪問就是線性的,在高并發(fā)場景下,這種方式會極大的限制吞吐量,而分布式鎖也大多是在這種場景下用的,很矛盾的設(shè)計。③這是所有分布式鎖的問題。這個方案是一個通用的方案,可以和 Redlock 用,也可以和其他的 lock 用。所以我理解僅僅是一個和 Redlock 無關(guān)的解決方案。這個問題只是考慮過,但在實(shí)際項(xiàng)目中并沒有碰到過,因?yàn)槔碚撋鲜强赡艹霈F(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服務(wù)器保持同步,但這顯然時不可能的。導(dǎo)致系統(tǒng)時鐘漂移的原因有兩個:系統(tǒng)的時鐘和 NTP 服務(wù)器不同步。這個目前沒有特別好的解決方案,只能相信運(yùn)維同學(xué)了。
clock realtime 被人為修改。在實(shí)現(xiàn)分布式鎖時,不要使用 clock realtime。
不過很可惜,Redis 使用的就是這個時間,我看了下 Redis 5.0 源碼,使用的還是 clock realtime。
Antirez 說過改成 clock monotonic 的,不過大佬還沒有改。也就是說,人為修改 Redis 服務(wù)器的時間,就能讓 Redis 出問題了。
本文從一個簡單的基于 Redis 的分布式鎖出發(fā),到更復(fù)雜的 Redlock 的實(shí)現(xiàn),介紹了在使用分布式鎖的過程中才踩過的一些坑以及解決方案。推薦閱讀:
互聯(lián)網(wǎng)公司忽悠員工的黑話,套路太深了。。。
SQL 查找是否"存在",別再 count 了,很耗費(fèi)時間的!
【02期】你能說說Spring框架中Bean的生命周期嗎?
5T技術(shù)資源大放送!包括但不限于:C/C++,Linux,Python,Java,PHP,人工智能,單片機(jī),樹莓派,等等。在公眾號內(nèi)回復(fù)「2048」,即可免費(fèi)獲?。?!微信掃描二維碼,關(guān)注我的公眾號
朕已閱?