分散式鎖沒那麼難,手把手教你實現 Redis 分佈鎖!|保姆級教程

樓下小黑哥發表於2020-06-08

書接上文

上篇文章「MySQL 可重複讀,差點就讓我背上了一個 P0 事故!」釋出之後,收到很多小夥伴們的留言,從中又學習到很多,總結一下。

上篇文章可能舉得例子有點不恰當,導致有些小夥伴沒看懂為什麼餘額會變負。

這次我們舉得實際一點,還是上篇文章 account 表,假設 id=1,balance=1000,不過這次我們扣款 1000,兩個事務的時序圖如下:

這次使用兩個命令視窗真實執行一把:

注意事務 2,③處查詢到 id=1,balance=1000,但是實際上由於此時事務 1 已經提交,最新結果如②處所示 id=1,balance=900

本來 Java 程式碼層會做一層餘額判斷:

if (balance - amount < 0) {
  throw new XXException("餘額不足,扣減失敗");
}

但是此時由於 ③ 處使用快照讀,讀到是個舊值,未讀到最新值,導致這層校驗失效,從而程式碼繼續往下執行,執行了資料更新。

更新語句又採用如下寫法:

UPDATE account set balance=balance-1000 WHERE id =1;

這條更新語句又必須是在這條記錄的最新值的基礎做更新,更新語句執行結束,這條記錄就變成了 id=1,balance=-1000

之前有朋友疑惑 t12 更新之後,再次進行快照讀,結果會是多少。

上圖執行結果 ④ 可以看到結果為 id=1,balance=-1000,可以看到已經查詢最新的結果記錄。

這行資料最新版本由於是事務 2 自己更新的,自身事務更新永遠對自己可見

另外這次問題上本質上因為 Java 層與資料庫層資料不一致導致,有的朋友留言提出,可以在更新餘額時加一層判斷:

UPDATE account set balance=balance-1000 WHERE id =1 and balance>0;

然後更新完成,Java 層判斷更新有效行數是否大於 0。這種做法確實能規避這個問題。

最後這位朋友留言總結的挺好,貼上一下:

先贊後看,微信搜尋「程式通事」,關注就完事了

手擼分散式鎖

現在切回正文,這篇文章本來是準備寫下 Mysql 查詢左匹配的問題,但是還沒研究出來。那就先寫下最近在鼓搗一個東西,使用 Redis 實現可重入分佈鎖。

看到這裡,有的朋友可能會提出來使用 redisson 不香嗎,為什麼還要自己實現?

哎,redisson 真的很香,但是現有專案中沒辦法使用,只好自己手擼一個可重入的分散式鎖了。

雖然用不了 redisson,但是我可以研究其原始碼,最後實現的可重入分佈鎖參考了 redisson 實現方式。

分散式鎖

分散式鎖特性就要在於排他性,同一時間內多個呼叫方加鎖競爭,只能有一個呼叫方加鎖成功。

Redis 由於內部單執行緒的執行,內部按照請求先後順序執行,沒有併發衝突,所以只會有一個呼叫方才會成功獲取鎖。

而且 Redis 基於記憶體操作,加解鎖速度效能高,另外我們還可以使用叢集部署增強 Redis 可用性。

加鎖

使用 Redis 實現一個簡單的分散式鎖,非常簡單,可以直接使用 SETNX 命令。

SETNX 是『SET if Not eXists』,如果不存在,才會設定,使用方法如下:

不過直接使用 SETNX 有一個缺陷,我們沒辦法對其設定過期時間,如果加鎖客戶端當機了,這就導致這把鎖獲取不了了。

有的同學可能會提出,執行 SETNX 之後,再執行 EXPIRE 命令,主動設定過期時間,偽碼如下:

var result = setnx lock "client"
if(result==1){
    // 有效期 30 s
    expire lock 30
}

不過這樣還是存在缺陷,加鎖程式碼並不能原子執行,如果呼叫加鎖語句,還沒來得及設定過期時間,應用就當機了,還是會存在鎖過期不了的問題。

不過這個問題在 Redis 2.6.12 版本 就可以被完美解決。這個版本增強了 SET 命令,可以通過帶上 NX,EX 命令原子執行加鎖操作,解決上述問題。引數含義如下:

  • EX second :設定鍵的過期時間,單位為秒
  • NX 當鍵不存在時,進行設定操作,等同與 SETNX 操作

使用 SET 命令實現分散式鎖只需要一行程式碼:

SET lock_name anystring NX EX lock_time

解鎖

