1. 程式人生 > 實用技巧 >Redis分散式鎖—SETNX+Lua指令碼實現篇

Redis分散式鎖—SETNX+Lua指令碼實現篇

前言

平時的工作中,由於生產環境中的專案是需要部署在多臺伺服器中的,所以經常會面臨解決分散式場景下資料一致性的問題,那麼就需要引入分散式鎖來解決這一問題。

針對分散式鎖的實現,目前比較常用的就如下幾種方案:

  1. 基於資料庫實現分散式鎖
  2. 基於Redis實現分散式鎖 【本文】
  3. 基於Zookeeper實現分散式鎖

接下來這個系列文章會跟大家一塊探討這三種方案,本篇為Redis實現分散式鎖篇。

Redis分散式環境搭建推薦:基於Docker的Redis叢集搭建

Redis分散式鎖一覽

說到 redis 鎖,能搜到的,或者說常用的無非就下面這兩個:

  • setNX + Lua 【本文】
  • redisson + redLock

接下來我們一一探索這兩個的實現,本文為 setNX + Lua 實現篇。

1、setNX

完整語法:SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]

必選引數說明:

  • SET:命令
  • key:待設定的key
  • value:設定的key的value,最好為隨機字串

可選引數說明:

  • NX:表示key不存在時才設定,如果存在則返回 null

  • XX:表示key存在時才設定,如果不存在則返回NULL

  • PX millseconds:設定過期時間,過期時間精確為毫秒

  • EX seconds:設定過期時間,過期時間精確為秒

注意:其實我們常說的通過 Redis 的 setnx 命令來實現分散式鎖,並不是直接使用 Redis 的 setnx 命令,因為在老版本之前 setnx 命令語法為「setnx key value」,並不支援同時設定過期時間的操作,那麼就需要再執行 expire 過期時間的命令,這樣的話加鎖就成了兩個命令,原子性就得不到保障,所以通常需要配合 Lua 指令碼使用,而從 Redis 2.6.12 版本後,set 命令開始整合了 setex 的功能,並且 set 本身就已經包含了設定過期時間,因此常說的 setnx 命令實則只用 set 命令就可以實現了,只是引數上加上了 NX 等引數。

大致說一下用 setnx 命令實現分散式鎖的流程:

在 Redis 2.6.12 版本之後,Redis 支援原子命令加鎖,我們可以通過向 Redis 傳送 「set key value NX 過期時間」 命令,實現原子的加鎖操作。比如某個客戶端想要獲取一個 key 為 niceyoo 的鎖,此時需要執行 「set niceyoo random_value NX PX 30000」 ,在這我們設定了 30 秒的鎖自動過期時間,超過 30 秒自動釋放。

如果 setnx 命令返回 ok,說明拿到了鎖,此時我們就可以做一些業務邏輯處理,業務處理完之後,需要釋放鎖,釋放鎖一般就是執行 Redis 的 del 刪除指令,「del niceyoo」

如果 setnx 命令返回 nil,說明拿鎖失敗,被其他執行緒佔用,如下是模擬截圖:

注意,這裡在設定值的時候,value 應該是隨機字串,比如 UUID,而不是隨便用一個固定的字串進去,為什麼這樣做呢?

value 的值設定為隨機數主要是為了更安全的釋放鎖,釋放鎖的時候需要檢查 key 是否存在,且 key 對應的 value 值是否和指定的值一樣,是一樣的才能釋放鎖。

感覺這樣說還是不清晰,舉個例子:例如程序 A,通過 setnx 指令獲取鎖成功(命令中設定了加鎖自動過期時間30 秒),既然拿到鎖了就開始執行業務吧,但是程序 A 在接下來的執行業務邏輯期間,程式響應時間竟然超過30秒了,鎖自動釋放了,而此時程序 B 進來了,由於程序 A 設定的過期時間一到,讓程序 B 拿到鎖了,然後程序 B 又開始執行業務邏輯,但是呢,這時候程序 A 突然又回來了,然後把程序 B 的鎖得釋放了,然後程序 C 又拿到鎖,然後開始執行業務邏輯,此時程序 B 又回來了,釋放了程序 C 的鎖,套娃開始了.....

