03.04 Redis——分佈式鎖深入探究



一、分佈式鎖簡介

是一種用來解決多個執行線程 訪問共享資源 錯誤或數據不一致問題的工具。

如果

把一臺服務器比作一個房子

,那麼 線程就好比裡面的住戶,當他們想要共同訪問一個共享資源,例如廁所的時候,如果廁所門上沒有鎖...更甚者廁所沒裝門...這是會出原則性的問題的..


裝上了鎖,大家用起來就安心多了,本質也就是 同一時間只允許一個住戶使用

而隨著互聯網世界的發展,單體應用已經越來越無法滿足複雜互聯網的高併發需求,轉而慢慢朝著分佈式方向發展,慢慢進化成了

更大一些的住戶。所以同樣,我們需要引入分佈式鎖來解決分佈式應用之間訪問共享資源的併發問題。

為何需要分佈式鎖

一般情況下,我們使用分佈式鎖主要有兩個場景:

避免不同節點重複相同的工作:比如用戶執行了某個操作有可能不同節點會發送多封郵件;避免破壞數據的正確性:如果兩個節點在同一條數據上同時進行操作,可能會造成數據錯誤或不一致的情況出現;

Java 中實現的常見方式

上面我們用簡單的比喻說明了鎖的本質:同一時間只允許一個用戶操作。所以理論上,能夠滿足這個需求的工具我們都能夠使用

(就是其他應用能幫我們加鎖的)


基於 MySQL 中的鎖:MySQL 本身有自帶的悲觀鎖 for update 關鍵字,也可以自己實現悲觀/樂觀鎖來達到目的;
基於 Zookeeper 有序節點:Zookeeper 允許臨時創建有序的子節點,這樣客戶端獲取節點列表時,就能夠當前子節點列表中的序號判斷是否能夠獲得鎖;基於 Redis 的單線程:由於 Redis 是單線程,所以命令會以串行的方式執行,並且本身提供了像 SETNX(set if not exists) 這樣的指令,本身具有互斥性;

每個方案都有各自的優缺點,例如 MySQL 雖然直觀理解容易,但是實現起來卻需要額外考慮 鎖超時加事務 等,並且性能侷限於數據庫,諸如此類我們在此不作討論,重點關注 Redis。

Redis 分佈式鎖的問題

1)鎖超時

假設現在我們有兩臺平行的服務 A B,其中 A 服務在 獲取鎖之後 由於未知神秘力量突然 掛了,那麼 B 服務就永遠無法獲取到鎖了:

所以我們需要額外設置一個超時時間,來保證服務的可用性。

但是另一個問題隨即而來:如果在加鎖和釋放鎖之間的邏輯執行得太長,以至於超出了鎖的超時限制,也會出現問題。因為這時候第一個線程持有鎖過期了,而臨界區的邏輯還沒有執行完,與此同時第二個線程就提前擁有了這把鎖,導致臨界區的代碼不能得到嚴格的串行執行。

為了避免這個問題,Redis 分佈式鎖不要用於較長時間的任務。如果真的偶爾出現了問題,造成的數據小錯亂可能就需要人工的干預。

有一個稍微安全一點的方案是 將鎖的 value 值設置為一個隨機數,釋放鎖時先匹配隨機數是否一致,然後再刪除 key,這是為了 確保當前線程佔有的鎖不會被其他線程釋放,除非這個鎖是因為過期了而被服務器自動釋放的。

但是匹配 value 和刪除 key 在 Redis 中並不是一個原子性的操作,也沒有類似保證原子性的指令,所以可能需要使用像 Lua 這樣的腳本來處理了,因為 Lua 腳本可以

保證多個指令的原子性執行

延伸的討論:GC 可能引發的安全問題

Martin Kleppmann 曾與 Redis 之父 Antirez 就 Redis 實現分佈式鎖的安全性問題進行過深入的討論,其中有一個問題就涉及到 GC

熟悉 Java 的同學肯定對 GC 不陌生,在 GC 的時候會發生 STW(Stop-The-World),這本身是為了保障垃圾回收器的正常執行,但可能會引發如下的問題:

服務 A 獲取了鎖並設置了超時時間,但是服務 A 出現了 STW 且時間較長,導致了分佈式鎖進行了超時釋放,在這個期間服務 B 獲取到了鎖,待服務 A STW 結束之後又恢復了鎖,這就導致了 服務 A 和服務 B 同時獲取到了鎖,這個時候分佈式鎖就不安全了。

不僅僅侷限於 Redis,Zookeeper 和 MySQL 有同樣的問題。

想吃更多瓜的童鞋,可以訪問下列網站看看 Redis 之父 Antirez 怎麼說:antirez.com/news/101

2)單點/多點問題

如果 Redis 採用單機部署模式,那就意味著當 Redis 故障了,就會導致整個服務不可用。

而如果採用主從模式部署,我們想象一個這樣的場景:

服務 A