解鎖相比加鎖過程,就顯得非常簡單,只要呼叫 DEL 命令刪除鎖即可:

DEL lock_name

不過這種方式卻存在一個缺陷,可能會發生錯解鎖問題。

假設應用 1 加鎖成功,鎖超時時間為 30s。由於應用 1 業務邏輯執行時間過長,30 s 之後,鎖過期自動釋放。

這時應用 2 接著加鎖,加鎖成功,執行業務邏輯。這個期間,應用 1 終於執行結束,使用 DEL 成功釋放鎖。

這樣就導致了應用 1 錯誤釋放應用 2 的鎖,另外鎖被釋放之後,其他應用可能再次加鎖成功,這就可能導致業務重複執行。

為了使鎖不被錯誤釋放,我們需要在加鎖時設定隨機字串,比如 UUID。

SET lock_name uuid NX EX lock_time

釋放鎖時,需要提前獲取當前鎖儲存的值,然後與加鎖時的 uuid 做比較,虛擬碼如下:

var value= get lock_name
if value == uuid
	// 釋放鎖成功
else
	// 釋放鎖失敗

上述程式碼我們不能通過 Java 程式碼執行,因為無法保證上述程式碼原子化執行。

幸好 Redis 2.6.0 增加執行 Lua 指令碼的功能,lua 程式碼可以執行在 Redis 伺服器的上下文中,並且整個操作將會被當成一個整體執行,中間不會被其他命令插入。

這就保證了指令碼將會以原子性的方式執行,當某個指令碼正在執行的時候,不會有其他指令碼或 Redis 命令被執行。在其他的別的客戶端看來,執行指令碼的效果,要麼是不可見的,要麼就是已完成的。

EVAL 與 EVALSHA

EVAL

Redis 可以使用 EVAL 執行 LUA 指令碼,而我們可以在 LUA 指令碼中執行判斷求值邏輯。EVAL 執行方式如下:

EVAL script numkeys key [key ...] arg [arg ...]

numkeys 引數用於建明引數,即後面 key 陣列的個數。

key [key ...] 代表需要在指令碼中用到的所有 Redis key,在 Lua 指令碼使用使用陣列的方式訪問 key,類似如下 KEYS[1]KEYS[2]。注意 Lua 陣列起始位置與 Java 不同,Lua 陣列是從 1 開始。

命令最後,是一些附加引數,可以用來當做 Redis Key 值儲存的 Value 值,使用方式如 KEYS 變數一樣,類似如下:ARGV[1]ARGV[2]

用一個簡單例子執行一下 EVAL 命令:

eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2],ARGV[3]}" 2 key1 key2 first second third

執行效果如下:

可以看到 KEYSARGVS內部陣列可以不一致。

在 Lua 指令碼可以使用下面兩個函式執行 Redis 命令:

  • redis.call()
  • redis.pcall()

兩個函式作用法與作用完全一致,只不過對於錯誤的處理方式不一致,感興趣的小夥伴可以具體點選以下連結,檢視錯誤處理一章。

http://doc.redisfans.com/script/eval.html

下面我們統一在 Lua 指令碼中使用 redis.call(),執行以下命令:

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 foo 樓下小黑哥

執行效果如下:

EVALSHA

EVAL 命令每次執行時都需要傳送 Lua 指令碼,但是 Redis 並不會每次都會重新編譯指令碼。

當 Redis 第一次收到 Lua 指令碼時,首先將會對 Lua 指令碼進行 sha1 獲取簽名值,然後內部將會對其快取起來。後續執行時,直接通過 sha1 計算過後簽名值查詢已經編譯過的指令碼,加快執行速度。

雖然 Redis 內部已經優化執行的速度,但是每次都需要傳送指令碼,還是有網路傳輸的成本,如果指令碼很大,這其中花在網路傳輸的時間就會相應的增加。

所以 Redis 又實現了 EVALSHA 命令,原理與 EVAL 一致。只不過 EVALSHA 只需要傳入指令碼經過 sha1計算過後的簽名值即可,這樣大大的減少了傳輸的位元組大小,減少了網路耗時。

EVALSHA命令如下:

evalsha c686f316aaf1eb01d5a4de1b0b63cd233010e63d 1 foo 樓下小黑哥

執行效果如下:

SCRIPT FLUSH 命令用來清除所有 Lua 指令碼快取。

可以看到,如果之前未執行過 EVAL命令,直接執行 EVALSHA 將會報錯。

優化執行 EVAL