總之,有了隨機數的 value 後,可以通過判斷 key 對應的 value 值是否和指定的值一樣,是一樣的才能釋放鎖。

接下來我們把 setnx 命令落地到專案例項中:

程式碼環境:SpringBoot2.2.2.RELEASE + spring-boot-starter-data-redis + StringRedisTemplate

StringRedisTemplate 或者 RedisTemplate 下對應的 setnx 指令的 API 方法如下:

/**
*Set{@codekey}toholdthestring{@codevalue}if{@codekey}isabsent.
*
*@paramkeymustnotbe{@literalnull}.
*@paramvalue
*@see<ahref="http://redis.io/commands/setnx">RedisDocumentation:SETNX</a>
*/
BooleansetIfAbsent(Kkey,Vvalue);

這個地方再補充一下,使用 jedis 跟使用 StringRedisTemplate 對應的 senx 命令的寫法是有區別的,jedis 下就是 set 方法,而 StringRedisTemplate 下使用的是 setIfAbsent 方法 。

1)Maven 依賴,pom.xml
<?xmlversion="1.0"encoding="UTF-8"?>
<projectxmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.2.RELEASE</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-redis</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo-redis</name>
<description>DemoprojectforSpringBoot</description>

<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!--Redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!--Lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.10</version>
</dependency>

<!--Gson-->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.8.6</version>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

這裡引入了 gson、redis 依賴。

2)application.yml 配置檔案
server:
port:6666
servlet:
context-path:/

spring:
redis:
host:127.0.0.1
password:
#資料庫索引預設0
database:0
port:6379
#超時時間Duration型別3秒
timeout:3S

#日誌
logging:
#輸出級別
level:
root:info
file:
#指定路徑
path:redis-logs
#最大儲存天數
max-history:7
#每個檔案最大大小
max-size:5MB

這裡設定的服務埠為 6666,大家可以根據自己環境修改。

3)測試的 Controller
@Slf4j
@RestController
@RequestMapping("/test")
publicclassTestController{

@Resource
privateRedisTemplate<String,Object>redisTemplate;

@PostMapping(value="/addUser")
publicStringcreateOrder(Useruser){

Stringkey=user.getUsername();
//如下為使用UUID、固定字串,固定字串容易出現執行緒不安全
Stringvalue=UUID.randomUUID().toString().replace("-","");
//Stringvalue="123";
/*
*setIfAbsent<=>SETkeyvalue[NX][XX][EX<seconds>][PX[millseconds]]
*setexpiretime5mins
*/
Booleanflag=redisTemplate.opsForValue().setIfAbsent(key,value,20000,TimeUnit.MILLISECONDS);
if(flag){
log.info("{}鎖定成功,開始處理業務",key);
try{
//模擬處理業務邏輯
Thread.sleep(1000*30);
}catch(InterruptedExceptione){
e.printStackTrace();
}
//判斷是否是key對應的value
StringlockValue=redisTemplate.opsForValue().get(key);
if(lockValue!=null&&lockValue.equals(value)){
redisTemplate.delete(key);
log.info("{}解鎖成功,結束處理業務",key);
}
return"SUCCESS";
}else{
log.info("{}獲取鎖失敗",key);
return"請稍後再試...";
}
}

}

大致流程就是,通過 RedisTemplate 的 setIfAbsent() 方法獲取原子鎖,並設定了鎖自動過期時間為 20秒,setIfAbsent() 方法返回 true,表示加鎖成功,加鎖成功後模擬了一段業務邏輯處理,耗時30秒,執行完邏輯之後呼叫 delete() 方法釋放鎖。

問題來了,由於鎖自動過期時間為 20秒,而業務邏輯耗時為 30秒,在不使用 random_value(隨機字串)下,如果有多程序操作的話就會出現前面提到的套娃騷操作......

所以在刪除鎖之前,我們先再次通過 get 命令獲取加鎖 key 的 value 值,然後判斷 value 跟加鎖時設定的 value 是否一致,這就看出 UUID 的重要性了,如果一致,就執行 delete() 方法釋放鎖,否則不執行。

如下是使用「固定字串」模擬的問題截圖:

兩次加鎖成功的時間間隔為11秒,不足20秒,顯然不是一個程序的使用者。

