鎖 是一種用來解決多個執行線程 訪問共享資源 錯誤或數據不一致問題的工具。html
若是 git
裝上了鎖,你們用起來就安心多了,本質也就是 同一時間只容許一個住戶使用。程序員
而隨着互聯網世界的發展,單體應用已經愈來愈沒法知足複雜互聯網的高併發需求,轉而慢慢朝着分佈式方向發展,慢慢進化成了 更大一些的住戶。因此一樣,咱們須要引入分佈式鎖來解決分佈式應用之間訪問共享資源的併發問題。github
通常狀況下,咱們使用分佈式鎖主要有兩個場景:redis
上面咱們用簡單的比喻說明了鎖的本質:同一時間只容許一個用戶操做。因此理論上,可以知足這個需求的工具咱們都可以使用 算法
for update
關鍵字,也能夠本身實現悲觀/樂觀鎖來達到目的;
SETNX(set if not exists)
這樣的指令,自己具備互斥性;
每一個方案都有各自的優缺點,例如 MySQL 雖然直觀理解容易,可是實現起來卻須要額外考慮 鎖超時、加事務 等,而且性能侷限於數據庫,諸如此類咱們在此不做討論,重點關注 Redis。數據庫
假設如今咱們有兩臺平行的服務 A B,其中 A 服務在 獲取鎖以後 因爲未知神祕力量忽然 掛了,那麼 B 服務就永遠沒法獲取到鎖了:緩存
因此咱們須要額外設置一個超時時間,來保證服務的可用性。安全
可是另外一個問題隨即而來:若是在加鎖和釋放鎖之間的邏輯執行得太長,以致於超出了鎖的超時限制,也會出現問題。由於這時候第一個線程持有鎖過時了,而臨界區的邏輯尚未執行完,與此同時第二個線程就提早擁有了這把鎖,致使臨界區的代碼不能獲得嚴格的串行執行。bash
爲了不這個問題,Redis 分佈式鎖不要用於較長時間的任務。若是真的偶爾出現了問題,形成的數據小錯亂可能就須要人工的干預。
有一個稍微安全一點的方案是 將鎖的 value
值設置爲一個隨機數,釋放鎖時先匹配隨機數是否一致,而後再刪除 key,這是爲了 確保當前線程佔有的鎖不會被其餘線程釋放,除非這個鎖是由於過時了而被服務器自動釋放的。
可是匹配 value
和刪除 key
在 Redis 中並非一個原子性的操做,也沒有相似保證原子性的指令,因此可能須要使用像 Lua 這樣的腳原本處理了,由於 Lua 腳本能夠 保證多個指令的原子性執行。
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
若是 Redis 採用單機部署模式,那就意味着當 Redis 故障了,就會致使整個服務不可用。
而若是採用主從模式部署,咱們想象一個這樣的場景:
// 三個 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();
複製代碼
分佈式鎖相似於 "佔坑",而 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;
}
複製代碼
本文已收錄至個人 Github 程序員成長系列 【More Than Java】,學習,不止 Code,歡迎 star:github.com/wmyskxz/Mor… 我的公衆號 :wmyskxz, 我的獨立域名博客:wmyskxz.com,堅持原創輸出,下方掃碼關注,2020,與您共同成長!
很是感謝各位人才能 看到這裏,若是以爲本篇文章寫得不錯,以爲 「我沒有三顆心臟」有點東西 的話,求點贊,求關注,求分享,求留言!
創做不易,各位的支持和承認,就是我創做的最大動力,咱們下篇文章見!