我們可以結合使用 EVALEVALSHA,優化程式。下面就不寫偽碼了,以 Jedis 為例,優化程式碼如下:

//連線本地的 Redis 服務
Jedis jedis = new Jedis("localhost", 6379);
jedis.auth("1234qwer");

System.out.println("服務正在執行: " + jedis.ping());

String lua_script = "return redis.call('set',KEYS[1],ARGV[1])";
String lua_sha1 = DigestUtils.sha1DigestAsHex(lua_script);

try {
    Object evalsha = jedis.evalsha(lua_sha1, Lists.newArrayList("foo"), Lists.newArrayList("樓下小黑哥"));
} catch (Exception e) {
    Throwable current = e;
    while (current != null) {
        String exMessage = current.getMessage();
        // 包含 NOSCRIPT,代表該 lua 指令碼從未被執行,需要先執行 eval 命令
        if (exMessage != null && exMessage.contains("NOSCRIPT")) {
            Object eval = jedis.eval(lua_script, Lists.newArrayList("foo"), Lists.newArrayList("樓下小黑哥"));
            break;
        }

    }
}
String foo = jedis.get("foo");
System.out.println(foo);

上面的程式碼看起來還是很複雜吧,不過這是使用原生 jedis 的情況下。如果我們使用 Spring Boot 的話,那就沒這麼麻煩了。Spring 元件執行的 Eval 方法內部就包含上述程式碼的邏輯。

不過需要注意的是,如果 Spring-Boot 使用 Jedis 作為連線客戶端,並且使用Redis Cluster 叢集模式,需要使用 2.1.9 以上版本的spring-boot-starter-data-redis,不然執行過程中將會丟擲:

org.springframework.dao.InvalidDataAccessApiUsageException: EvalSha is not supported in cluster environment.

詳細情況可以參考這個修復的 IssueAdd support for scripting commands with Jedis Cluster

優化分散式鎖

講完 Redis 執行 LUA 指令碼的相關命令,我們來看下如何優化上面的分散式鎖,使其無法釋放其他應用加的鎖。

以下程式碼基於 spring-boot 2.2.7.RELEASE 版本,Redis 底層連線使用 Jedis。

加鎖的 Redis 命令如下:

SET lock_name uuid NX EX lock_time

加鎖程式碼如下:

/**
 * 非阻塞式加鎖,若鎖存在,直接返回
 *
 * @param lockName  鎖名稱
 * @param request   唯一標識,防止其他應用/執行緒解鎖,可以使用 UUID 生成
 * @param leaseTime 超時時間
 * @param unit      時間單位
 * @return
 */
public Boolean tryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
    // 注意該方法是在 spring-boot-starter-data-redis 2.1 版本新增加的,若是之前版本 可以執行下面的方法
    return stringRedisTemplate.opsForValue().setIfAbsent(lockName, request, leaseTime, unit);
}

由於setIfAbsent方法是在 spring-boot-starter-data-redis 2.1 版本新增加,之前版本無法設定超時時間。如果使用之前的版本的,需要如下方法:

/**
 * 適用於 spring-boot-starter-data-redis 2.1 之前的版本
 *
 * @param lockName
 * @param request
 * @param leaseTime
 * @param unit
 * @return
 */
public Boolean doOldTryLock(String lockName, String request, long leaseTime, TimeUnit unit) {
    Boolean result = stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> {
        RedisSerializer valueSerializer = stringRedisTemplate.getValueSerializer();
        RedisSerializer keySerializer = stringRedisTemplate.getKeySerializer();

        Boolean innerResult = connection.set(keySerializer.serialize(lockName),
                valueSerializer.serialize(request),
                Expiration.from(leaseTime, unit),
                RedisStringCommands.SetOption.SET_IF_ABSENT
        );
        return innerResult;
    });
    return result;
}

解鎖需要使用 Lua 指令碼:

-- 解鎖程式碼
-- 首先判斷傳入的唯一標識是否與現有標識一致
-- 如果一致,釋放這個鎖,否則直接返回
if redis.call('get', KEYS[1]) == ARGV[1] then
   return redis.call('del', KEYS[1])
else
   return 0
end

這段指令碼將會判斷傳入的唯一標識是否與 Redis 儲存的標示一致,如果一直,釋放該鎖,否則立刻返回。

釋放鎖的方法如下:

/**
 * 解鎖
 * 如果傳入應用標識與之前加鎖一致,解鎖成功
 * 否則直接返回
 * @param lockName 鎖
 * @param request 唯一標識
 * @return
 */
