|
作者介紹 陳寒立,一個不誤正業(yè)的程序員。先后在物流金融組、物流末端業(yè)務(wù)組和壓力平衡組打過雜,技術(shù)棧從Python玩到了Java,依然沒學(xué)會好好寫業(yè)務(wù)代碼,夢想著用抽象的模型拯救業(yè)務(wù)于水火之中。 基于Redis的分布式鎖對大家來說并不陌生,可是你的分布式鎖有失敗的時候嗎?在失敗的時候可曾懷疑過你在用的分布式鎖真的靠譜嗎?以下是結(jié)合自己的踩坑經(jīng)驗總結(jié)的一些經(jīng)驗之談。 用到分布式鎖說明遇到了多個進程共同訪問同一個資源的問題, 一般是在兩個場景下會防止對同一個資源的重復(fù)訪問:
引入分布式鎖勢必要引入一個第三方的基礎(chǔ)設(shè)施,比如MySQL,Redis,Zookeeper等,這些實現(xiàn)分布式鎖的基礎(chǔ)設(shè)施出問題了,也會影響業(yè)務(wù),所以在使用分布式鎖前可以考慮下是否可以不用加鎖的方式實現(xiàn)? 不過這個不在本文的討論范圍內(nèi),本文假設(shè)加鎖的需求是合理的,并且偏向于上面的第二種情況,為什么是偏向?因為不存在100%靠譜的分布式鎖,看完下面的內(nèi)容就明白了。 分布式鎖的Redis實現(xiàn)很常見,自己實現(xiàn)和使用第三方庫都很簡單,至少看上去是這樣的,這里就介紹一個最簡單靠譜的Redis實現(xiàn)。 實現(xiàn)很經(jīng)典了,這里只提兩個要點?
一個可復(fù)制粘貼的實現(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:///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)有什么問題呢?
如何解決這兩個問題呢?試試看更復(fù)雜的實現(xiàn)吧。 對于第一個單點問題,順著redis的思路,接下來想到的肯定是Redlock了。Redlock為了解決單機的問題,需要多個(大于2)redis的master節(jié)點,多個master節(jié)點互相獨立,沒有數(shù)據(jù)同步。 Redlock的實現(xiàn)如下: 1)獲取當前時間。 2)依次獲取N個節(jié)點的鎖。每個節(jié)點加鎖的實現(xiàn)方式同上。這里有個細節(jié),就是每次獲取鎖的時候的過期時間都不同,需要減去之前獲取鎖的操作的耗時:
3)判斷是否獲取鎖成功。如果client在上述步驟中獲取到了(N/2 + 1)個節(jié)點鎖,并且每個鎖的過期時間都是大于0的,則獲取鎖成功,否則失敗。失敗時釋放鎖。 4)釋放鎖。對所有節(jié)點發(fā)送釋放鎖的指令,每個節(jié)點的實現(xiàn)邏輯和上面的簡單實現(xiàn)一樣。為什么要對所有節(jié)點操作?因為分布式場景下從一個節(jié)點獲取鎖失敗不代表在那個節(jié)點上加速失敗,可能實際上加鎖已經(jīng)成功了,但是返回時因為網(wǎng)絡(luò)抖動超時了。 以上就是大家常見的redlock實現(xiàn)的描述了,一眼看上去就是簡單版本的多master版本,如果真是這樣就太簡單了,接下來分析下這個算法在各個場景下是怎樣被玩壞的。 以下問題不是說在并發(fā)不高的場景下不容易出現(xiàn),只是在高并發(fā)場景下出現(xiàn)的概率更高些而已。 性能問題。 性能問題來自于兩個方面。 1)獲取鎖的時間上。如果redlock運用在高并發(fā)的場景下,存在N個master節(jié)點,一個一個去請求,耗時會比較長,從而影響性能。這個好解決。通過上面描述不難發(fā)現(xiàn),從多個節(jié)點獲取鎖的操作并不是一個同步操作,可以是異步操作,這樣可以多個節(jié)點同時獲取。即使是并行處理的,還是得預(yù)估好獲取鎖的時間,保證鎖的TTL > 獲取鎖的時間+任務(wù)處理時間。 2)被加鎖的資源太大。加鎖的方案本身就是會為了正確性而犧牲并發(fā)的,犧牲和資源大小成正比。這個時候可以考慮對資源做拆分,拆分的方式有兩種:
重試的問題。無論是簡單實現(xiàn)還是redlock實現(xiàn),都會有重試的邏輯。如果直接按上面的算法實現(xiàn),是會存在多個client幾乎在同一時刻獲取同一個鎖,然后每個client都鎖住了部分節(jié)點,但是沒有一個client獲取大多數(shù)節(jié)點的情況。解決的方案也很常見,在重試的時候讓多個節(jié)點錯開,錯開的方式就是在重試時間中加一個隨機時間。這樣并不能根治這個問題,但是可以有效緩解問題,親試有效。 對于單master節(jié)點且沒有做持久化的場景,宕機就掛了,這個就必須在實現(xiàn)上支持重復(fù)操作,自己做好冪等。 對于多master的場景,比如redlock,我們來看這樣一個場景:
怎么解決呢?最容易想到的方案是打開持久化。持久化可以做到持久化每一條redis命令,但這對性能影響會很大,一般不會采用,如果不采用這種方式,在節(jié)點掛的時候肯定會損失小部分的數(shù)據(jù),可能我們的鎖就在其中。 另一個方案是延遲啟動。就是一個節(jié)點掛了修復(fù)后,不立即加入,而是等待一段時間再加入,等待時間要大于宕機那一刻所有鎖的最大TTL。 但這個方案依然不能解決問題,如果在上述步驟3中B和C都掛了呢,那么只剩A、D、E三個節(jié)點,從D和E獲取鎖成功就可以了,還是會出問題。那么只能增加master節(jié)點的總量,緩解這個問題了。增加master節(jié)點會提高穩(wěn)定性,但是也增加了成本,需要在兩者之間權(quán)衡。 之前產(chǎn)線上出現(xiàn)過因為網(wǎng)絡(luò)延遲導(dǎo)致任務(wù)的執(zhí)行時間遠超預(yù)期,鎖過期,被多個線程執(zhí)行的情況。 這個問題是所有分布式鎖都要面臨的問題,包括基于zookeeper和DB實現(xiàn)的分布式鎖,這是鎖過期了和client不知道鎖過期了之間的矛盾。 在加鎖的時候,我們一般都會給一個鎖的TTL,這是為了防止加鎖后client宕機,鎖無法被釋放的問題。但是所有這種姿勢的用法都會面臨同一個問題,就是沒法保證client的執(zhí)行時間一定小于鎖的TTL。雖然大多數(shù)程序員都會樂觀的認為這種情況不可能發(fā)生,我也曾經(jīng)這么認為,直到被現(xiàn)實一次又一次的打臉。 Martin Kleppmann也質(zhì)疑過這一點,這里直接用他的圖:
Martin Kleppmann舉的是GC的例子,我碰到的是網(wǎng)絡(luò)延遲的情況。不管是哪種情況,不可否認的是這種情況無法避免,一旦出現(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自己維護,會主動續(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<RedissonLockEntry> 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 <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) { if (leaseTime != -1) { return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(LOCK_EXPIRATION_INTERVAL_SECONDS, TimeUnit.SECONDS, threadId, RedisCommands.EVAL_LONG); ttlRemainingFuture.addListener(new FutureListener<Long>() { @Override public void operationComplete(Future<Long> 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<Boolean> 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.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); future.addListener(new FutureListener<Boolean>() { @Override public void operationComplete(Future<Boolean> future) throws Exception { expirationRenewalMap.remove(getEntryName()); if (!future.isSuccess()) { log.error('Can't update lock ' + getName() + ' expiration', future.cause()); return; } if (future.getNow()) { // reschedule itself scheduleExpirationRenewal(threadId); } } }); } }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) { task.cancel(); } } ... } 可以看到,最后加鎖的邏輯會進入到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ù)期失敗終止任務(wù)的邏輯。這種做法在以前Python的代碼中實現(xiàn)過,Java還沒有碰到這么嚴格的情況。 這里也提下Martin Kleppmann的解決方案,我自己覺得這個方案并不靠譜,原因后面會提到。 他的方案是讓加鎖的資源自己維護一套保證不會因加鎖失敗而導(dǎo)致多個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的方案,我有幾點問題:
這個問題只是考慮過,但在實際項目中并沒有碰到過,因為理論上是可能出現(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)時鐘漂移的原因有兩個:
本文從一個簡單的基于redis的分布式鎖出發(fā),到更復(fù)雜的Redlock的實現(xiàn),介紹了在使用分布式鎖的過程中才踩過的一些坑以及解決方案。 |
|
|