申請到一把鎖之後,如果作為主機的 Redis 宕機了,那麼 服務 B 在申請鎖的時候就會從從機那裡獲取到這把鎖,為了解決這個問題,Redis 作者提出了一種 RedLock 紅鎖 的算法 (Redission 同 Jedis):


<code>// 三個 Redis 集群
RLock lock1 = redissionInstance1.getLock("lock1");
RLock lock2 = redissionInstance2.getLock("lock2");
RLock lock3 = redissionInstance3.getLock("lock3");

RedissionRedLock lock = new RedissionLock(lock1, lock2, lock2);
lock.lock();
// do something....
lock.unlock();/<code>

二、Redis 分佈式鎖的實現

分佈式鎖類似於 "佔坑",而 SETNX(SET if Not eXists) 指令就是這樣的一個操作,只允許被一個客戶端佔有,我們來看看 源碼(t_string.c/setGenericCommand) 吧:

<code>// SET/ SETEX/ SETTEX/ SETNX 最底層實現
void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
long long milliseconds = 0; /* initialized to avoid any harmness warning */

// 如果定義了 key 的過期時間則保存到上面定義的變量中
// 如果過期時間設置錯誤則返回錯誤信息
if (expire) {
if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK)
return;
if (milliseconds <= 0) {
addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name);
return;
}
if (unit == UNIT_SECONDS) milliseconds *= 1000;
}

// lookupKeyWrite 函數是為執行寫操作而取出 key 的值對象
// 這裡的判斷條件是:
// 1.如果設置了 NX(不存在),並且在數據庫中找到了 key 值


// 2.或者設置了 XX(存在),並且在數據庫中沒有找到該 key
// => 那麼回覆 abort_reply 給客戶端
if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
(flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL))
{
addReply(c, abort_reply ? abort_reply : shared.null[c->resp]);
return;
}

// 在當前的數據庫中設置鍵為 key 值為 value 的數據
genericSetKey(c->db,key,val,flags & OBJ_SET_KEEPTTL);
// 服務器每修改一個 key 後都會修改 dirty 值
server.dirty++;
if (expire) setExpire(c,c->db,key,mstime()+milliseconds);
notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC,
"expire",key,c->db->id);
addReply(c, ok_reply ? ok_reply : shared.ok);
}/<code>

就像上面介紹的那樣,其實在之前版本的 Redis 中,由於 SETNX 和 EXPIRE 並不是 原子指令,所以在一起執行會出現問題。

也許你會想到使用 Redis 事務來解決,但在這裡不行,因為 EXPIRE 命令依賴於 SETNX 的執行結果,而事務中沒有 if-else 的分支邏輯,如果 SETNX 沒有搶到鎖,EXPIRE 就不應該執行。

為了解決這個疑難問題,Redis 開源社區湧現了許多分佈式鎖的 library,為了治理這個亂象,後來在 Redis 2.8 的版本中,加入了 SET 指令的擴展參數,使得 SETNX 可以和 EXPIRE 指令一起執行了:

<code>> SET lock:test true ex 5 nx
OK
... do something critical ...
> del lock:test/<code>

你只需要符合 SET key value [EX seconds | PX milliseconds] [NX | XX] [KEEPTTL] 這樣的格式就好了,你也在下方右拐參照官方的文檔:

官方文檔:redis.io/commands/se…

另外,官方文檔也在 SETNX 文檔中提到了這樣一種思路:把 SETNX 對應 key 的 value 設置為 <current>,這樣在其他客戶端訪問時就能夠自己判斷是否能夠獲取下一個 value 為上述格式的鎖了。

代碼實現

下面用 Jedis 來模擬實現以下,關鍵代碼如下:

<code>private static final String LOCK_SUCCESS = "OK";
private static final Long RELEASE_SUCCESS = 1L;
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";

@Override
public String acquire() {
try {
// 獲取鎖的超時時間,超過這個時間則放棄獲取鎖
long end = System.currentTimeMillis() + acquireTimeout;
// 隨機生成一個 value
String requireToken = UUID.randomUUID().toString();
while (System.currentTimeMillis() < end) {
String result = jedis
.set(lockKey, requireToken, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return requireToken;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {


Thread.currentThread().interrupt();
}
}
} catch (Exception e) {
log.error("acquire lock due to error", e);
}

return null;
}

@Override
public boolean release(String identify) {
if (identify == null) {
return false;
}

String/> Object result = new Object();
try {
result = jedis.eval(script, Collections.singletonList(lockKey),
Collections.singletonList(identify));
if (RELEASE_SUCCESS.equals(result)) {
log.info("release lock success, requestToken:{}", identify);
return true;
}
} catch (Exception e) {
log.error("release lock due to error", e);
} finally {
if (jedis != null) {
jedis.close();
}
}

log.info("release lock failed, requestToken:{}, result:{}", identify, result);
return false;
}/<code>

最後:


上面都是自己整理好的!我就把資料貢獻出來給有需要的人!順便求一波關注,哈哈~各位小夥伴關注我後私信【Java】就可以免費領取噠