造了一個 Redis 分佈鎖的輪子,沒想到還學到這麼多東西!!!
阿新 • • 發佈:2020-06-08
## 書接上文
上篇文章「[MySQL 可重複讀,差點就讓我背上了一個 P0 事故!](https://studyidea.cn/mysql-rr-bug)」釋出之後,收到很多小夥伴們的留言,從中又學習到很多,總結一下。
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073932644-1578286838.jpg)
上篇文章可能舉得例子有點不恰當,導致有些小夥伴沒看懂為什麼餘額會變負。
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073932992-1795582857.gif)
這次我們舉得實際一點,還是上篇文章 account 表,假設 **id=1,balance=1000**,不過這次我們扣款 **1000**,兩個事務的時序圖如下:
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073933500-412750065.jpg)
這次使用兩個命令視窗真實執行一把:
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073933872-1392031325.jpg)
注意事務 2,③處查詢到 **id=1,balance=1000**,但是實際上由於此時事務 1 已經提交,最新結果如②處所示 **id=1,balance=900**。
本來 Java 程式碼層會做一層餘額判斷:
```java
if (balance - amount < 0) {
throw new XXException("餘額不足,扣減失敗");
}
```
但是此時由於 ③ 處使用快照讀,讀到是個舊值,未讀到最新值,導致這層校驗失效,從而程式碼繼續往下執行,執行了資料更新。
更新語句又採用如下寫法:
```sql
UPDATE account set balance=balance-1000 WHERE id =1;
```
這條更新語句又必須是在這條記錄的最新值的基礎做更新,更新語句執行結束,這條記錄就變成了 **id=1,balance=-1000**。
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073934511-1263757950.gif)
之前有朋友疑惑 t12 更新之後,再次進行快照讀,結果會是多少。
上圖執行結果 ④ 可以看到結果為 **id=1,balance=-1000**,可以看到已經查詢最新的結果記錄。
這行資料最新版本由於是事務 2 自己更新的,**自身事務更新永遠對自己可見**。
另外這次問題上本質上因為 Java 層與資料庫層資料不一致導致,有的朋友留言提出,可以在更新餘額時加一層判斷:
```sql
UPDATE account set balance=balance-1000 WHERE id =1 and balance>0;
```
然後更新完成,Java 層判斷更新有效行數是否大於 0。這種做法確實能規避這個問題。
最後這位朋友留言總結的挺好,貼上一下:
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073934883-216786857.jpg)
>先贊後看,微信搜尋「程式通事」,關注就完事了
## 手擼分散式鎖
現在切回正文,這篇文章本來是準備寫下 Mysql 查詢左匹配的問題,但是還沒研究出來。那就先寫下最近在鼓搗一個東西,使用 Redis 實現可重入分佈鎖。
看到這裡,有的朋友可能會提出來使用 **redisson** 不香嗎,為什麼還要自己實現?
哎,**redisson** 真的很香,但是現有專案中沒辦法使用,只好自己手擼一個可重入的分散式鎖了。
雖然用不了 **redisson**,但是我可以研究其原始碼,最後實現的可重入分佈鎖參考了 **redisson** 實現方式。
## 分散式鎖
分散式鎖特性就要在於排他性,同一時間內多個呼叫方加鎖競爭,只能有一個呼叫方加鎖成功。
Redis 由於內部單執行緒的執行,內部按照請求先後順序執行,沒有併發衝突,所以只會有一個呼叫方才會成功獲取鎖。
而且 Redis 基於記憶體操作,加解鎖速度效能高,另外我們還可以使用叢集部署增強 Redis 可用性。
### 加鎖
使用 Redis 實現一個簡單的分散式鎖,非常簡單,可以直接使用 **SETNX** 命令。
**SETNX** 是『SET if Not eXists』,如果不存在,才會設定,使用方法如下:![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073935115-1948756209.jpg)
不過直接使用 **SETNX** 有一個缺陷,我們沒辦法對其設定過期時間,如果加鎖客戶端宕機了,這就導致這把鎖獲取不了了。
有的同學可能會提出,執行 **SETNX** 之後,再執行 **EXPIRE** 命令,主動設定過期時間,偽碼如下:
```lua
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 命令實現分散式鎖只需要一行程式碼:
```shell
SET lock_name anystring NX EX lock_time
```
### 解鎖
解鎖相比加鎖過程,就顯得非常簡單,只要呼叫 `DEL` 命令刪除鎖即可:
```
DEL lock_name
```
不過這種方式卻存在一個缺陷,可能會發生錯解鎖問題。
假設應用 1 加鎖成功,鎖超時時間為 30s。由於應用 1 業務邏輯執行時間過長,30 s 之後,鎖過期自動釋放。
這時應用 2 接著加鎖,加鎖成功,執行業務邏輯。這個期間,應用 1 終於執行結束,使用 `DEL` 成功釋放鎖。
這樣就導致了應用 1 錯誤釋放應用 2 的鎖,另外鎖被釋放之後,其他應用可能再次加鎖成功,這就可能導致業務重複執行。
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073935698-941204302.png)
為了使鎖不被錯誤釋放,我們需要在加鎖時設定隨機字串,比如 UUID。
```lua
SET lock_name uuid NX EX lock_time
```
釋放鎖時,需要提前獲取當前鎖儲存的值,然後與加鎖時的 uuid 做比較,虛擬碼如下:
```lua
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 執行方式如下:
```shell
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 命令:
```shell
eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2],ARGV[3]}" 2 key1 key2 first second third
```
執行效果如下:
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073936003-97799494.jpg)
可以看到 `KEYS` 與 `ARGVS`內部陣列可以不一致。
在 Lua 指令碼可以使用下面兩個函式執行 Redis 命令:
- redis.call()
- redis.pcall()
兩個函式作用法與作用完全一致,只不過對於錯誤的處理方式不一致,感興趣的小夥伴可以具體點選以下連結,檢視錯誤處理一章。
http://doc.redisfans.com/script/eval.html
下面我們統一在 Lua 指令碼中使用 `redis.call()`,執行以下命令:
```shell
eval "return redis.call('set',KEYS[1],ARGV[1])" 1 foo 樓下小黑哥
```
執行效果如下:
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073936258-891721940.jpg)
### EVALSHA
`EVAL` 命令每次執行時都需要傳送 Lua 指令碼,但是 Redis 並不會每次都會重新編譯指令碼。
當 Redis 第一次收到 Lua 指令碼時,首先將會對 Lua 指令碼進行 **sha1** 獲取簽名值,然後內部將會對其快取起來。後續執行時,直接通過 **sha1** 計算過後簽名值查詢已經編譯過的指令碼,加快執行速度。
雖然 Redis 內部已經優化執行的速度,但是每次都需要傳送指令碼,還是有網路傳輸的成本,如果指令碼很大,這其中花在網路傳輸的時間就會相應的增加。
所以 Redis 又實現了 `EVALSHA` 命令,原理與 `EVAL` 一致。只不過 `EVALSHA` 只需要傳入指令碼經過 **sha1**計算過後的簽名值即可,這樣大大的減少了傳輸的位元組大小,減少了網路耗時。
`EVALSHA`命令如下:
```lua
evalsha c686f316aaf1eb01d5a4de1b0b63cd233010e63d 1 foo 樓下小黑哥
```
執行效果如下:
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073936676-406674675.jpg)
> `SCRIPT FLUSH` 命令用來清除所有 Lua 指令碼快取。
可以看到,如果之前未執行過 `EVAL`命令,直接執行 `EVALSHA` 將會報錯。
### 優化執行 EVAL
我們可以結合使用 `EVAL` 與 `EVALSHA`,優化程式。下面就不寫偽碼了,以 Jedis 為例,優化程式碼如下:
```java
//連線本地的 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**,不然執行過程中將會丟擲:
```verilog
org.springframework.dao.InvalidDataAccessApiUsageException: EvalSha is not supported in cluster environment.
```
詳細情況可以參考這個修復的 **Issue**[Add support for scripting commands with Jedis Cluster](https://jira.spring.io/browse/DATAREDIS-1005)
## 優化分散式鎖
講完 Redis 執行 LUA 指令碼的相關命令,我們來看下如何優化上面的分散式鎖,使其無法釋放其他應用加的鎖。
> 以下程式碼基於 spring-boot 2.2.7.RELEASE 版本,Redis 底層連線使用 Jedis。
加鎖的 Redis 命令如下:
```shell
SET lock_name uuid NX EX lock_time
```
加鎖程式碼如下:
```java
/**
* 非阻塞式加鎖,若鎖存在,直接返回
*
* @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 版本新增加,之前版本無法設定超時時間。如果使用之前的版本的,需要如下方法:
```java
/**
* 適用於 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) 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 指令碼:
```lua
-- 解鎖程式碼
-- 首先判斷傳入的唯一標識是否與現有標識一致
-- 如果一致,釋放這個鎖,否則直接返回
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
```
這段指令碼將會判斷傳入的唯一標識是否與 Redis 儲存的標示一致,如果一直,釋放該鎖,否則立刻返回。
釋放鎖的方法如下:
```java
/**
* 解鎖
* 如果傳入應用標識與之前加鎖一致,解鎖成功
* 否則直接返回
* @param lockName 鎖
* @param request 唯一標識
* @return
*/
public Boolean unlock(String lockName, String request) {
DefaultRedisScript 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 篇](https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/)。第二個解決方案,下一篇文章就會具體來聊聊,敬請期待。
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073936936-2074260268.jpg)
### 鎖超時釋放
假設執行緒 A 加鎖成功,鎖超時時間為 30s。由於執行緒 A 內部業務邏輯執行時間過長,30s 之後鎖過期自動釋放。
此時執行緒 B 成功獲取到鎖,進入執行內部業務邏輯。此時執行緒 A 還在執行執行業務,而執行緒 B 又進入執行這段業務邏輯,這就導致業務邏輯重複被執行。
這個問題我覺得,一般由於鎖的超時時間設定不當引起,可以評估下業務邏輯執行時間,在這基礎上再延長一下超時時間。
如果超時時間設定合理,但是業務邏輯還有偶發的超時,個人覺得需要排查下業務執行過長的問題。
如果說一定要做到業務執行期間,鎖只能被一個執行緒佔有的,那就需要增加一個守護執行緒,定時為即將的過期的但未釋放的鎖增加有效時間。
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073937138-1582903812.jpg)
加鎖成功後,同時建立一個守護執行緒。守護執行緒將會定時檢視鎖是否即將到期,如果鎖即將過期,那就執行 **EXPIRE** 等命令重新設定過期時間。
說實話,如果要這麼做,真的挺複雜的,感興趣的話可以參考下 **redisson watchdog** 實現方式。
### Redis 分散式鎖叢集問題
為了保證生產高可用,一般我們會採用主從部署方式。採用這種方式,我們可以將讀寫分離,主節點提供寫服務,從節點提供讀服務。
Redis 主從之間資料同步採用非同步複製方式,主節點寫入成功後,立刻返回給客戶端,然後非同步複製給從節點。
如果資料寫入主節點成功,但是還未複製給從節點。此時主節點掛了,從節點立刻被提升為主節點。
這種情況下,還未同步的資料就丟失了,其他執行緒又可以被加鎖了。
針對這種情況, Redis 官方提出一種 **RedLock** 的演算法,需要有 N 個Redis 主從節點,解決該問題,詳情參考:
https://redis.io/topics/distlock。
這個演算法自己實現還是很複雜的,幸好 **redisson** 已經實現的 **RedLock**,詳情參考:[redisson redlock](https://github.com/redisson/redisson/wiki/8.-%E5%88%86%E5%B8%83%E5%BC%8F%E9%94%81%E5%92%8C%E5%90%8C%E6%AD%A5%E5%99%A8#84-%E7%BA%A2%E9%94%81redlock)
## 總結
本來這篇文章是想寫 Redis 可重入分散式鎖的,可是沒想到寫分散式鎖的實現方案就已經寫了這麼多,再寫下去,文章可能就很長,所以拆分成兩篇來寫。
嘿嘿,這不下星期不用想些什麼了,真是個小機靈鬼~
![](https://img2020.cnblogs.com/other/1419561/202006/1419561-20200608073937365-1826120615.jpg)
好了,幫大家再次總結一下本文內容。
簡單的 Redis 分散式鎖的實現方式還是很簡單的,我們可以直接用 SETNX/DEL 命令實現加解鎖。
不過這種實現方式不夠健壯,可能存在應用宕機,鎖就無法被釋放的問題。
所以我們接著引入以下命令以及 Lua 指令碼增強 Redis 分散式鎖。
```shell
SET lock_name anystring NX EX lock_time
```
最後 Redis 分佈鎖還是存在一些缺陷,在這裡提出一些解決方案,感興趣同學可以自己實現一下。
下篇文章再來將將 Redis 可重入分散式鎖~
## 參考資料
1. [分散式鎖的實現之 redis 篇](https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/)
2. [基於 Redis 的分散式鎖](https://crossoverjie.top/2018/03/29/distributed-lock/distributed-lock-redis/)
> 歡迎關注我的公眾號:程式通事,獲得日常乾貨推送。如果您對我的專題內容感興趣,也可以關注我的部落格:[studyidea.cn](https://studyi