做者簡介java
陳寒立,一個不誤正業的程序員。前後在物流金融組、物流末端業務組和壓力平衡組打過雜,技術棧從Python玩到了Java,依然沒學會好好寫業務代碼,夢想着用抽象的模型拯救業務於水火之中。linux
基於Redis的分佈式鎖對你們來講並不陌生,但是你的分佈式鎖有失敗的時候嗎?在失敗的時候可曾懷疑過你在用的分佈式鎖真的靠譜嗎?如下是結合本身的踩坑經驗總結的一些經驗之談。git
用到分佈式鎖說明遇到了多個進程共同訪問同一個資源的問題, 通常是在兩個場景下會防止對同一個資源的重複訪問:程序員
引入分佈式鎖勢必要引入一個第三方的基礎設施,好比MySQL,Redis,Zookeeper等,這些實現分佈式鎖的基礎設施出問題了,也會影響業務,因此在使用分佈式鎖前能夠考慮下是否能夠不用加鎖的方式實現?不過這個不在本文的討論範圍內,本文假設加鎖的需求是合理的,而且偏向於上面的第二種狀況,爲何是偏向?由於不存在100%靠譜的分佈式鎖,看完下面的內容就明白了。github
分佈式鎖的Redis實現很常見,本身實現和使用第三方庫都很簡單,至少看上去是這樣的,這裏就介紹一個最簡單靠譜的Redis實現。redis
實現很經典了,這裏只提兩個要點?算法
一個可複製粘貼的實現方式以下:c#
加鎖api
public static boolean tryLock(String key, String uniqueId, int seconds) {
return "OK".equals(jedis.set(key, uniqueId, "NX", "EX", seconds));
}
複製代碼
這裏實際上是調用了 SET key value PX milliseoncds NX
,不明白這個命令的參考下SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]服務器
解鎖
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);
}
複製代碼
這段實現的精髓在那個簡單的lua腳本上,先判斷惟一ID是否相等再操做。
這樣的實現有什麼問題呢?
如何解決這兩個問題呢?試試看更復雜的實現吧。
對於第一個單點問題,順着redis的思路,接下來想到的確定是Redlock了。Redlock爲了解決單機的問題,須要多個(大於2)redis的master節點,多個master節點互相獨立,沒有數據同步。
Redlock的實現以下:
以上就是你們常見的redlock實現的描述了,一眼看上去就是簡單版本的多master版本,若是真是這樣就太簡單了,接下來分析下這個算法在各個場景下是怎樣被玩壞的。
如下問題不是說在併發不高的場景下不容易出現,只是在高併發場景下出現的機率更高些而已。 性能問題。 性能問題來自於兩個方面。
重試的問題。 不管是簡單實現仍是redlock實現,都會有重試的邏輯。若是直接按上面的算法實現,是會存在多個client幾乎在同一時刻獲取同一個鎖,而後每一個client都鎖住了部分節點,可是沒有一個client獲取大多數節點的狀況。解決的方案也很常見,在重試的時候讓多個節點錯開,錯開的方式就是在重試時間中加一個隨機時間。這樣並不能根治這個問題,可是能夠有效緩解問題,親試有效。
對於單master節點且沒有作持久化的場景,宕機就掛了,這個就必須在實現上支持重複操做,本身作好冪等。
對於多master的場景,好比redlock,咱們來看這樣一個場景:
怎麼解決呢?最容易想到的方案是打開持久化。持久化能夠作到持久化每一條redis命令,但這對性能影響會很大,通常不會採用,若是不採用這種方式,在節點掛的時候確定會損失小部分的數據,可能咱們的鎖就在其中。 另外一個方案是延遲啓動。就是一個節點掛了修復後,不當即加入,而是等待一段時間再加入,等待時間要大於宕機那一刻全部鎖的最大TTL。 但這個方案依然不能解決問題,若是在上述步驟3中B和C都掛了呢,那麼只剩A、D、E三個節點,從D和E獲取鎖成功就能夠了,仍是會出問題。那麼只能增長master節點的總量,緩解這個問題了。增長master節點會提升穩定性,可是也增長了成本,須要在二者之間權衡。
以前產線上出現過由於網絡延遲致使任務的執行時間遠超預期,鎖過時,被多個線程執行的狀況。 這個問題是全部分佈式鎖都要面臨的問題,包括基於zookeeper和DB實現的分佈式鎖,這是鎖過時了和client不知道鎖過時了之間的矛盾。 在加鎖的時候,咱們通常都會給一個鎖的TTL,這是爲了防止加鎖後client宕機,鎖沒法被釋放的問題。可是全部這種姿式的用法都會面臨同一個問題,就是沒發保證client的執行時間必定小於鎖的TTL。雖然大多數程序員都會樂觀的認爲這種狀況不可能發生,我也曾經這麼認爲,直到被現實一次又一次的打臉。
Martin Kleppmann也質疑過這一點,這裏直接用他的圖:
Martin Kleppmann舉的是GC的例子,我碰到的是網絡延遲的狀況。無論是哪一種狀況,不能否認的是這種狀況沒法避免,一旦出現很容易懵逼。
如何解決呢?一種解決方案是不設置TTL,而是在獲取鎖成功後,給鎖加一個watchdog,watchdog會起一個定時任務,在鎖沒有被釋放且快要過時的時候會續期。這樣說有些抽象,下面結合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本身維護,會主動續期;另一種是本身傳入TTL,這種redisson就不會幫咱們自動續期了,或者本身將leaseTime的值傳成-1,可是不建議這種方式,既然已經有現成的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續期一次,每次30s。 若是是基於zookeeper實現的分佈式鎖,能夠利用zookeeper檢查節點是否存活,從而實現續期,zookeeper分佈式鎖沒用過,不詳細說。
不過這種作法也沒法百分百作到同一時刻只有一個client獲取到鎖,若是續期失敗,好比發生了Martin Kleppmann所說的STW的GC,或者client和redis集羣失聯了,只要續期失敗,就會形成同一時刻有多個client得到鎖了。在個人場景下,我將鎖的粒度拆小了,redisson的續期機制已經夠用了。 若是要作得更嚴格,得加一個續期失敗終止任務的邏輯。這種作法在之前Python的代碼中實現過,Java尚未碰到這麼嚴格的狀況。
這裏也提下Martin Kleppmann的解決方案,我本身以爲這個方案並不靠譜,緣由後面會提到。 他的方案是讓加鎖的資源本身維護一套保證不會因加鎖失敗而致使多個client在同一時刻訪問同一個資源的狀況。
在客戶端獲取鎖的同時,也獲取到一個資源的token,這個token是單調遞增的,每次在寫資源時,都檢查當前的token是不是較老的token,若是是就不讓寫。對於上面的場景,Client1獲取鎖的同時分配一個33的token,Client2獲取鎖的時候分配一個34的token,在client1 GC期間,Client2已經寫了資源,這時最大的token就是34了,client1 從GC中回來,再帶着33的token寫資源時,會由於token過時被拒絕。這種作法須要資源那一邊提供一個token生成器。 對於這種fencing的方案,我有幾點問題:這個問題只是考慮過,但在實際項目中並無碰到過,由於理論上是可能出現的,這裏也說下。 redis的過時時間是依賴系統時鐘的,若是時鐘漂移過大時會影響到過時時間的計算。
爲何系統時鐘會存在漂移呢?先簡單說下系統時間,linux提供了兩個系統時間:clock realtime和clock monotonic。clock realtime也就是xtime/wall time,這個時間時能夠被用戶改變的,被NTP改變,gettimeofday拿的就是這個時間,redis的過時計算用的也是這個時間。 clock monotonic ,直譯過來時單調時間,不會被用戶改變,可是會被NTP改變。
最理想的狀況時,全部系統的時鐘都時時刻刻和NTP服務器保持同步,但這顯然時不可能的。致使系統時鐘漂移的緣由有兩個:
本文從一個簡單的基於redis的分佈式鎖出發,到更復雜的Redlock的實現,介紹了在使用分佈式鎖的過程當中才踩過的一些坑以及解決方案。
clock_gettime(2) - Linux man page
閱讀博客還不過癮?
歡迎你們掃二維碼經過添加羣助手,加入交流羣,討論和博客有關的技術問題,還能夠和博主有更多互動
博客轉載、線下活動及合做等問題請郵件至 yidong.zheng@ele.me 進行溝通