Redis 實(shí)現(xiàn)分布式鎖真的安全嗎?
Spring Boot 作為主流微服務(wù)框架,擁有成熟的社區(qū)生態(tài)。市場(chǎng)應(yīng)用廣泛,為了方便大家,整理了一個(gè)基于spring boot的常用中間件快速集成入門系列手冊(cè),涉及RPC、緩存、消息隊(duì)列、分庫分表、注冊(cè)中心、分布式配置等常用開源組件,大概有幾十篇文章,陸續(xù)會(huì)開放出來,感興趣同學(xué)可以關(guān)注&收藏
鎖的種類非常多。之前寫過一篇文章,對(duì)工作中常用鎖做了總結(jié),如:樂觀鎖、悲觀鎖、分布式鎖、可重入鎖、自旋鎖、獨(dú)享鎖、共享鎖、互斥鎖、讀寫鎖、阻塞鎖、公平鎖、非公平鎖、分段鎖、對(duì)象鎖、類鎖、信號(hào)量、行鎖。
之前文章:一文全面梳理各種鎖機(jī)制
什么是分布式鎖
Java中常用的鎖有 synchronized、Lock鎖,并發(fā)編程中,我們通過鎖實(shí)現(xiàn)多個(gè)線程競(jìng)爭(zhēng)同一個(gè)共享資源或者變量而造成的數(shù)據(jù)不一致問題,但是JVM鎖只適用于單體應(yīng)用。
隨著互聯(lián)網(wǎng)業(yè)務(wù)快速發(fā)展,軟件架構(gòu)開始向分布式集群演化。由于分布式系統(tǒng)的多線程分布在不同的服務(wù)器上,為了跨JVM控制全局共享資源的訪問,于是誕生了分布式鎖。
定義:
分布式鎖是控制分布式系統(tǒng)之間同步訪問共享資源的一種方式。在分布式系統(tǒng)中,常常需要協(xié)調(diào)他們的動(dòng)作,如果不同的系統(tǒng)或是同一個(gè)系統(tǒng)的不同服務(wù)器之間共享了一個(gè)或一組資源,那么訪問這些資源的時(shí)候,往往需要互斥來防止彼此干擾來保證一致性,在這種情況下,便需要使用到分布式鎖。
特點(diǎn):
互斥性。任意時(shí)刻,只有一個(gè)客戶端能持有鎖。 鎖超時(shí)。和本地鎖一樣支持鎖超時(shí),防止死鎖 高可用。加鎖和解鎖要保證性能,同時(shí)也需要保證高可用防止分布式鎖失效,可以增加降級(jí)。 支持阻塞和非阻塞。和 ReentrantLock一樣支持 lock 和 trylock 以及 tryLock(long timeOut)。
實(shí)現(xiàn)方式:
數(shù)據(jù)庫鎖 基于Redis的分布式鎖 基于Zookeeper的分布式鎖
考慮到性能要求,一般采用redis來實(shí)現(xiàn)分布式鎖。另外,在實(shí)際的業(yè)務(wù)應(yīng)用中,如果你想要提升分布式鎖的可靠性,可以通過
Redlock算法來實(shí)現(xiàn)。
代碼示例
通過redis原子命令 set key value [NX|XX] [EX seconds | PX milliseconds] 來是實(shí)現(xiàn)加鎖操作。
參數(shù)解釋:
EX seconds:設(shè)置失效時(shí)長,單位秒 PX milliseconds:表示這個(gè) key 的存活時(shí)間,稱作鎖過期時(shí)間,單位毫秒。當(dāng)資源被鎖定超過這個(gè)時(shí)間時(shí),鎖將自動(dòng)釋放。 NX:key不存在時(shí)設(shè)置value,成功返回OK,失敗返回(nil) XX:key存在時(shí)設(shè)置value,成功返回OK,失敗返回(nil) value:必須是全局唯一的值。這個(gè)隨機(jī)數(shù)在釋放鎖時(shí)保證釋放鎖操作的安全性。
原理:只有在某個(gè) key 不存在的情況下才能設(shè)置(set)成功該 key。于是,這就可以讓多個(gè)線程并發(fā)去設(shè)置同一個(gè) key,只有一個(gè)線程能設(shè)置成功。而其它的線程因?yàn)橹坝腥税?key 設(shè)置成功了,而導(dǎo)致失敗(也就是獲得鎖失敗)。
/**
* 獲取鎖
* <p>
* true:成功獲取鎖
* false:本次請(qǐng)求沒有拿到鎖
*/
public boolean lock(String key, String value, long expireTime) {
key = prefixKey + key;
boolean lock = false;
try {
lock = redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, TimeUnit.MILLISECONDS);
} catch (Exception e) {
e.printStackTrace();
lock = false;
}
if (lock) {
System.out.println(String.format("%s 已經(jīng)拿到了鎖,當(dāng)前時(shí)間:%s", Thread.currentThread().getName(), System.currentTimeMillis() / 1000));
}
return lock;
}
分布式鎖使用結(jié)束后需要手動(dòng)來釋放鎖。可以直接通過 del 命令刪除key即可,但是從高可用性上講,如果業(yè)務(wù)的執(zhí)行時(shí)間超過了鎖釋放的時(shí)間,導(dǎo)致 redis 中的key 自動(dòng)超時(shí)過期,鎖被動(dòng)釋放。然后被其他線程競(jìng)爭(zhēng)獲取了鎖,此時(shí)之前的線程再釋放的就是別人的鎖,會(huì)引發(fā)混亂。
為了避免該問題,我們通過lua腳本,在釋放鎖時(shí),先進(jìn)行值比較判斷,只能釋放自己的鎖!!!
public boolean unLock(String key, String value) {
key = prefixKey + key;
Long result = -1L;
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript(luaScript, Long.class);
try {
// del 成功返回 1
result = (Long) redisTemplate.execute(redisScript, Lists.list(key), value);
// System.out.println(result);
} catch (Exception e) {
e.printStackTrace();
}
return result == 1 ? true : false;
}
在這種場(chǎng)景(主從結(jié)構(gòu))中存在明顯的競(jìng)態(tài): 客戶端A從master獲取到鎖, 在master將鎖同步到slave之前,master宕掉了。slave節(jié)點(diǎn)被晉升為新的master節(jié)點(diǎn), 客戶端B取得了同一個(gè)資源被客戶端A已經(jīng)獲取到的另外一個(gè)鎖。「安全失效」!
Redisson實(shí)現(xiàn)分布式鎖
為了避免 Redis 實(shí)例故障而導(dǎo)致的鎖無法工作的問題,Redis 的開發(fā)者 Antirez 提出了分布式鎖算法Redlock。
Redlock 算法的基本思路,是讓客戶端和多個(gè)獨(dú)立的 Redis 實(shí)例依次請(qǐng)求加鎖,如果客戶端能夠和半數(shù)以上的實(shí)例成功地完成加鎖操作,那么我們就認(rèn)為,客戶端成功地獲得分布式鎖了,否則加鎖失敗。這樣一來,即使有單個(gè) Redis 實(shí)例發(fā)生故障,因?yàn)殒i變量在其它實(shí)例上也有保存,所以,客戶端仍然可以正常地進(jìn)行鎖操作。
執(zhí)行步驟:
1、第一步,客戶端獲取當(dāng)前時(shí)間。
2、第二步,客戶端按順序依次向 N 個(gè) Redis 實(shí)例執(zhí)行加鎖操作。
這里的加鎖操作和在單實(shí)例上執(zhí)行的加鎖操作一樣,使用 SET 命令,帶上 NX,EX/PX 選項(xiàng),以及帶上客戶端的唯一標(biāo)識(shí)。當(dāng)然,如果某個(gè) Redis 實(shí)例發(fā)生故障了,為了保證在這種情況下,Redlock 算法能夠繼續(xù)運(yùn)行,我們需要給加鎖操作設(shè)置一個(gè)超時(shí)時(shí)間。
如果客戶端在和一個(gè) Redis 實(shí)例請(qǐng)求加鎖時(shí),一直到超時(shí)都沒有成功,那么此時(shí),客戶端會(huì)和下一個(gè) Redis 實(shí)例繼續(xù)請(qǐng)求加鎖。加鎖操作的超時(shí)時(shí)間需要遠(yuǎn)遠(yuǎn)地小于鎖的有效時(shí)間,一般為幾十毫秒。
3、第三步,一旦客戶端完成了和所有 Redis 實(shí)例的加鎖操作,客戶端就要計(jì)算整個(gè)加鎖過程的總耗時(shí)。只有在滿足下面的這兩個(gè)條件時(shí),才能認(rèn)為是加鎖成功。
條件一:客戶端從超過半數(shù)(大于等于 N/2+1)的 Redis 實(shí)例上成功獲取到了鎖; 條件二:客戶端獲取鎖的總耗時(shí)沒有超過鎖的有效時(shí)間。
在滿足了這兩個(gè)條件后,我們需要重新計(jì)算這把鎖的有效時(shí)間,計(jì)算的結(jié)果是鎖的最初有效時(shí)間減去客戶端為獲取鎖的總耗時(shí)。如果鎖的有效時(shí)間已經(jīng)來不及完成共享數(shù)據(jù)的操作了,我們可以釋放鎖,以免出現(xiàn)還沒完成數(shù)據(jù)操作,鎖就過期了的情況。
當(dāng)然,如果客戶端在和所有實(shí)例執(zhí)行完加鎖操作后,沒能同時(shí)滿足這兩個(gè)條件,那么,客戶端向所有 Redis 節(jié)點(diǎn)發(fā)起釋放鎖的操作。
在 Redlock 算法中,釋放鎖的操作和在單實(shí)例上釋放鎖的操作一樣,只要執(zhí)行釋放鎖的 Lua 腳本就可以了。這樣一來,只要 N 個(gè) Redis 實(shí)例中的半數(shù)以上實(shí)例能正常工作,就能保證分布式鎖的正常工作了。
代碼示例:
首先引入Redisson依賴的Jar包
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.9.1</version>
</dependency>
Redisson 支持3種方式連接redis,分別為單機(jī)、Sentinel 哨兵、Cluster 集群,項(xiàng)目中使用的連接方式是 Sentinel。
Sentinel配置,首先創(chuàng)建RedissonClient客戶端實(shí)例
Config config = new Config();
config.useSentinelServers().addSentinelAddress("127.0.0.1:6479", "127.0.0.1:6489").setMasterName("master").setPassword("password").setDatabase(0);
RedissonClient redisson = Redisson.create(config);
加鎖、釋放鎖
RLock lock = redisson.getLock("test_lock");
try{
boolean isLock=lock.tryLock();
if(isLock){
// 模擬業(yè)務(wù)處理
doBusiness();
}
}catch(exception e){
}finally{
lock.unlock();
}
項(xiàng)目源碼地址
https://github.com/aalansehaiyang/spring-boot-bulking
模塊:spring-boot-bulking-redis-lock
歡迎關(guān)注微信公眾號(hào):互聯(lián)網(wǎng)全棧架構(gòu),收取更多有價(jià)值的信息。
