Redis分布式鎖深入探究
公眾號來源:我沒有三顆心臟??
作者:我沒有三顆心臟一、分布式鎖簡介
鎖 是一種用來解決多個執(zhí)行線程 訪問共享資源 錯誤或數(shù)據(jù)不一致問題的工具。
如果 把一臺服務(wù)器比作一個房子,那么 線程就好比里面的住戶,當他們想要共同訪問一個共享資源,例如廁所的時候,如果廁所門上沒有鎖...更甚者廁所沒裝門...這是會出原則性的問題的..

裝上了鎖,大家用起來就安心多了,本質(zhì)也就是 同一時間只允許一個住戶使用。
而隨著互聯(lián)網(wǎng)世界的發(fā)展,單體應(yīng)用已經(jīng)越來越無法滿足復(fù)雜互聯(lián)網(wǎng)的高并發(fā)需求,轉(zhuǎn)而慢慢朝著分布式方向發(fā)展,慢慢進化成了 更大一些的住戶。所以同樣,我們需要引入分布式鎖來解決分布式應(yīng)用之間訪問共享資源的并發(fā)問題。
為何需要分布式鎖
一般情況下,我們使用分布式鎖主要有兩個場景:
- 避免不同節(jié)點重復(fù)相同的工作:比如用戶執(zhí)行了某個操作有可能不同節(jié)點會發(fā)送多封郵件;
- 避免破壞數(shù)據(jù)的正確性:如果兩個節(jié)點在同一條數(shù)據(jù)上同時進行操作,可能會造成數(shù)據(jù)錯誤或不一致的情況出現(xiàn);
Java 中實現(xiàn)的常見方式
上面我們用簡單的比喻說明了鎖的本質(zhì):同一時間只允許一個用戶操作。所以理論上,能夠滿足這個需求的工具我們都能夠使用 (就是其他應(yīng)用能幫我們加鎖的):
- 基于 MySQL 中的鎖:MySQL 本身有自帶的悲觀鎖
for update關(guān)鍵字,也可以自己實現(xiàn)悲觀/樂觀鎖來達到目的; - 基于 Zookeeper 有序節(jié)點:Zookeeper 允許臨時創(chuàng)建有序的子節(jié)點,這樣客戶端獲取節(jié)點列表時,就能夠當前子節(jié)點列表中的序號判斷是否能夠獲得鎖;
- 基于 Redis 的單線程:由于 Redis 是單線程,所以命令會以串行的方式執(zhí)行,并且本身提供了像
SETNX(set if not exists)這樣的指令,本身具有互斥性;
每個方案都有各自的優(yōu)缺點,例如 MySQL 雖然直觀理解容易,但是實現(xiàn)起來卻需要額外考慮 鎖超時、加事務(wù) 等,并且性能局限于數(shù)據(jù)庫,諸如此類我們在此不作討論,重點關(guān)注 Redis。
Redis 分布式鎖的問題
1)鎖超時
假設(shè)現(xiàn)在我們有兩臺平行的服務(wù) A B,其中 A 服務(wù)在 獲取鎖之后 由于未知神秘力量突然 掛了,那么 B 服務(wù)就永遠無法獲取到鎖了:

所以我們需要額外設(shè)置一個超時時間,來保證服務(wù)的可用性。
但是另一個問題隨即而來:如果在加鎖和釋放鎖之間的邏輯執(zhí)行得太長,以至于超出了鎖的超時限制,也會出現(xiàn)問題。因為這時候第一個線程持有鎖過期了,而臨界區(qū)的邏輯還沒有執(zhí)行完,與此同時第二個線程就提前擁有了這把鎖,導(dǎo)致臨界區(qū)的代碼不能得到嚴格的串行執(zhí)行。
為了避免這個問題,Redis 分布式鎖不要用于較長時間的任務(wù)。如果真的偶爾出現(xiàn)了問題,造成的數(shù)據(jù)小錯亂可能就需要人工的干預(yù)。
有一個稍微安全一點的方案是 將鎖的 value 值設(shè)置為一個隨機數(shù),釋放鎖時先匹配隨機數(shù)是否一致,然后再刪除 key,這是為了 確保當前線程占有的鎖不會被其他線程釋放,除非這個鎖是因為過期了而被服務(wù)器自動釋放的。
但是匹配 value 和刪除 key 在 Redis 中并不是一個原子性的操作,也沒有類似保證原子性的指令,所以可能需要使用像 Lua 這樣的腳本來處理了,因為 Lua 腳本可以 保證多個指令的原子性執(zhí)行。
延伸的討論:GC 可能引發(fā)的安全問題
Martin Kleppmann 曾與 Redis 之父 Antirez 就 Redis 實現(xiàn)分布式鎖的安全性問題進行過深入的討論,其中有一個問題就涉及到 GC。
熟悉 Java 的同學(xué)肯定對 GC 不陌生,在 GC 的時候會發(fā)生 STW(Stop-The-World),這本身是為了保障垃圾回收器的正常執(zhí)行,但可能會引發(fā)如下的問題:

