Redis分布式锁—SETNX+Lua脚本实现篇
前言
平時的工作中,由于生產(chǎn)環(huán)境中的項目是需要部署在多臺服務(wù)器中的,所以經(jīng)常會面臨解決分布式場景下數(shù)據(jù)一致性的問題,那么就需要引入分布式鎖來解決這一問題。
針對分布式鎖的實現(xiàn),目前比較常用的就如下幾種方案:
接下來這個系列文章會跟大家一塊探討這三種方案,本篇為Redis實現(xiàn)分布式鎖篇。
Redis分布式環(huán)境搭建推薦:基于Docker的Redis集群搭建
Redis分布式鎖一覽
說到 redis 鎖,能搜到的,或者說常用的無非就下面這兩個:
- setNX + Lua腳本 【本文】
- redisson + RLock可重入鎖
接下來我們一一探索這兩個的實現(xiàn),本文為 setNX + Lua腳本 實現(xiàn)篇。
1、setNX
完整語法:SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]
必選參數(shù)說明:
- SET:命令
- key:待設(shè)置的key
- value:設(shè)置的key的value,最好為隨機字符串
可選參數(shù)說明:
-
NX:表示key不存在時才設(shè)置,如果存在則返回 null
-
XX:表示key存在時才設(shè)置,如果不存在則返回NULL
-
PX millseconds:設(shè)置過期時間,過期時間精確為毫秒
-
EX seconds:設(shè)置過期時間,過期時間精確為秒
注意:其實我們常說的通過 Redis 的 setnx 命令來實現(xiàn)分布式鎖,并不是直接使用 Redis 的 setnx 命令,因為在老版本之前 setnx 命令語法為「setnx key value」,并不支持同時設(shè)置過期時間的操作,那么就需要再執(zhí)行 expire 過期時間的命令,這樣的話加鎖就成了兩個命令,原子性就得不到保障,所以通常需要配合 Lua 腳本使用,而從 Redis 2.6.12 版本后,set 命令開始整合了 setex 的功能,并且 set 本身就已經(jīng)包含了設(shè)置過期時間,因此常說的 setnx 命令實則只用 set 命令就可以實現(xiàn)了,只是參數(shù)上加上了 NX 等參數(shù)。
大致說一下用 setnx 命令實現(xiàn)分布式鎖的流程:
在 Redis 2.6.12 版本之后,Redis 支持原子命令加鎖,我們可以通過向 Redis 發(fā)送 「set key value NX 過期時間」 命令,實現(xiàn)原子的加鎖操作。比如某個客戶端想要獲取一個 key 為 niceyoo 的鎖,此時需要執(zhí)行 「set niceyoo random_value NX PX 30000」 ,在這我們設(shè)置了 30 秒的鎖自動過期時間,超過 30 秒自動釋放。
如果 setnx 命令返回 ok,說明拿到了鎖,此時我們就可以做一些業(yè)務(wù)邏輯處理,業(yè)務(wù)處理完之后,需要釋放鎖,釋放鎖一般就是執(zhí)行 Redis 的 del 刪除指令,「del niceyoo」
如果 setnx 命令返回 nil,說明拿鎖失敗,被其他線程占用,如下是模擬截圖:
注意,這里在設(shè)置值的時候,value 應(yīng)該是隨機字符串,比如 UUID,而不是隨便用一個固定的字符串進去,為什么這樣做呢?
value 的值設(shè)置為隨機數(shù)主要是為了更安全的釋放鎖,釋放鎖的時候需要檢查 key 是否存在,且 key 對應(yīng)的 value 值是否和指定的值一樣,是一樣的才能釋放鎖。
感覺這樣說還是不清晰,舉個例子:例如進程 A,通過 setnx 指令獲取鎖成功(命令中設(shè)置了加鎖自動過期時間30 秒),既然拿到鎖了就開始執(zhí)行業(yè)務(wù)吧,但是進程 A 在接下來的執(zhí)行業(yè)務(wù)邏輯期間,程序響應(yīng)時間竟然超過30秒了,鎖自動釋放了,而此時進程 B 進來了,由于進程 A 設(shè)置的過期時間一到,讓進程 B 拿到鎖了,然后進程 B 又開始執(zhí)行業(yè)務(wù)邏輯,但是呢,這時候進程 A 突然又回來了,然后把進程 B 的鎖得釋放了,然后進程 C 又拿到鎖,然后開始執(zhí)行業(yè)務(wù)邏輯,此時進程 B 又回來了,釋放了進程 C 的鎖,套娃開始了…
總之,有了隨機數(shù)的 value 后,可以通過判斷 key 對應(yīng)的 value 值是否和指定的值一樣,是一樣的才能釋放鎖。
接下來我們把 setnx 命令落地到項目實例中:
代碼環(huán)境:SpringBoot2.2.2.RELEASE + spring-boot-starter-data-redis + StringRedisTemplate
StringRedisTemplate 或者 RedisTemplate 下對應(yīng)的 setnx 指令的 API 方法如下:
/*** Set {@code key} to hold the string {@code value} if {@code key} is absent.** @param key must not be {@literal null}.* @param value* @see <a href="http://redis.io/commands/setnx">Redis Documentation: SETNX</a>*/ Boolean setIfAbsent(K key, V value);這個地方再補充一下,使用 jedis 跟使用 StringRedisTemplate 對應(yīng)的 senx 命令的寫法是有區(qū)別的,jedis 下就是 set 方法,而 StringRedisTemplate 下使用的是 setIfAbsent 方法 。
1)Maven 依賴,pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="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.0 https://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>Demo project for Spring Boot</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: 6666servlet:context-path: /spring:redis:host: 127.0.0.1password:# 數(shù)據(jù)庫索引 默認(rèn)0database: 0port: 6379# 超時時間 Duration類型 3秒timeout: 3S# 日志 logging:# 輸出級別level:root: infofile:# 指定路徑path: redis-logs# 最大保存天數(shù)max-history: 7# 每個文件最大大小max-size: 5MB這里設(shè)置的服務(wù)端口為 6666,大家可以根據(jù)自己環(huán)境修改。
3)測試的 Controller
@Slf4j @RestController @RequestMapping("/test") public class TestController {@Resourceprivate RedisTemplate<String,Object> redisTemplate;@PostMapping(value = "/addUser")public String createOrder(User user) {String key = user.getUsername();// 如下為使用UUID、固定字符串,固定字符串容易出現(xiàn)線程不安全String value = UUID.randomUUID().toString().replace("-","");// String value = "123";/** setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]* set expire time 5 mins*/Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);if (flag) {log.info("{} 鎖定成功,開始處理業(yè)務(wù)", key);try {// 模擬處理業(yè)務(wù)邏輯Thread.sleep(1000 * 30);} catch (InterruptedException e) {e.printStackTrace();}// 判斷是否是key對應(yīng)的valueString lockValue = redisTemplate.opsForValue().get(key);if (lockValue != null && lockValue.equals(value)) {redisTemplate.delete(key);log.info("{} 解鎖成功,結(jié)束處理業(yè)務(wù)", key);}return "SUCCESS";} else {log.info("{} 獲取鎖失敗", key);return "請稍后再試...";}}}大致流程就是,通過 RedisTemplate 的 setIfAbsent() 方法獲取原子鎖,并設(shè)置了鎖自動過期時間為 20秒,setIfAbsent() 方法返回 true,表示加鎖成功,加鎖成功后模擬了一段業(yè)務(wù)邏輯處理,耗時30秒,執(zhí)行完邏輯之后調(diào)用 delete() 方法釋放鎖。
問題來了,由于鎖自動過期時間為 20秒,而業(yè)務(wù)邏輯耗時為 30秒,在不使用 random_value(隨機字符串)下,如果有多進程操作的話就會出現(xiàn)前面提到的套娃騷操作…
所以在刪除鎖之前,我們先再次通過 get 命令獲取加鎖 key 的 value 值,然后判斷 value 跟加鎖時設(shè)置的 value 是否一致,這就看出 UUID 的重要性了,如果一致,就執(zhí)行 delete() 方法釋放鎖,否則不執(zhí)行。
如下是使用「固定字符串」模擬的問題截圖:
兩次加鎖成功的時間間隔為11秒,不足20秒,顯然不是一個進程的用戶。
而在 value 使用 UUID 隨機字符串時沒有出現(xiàn)上述問題。
但隨機字符串就真的安全了嗎?
不安全…
因為還是無法保證 redisTemplate.delete(key); 的原子操作,在多進程下還是會有進程安全問題。
就有小伙伴可能鉆牛角尖,怎么就不能原子性操作了,你在刪除之前不都已經(jīng)判斷了嗎?
再舉個例子,比如進程 A 執(zhí)行完業(yè)務(wù)邏輯,在 redisTemplate.opsForValue().get(key); 獲得 key 這一步執(zhí)行沒問題,同時也進入了 if 判斷中,但是恰好這時候進程 A 的鎖自動過期時間到了(別問為啥,就是這么巧),而另一個進程 B 獲得鎖成功,然后還沒來得及執(zhí)行,進程 A 就執(zhí)行了 delete(key) ,釋放了進程 B 的鎖…
我操?那你上邊巴拉巴拉那么多,說啥呢?
咳咳,解鎖正確刪除鎖的方式之一:為了保障原子性,我們需要用 Lua 腳本進行完美解鎖。
Lua腳本
可能有小伙伴不熟悉 Lua,先簡單介紹一下 Lua 腳本:
Lua 是一種輕量小巧的腳本語言,用標(biāo)準(zhǔn) C 語言編寫并以源代碼形式開放, 其設(shè)計目的是為了嵌入應(yīng)用程序中,從而為應(yīng)用程序提供靈活的擴展和定制功能。
Lua 提供了交互式編程模式。我們可以在命令行中輸入程序并立即查看效果。
lua腳本優(yōu)點:
- 減少網(wǎng)絡(luò)開銷:原先多次請求的邏輯放在 redis 服務(wù)器上完成。使用腳本,減少了網(wǎng)絡(luò)往返時延
- 原子操作:Redis會將整個腳本作為一個整體執(zhí)行,中間不會被其他命令插入(想象為事務(wù))
- 復(fù)用:客戶端發(fā)送的腳本會永久存儲在Redis中,意味著其他客戶端可以復(fù)用這一腳本而不需要使用代碼完成同樣的邏輯
先大致了解一下,后面我會單獨寫一篇 Lua 從入門到放棄的文章。。
如下是Lua腳本,通過 Redis 的 eval/evalsha 命令來運行:
-- lua刪除鎖: -- KEYS和ARGV分別是以集合方式傳入的參數(shù),對應(yīng)上文的Test和uuid。 -- 如果對應(yīng)的value等于傳入的uuid。 if redis.call('get', KEYS[1]) == ARGV[1] then -- 執(zhí)行刪除操作return redis.call('del', KEYS[1]) else -- 不成功,返回0return 0 end好了,看到 Lua 腳本了,然后代碼中如何使用?
為了讓大家更清楚,我們在 SpringBoot 中使用這個 Lua 腳本
1)在 resources 文件下創(chuàng)建 niceyoo.lua 文件
文件內(nèi)容如下:
2)修改 TestController
在 SpringBoot中,是使用 DefaultRedisScript 類來加載腳本的,并設(shè)置相應(yīng)的數(shù)據(jù)類型來接收 Lua 腳本返回的數(shù)據(jù),這個泛型類在使用時設(shè)置泛型是什么類型,腳本返回的結(jié)果就是用什么類型接收。
@Slf4j @RestController @RequestMapping("/test") public class TestController {@Resourceprivate RedisTemplate<String,Object> redisTemplate;private DefaultRedisScript<Long> script;@PostConstructpublic void init(){script = new DefaultRedisScript<Long>();script.setResultType(Long.class);script.setScriptSource(new ResourceScriptSource(new ClassPathResource("niceyoo.lua")));}@PostMapping(value = "/addUser")public String createOrder(User user) {String key = user.getUsername();String value = UUID.randomUUID().toString().replace("-","");/** setIfAbsent <=> SET key value [NX] [XX] [EX <seconds>] [PX [millseconds]]* set expire time 5 mins*/Boolean flag = redisTemplate.opsForValue().setIfAbsent(key, value, 20000, TimeUnit.MILLISECONDS);if (flag) {log.info("{} 鎖定成功,開始處理業(yè)務(wù)", key);try {// 模擬處理業(yè)務(wù)邏輯Thread.sleep(1000 * 10);} catch (InterruptedException e) {e.printStackTrace();}// 業(yè)務(wù)邏輯處理完畢,釋放鎖String lockValue = redisTemplate.opsForValue().get(key).toString();if (lockValue != null && lockValue.equals(value)) {System.out.println("lockValue========:" + lockValue);List<String> keys = new ArrayList<>();keys.add(key);Long execute = redisTemplate.execute(script, keys, lockValue);System.out.println("execute執(zhí)行結(jié)果,1表示執(zhí)行del,0表示未執(zhí)行 ===== " + execute);log.info("{} 解鎖成功,結(jié)束處理業(yè)務(wù)", key);}return "SUCCESS";} else {log.info("{} 獲取鎖失敗", key);return "請稍后再試...";}}}3)測試結(jié)果
Lua 腳本替換 RedisTemplate 執(zhí)行 delete() 方法,測試結(jié)果如下:
最后總結(jié)
1、所謂的 setnx 命令來實現(xiàn)分布式鎖,其實不是直接使用 Redis 的 setnx 命令,因為 setnx 不支持設(shè)置自動釋放鎖的時間(至于為什么要設(shè)置自動釋放鎖,是因為防止被某個進程不釋放鎖而造成死鎖的情況),不支持設(shè)置過期時間,就得分兩步命令進行操作,一步是 setnx key value,一步是設(shè)置過期時間,這種情況的弊端很顯然,無原子性操作。
2、 Redis 2.6.12 版本后,set 命令開始整合了 setex 的功能,并且 set 本身就已經(jīng)包含了設(shè)置過期時間,因此常說的 setnx 命令實則只用 set 命令就可以實現(xiàn)了,只是參數(shù)上加上了 NX 等參數(shù)。
3、經(jīng)過分析,在使用 set key value nx px xxx 命令時,value 最好是隨機字符串,這樣可以防止業(yè)務(wù)代碼執(zhí)行時間超過設(shè)置的鎖自動過期時間,而導(dǎo)致再次釋放鎖時出現(xiàn)釋放其他進程鎖的情況(套娃)
4、盡管使用隨機字符串的 value,但是在釋放鎖時(delete方法),還是無法做到原子操作,比如進程 A 執(zhí)行完業(yè)務(wù)邏輯,在準(zhǔn)備釋放鎖時,恰好這時候進程 A 的鎖自動過期時間到了,而另一個進程 B 獲得鎖成功,然后 B 還沒來得及執(zhí)行,進程 A 就執(zhí)行了 delete(key) ,釋放了進程 B 的鎖… ,因此需要配合 Lua 腳本釋放鎖,文章也給出了 SpringBoot 的使用示例。
至此,帶大家一塊查看了 setnx 命令如何實現(xiàn)分布式鎖,但是下面還是要潑一下冷水…
經(jīng)過測試,在單機 Redis 模式下,這種分布式鎖,簡直是無敵(求生欲:純個人看法),咳咳,沒錯,你沒看錯,單機下的 Redis 無敵…
所以在那些主從模式、哨兵模式、或者是 cluster 模式下,可能會出現(xiàn)問題,出現(xiàn)什么問題呢?
setNX 的缺陷
setnx 瑣最大的缺點就是它加鎖時只作用在一個 Redis 節(jié)點上,即使 Redis 通過 Sentinel(哨崗、哨兵) 保證高可用,如果這個 master 節(jié)點由于某些原因發(fā)生了主從切換,那么就會出現(xiàn)鎖丟失的情況,下面是個例子:
有的時候甚至不單單是鎖丟失這么簡單,新選出來的 master 節(jié)點可以重新獲取同樣的鎖,出現(xiàn)一把鎖被拿兩次的場景。
鎖被拿兩次,也就不能滿足安全性了…
盡管單機 Redis 下并不會出現(xiàn)如上問題,但畢竟我們在生產(chǎn)環(huán)境中,一般都是采用的集群模式,所以這本身也是 Redis 分布式鎖的詬病。
缺陷看完了,怎么解決嘛~
然后 Redis 的作者就提出了著名遠洋的 RedLock 算法…
下節(jié)講。
在寫這篇文章過程中,本來計劃將 Redis 里的 setnx、redisson、redLock 一塊寫出來發(fā)一篇文章;
但由于文章中貼了一些代碼片段,會讓文章整體的節(jié)奏偏長,不適用于后面自己的復(fù)習(xí),所以拆分成兩篇文章,
下一篇我們一塊探索 Redisson + RedLock 的分布式鎖的實現(xiàn)。
2、Redisson + RedLock
跳轉(zhuǎn)鏈接:https://www.cnblogs.com/niceyoo/p/13736140.html
博客園持續(xù)更新,訂閱關(guān)注,未來,我們一起成長。
總結(jié)
以上是生活随笔為你收集整理的Redis分布式锁—SETNX+Lua脚本实现篇的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: jQuery - 滚动条插件 NiceS
- 下一篇: solaris11-text-安装GUI