而在 value 使用 UUID 隨機字串時沒有出現上述問題。

但隨機字串就真的安全了嗎?

不安全...

因為還是無法保證 redisTemplate.delete(key); 的原子操作,在多程序下還是會有程序安全問題。

就有小夥伴可能鑽牛角尖,怎麼就不能原子性操作了,你在刪除之前不都已經判斷了嗎?

再舉個例子,比如程序 A 執行完業務邏輯,在 redisTemplate.opsForValue().get(key); 獲得 key 這一步執行沒問題,同時也進入了 if 判斷中,但是恰好這時候程序 A 的鎖自動過期時間到了(別問為啥,就是這麼巧),而另一個程序 B 獲得鎖成功,然後還沒來得及執行,程序 A 就執行了 delete(key) ,釋放了程序 B 的鎖....

我操?那你上邊巴拉巴拉那麼多,說啥呢?

咳咳,解鎖正確刪除鎖的方式之一:為了保障原子性,我們需要用 Lua 指令碼進行完美解鎖。

Lua指令碼

可能有小夥伴不熟悉 Lua,先簡單介紹一下 Lua 指令碼:

Lua 是一種輕量小巧的指令碼語言,用標準 C 語言編寫並以原始碼形式開放, 其設計目的是為了嵌入應用程式中,從而為應用程式提供靈活的擴充套件和定製功能。

Lua 提供了互動式程式設計模式。我們可以在命令列中輸入程式並立即檢視效果。

lua指令碼優點:

  • 減少網路開銷:原先多次請求的邏輯放在 redis 伺服器上完成。使用指令碼,減少了網路往返時延
  • 原子操作:Redis會將整個指令碼作為一個整體執行,中間不會被其他命令插入(想象為事務)
  • 複用:客戶端傳送的指令碼會永久儲存在Redis中,意味著其他客戶端可以複用這一指令碼而不需要使用程式碼完成同樣的邏輯

先大致瞭解一下,後面我會單獨寫一篇 Lua 從入門到放棄的文章。。

如下是Lua指令碼,通過 Redis 的 eval/evalsha 命令來執行:

--lua刪除鎖:
--KEYS和ARGV分別是以集合方式傳入的引數,對應上文的Test和uuid。
--如果對應的value等於傳入的uuid。
ifredis.call('get',KEYS[1])==ARGV[1]
then
--執行刪除操作
returnredis.call('del',KEYS[1])
else
--不成功,返回0
return0
end

好了,看到 Lua 指令碼了,然後程式碼中如何使用?

為了讓大家更清楚,我們在 SpringBoot 中使用這個 Lua 指令碼
1)在 resources 檔案下建立 niceyoo.lua 檔案

檔案內容如下:

ifredis.call('get',KEYS[1])==ARGV[1]
then
returnredis.call('del',KEYS[1])
else
return0
end
2)修改 TestController

在 SpringBoot中,是使用 DefaultRedisScript 類來載入指令碼的,並設定相應的資料型別來接收 Lua 指令碼返回的資料,這個泛型類在使用時設定泛型是什麼型別,指令碼返回的結果就是用什麼型別接收。

@Slf4j
@RestController
@RequestMapping("/test")
publicclassTestController{

@Resource
privateRedisTemplate<String,Object>redisTemplate;

privateDefaultRedisScript<Long>script;

@PostConstruct
publicvoidinit(){
script=newDefaultRedisScript<Long>();
script.setResultType(Long.class);
script.setScriptSource(newResourceScriptSource(newClassPathResource("niceyoo.lua")));
}

@PostMapping(value="/addUser")
publicStringcreateOrder(Useruser){

Stringkey=user.getUsername();
Stringvalue=UUID.randomUUID().toString().replace("-","");

/*
*setIfAbsent<=>SETkeyvalue[NX][XX][EX<seconds>][PX[millseconds]]
*setexpiretime5mins
*/
Booleanflag=redisTemplate.opsForValue().setIfAbsent(key,value,20000,TimeUnit.MILLISECONDS);
if(flag){
log.info("{}鎖定成功,開始處理業務",key);
try{
//模擬處理業務邏輯
Thread.sleep(1000*10);

}catch(InterruptedExceptione){
e.printStackTrace();
}

//業務邏輯處理完畢,釋放鎖
StringlockValue=redisTemplate.opsForValue().get(key).toString();
if(lockValue!=null&&lockValue.equals(value)){
System.out.println("lockValue========:"+lockValue);
List<String>keys=newArrayList<>();
keys.add(key);
Longexecute=redisTemplate.execute(script,keys,lockValue);
System.out.println("execute執行結果,1表示執行del,0表示未執行====="+execute);
log.info("{}解鎖成功,結束處理業務",key);
}
return"SUCCESS";
}else{
log.info("{}獲取鎖失敗",key);
return"請稍後再試...";
}
}

}
3)測試結果