服務(wù) A 獲取了鎖并設(shè)置了超時時間,但是服務(wù) A 出現(xiàn)了 STW 且時間較長,導(dǎo)致了分布式鎖進行了超時釋放,在這個期間服務(wù) B 獲取到了鎖,待服務(wù) A STW 結(jié)束之后又恢復(fù)了鎖,這就導(dǎo)致了 服務(wù) A 和服務(wù) B 同時獲取到了鎖,這個時候分布式鎖就不安全了。
不僅僅局限于 Redis,Zookeeper 和 MySQL 有同樣的問題。
想吃更多瓜的童鞋,可以訪問下列網(wǎng)站看看 Redis 之父 Antirez 怎么說:http://antirez.com/news/101
2)單點/多點問題
如果 Redis 采用單機部署模式,那就意味著當 Redis 故障了,就會導(dǎo)致整個服務(wù)不可用。
而如果采用主從模式部署,我們想象一個這樣的場景:服務(wù) A 申請到一把鎖之后,如果作為主機的 Redis 宕機了,那么 服務(wù) B 在申請鎖的時候就會從從機那里獲取到這把鎖,為了解決這個問題,Redis 作者提出了一種 RedLock 紅鎖 的算法 (Redission 同 Jedis):
// 三個 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();
二、Redis 分布式鎖的實現(xiàn)分布式鎖類似于 "占坑",而 SETNX(SET if Not eXists) 指令就是這樣的一個操作,只允許被一個客戶端占有,我們來看看 源碼(t_string.c/setGenericCommand) 吧:
// SET/ SETEX/ SETTEX/ SETNX 最底層實現(xiàn)
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 的過期時間則保存到上面定義的變量中
// 如果過期時間設(shè)置錯誤則返回錯誤信息
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 函數(shù)是為執(zhí)行寫操作而取出 key 的值對象
// 這里的判斷條件是:
// 1.如果設(shè)置了 NX(不存在),并且在數(shù)據(jù)庫中找到了 key 值
// 2.或者設(shè)置了 XX(存在),并且在數(shù)據(jù)庫中沒有找到該 key
// => 那么回復(fù) 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;
}
// 在當前的數(shù)據(jù)庫中設(shè)置鍵為 key 值為 value 的數(shù)據(jù)
genericSetKey(c->db,key,val,flags & OBJ_SET_KEEPTTL);
// 服務(wù)器每修改一個 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);
}
就像上面介紹的那樣,其實在之前版本的 Redis 中,由于 SETNX 和 EXPIRE 并不是 原子指令,所以在一起執(zhí)行會出現(xiàn)問題。
也許你會想到使用 Redis 事務(wù)來解決,但在這里不行,因為 EXPIRE 命令依賴于 SETNX 的執(zhí)行結(jié)果,而事務(wù)中沒有 if-else 的分支邏輯,如果 SETNX 沒有搶到鎖,EXPIRE 就不應(yīng)該執(zhí)行。
為了解決這個疑難問題,Redis 開源社區(qū)涌現(xiàn)了許多分布式鎖的 library,為了治理這個亂象,后來在 Redis 2.8 的版本中,加入了 SET 指令的擴展參數(shù),使得 SETNX 可以和 EXPIRE 指令一起執(zhí)行了:
> SET lock:test true ex 5 nx
OK
... do something critical ...
> del lock:test
你只需要符合 SET key value [EX seconds | PX milliseconds] [NX | XX] [KEEPTTL] 這樣的格式就好了,你也在下方右拐參照官方的文檔:
- 官方文檔:https://redis.io/commands/set
另外,官方文檔也在 SETNX 文檔中提到了這樣一種思路:把 SETNX 對應(yīng) key 的 value 設(shè)置為
代碼實現(xiàn)
下面用 Jedis 來模擬實現(xiàn)一下,關(guān)鍵代碼如下:
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 script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
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;
}
如果大家想要實時關(guān)注我更新的文章以及分享的干貨的話,可以關(guān)注我的公眾號Java3y。
獲取Java精美腦圖

?獲取Java學(xué)習(xí)路線

獲取開發(fā)常用工具

?加入技術(shù)交流群

在公眾號下回復(fù)「888」即可獲?。?!

點個在看
,分享到朋友圈
,對我真的很重要!!
