Redis分布式锁—SETNX+Lua脚本实现篇
前言
平時(shí)的工作中,由于生產(chǎn)環(huán)境中的項(xiàng)目是需要部署在多臺(tái)服務(wù)器中的,所以經(jīng)常會(huì)面臨解決分布式場景下數(shù)據(jù)一致性的問題,那么就需要引入分布式鎖來解決這一問題。
針對分布式鎖的實(shí)現(xiàn),目前比較常用的就如下幾種方案:
接下來這個(gè)系列文章會(huì)跟大家一塊探討這三種方案,本篇為Redis實(shí)現(xiàn)分布式鎖篇。
Redis分布式環(huán)境搭建推薦:基于Docker的Redis集群搭建
Redis分布式鎖一覽
說到 redis 鎖,能搜到的,或者說常用的無非就下面這兩個(gè):
- setNX + Lua腳本 【本文】
- redisson + RLock可重入鎖
接下來我們一一探索這兩個(gè)的實(shí)現(xiàn),本文為 setNX + Lua腳本 實(shí)現(xiàn)篇。
1、setNX
完整語法:SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]
必選參數(shù)說明:
- SET:命令
- key:待設(shè)置的key
- value:設(shè)置的key的value,最好為隨機(jī)字符串
可選參數(shù)說明:
-
NX:表示key不存在時(shí)才設(shè)置,如果存在則返回 null
-
XX:表示key存在時(shí)才設(shè)置,如果不存在則返回NULL
-
PX millseconds:設(shè)置過期時(shí)間,過期時(shí)間精確為毫秒
-
EX seconds:設(shè)置過期時(shí)間,過期時(shí)間精確為秒
注意:其實(shí)我們常說的通過 Redis 的 setnx 命令來實(shí)現(xiàn)分布式鎖,并不是直接使用 Redis 的 setnx 命令,因?yàn)樵诶习姹局?setnx 命令語法為「setnx key value」,并不支持同時(shí)設(shè)置過期時(shí)間的操作,那么就需要再執(zhí)行 expire 過期時(shí)間的命令,這樣的話加鎖就成了兩個(gè)命令,原子性就得不到保障,所以通常需要配合 Lua 腳本使用,而從 Redis 2.6.12 版本后,set 命令開始整合了 setex 的功能,并且 set 本身就已經(jīng)包含了設(shè)置過期時(shí)間,因此常說的 setnx 命令實(shí)則只用 set 命令就可以實(shí)現(xiàn)了,只是參數(shù)上加上了 NX 等參數(shù)。
大致說一下用 setnx 命令實(shí)現(xiàn)分布式鎖的流程:
在 Redis 2.6.12 版本之后,Redis 支持原子命令加鎖,我們可以通過向 Redis 發(fā)送 「set key value NX 過期時(shí)間」 命令,實(shí)現(xiàn)原子的加鎖操作。比如某個(gè)客戶端想要獲取一個(gè) key 為 niceyoo 的鎖,此時(shí)需要執(zhí)行 「set niceyoo random_value NX PX 30000」 ,在這我們設(shè)置了 30 秒的鎖自動(dòng)過期時(shí)間,超過 30 秒自動(dòng)釋放。
如果 setnx 命令返回 ok,說明拿到了鎖,此時(shí)我們就可以做一些業(yè)務(wù)邏輯處理,業(yè)務(wù)處理完之后,需要釋放鎖,釋放鎖一般就是執(zhí)行 Redis 的 del 刪除指令,「del niceyoo」
如果 setnx 命令返回 nil,說明拿鎖失敗,被其他線程占用,如下是模擬截圖:
注意,這里在設(shè)置值的時(shí)候,value 應(yīng)該是隨機(jī)字符串,比如 UUID,而不是隨便用一個(gè)固定的字符串進(jìn)去,為什么這樣做呢?
value 的值設(shè)置為隨機(jī)數(shù)主要是為了更安全的釋放鎖,釋放鎖的時(shí)候需要檢查 key 是否存在,且 key 對應(yīng)的 value 值是否和指定的值一樣,是一樣的才能釋放鎖。
感覺這樣說還是不清晰,舉個(gè)例子:例如進(jìn)程 A,通過 setnx 指令獲取鎖成功(命令中設(shè)置了加鎖自動(dòng)過期時(shí)間30 秒),既然拿到鎖了就開始執(zhí)行業(yè)務(wù)吧,但是進(jìn)程 A 在接下來的執(zhí)行業(yè)務(wù)邏輯期間,程序響應(yīng)時(shí)間竟然超過30秒了,鎖自動(dòng)釋放了,而此時(shí)進(jìn)程 B 進(jìn)來了,由于進(jìn)程 A 設(shè)置的過期時(shí)間一到,讓進(jìn)程 B 拿到鎖了,然后進(jìn)程 B 又開始執(zhí)行業(yè)務(wù)邏輯,但是呢,這時(shí)候進(jìn)程 A 突然又回來了,然后把進(jìn)程 B 的鎖得釋放了,然后進(jìn)程 C 又拿到鎖,然后開始執(zhí)行業(yè)務(wù)邏輯,此時(shí)進(jìn)程 B 又回來了,釋放了進(jìn)程 C 的鎖,套娃開始了…
總之,有了隨機(jī)數(shù)的 value 后,可以通過判斷 key 對應(yīng)的 value 值是否和指定的值一樣,是一樣的才能釋放鎖。
接下來我們把 setnx 命令落地到項(xiàng)目實(shí)例中:
代碼環(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);這個(gè)地方再補(bǔ)充一下,使用 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# 超時(shí)時(shí)間 Duration類型 3秒timeout: 3S# 日志 logging:# 輸出級(jí)別level:root: infofile:# 指定路徑path: redis-logs# 最大保存天數(shù)max-history: 7# 每個(gè)文件最大大小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è)置了鎖自動(dòng)過期時(shí)間為 20秒,setIfAbsent() 方法返回 true,表示加鎖成功,加鎖成功后模擬了一段業(yè)務(wù)邏輯處理,耗時(shí)30秒,執(zhí)行完邏輯之后調(diào)用 delete() 方法釋放鎖。
問題來了,由于鎖自動(dòng)過期時(shí)間為 20秒,而業(yè)務(wù)邏輯耗時(shí)為 30秒,在不使用 random_value(隨機(jī)字符串)下,如果有多進(jìn)程操作的話就會(huì)出現(xiàn)前面提到的套娃騷操作…
所以在刪除鎖之前,我們先再次通過 get 命令獲取加鎖 key 的 value 值,然后判斷 value 跟加鎖時(shí)設(shè)置的 value 是否一致,這就看出 UUID 的重要性了,如果一致,就執(zhí)行 delete() 方法釋放鎖,否則不執(zhí)行。
如下是使用「固定字符串」模擬的問題截圖:
兩次加鎖成功的時(shí)間間隔為11秒,不足20秒,顯然不是一個(gè)進(jìn)程的用戶。
而在 value 使用 UUID 隨機(jī)字符串時(shí)沒有出現(xiàn)上述問題。
但隨機(jī)字符串就真的安全了嗎?
不安全…
因?yàn)檫€是無法保證 redisTemplate.delete(key); 的原子操作,在多進(jìn)程下還是會(huì)有進(jìn)程安全問題。
就有小伙伴可能鉆牛角尖,怎么就不能原子性操作了,你在刪除之前不都已經(jīng)判斷了嗎?
再舉個(gè)例子,比如進(jìn)程 A 執(zhí)行完業(yè)務(wù)邏輯,在 redisTemplate.opsForValue().get(key); 獲得 key 這一步執(zhí)行沒問題,同時(shí)也進(jìn)入了 if 判斷中,但是恰好這時(shí)候進(jìn)程 A 的鎖自動(dòng)過期時(shí)間到了(別問為啥,就是這么巧),而另一個(gè)進(jìn)程 B 獲得鎖成功,然后還沒來得及執(zhí)行,進(jìn)程 A 就執(zhí)行了 delete(key) ,釋放了進(jìn)程 B 的鎖…
我操?那你上邊巴拉巴拉那么多,說啥呢?
咳咳,解鎖正確刪除鎖的方式之一:為了保障原子性,我們需要用 Lua 腳本進(jìn)行完美解鎖。
Lua腳本
可能有小伙伴不熟悉 Lua,先簡單介紹一下 Lua 腳本:
Lua 是一種輕量小巧的腳本語言,用標(biāo)準(zhǔn) C 語言編寫并以源代碼形式開放, 其設(shè)計(jì)目的是為了嵌入應(yīng)用程序中,從而為應(yīng)用程序提供靈活的擴(kuò)展和定制功能。
Lua 提供了交互式編程模式。我們可以在命令行中輸入程序并立即查看效果。
lua腳本優(yōu)點(diǎn):
- 減少網(wǎng)絡(luò)開銷:原先多次請求的邏輯放在 redis 服務(wù)器上完成。使用腳本,減少了網(wǎng)絡(luò)往返時(shí)延
- 原子操作:Redis會(huì)將整個(gè)腳本作為一個(gè)整體執(zhí)行,中間不會(huì)被其他命令插入(想象為事務(wù))
- 復(fù)用:客戶端發(fā)送的腳本會(huì)永久存儲(chǔ)在Redis中,意味著其他客戶端可以復(fù)用這一腳本而不需要使用代碼完成同樣的邏輯
先大致了解一下,后面我會(huì)單獨(dú)寫一篇 Lua 從入門到放棄的文章。。
如下是Lua腳本,通過 Redis 的 eval/evalsha 命令來運(yùn)行:
-- 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 中使用這個(gè) Lua 腳本
1)在 resources 文件下創(chuàng)建 niceyoo.lua 文件
文件內(nèi)容如下:
2)修改 TestController
在 SpringBoot中,是使用 DefaultRedisScript 類來加載腳本的,并設(shè)置相應(yīng)的數(shù)據(jù)類型來接收 Lua 腳本返回的數(shù)據(jù),這個(gè)泛型類在使用時(shí)設(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 命令來實(shí)現(xiàn)分布式鎖,其實(shí)不是直接使用 Redis 的 setnx 命令,因?yàn)?setnx 不支持設(shè)置自動(dòng)釋放鎖的時(shí)間(至于為什么要設(shè)置自動(dòng)釋放鎖,是因?yàn)榉乐贡荒硞€(gè)進(jìn)程不釋放鎖而造成死鎖的情況),不支持設(shè)置過期時(shí)間,就得分兩步命令進(jìn)行操作,一步是 setnx key value,一步是設(shè)置過期時(shí)間,這種情況的弊端很顯然,無原子性操作。
2、 Redis 2.6.12 版本后,set 命令開始整合了 setex 的功能,并且 set 本身就已經(jīng)包含了設(shè)置過期時(shí)間,因此常說的 setnx 命令實(shí)則只用 set 命令就可以實(shí)現(xiàn)了,只是參數(shù)上加上了 NX 等參數(shù)。
3、經(jīng)過分析,在使用 set key value nx px xxx 命令時(shí),value 最好是隨機(jī)字符串,這樣可以防止業(yè)務(wù)代碼執(zhí)行時(shí)間超過設(shè)置的鎖自動(dòng)過期時(shí)間,而導(dǎo)致再次釋放鎖時(shí)出現(xiàn)釋放其他進(jìn)程鎖的情況(套娃)
4、盡管使用隨機(jī)字符串的 value,但是在釋放鎖時(shí)(delete方法),還是無法做到原子操作,比如進(jìn)程 A 執(zhí)行完業(yè)務(wù)邏輯,在準(zhǔn)備釋放鎖時(shí),恰好這時(shí)候進(jìn)程 A 的鎖自動(dòng)過期時(shí)間到了,而另一個(gè)進(jìn)程 B 獲得鎖成功,然后 B 還沒來得及執(zhí)行,進(jìn)程 A 就執(zhí)行了 delete(key) ,釋放了進(jìn)程 B 的鎖… ,因此需要配合 Lua 腳本釋放鎖,文章也給出了 SpringBoot 的使用示例。
至此,帶大家一塊查看了 setnx 命令如何實(shí)現(xiàn)分布式鎖,但是下面還是要潑一下冷水…
經(jīng)過測試,在單機(jī) Redis 模式下,這種分布式鎖,簡直是無敵(求生欲:純個(gè)人看法),咳咳,沒錯(cuò),你沒看錯(cuò),單機(jī)下的 Redis 無敵…
所以在那些主從模式、哨兵模式、或者是 cluster 模式下,可能會(huì)出現(xiàn)問題,出現(xiàn)什么問題呢?
setNX 的缺陷
setnx 瑣最大的缺點(diǎn)就是它加鎖時(shí)只作用在一個(gè) Redis 節(jié)點(diǎn)上,即使 Redis 通過 Sentinel(哨崗、哨兵) 保證高可用,如果這個(gè) master 節(jié)點(diǎn)由于某些原因發(fā)生了主從切換,那么就會(huì)出現(xiàn)鎖丟失的情況,下面是個(gè)例子:
有的時(shí)候甚至不單單是鎖丟失這么簡單,新選出來的 master 節(jié)點(diǎn)可以重新獲取同樣的鎖,出現(xiàn)一把鎖被拿兩次的場景。
鎖被拿兩次,也就不能滿足安全性了…
盡管單機(jī) Redis 下并不會(huì)出現(xiàn)如上問題,但畢竟我們在生產(chǎn)環(huán)境中,一般都是采用的集群模式,所以這本身也是 Redis 分布式鎖的詬病。
缺陷看完了,怎么解決嘛~
然后 Redis 的作者就提出了著名遠(yuǎn)洋的 RedLock 算法…
下節(jié)講。
在寫這篇文章過程中,本來計(jì)劃將 Redis 里的 setnx、redisson、redLock 一塊寫出來發(fā)一篇文章;
但由于文章中貼了一些代碼片段,會(huì)讓文章整體的節(jié)奏偏長,不適用于后面自己的復(fù)習(xí),所以拆分成兩篇文章,
下一篇我們一塊探索 Redisson + RedLock 的分布式鎖的實(shí)現(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
- 下一篇: 在db2数据库上模拟死锁场景 还是z上的