Lua 指令碼替換 RedisTemplate 執行 delete() 方法,測試結果如下:

最後總結

1、所謂的 setnx 命令來實現分散式鎖,其實不是直接使用 Redis 的 setnx 命令,因為 setnx 不支援設定自動釋放鎖的時間(至於為什麼要設定自動釋放鎖,是因為防止被某個程序不釋放鎖而造成死鎖的情況),不支援設定過期時間,就得分兩步命令進行操作,一步是 setnx key value,一步是設定過期時間,這種情況的弊端很顯然,無原子性操作。

2、 Redis 2.6.12 版本後,set 命令開始整合了 setex 的功能,並且 set 本身就已經包含了設定過期時間,因此常說的 setnx 命令實則只用 set 命令就可以實現了,只是引數上加上了 NX 等引數。

3、經過分析,在使用 set key value nx px xxx 命令時,value 最好是隨機字串,這樣可以防止業務程式碼執行時間超過設定的鎖自動過期時間,而導致再次釋放鎖時出現釋放其他程序鎖的情況(套娃)

4、儘管使用隨機字串的 value,但是在釋放鎖時(delete方法),還是無法做到原子操作,比如程序 A 執行完業務邏輯,在準備釋放鎖時,恰好這時候程序 A 的鎖自動過期時間到了,而另一個程序 B 獲得鎖成功,然後 B 還沒來得及執行,程序 A 就執行了 delete(key) ,釋放了程序 B 的鎖.... ,因此需要配合 Lua 指令碼釋放鎖,文章也給出了 SpringBoot 的使用示例。

至此,帶大家一塊查看了 setnx 命令如何實現分散式鎖,但是下面還是要潑一下冷水...

經過測試,在單機 Redis 模式下,這種分散式鎖,簡直是無敵(求生欲:純個人看法),咳咳,沒錯,你沒看錯,單機下的 Redis 無敵...

所以在那些主從模式、哨兵模式、或者是 cluster 模式下,可能會出現問題,出現什麼問題呢?

setNX 的缺陷

setnx 瑣最大的缺點就是它加鎖時只作用在一個 Redis 節點上,即使 Redis 通過 Sentinel(哨崗、哨兵) 保證高可用,如果這個 master 節點由於某些原因發生了主從切換,那麼就會出現鎖丟失的情況,下面是個例子:

  1. 在 Redis 的 master 節點上拿到了鎖;
  2. 但是這個加鎖的 key 還沒有同步到 slave 節點;
  3. master 故障,發生故障轉移,slave 節點升級為 master節點;
  4. 上邊 master 節點上的鎖丟失。

有的時候甚至不單單是鎖丟失這麼簡單,新選出來的 master 節點可以重新獲取同樣的鎖,出現一把鎖被拿兩次的場景。

鎖被拿兩次,也就不能滿足安全性了...

缺陷看完了,怎麼解決嘛~

然後 Redis 的作者就提出了著名遠洋的 RedLock 演算法...

下節講。


在寫這篇文章過程中,本來計劃將 Redis 裡的 setnx、redisson、redLock 一塊寫出來發一篇文章;

但由於文章中貼了一些程式碼片段,會讓文章整體的節奏偏長,不適用於後面自己的複習,所以拆分成兩篇文章,

下一篇我們一塊探索 Redisson + RedLock 的分散式鎖的實現。

2、Redisson + RedLock

連結待補充:https://www.cnblogs.com/niceyoo

部落格園持續更新,訂閱關注,未來,我們一起成長。