在如今這樣一個(gè)張口分布式,閉口微服務(wù)的軟件開(kāi)發(fā)趨勢(shì)下瓮恭,多實(shí)例似乎已經(jīng)不是某種選擇而是一個(gè)無(wú)需多說(shuō)的基本技術(shù)要求了雄坪。
多實(shí)例為我們帶來(lái)穩(wěn)定性提升的同時(shí),也伴隨著更復(fù)雜的技術(shù)要求偎血,原先在本地即可處理的問(wèn)題,全部擴(kuò)展為分布式問(wèn)題盯漂,其中就包含我們今天會(huì)聊到的多實(shí)例同步即分布式鎖問(wèn)題颇玷。JDK 提供的鎖實(shí)現(xiàn)已經(jīng)能夠非常好的解決本地同步問(wèn)題,而擴(kuò)展到多實(shí)例環(huán)境下就缆,Redis帖渠、ZooKeeper 等優(yōu)秀的實(shí)現(xiàn)也使得我們使用分布式鎖變得更加簡(jiǎn)單。
其實(shí)對(duì)于分布式鎖的原理竭宰、分布式鎖的 Redis 實(shí)現(xiàn)空郊、ZK 實(shí)現(xiàn)等等各類文章不計(jì)其數(shù),然而只要簡(jiǎn)單一搜就會(huì)發(fā)現(xiàn)切揭,大多數(shù)文章都在教大家 Redis 分布式鎖的原理和實(shí)現(xiàn)方法狞甚,但卻沒(méi)有幾篇會(huì)寫(xiě)什么實(shí)現(xiàn)是好的實(shí)現(xiàn),是適合用于生產(chǎn)環(huán)境廓旬,高效而考慮全面的實(shí)現(xiàn)哼审。這將是本文討論的內(nèi)容。
分布式鎖的要求
本節(jié)簡(jiǎn)單闡述分布式鎖的基本要求旗吁,通常滿足下述要求便可以說(shuō)是比較完整的實(shí)現(xiàn)了废累。
- 操作原子性
- 與本地鎖一樣,加鎖的過(guò)程必須保證原子性瑟枫,否則失去鎖的意義
- Redis 的單線程模型幫我們解決了大部分原子性的問(wèn)題春霍,但仍然要考慮客戶端代碼的原子性
- 可重入性
- 分布式鎖一樣要考慮可重入的問(wèn)題
- Redis 通常能解決實(shí)例間的可重入問(wèn)題砸西,那么實(shí)例內(nèi)線程間的可重入怎么辦?
- 效率
- Redis 作為通過(guò) TCP 通信的外部服務(wù)址儒,網(wǎng)絡(luò)延遲不可避免芹枷,因此相比本地鎖操作時(shí)間更久
- 分布式鎖獲取失敗的通常做法是線程休眠一段時(shí)間
- 如何才能盡可能減少不必要的通信與休眠?
Local + Remote 結(jié)合實(shí)現(xiàn)分布式鎖
正如上一節(jié)所述离福,采用 Redis杖狼,我們能很好的實(shí)現(xiàn)實(shí)例間的原子性(單線程模型),可重入性(各實(shí)例分配 UUID)妖爷。
而 JDK 的本地鎖(如 ReentrantLock)又能非常完善的解決線程間同步的原子性蝶涩、可重入性。
此外絮识,對(duì)于實(shí)例內(nèi)不同線程間的同步绿聘,JDK 通過(guò) AQS 中一系列的方法確保高效穩(wěn)定,因此省去了與 Redis 通信的消耗次舌。
綜上熄攘,如果將本地鎖與遠(yuǎn)程鎖結(jié)合在一起,便可以分別實(shí)現(xiàn)分布式鎖在實(shí)例內(nèi)與實(shí)例間的各項(xiàng)要求了彼念。
代碼實(shí)現(xiàn)
下文代碼中挪圾,本地鎖使用 ReentrantLock, Redis client 使用 Jedis逐沙。如替換其他方案哲思,按照流程也很簡(jiǎn)單。
整體架構(gòu)
- 初始化鎖
.
└── 初始化鎖
└── new instance
- 獲取鎖
.
└── 獲取鎖
└── 嘗試獲取本地鎖
├── 成功
│ └── 嘗試獲取遠(yuǎn)程鎖
│ ├── 成功
│ │ └── 加鎖完成
│ ├── 失敗
│ │ └── 輪詢遠(yuǎn)程鎖
│ └── 超時(shí)
│ ├── 釋放本地鎖
│ └── 退出
├── 失敗
│ └── 阻塞等待
└── 超時(shí)
└── 退出
- 釋放鎖
.
└── 釋放鎖
├── 當(dāng)前線程持有本地鎖吩案?
│ ├── 是重入狀態(tài)棚赔?(hold count > 1)
│ │ └── 釋放本地鎖
│ └── 非重入狀態(tài)
│ ├── 釋放遠(yuǎn)程鎖
│ └── 釋放本地鎖
└── 未持有本地鎖
└── 無(wú)法釋放,拋出錯(cuò)誤
代碼框架
public class RedisDistributedLock implements Lock {
private static final String OBTAIN_LOCK_SCRIPT = ...
private static final String clientId = UUID.randomUUID().toString();
private static final int EXPIRE_SECONDS = ...;
private final String lockKey;
private final ReentrantLock localLock = new ReentrantLock();
public RedisDistributedLock(String lockKey) {
this.lockKey = lockKey;
}
@Override
public void lock() {
...
}
@Override
public void lockInterruptibly() throws InterruptedException {
throw new UnsupportedOperationException();
}
@Override
public boolean tryLock() {
throw new UnsupportedOperationException();
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
...
}
@Override
public void unlock() {
...
}
@Override
public Condition newCondition() {
throw new UnsupportedOperationException();
}
}
由上述代碼可見(jiàn)徘郭,我們的分布式鎖實(shí)現(xiàn)了 Lock
接口靠益,來(lái)確保依賴倒置,用戶可以方便的在本地鎖與分布式鎖之前切換而無(wú)需改動(dòng)邏輯残揉。
在 class field 中胧后,
-
OBTAIN_LOCK_SCRIPT
是用于執(zhí)行 redis 獲取鎖操作的 lua script,詳情見(jiàn)后文抱环。 -
clientId
用于唯一標(biāo)識(shí)當(dāng)前所在實(shí)例绩卤,是分布式鎖進(jìn)行重入的重要屬性途样,注意該 field 為 static,因此僅此一份濒憋。 -
lockKey
為鎖 key何暇,用于標(biāo)識(shí)一個(gè)鎖,在構(gòu)造函數(shù)中初始化凛驮。 -
localLock
即本地鎖裆站,代碼中采用ReentrantLock
用作本地鎖。
出于演示性質(zhì)考慮黔夭,只實(shí)現(xiàn)了 Lock
中定義的三個(gè)方法:lock()
, tryLock(long time, TimeUnit unit)
, unlock()
宏胯,其他方法可以自由發(fā)散。
接下來(lái)我們將主要介紹獲取鎖本姥、釋放鎖這兩部分代碼肩袍。
lock
private static final String OBTAIN_LOCK_SCRIPT =
"local lockClientId = redis.call('GET', KEYS[1])\n" +
"if lockClientId == ARGV[1] then\n" +
" redis.call('EXPIRE', ARGV[2])\n" +
" return true\n" +
"else if not lockClientId then\n" +
" redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])\n" +
" return true\n" +
"end\n" +
"return false";
@Override
public void lock() {
localLock.lock();
boolean acquired = false;
try {
while (!(acquired = obtainRemoteLock())) {
sleep();
}
} finally {
if (!acquired) {
localLock.unlock();
}
}
}
private boolean obtainRemoteLock() {
return Boolean.parseBoolean((String) getJedis().eval(
OBTAIN_LOCK_SCRIPT, 1, lockKey, clientId, String.valueOf(EXPIRE_SECONDS)));
}
private void sleep() {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
// do not response interrupt
}
}
lock()
中包含了絕大多數(shù)的核心邏輯,可以看到其主要流程如下:
- 獲取本地鎖
- 循環(huán)調(diào)用
obtainRemoteLock()
直至其返回 true婚惫,或拋出異常 - 假如跳出循環(huán)后仍未能獲取到鎖氛赐,則釋放本地鎖
以上流程中,需要細(xì)說(shuō)的正是 obtainRemoteLock()
:
該方法直接通過(guò) eval
來(lái)執(zhí)行了前面提到的 lua 腳本先舷,我們來(lái)看看腳本的內(nèi)容:
-
local lockClientId = redis.call('GET', KEYS[1])
- 此處是通過(guò) get 獲取到了 key 值艰管,并賦值為 lockClientId,其中
KEYS[1]
是 eval 傳入的 key 參數(shù)
- 此處是通過(guò) get 獲取到了 key 值艰管,并賦值為 lockClientId,其中
-
if lockClientId == ARGV[1] then
- 這里將拿到的值與參數(shù) ARGV[1] 進(jìn)行判斷蒋川,結(jié)合
obtainRemoteLock()
的邏輯我們發(fā)現(xiàn) ARGV[1] 其實(shí)是clientId
牲芋,所以假如獲取的值與 clientId 相等,則代表一種情況:獲取鎖的線程與鎖處于同一個(gè)實(shí)例 - 又因?yàn)椋好看潍@取遠(yuǎn)程鎖之前需要先獲取本地鎖捺球,在同一實(shí)例下缸浦,本地鎖確保了同一時(shí)間只能有一個(gè)線程嘗試獲取遠(yuǎn)程鎖
- 結(jié)合上述兩點(diǎn),可以確定:當(dāng) lockClientId 等于 clientId 的時(shí)候氮兵,是同一實(shí)例下的同一線程重入了代碼段裂逐。
-
redis.call('EXPIRE', ARGV[2])
在重入之后刷新鎖超時(shí)時(shí)間,ARGV[2] 即我們傳入的EXPIRE_SECONDS
- 最后直接返回 true胆剧,結(jié)束邏輯
- 這里將拿到的值與參數(shù) ARGV[1] 進(jìn)行判斷蒋川,結(jié)合
-
else if not lockClientId then
- 假如 get 的結(jié)果為 null(nil) 表明鎖還沒(méi)有被任何人獲取絮姆,直接獲取后返回 true
- 這里用到了 redis 的 set 命令
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
-
return false
- 即不是重入醉冤,鎖又存在秩霍,證明鎖被其他實(shí)例持有了,返回 false
上述一連串判斷邏輯蚁阳,因?yàn)槿慷际窃?Redis 內(nèi)執(zhí)行的铃绒,我們完全不用考慮原子性問(wèn)題,因此可以放心大膽的相信執(zhí)行結(jié)果螺捐。
tryLock
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
if ( !localLock.tryLock(time, unit)) {
return false;
}
boolean acquired = false;
try {
long expire = System.currentTimeMillis() + TimeUnit.MILLISECONDS.convert(time, unit);
while (!(acquired = obtainRemoteLock()) && System.currentTimeMillis() < expire) {
sleep();
}
return acquired;
} finally {
if (!acquired) {
localLock.unlock();
}
}
}
結(jié)合 lock()
的邏輯颠悬,tryLock()
看起來(lái)只是增加了超時(shí)邏輯矮燎,并沒(méi)有本質(zhì)的區(qū)別。
unlock
@Override
public void unlock() {
if (!localLock.isHeldByCurrentThread()) {
throw new IllegalStateException("You do not own lock at " + lockKey);
}
if (localLock.getHoldCount() > 1) {
localLock.unlock();
return;
}
try {
if (clientId.equals(getJedis().get(lockKey))) {
throw new IllegalStateException("Lock was released in the store due to expiration. " +
"The integrity of data protected by this lock may have been compromised.");
}
getJedis().del(lockKey);
} finally {
localLock.unlock();
}
}
相比本地鎖赔癌,分布式鎖的解鎖過(guò)程需要考慮的多一些:
- 先判斷嘗試解鎖的線程與持有本地鎖的線程是否一致诞外,實(shí)際上 ReentrantLock.unlock() 原生即有相關(guān)判斷,但是目前我們還暫時(shí)不想讓本地鎖直接被解鎖灾票,因此手動(dòng)判斷一下峡谊。
- 當(dāng)本地鎖重入計(jì)數(shù)大于 1 時(shí),本地鎖解鎖后直接返回刊苍。由于我們的遠(yuǎn)程鎖并沒(méi)有記錄重入計(jì)數(shù)這一參數(shù)既们,因此對(duì)于重入線程的解鎖,只解鎖本地正什。
- 先判斷當(dāng)前遠(yuǎn)程鎖的值是否與本實(shí)例 clientId 相等啥纸,如果不等則認(rèn)為是遠(yuǎn)程鎖超時(shí)被釋放,因此分布式鎖的邏輯已經(jīng)被破壞婴氮,只能拋出異常斯棒。
- 這里涉及到遠(yuǎn)程鎖超時(shí)時(shí)間的設(shè)定問(wèn)題,設(shè)定過(guò)長(zhǎng)可能會(huì)導(dǎo)致死鎖時(shí)間過(guò)長(zhǎng)莹妒,設(shè)定過(guò)短則容易在邏輯未執(zhí)行完便自動(dòng)釋放名船,因此實(shí)際上應(yīng)該結(jié)合業(yè)務(wù)來(lái)設(shè)定。
- 假如一切正常旨怠,則釋放遠(yuǎn)程鎖渠驼,之后再釋放本地鎖。
RedisLockRegistry
對(duì) Spring Integration Redis 熟悉的同學(xué)鉴腻,一定已經(jīng)發(fā)現(xiàn)迷扇,前面的代碼完全就是 RedisLockRegistry 的簡(jiǎn)化版,許多變量名都沒(méi)改爽哎。
是的蜓席,其實(shí)前文所述的代碼就是 RedisLockRegistry 的核心邏輯。RedisLockRegistry 是 Redis 分布式鎖中代碼比較簡(jiǎn)單课锌、功能比較完善的一種實(shí)現(xiàn)厨内,可以很好的滿足常見(jiàn)的分布式鎖要求。(由于采用 sleep-retry 的方式嘗試獲取鎖渺贤,在低時(shí)延或高并發(fā)要求下并不適用)
RedisLockRegistry 對(duì)外部庫(kù)的依賴較少雏胃,雖然執(zhí)行 redis 命令主要使用的 Spring Redis Template,不過(guò)也很容易遷移為類似 Jedis 的方案志鞍。
不過(guò)截至目前 Spring-Integration-Redis 在 github 上面并沒(méi)有放置任何 licence瞭亮,按照 github 的規(guī)定,沒(méi)有 licence 的代碼版權(quán)默認(rèn)受到保護(hù)固棚,因此我們可以學(xué)習(xí)其設(shè)計(jì)思想并自己嘗試實(shí)現(xiàn)统翩,但是最好不要直接移植代碼仙蚜。
Redis 多實(shí)例
通過(guò)上述方法,我們似乎可以成功的將實(shí)例間同步的問(wèn)題轉(zhuǎn)交給 Redis 來(lái)處理厂汗。然而就存在兩種情況:
- 采用單實(shí)例 Redis -- Redis 存在單點(diǎn)風(fēng)險(xiǎn)委粉,應(yīng)用服務(wù)都依賴 Redis, 一旦宕機(jī)業(yè)務(wù)全掛
- 采用 Redis 集群 -- 應(yīng)用服務(wù)實(shí)例間的同步問(wèn)題轉(zhuǎn)化為了 Redis 實(shí)例間的同步問(wèn)題
單實(shí)例 Redis 一定是不可接受的娶桦,所以似乎允許上生產(chǎn)環(huán)境的唯一方案就是 Redis 集群了艳丛。那么如何保證 Redis 實(shí)例間的同步呢?
我們知道趟紊,Redis 集群的數(shù)據(jù)冗余策略不同于類似 HDFS 的 3 Replica氮双,而是采用一對(duì)一主從的形式,每個(gè)節(jié)點(diǎn)一主一從霎匈,主節(jié)點(diǎn)宕機(jī)備節(jié)點(diǎn)上戴差,備節(jié)點(diǎn)也宕機(jī)就全完。同時(shí)铛嘱,主從之間的數(shù)據(jù)同步是異步的暖释。以上這些都是為了超高吞吐量而做出的妥協(xié)。
所以墨吓,設(shè)想會(huì)有這種情況:
當(dāng)應(yīng)用服務(wù)節(jié)點(diǎn) App-A 從 Redis 某主節(jié)點(diǎn) R-Master 獲取到鎖后球匕,R-Master 宕機(jī),此時(shí) R-Master 的數(shù)據(jù)還沒(méi)來(lái)得及同步到 R-Slave√妫現(xiàn)在 R-Slave 成為了主節(jié)點(diǎn)亮曹,這時(shí)候 App-B 嘗試獲取鎖,不出意外的也獲取成功了秘症。
基于以上問(wèn)題照卦,Redis 給出了 RedLock 方案,該方案采用相互孤立的奇數(shù)個(gè) Redis 節(jié)點(diǎn)來(lái)共同存儲(chǔ)鎖乡摹,對(duì)于獲取鎖的操作役耕,只有當(dāng) (N-1)/2 + 1 個(gè) Redis 實(shí)例都獲取成功且獲取時(shí)間不超過(guò)鎖失效時(shí)間的前提下,才真正被判定為獲取到了鎖聪廉,這種場(chǎng)景下鎖的爭(zhēng)搶就看誰(shuí)能先成功操作超過(guò)半數(shù)的 Redis 實(shí)例瞬痘。Redisson 實(shí)現(xiàn)了 RedLock 的客戶端方案。
當(dāng)然板熊,在 Redis 官網(wǎng)上也貼出了各方對(duì)于 RedLock 方案的爭(zhēng)論框全,這里不再贅述。
總之邻邮,對(duì)于問(wèn)題的處理終歸是結(jié)合實(shí)際情況來(lái)權(quán)衡的竣况,
- 假如小概率(但幾乎一定會(huì)發(fā)生)的 Redis 宕機(jī)未同步導(dǎo)致鎖失效的問(wèn)題克婶,業(yè)務(wù)可以承受筒严,那么 RedisLockRegistry + Redis 集群的方案就沒(méi)問(wèn)題
- 對(duì)性能和可靠性都有更高要求的情況下丹泉,不妨使用 RedLock 方案
- 業(yè)務(wù)非常關(guān)鍵,一定要求強(qiáng)一致的分布式鎖鸭蛙,使用 ZooKeeper 的方案會(huì)更好(性能沒(méi)法和 Redis 比)
參考
原創(chuàng)文章摹恨,作者 LENSHOOD, 首發(fā)自:https://lenshood.github.io/2020/02/04/redis-distributed-lock/