public Boolean unlock(String lockName, String request) {
    DefaultRedisScript<Boolean> unlockScript = new DefaultRedisScript<>();
    unlockScript.setLocation(new ClassPathResource("simple_unlock.lua"));
    unlockScript.setResultType(Boolean.class);
    return stringRedisTemplate.execute(unlockScript, Lists.newArrayList(lockName), request);
}

由於公號外鏈無法直接跳轉,關注『程式通事』,回覆分散式鎖獲取原始碼。

Redis 分散式鎖的缺陷

無法重入

由於上述加鎖命令使用了 SETNX ,一旦鍵存在就無法再設定成功,這就導致後續同一執行緒內繼續加鎖,將會加鎖失敗。

如果想將 Redis 分散式鎖改造成可重入的分散式鎖,有兩種方案:

  • 本地應用使用 ThreadLocal 進行重入次數計數,加鎖時加 1,解鎖時減 1,當計數變為 0 釋放鎖
  • 第二種,使用 Redis Hash 表儲存可重入次數,使用 Lua 指令碼加鎖/解鎖

第一種方案可以參考這篇文章分散式鎖的實現之 redis 篇。第二個解決方案,下一篇文章就會具體來聊聊,敬請期待。

鎖超時釋放

假設執行緒 A 加鎖成功,鎖超時時間為 30s。由於執行緒 A 內部業務邏輯執行時間過長,30s 之後鎖過期自動釋放。

此時執行緒 B 成功獲取到鎖,進入執行內部業務邏輯。此時執行緒 A 還在執行執行業務,而執行緒 B 又進入執行這段業務邏輯,這就導致業務邏輯重複被執行。

這個問題我覺得,一般由於鎖的超時時間設定不當引起,可以評估下業務邏輯執行時間,在這基礎上再延長一下超時時間。

如果超時時間設定合理,但是業務邏輯還有偶發的超時,個人覺得需要排查下業務執行過長的問題。

如果說一定要做到業務執行期間,鎖只能被一個執行緒佔有的,那就需要增加一個守護執行緒,定時為即將的過期的但未釋放的鎖增加有效時間。

加鎖成功後,同時建立一個守護執行緒。守護執行緒將會定時檢視鎖是否即將到期,如果鎖即將過期,那就執行 EXPIRE 等命令重新設定過期時間。

說實話,如果要這麼做,真的挺複雜的,感興趣的話可以參考下 redisson watchdog 實現方式。

Redis 分散式鎖叢集問題

為了保證生產高可用,一般我們會採用主從部署方式。採用這種方式,我們可以將讀寫分離,主節點提供寫服務,從節點提供讀服務。

Redis 主從之間資料同步採用非同步複製方式,主節點寫入成功後,立刻返回給客戶端,然後非同步複製給從節點。

如果資料寫入主節點成功,但是還未複製給從節點。此時主節點掛了,從節點立刻被提升為主節點。

這種情況下,還未同步的資料就丟失了,其他執行緒又可以被加鎖了。

針對這種情況, Redis 官方提出一種 RedLock 的演算法,需要有 N 個Redis 主從節點,解決該問題,詳情參考:

https://redis.io/topics/distlock。

這個演算法自己實現還是很複雜的,幸好 redisson 已經實現的 RedLock,詳情參考:redisson redlock

總結

本來這篇文章是想寫 Redis 可重入分散式鎖的,可是沒想到寫分散式鎖的實現方案就已經寫了這麼多,再寫下去,文章可能就很長,所以拆分成兩篇來寫。

嘿嘿,這不下星期不用想些什麼了,真是個小機靈鬼~

好了,幫大家再次總結一下本文內容。

簡單的 Redis 分散式鎖的實現方式還是很簡單的,我們可以直接用 SETNX/DEL 命令實現加解鎖。

不過這種實現方式不夠健壯,可能存在應用當機,鎖就無法被釋放的問題。

所以我們接著引入以下命令以及 Lua 指令碼增強 Redis 分散式鎖。

SET lock_name anystring NX EX lock_time

最後 Redis 分佈鎖還是存在一些缺陷,在這裡提出一些解決方案,感興趣同學可以自己實現一下。

下篇文章再來將將 Redis 可重入分散式鎖~

參考資料

  1. 分散式鎖的實現之 redis 篇
  2. 基於 Redis 的分散式鎖

歡迎關注我的公眾號:程式通事,獲得日常乾貨推送。如果您對我的專題內容感興趣,也可以關注我的部落格:studyidea.cn

相關文章