新聞中心
日常開發(fā)中,秒殺下單、搶紅包等等業(yè)務(wù)場(chǎng)景,都需要用到分布式鎖。而Redis非常適合作為分布式鎖使用,本篇文章重點(diǎn)為大家講解一下Redis分布式鎖解決方案。

創(chuàng)新互聯(lián)于2013年成立,是專業(yè)互聯(lián)網(wǎng)技術(shù)服務(wù)公司,擁有項(xiàng)目成都網(wǎng)站設(shè)計(jì)、成都網(wǎng)站建設(shè)網(wǎng)站策劃,項(xiàng)目實(shí)施與項(xiàng)目整合能力。我們以讓每一個(gè)夢(mèng)想脫穎而出為使命,1280元安義做網(wǎng)站,已為上家服務(wù),為安義各地企業(yè)和個(gè)人服務(wù),聯(lián)系電話:13518219792
從一個(gè)簡單的分布式鎖實(shí)現(xiàn)說起
分布式鎖的Redis實(shí)現(xiàn)很常見,自己實(shí)現(xiàn)和使用第三方庫都很簡單,至少看上去是這樣的,這里就介紹一個(gè)最簡單靠譜的Redis實(shí)現(xiàn)。
最簡單的實(shí)現(xiàn)
實(shí)現(xiàn)很經(jīng)典了,這里只提兩個(gè)要點(diǎn)?
加鎖和解鎖的鎖必須是同一個(gè),常見的解決方案是給每個(gè)鎖一個(gè)鑰匙(唯一ID),加鎖時(shí)生成,解鎖時(shí)判斷。 不能讓一個(gè)資源永久加鎖。常見的解決方案是給一個(gè)鎖的過期時(shí)間。當(dāng)然了還有其他方案,后面再說。 一個(gè)可復(fù)制粘貼的實(shí)現(xiàn)方式如下:
加鎖
public static boolean tryLock(String key, String uniqueId, int seconds) {
return "OK".equals(jedis.set(key, uniqueId, "NX", "EX", seconds));
}
這里其實(shí)是調(diào)用了 SET key value PX milliseoncds NX。
不明白這個(gè)命令的參考下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)的精髓在那個(gè)簡單的lua腳本上,先判斷唯一ID是否相等再操作。
靠譜嗎?
這樣的實(shí)現(xiàn)有什么問題呢?
單點(diǎn)問題。上面的實(shí)現(xiàn)只要一個(gè)master節(jié)點(diǎn)就能搞定,這里的單點(diǎn)指的是單master,就算是個(gè)集群,如果加鎖成功后,鎖從master復(fù)制到slave的時(shí)候掛了,也是會(huì)出現(xiàn)同一資源被多個(gè)client加鎖的。
執(zhí)行時(shí)間超過了鎖的過期時(shí)間。上面寫到為了不出現(xiàn)一直上鎖的情況,加了一個(gè)兜底的過期時(shí)間,時(shí)間到了鎖自動(dòng)釋放,但是,如果在這期間任務(wù)并沒有做完怎么辦?由于GC或者網(wǎng)絡(luò)延遲導(dǎo)致的任務(wù)時(shí)間變長,很難保證任務(wù)一定能在鎖的過期時(shí)間內(nèi)完成。
如何解決這兩個(gè)問題呢?試試看更復(fù)雜的實(shí)現(xiàn)吧。
Redlock算法
對(duì)于第一個(gè)單點(diǎn)問題,順著redis的思路,接下來想到的肯定是Redlock了。Redlock為了解決單機(jī)的問題,需要多個(gè)(大于2)redis的master節(jié)點(diǎn),多個(gè)master節(jié)點(diǎn)互相獨(dú)立,沒有數(shù)據(jù)同步。
Redlock的實(shí)現(xiàn)如下:
1)獲取當(dāng)前時(shí)間。
2)依次獲取N個(gè)節(jié)點(diǎn)的鎖。每個(gè)節(jié)點(diǎn)加鎖的實(shí)現(xiàn)方式同上。這里有個(gè)細(xì)節(jié),就是每次獲取鎖的時(shí)候的過期時(shí)間都不同,需要減去之前獲取鎖的操作的耗時(shí):
比如傳入的鎖的過期時(shí)間為500ms;
獲取第一個(gè)節(jié)點(diǎn)的鎖花了1ms,那么第一個(gè)節(jié)點(diǎn)的鎖的過期時(shí)間就是499ms; 獲取第二個(gè)節(jié)點(diǎn)的鎖花了2ms,那么第二個(gè)節(jié)點(diǎn)的鎖的過期時(shí)間就是497ms; 如果鎖的過期時(shí)間小于等于0了,說明整個(gè)獲取鎖的操作超時(shí)了,整個(gè)操作失敗。 3)判斷是否獲取鎖成功。如果client在上述步驟中獲取到了(N/2 + 1)個(gè)節(jié)點(diǎn)鎖,并且每個(gè)鎖的過期時(shí)間都是大于0的,則獲取鎖成功,否則失敗。失敗時(shí)釋放鎖。
4)釋放鎖。對(duì)所有節(jié)點(diǎn)發(fā)送釋放鎖的指令,每個(gè)節(jié)點(diǎn)的實(shí)現(xiàn)邏輯和上面的簡單實(shí)現(xiàn)一樣。為什么要對(duì)所有節(jié)點(diǎn)操作?因?yàn)榉植际綀?chǎng)景下從一個(gè)節(jié)點(diǎn)獲取鎖失敗不代表在那個(gè)節(jié)點(diǎn)上加速失敗,可能實(shí)際上加鎖已經(jīng)成功了,但是返回時(shí)因?yàn)榫W(wǎng)絡(luò)抖動(dòng)超時(shí)了。
以上就是大家常見的redlock實(shí)現(xiàn)的描述了,一眼看上去就是簡單版本的多master版本,如果真是這樣就太簡單了,接下來分析下這個(gè)算法在各個(gè)場(chǎng)景下是怎樣被玩壞的。
分布式鎖的坑
高并發(fā)場(chǎng)景下的問題
以下問題不是說在并發(fā)不高的場(chǎng)景下不容易出現(xiàn),只是在高并發(fā)場(chǎng)景下出現(xiàn)的概率更高些而已。
性能問題。 性能問題來自于兩個(gè)方面。
獲取鎖的時(shí)間上。如果redlock運(yùn)用在高并發(fā)的場(chǎng)景下,存在N個(gè)master節(jié)點(diǎn),一個(gè)一個(gè)去請(qǐng)求,耗時(shí)會(huì)比較長,從而影響性能。這個(gè)好解決。通過上面描述不難發(fā)現(xiàn),從多個(gè)節(jié)點(diǎn)獲取鎖的操作并不是一個(gè)同步操作,可以是異步操作,這樣可以多個(gè)節(jié)點(diǎn)同時(shí)獲取。即使是并行處理的,還是得預(yù)估好獲取鎖的時(shí)間,保證鎖的TTL > 獲取鎖的時(shí)間+任務(wù)處理時(shí)間。 被加鎖的資源太大。加鎖的方案本身就是會(huì)為了正確性而犧牲并發(fā)的,犧牲和資源大小成正比。這個(gè)時(shí)候可以考慮對(duì)資源做拆分,拆分的方式有兩種: 從業(yè)務(wù)上將鎖住的資源拆分成多段,每段分開加鎖。比如,我要對(duì)一個(gè)商戶做若干個(gè)操作,操作前要鎖住這個(gè)商戶,這時(shí)我可以將若干個(gè)操作拆成多個(gè)獨(dú)立的步驟分開加鎖,提高并發(fā)。
用分桶的思想,將一個(gè)資源拆分成多個(gè)桶,一個(gè)加鎖失敗立即嘗試下一個(gè)。比如批量任務(wù)處理的場(chǎng)景,要處理200w個(gè)商戶的任務(wù),為了提高處理速度,用多個(gè)線程,每個(gè)線程取100個(gè)商戶處理,就得給這100個(gè)商戶加鎖,如果不加處理,很難保證同一時(shí)刻兩個(gè)線程加鎖的商戶沒有重疊,這時(shí)可以按一個(gè)維度,比如某個(gè)標(biāo)簽,對(duì)商戶進(jìn)行分桶,然后一個(gè)任務(wù)處理一個(gè)分桶,處理完這個(gè)分桶再處理下一個(gè)分桶,減少競(jìng)爭。
重試的問題。無論是簡單實(shí)現(xiàn)還是redlock實(shí)現(xiàn),都會(huì)有重試的邏輯。如果直接按上面的算法實(shí)現(xiàn),是會(huì)存在多個(gè)client幾乎在同一時(shí)刻獲取同一個(gè)鎖,然后每個(gè)client都鎖住了部分節(jié)點(diǎn),但是沒有一個(gè)client獲取大多數(shù)節(jié)點(diǎn)的情況。解決的方案也很常見,在重試的時(shí)候讓多個(gè)節(jié)點(diǎn)錯(cuò)開,錯(cuò)開的方式就是在重試時(shí)間中加一個(gè)隨機(jī)時(shí)間。這樣并不能根治這個(gè)問題,但是可以有效緩解問題,親試有效。
節(jié)點(diǎn)宕機(jī)
對(duì)于單master節(jié)點(diǎn)且沒有做持久化的場(chǎng)景,宕機(jī)就掛了,這個(gè)就必須在實(shí)現(xiàn)上支持重復(fù)操作,自己做好冪等。
對(duì)于多master的場(chǎng)景,比如redlock,我們來看這樣一個(gè)場(chǎng)景:
假設(shè)有5個(gè)redis的節(jié)點(diǎn):A、B、C、D、E,沒有做持久化。 client1從A、B、C 3個(gè)節(jié)點(diǎn)獲取鎖成功,那么client1獲取鎖成功。 節(jié)點(diǎn)C掛了。 client2從C、D、E獲取鎖成功,client2也獲取鎖成功,那么在同一時(shí)刻client1和client2同時(shí)獲取鎖,redlock被玩壞了。 怎么解決呢?最容易想到的方案是打開持久化。持久化可以做到持久化每一條redis命令,但這對(duì)性能影響會(huì)很大,一般不會(huì)采用,如果不采用這種方式,在節(jié)點(diǎn)掛的時(shí)候肯定會(huì)損失小部分的數(shù)據(jù),可能我們的鎖就在其中。
另一個(gè)方案是延遲啟動(dòng)。就是一個(gè)節(jié)點(diǎn)掛了修復(fù)后,不立即加入,而是等待一段時(shí)間再加入,等待時(shí)間要大于宕機(jī)那一刻所有鎖的最大TTL。
但這個(gè)方案依然不能解決問題,如果在上述步驟3中B和C都掛了呢,那么只剩A、D、E三個(gè)節(jié)點(diǎn),從D和E獲取鎖成功就可以了,還是會(huì)出問題。那么只能增加master節(jié)點(diǎn)的總量,緩解這個(gè)問題了。增加master節(jié)點(diǎn)會(huì)提高穩(wěn)定性,但是也增加了成本,需要在兩者之間權(quán)衡。
任務(wù)執(zhí)行時(shí)間超過鎖的TTL
之前產(chǎn)線上出現(xiàn)過因?yàn)榫W(wǎng)絡(luò)延遲導(dǎo)致任務(wù)的執(zhí)行時(shí)間遠(yuǎn)超預(yù)期,鎖過期,被多個(gè)線程執(zhí)行的情況。
這個(gè)問題是所有分布式鎖都要面臨的問題,包括基于zookeeper和DB實(shí)現(xiàn)的分布式鎖,這是鎖過期了和client不知道鎖過期了之間的矛盾。
在加鎖的時(shí)候,我們一般都會(huì)給一個(gè)鎖的TTL,這是為了防止加鎖后client宕機(jī),鎖無法被釋放的問題。但是所有這種姿勢(shì)的用法都會(huì)面臨同一個(gè)問題,就是沒法保證client的執(zhí)行時(shí)間一定小于鎖的TTL。雖然大多數(shù)程序員都會(huì)樂觀的認(rèn)為這種情況不可能發(fā)生,我也曾經(jīng)這么認(rèn)為,直到被現(xiàn)實(shí)一次又一次的打臉。
Martin Kleppmann也質(zhì)疑過這一點(diǎn),這里直接用他的圖:
Client1獲取到鎖; Client1開始任務(wù),然后發(fā)生了STW的GC,時(shí)間超過了鎖的過期時(shí)間; Client2 獲取到鎖,開始了任務(wù); Client1的GC結(jié)束,繼續(xù)任務(wù),這個(gè)時(shí)候Client1和Client2都認(rèn)為自己獲取了鎖,都會(huì)處理任務(wù),從而發(fā)生錯(cuò)誤。 Martin Kleppmann舉的是GC的例子,我碰到的是網(wǎng)絡(luò)延遲的情況。不管是哪種情況,不可否認(rèn)的是這種情況無法避免,一旦出現(xiàn)很容易懵逼。
如何解決呢?一種解決方案是不設(shè)置TTL,而是在獲取鎖成功后,給鎖加一個(gè)watchdog,watchdog會(huì)起一個(gè)定時(shí)任務(wù),在鎖沒有被釋放且快要過期的時(shí)候會(huì)續(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是上面兩個(gè),一個(gè)是不傳入TTL,這時(shí)是redisson自己維護(hù),會(huì)主動(dòng)續(xù)期;另外一種是自己傳入TTL,這種redisson就不會(huì)幫我們自動(dòng)續(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.
可以看到,最后加鎖的邏輯會(huì)進(jìn)入到org.redisson.RedissonLock#tryAcquireAsync中,在獲取鎖成功后,會(huì)進(jìn)入scheduleExpirationRenewal,這里面初始化了一個(gè)定時(shí)器,dely的時(shí)間是internalLockLeaseTime / 3。在redisson中,internalLockLeaseTime是30s,也就是每隔10s續(xù)期一次,每次30s。
如果是基于zookeeper實(shí)現(xiàn)的分布式鎖,可以利用zookeeper檢查節(jié)點(diǎn)是否存活,從而實(shí)現(xiàn)續(xù)期,zookeeper分布式鎖沒用過,不詳細(xì)說。
不過這種做法也無法百分百做到同一時(shí)刻只有一個(gè)client獲取到鎖,如果續(xù)期失敗,比如發(fā)生了Martin Kleppmann所說的STW的GC,或者client和redis集群失聯(lián)了,只要續(xù)期失敗,就會(huì)造成同一時(shí)刻有多個(gè)client獲得鎖了。在我的場(chǎng)景下,我將鎖的粒度拆小了,redisson的續(xù)期機(jī)制已經(jīng)夠用了。
如果要做得更嚴(yán)格,得加一個(gè)續(xù)期失敗終止任務(wù)的邏輯。這種做法在以前Python的代碼中實(shí)現(xiàn)過,Java還沒有碰到這么嚴(yán)格的情況。
這里也提下Martin Kleppmann的解決方案,我自己覺得這個(gè)方案并不靠譜,原因后面會(huì)提到。
他的方案是讓加鎖的資源自己維護(hù)一套保證不會(huì)因加鎖失敗而導(dǎo)致多個(gè)client在同一時(shí)刻訪問同一個(gè)資源的情況。
在客戶端獲取鎖的同時(shí),也獲取到一個(gè)資源的token,這個(gè)token是單調(diào)遞增的,每次在寫資源時(shí),都檢查當(dāng)前的token是否是較老的token,如果是就不讓寫。對(duì)于上面的場(chǎng)景,Client1獲取鎖的同時(shí)分配一個(gè)33的token,Client2獲取鎖的時(shí)候分配一個(gè)34的token,在client1 GC期間,Client2已經(jīng)寫了資源,這時(shí)最大的token就是34了,client1 從GC中回來,再帶著33的token寫資源時(shí),會(huì)因?yàn)閠oken過期被拒絕。這種做法需要資源那一邊提供一個(gè)token生成器。
對(duì)于這種fencing的方案,我有幾點(diǎn)問題:
無法保證事務(wù)。示意圖中畫的只有34訪問了storage,但是在實(shí)際場(chǎng)景中,可能出現(xiàn)在一個(gè)任務(wù)內(nèi)多次訪問storage的情況,而且必須是原子的。如果client1帶著33token在GC前訪問過一次storage,然后發(fā)生了GC。client2獲取到鎖,帶著34的token也訪問了storage,這時(shí)兩個(gè)client寫入的數(shù)據(jù)是否還能保證數(shù)據(jù)正確?如果不能,那么這種方案就有缺陷,除非storage自己有其他機(jī)制可以保證,比如事務(wù)機(jī)制;如果能,那么這里的token就是多余的,fencing的方案就是多此一舉。 高并發(fā)場(chǎng)景不實(shí)用。因?yàn)槊看沃挥凶畲蟮膖oken能寫,這樣storage的訪問就是線性的,在高并發(fā)場(chǎng)景下,這種方式會(huì)極大的限制吞吐量,而分布式鎖也大多是在這種場(chǎng)景下用的,很矛盾的設(shè)計(jì)。 這是所有分布式鎖的問題。這個(gè)方案是一個(gè)通用的方案,可以和Redlock用,也可以和其他的lock用。所以我理解僅僅是一個(gè)和Redlock無關(guān)的解決方案。
系統(tǒng)時(shí)鐘漂移
這個(gè)問題只是考慮過,但在實(shí)際項(xiàng)目中并沒有碰到過,因?yàn)槔碚撋鲜强赡艹霈F(xiàn)的,這里也說下。
redis的過期時(shí)間是依賴系統(tǒng)時(shí)鐘的,如果時(shí)鐘漂移過大時(shí)會(huì)影響到過期時(shí)間的計(jì)算。
為什么系統(tǒng)時(shí)鐘會(huì)存在漂移呢?先簡單說下系統(tǒng)時(shí)間,linux提供了兩個(gè)系統(tǒng)時(shí)間:clock realtime和clock monotonic。clock realtime也就是xtime/wall time,這個(gè)時(shí)間可以被用戶改變的,被NTP改變,gettimeofday拿的就是這個(gè)時(shí)間,redis的過期計(jì)算用的也是這個(gè)時(shí)間。
clock monotonic ,直譯過來時(shí)單調(diào)時(shí)間,不會(huì)被用戶改變,但是會(huì)被NTP改變。
最理想的情況,所有系統(tǒng)的時(shí)鐘都時(shí)時(shí)刻刻和NTP服務(wù)器保持同步,但這顯然是不可能的。導(dǎo)致系統(tǒng)時(shí)鐘漂移的原因有兩個(gè): 系統(tǒng)的時(shí)鐘和NTP服務(wù)器不同步。這個(gè)目前沒有特別好的解決方案,只能相信運(yùn)維同學(xué)了。 clock realtime被人為修改。在實(shí)現(xiàn)分布式鎖時(shí),不要使用clock realtime。不過很可惜,redis使用的就是這個(gè)時(shí)間,我看了下Redis 5.0源碼,使用的還是clock realtime。Antirez說過改成clock monotonic的,不過大佬還沒有改。也就是說,人為修改redis服務(wù)器的時(shí)間,就能讓redis出問題了。
新聞標(biāo)題:Redis分布式鎖解決方案
網(wǎng)頁URL:http://m.fisionsoft.com.cn/article/cdjeiod.html


咨詢
建站咨詢
