一、分布式鎖簡介
鎖 是一種用來解決多個執行線程 訪問共享資源 錯誤或數據不一致問題的工具。
如果 把一台服務器比作一個房子,那么 線程就好比里面的住戶,當他們想要共同訪問一個共享資源,例如廁所的時候,如果廁所門上沒有鎖...更甚者廁所沒裝門...這是會出原則性的問題的..
裝上了鎖,大家用起來就安心多了,本質也就是 同一時間只允許一個住戶使用。
而隨着互聯網世界的發展,單體應用已經越來越無法滿足復雜互聯網的高並發需求,轉而慢慢朝着分布式方向發展,慢慢進化成了 更大一些的住戶。所以同樣,我們需要引入分布式鎖來解決分布式應用之間訪問共享資源的並發問題。
為何需要分布式鎖
一般情況下,我們使用分布式鎖主要有兩個場景:
- 避免不同節點重復相同的工作:比如用戶執行了某個操作有可能不同節點會發送多封郵件;
- 避免破壞數據的正確性:如果兩個節點在同一條數據上同時進行操作,可能會造成數據錯誤或不一致的情況出現;
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 怎么說:http://antirez.com/news/101
2)單點/多點問題
如果 Redis 采用單機部署模式,那就意味着當 Redis 故障了,就會導致整個服務不可用。
而如果采用主從模式部署,我們想象一個這樣的場景:服務 A 申請到一把鎖之后,如果作為主機的 Redis 宕機了,那么 服務 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 分布式鎖的實現
分布式鎖類似於 "占坑",而 SETNX(SET if Not eXists)
指令就是這樣的一個操作,只允許被一個客戶端占有,我們來看看 源碼(t_string.c/setGenericCommand) 吧:
// 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);
}
就像上面介紹的那樣,其實在之前版本的 Redis 中,由於 SETNX
和 EXPIRE
並不是 原子指令,所以在一起執行會出現問題。
也許你會想到使用 Redis 事務來解決,但在這里不行,因為 EXPIRE
命令依賴於 SETNX
的執行結果,而事務中沒有 if-else
的分支邏輯,如果 SETNX
沒有搶到鎖,EXPIRE
就不應該執行。
為了解決這個疑難問題,Redis 開源社區涌現了許多分布式鎖的 library,為了治理這個亂象,后來在 Redis 2.8 的版本中,加入了 SET
指令的擴展參數,使得 SETNX
可以和 EXPIRE
指令一起執行了:
> SET lock:test true ex 5 nx
OK
... do something critical ...
> del lock:test
你只需要符合 SET key value [EX seconds | PX milliseconds] [NX | XX] [KEEPTTL]
這樣的格式就好了,你也在下方右拐參照官方的文檔:
另外,官方文檔也在 SETNX
文檔中提到了這樣一種思路:把 SETNX 對應 key 的 value 設置為 <current Unix time + lock timeout + 1>,這樣在其他客戶端訪問時就能夠自己判斷是否能夠獲取下一個 value 為上述格式的鎖了。
代碼實現
下面用 Jedis 來模擬實現以下,關鍵代碼如下:
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;
}
- 引用自下方 參考資料 3,其中還有 RedLock 的實現和測試,有興趣的童鞋可以戳一下
推薦閱讀
- 【官方文檔】Distributed locks with Redis - https://redis.io/topics/distlock
- Redis【入門】就這一篇! - https://www.wmyskxz.com/2018/05/31/redis-ru-men-jiu-zhe-yi-pian/
- Redission - Redis Java Client 源碼 - https://github.com/redisson/redisson
- 手寫一個 Jedis 以及 JedisPool - https://juejin.im/post/5e5101c46fb9a07cab3a953a
參考資料
- 再有人問你分布式鎖,這篇文章扔給他 - https://juejin.im/post/5bbb0d8df265da0abd3533a5#heading-0
- 【官方文檔】Distributed locks with Redis - https://redis.io/topics/distlock
- 【分布式緩存系列】Redis實現分布式鎖的正確姿勢 - https://www.cnblogs.com/zhili/p/redisdistributelock.html
- Redis源碼剖析和注釋(九)--- 字符串命令的實現(t_string) - https://blog.csdn.net/men_wen/article/details/70325566
- 《Redis 深度歷險》 - 錢文品/ 著
- 本文已收錄至我的 Github 程序員成長系列 【More Than Java】,學習,不止 Code,歡迎 star:https://github.com/wmyskxz/MoreThanJava
- 個人公眾號 :wmyskxz,個人獨立域名博客:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!
非常感謝各位人才能 看到這里,如果覺得本篇文章寫得不錯,覺得 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!
創作不易,各位的支持和認可,就是我創作的最大動力,我們下篇文章見!