首頁  >  文章  >  資料庫  >  帶你深入了解Redis中的分散式鎖

帶你深入了解Redis中的分散式鎖

青灯夜游
青灯夜游轉載
2022-03-08 10:13:502016瀏覽

你真的了解Redis分散式鎖定嗎?以下這篇文章就來帶大家深入介紹下Redis中的分散式鎖,聊聊怎麼實現鎖、釋放鎖,分散式鎖的缺陷等,希望對大家有幫助!

帶你深入了解Redis中的分散式鎖

什麼是分散式鎖定

說到Redis,我們第一想到的功能就是可以快取數據,除此之外,Redis因為單一進程、性能高的特點,它也常被用來做分散式鎖。 【相關推薦:Redis影片教學

鎖定我們都知道,在程式中的作用就是同步工具,保證共享資源在同一時刻只能被一個執行緒訪問,Java中的鎖我們都很熟悉了,像synchronized 、Lock都是我們常用的,但是Java的鎖只能保證單機的時候有效,分散式叢集環境就無能為力了,這個時候我們就需要用到分散式鎖。

分散式鎖,顧名思義,就是分散式專案開發中用到的鎖,可以用來控制分散式系統之間同步存取共享資源,一般來說,分散式鎖需要滿足的特性有這麼幾點:

1、互斥性:在任何時刻,對於同一條數據,只有一台應用可以獲取到分散式鎖定;

2、高可用性:在分散式場景下,一小部分伺服器宕機不影響正常使用,這種情況就需要將提供分散式鎖的服務以集群的方式部署;

3、防止鎖超時:如果客戶端沒有主動釋放鎖,伺服器會在一段時間之後自動釋放鎖,防止客戶端宕機或網路不可達時產生死鎖;

4、獨佔性:加鎖解鎖必須由同一台伺服器進行,也就是鎖的持有者才可以釋放鎖,不能出現你加的鎖,別人給你解鎖了;

業界裡可以實現分散式鎖效果的工具很多,但操作無非這麼幾個:加鎖、解鎖、防止鎖超時。

既然本文說的是Redis分散式鎖,那我們理所當然就以Redis的知識點來延伸。

實作鎖定的指令

先介紹下Redis的幾個指令,

#1、SETNX,用法是SETNX key value

SETNX是『 SET if Not eXists』(如果不存在,則SET)的簡寫,設定成功就回傳1,否則回傳0。

setnx用法

可以看出,當把keylock的值設定為"Java"後,再設定成別的值就會失敗,看起來很簡單,也好像獨佔了鎖,但有個致命的問題,就是key沒有過期時間,這樣一來,除非手動刪除key或取得鎖後設定過期時間,不然其他執行緒永遠拿不到鎖。

既然這樣,我們給key加個過期時間總可以吧,直接讓執行緒取得鎖的時候執行兩步驟操作:

`SETNX Key 1`
`EXPIRE Key Seconds`

這個方案也有問題,因為取得鎖定和設定過期時間分成兩步了,不是原子性操作,有可能取得鎖成功但設定時間失敗,那樣不就白乾了嗎。

不過也不用急,這種事Redis官方早為我們考慮到了,所以就引出了下面這個指令

2、SETEX,用法SETEX key seconds value

將值value 關聯到key ,並將key 的生存時間設為seconds (以秒為單位)。如果 key 已經存在,SETEX 指令將會覆寫舊值。

這個指令類似以下兩個指令:

`SET key value`
`EXPIRE key seconds  # 设置生存时间`

這兩步驟動作是原子性的,會在同一時間完成。

setex用法

3、PSETEX ,用法PSETEX key milliseconds value

#這個指令和SETEX指令相似,但它以毫秒為單位設定 key 的生存時間,而不是像SETEX指令那樣,以秒為單位。

不過,從Redis 2.6.12 版本開始,SET指令可以透過參數來實現和SETNX、SETEX、PSETEX 三個指令相同的效果。

就例如這條指令

`SET key value NX EX seconds`

加上NX、EX參數後,效果就相當於SETEX,這也是Redis取得鎖定寫入法裡面最常見的。

怎麼釋放鎖定

釋放鎖的指令就簡單了,直接刪除key就行,但我們前面說了,因為分散式鎖必須由鎖的持有者自己釋放,所以我們必須先確保目前釋放鎖的執行緒是持有者,沒問題了再刪除,這樣一來,就變成兩個步驟了,似乎又違背了原子性了,怎麼辦呢?

不慌,我們可以用lua腳本把兩步驟操作做拼裝,就好像這樣:

`if redis.call("get",KEYS[1]) == ARGV[1]`
`then`
 `return redis.call("del",KEYS[1])`
`else`
 `return 0`
`end`

KEYS[1]是当前key的名称,ARGV[1]可以是当前线程的ID(或者其他不固定的值,能识别所属线程即可),这样就可以防止持有过期锁的线程,或者其他线程误删现有锁的情况出现。

代码实现

知道了原理后,我们就可以手写代码来实现Redis分布式锁的功能了,因为本文的目的主要是为了讲解原理,不是为了教大家怎么写分布式锁,所以我就用伪代码实现了。

首先是redis锁的工具类,包含了加锁和解锁的基础方法:

`public class RedisLockUtil {`
 `private String LOCK_KEY = "redis_lock";`
 `// key的持有时间,5ms`
 `private long EXPIRE_TIME = 5;`
 `// 等待超时时间,1s`
 `private long TIME_OUT = 1000;`
 `// redis命令参数,相当于nx和px的命令合集`
 `private SetParams params = SetParams.setParams().nx().px(EXPIRE_TIME);`
 `// redis连接池,连的是本地的redis客户端`
 `JedisPool jedisPool = new JedisPool("127.0.0.1", 6379);`
 `/**`
 `* 加锁`
 `*`
 `* @param id`
 `*            线程的id,或者其他可识别当前线程且不重复的字段`
 `* @return`
 `*/`
 `public boolean lock(String id) {`
 `Long start = System.currentTimeMillis();`
 `Jedis jedis = jedisPool.getResource();`
 `try {`
 `for (;;) {`
 `// SET命令返回OK ,则证明获取锁成功`
 `String lock = jedis.set(LOCK_KEY, id, params);`
 `if ("OK".equals(lock)) {`
 `return true;`
 `}`
 `// 否则循环等待,在TIME_OUT时间内仍未获取到锁,则获取失败`
 `long l = System.currentTimeMillis() - start;`
 `if (l >= TIME_OUT) {`
 `return false;`
 `}`
 `try {`
 `// 休眠一会,不然反复执行循环会一直失败`
 `Thread.sleep(100);`
 `} catch (InterruptedException e) {`
 `e.printStackTrace();`
 `}`
 `}`
 `} finally {`
 `jedis.close();`
 `}`
 `}`
 `/**`
 `* 解锁`
 `*`
 `* @param id`
 `*            线程的id,或者其他可识别当前线程且不重复的字段`
 `* @return`
 `*/`
 `public boolean unlock(String id) {`
 `Jedis jedis = jedisPool.getResource();`
 `// 删除key的lua脚本`
 `String script = "if redis.call('get',KEYS[1]) == ARGV[1] then" + "   return redis.call('del',KEYS[1]) " + "else"`
 `+ "   return 0 " + "end";`
 `try {`
 `String result =`
 `jedis.eval(script, Collections.singletonList(LOCK_KEY), Collections.singletonList(id)).toString();`
 `return "1".equals(result);`
 `} finally {`
 `jedis.close();`
 `}`
 `}`
`}`

具体的代码作用注释已经写得很清楚了,然后我们就可以写一个demo类来测试一下效果:

`public class RedisLockTest {`
 `private static RedisLockUtil demo = new RedisLockUtil();`
 `private static Integer NUM = 101;`
 `public static void main(String[] args) {`
 `for (int i = 0; i < 100; i++) {`
 `new Thread(() -> {`
 `String id = Thread.currentThread().getId() + "";`
 `boolean isLock = demo.lock(id);`
 `try {`
 `// 拿到锁的话,就对共享参数减一`
 `if (isLock) {`
 `NUM--;`
 `System.out.println(NUM);`
 `}`
 `} finally {`
 `// 释放锁一定要注意放在finally`
 `demo.unlock(id);`
 `}`
 `}).start();`
 `}`
 `}`
`}`

我们创建100个线程来模拟并发的情况,执行后的结果是这样的:

代码执行结果

可以看出,锁的效果达到了,线程安全是可以保证的。

当然,上面的代码只是简单的实现了效果,功能肯定是不完整的,一个健全的分布式锁要考虑的方面还有很多,实际设计起来不是那么容易的。

我们的目的只是为了学习和了解原理,手写一个工业级的分布式锁工具不现实,也没必要,类似的开源工具一大堆(Redisson),原理都差不多,而且早已经过业界同行的检验,直接拿来用就行。

虽然功能是实现了,但其实从设计上来说,这样的分布式锁存在着很大的缺陷,这也是本篇文章想重点探讨的内容。

分布式锁的缺陷

一、客户端长时间阻塞导致锁失效问题

客户端1得到了锁,因为网络问题或者GC等原因导致长时间阻塞,然后业务程序还没执行完锁就过期了,这时候客户端2也能正常拿到锁,可能会导致线程安全的问题。

客户端长时间阻塞

那么该如何防止这样的异常呢?我们先不说解决方案,介绍完其他的缺陷后再来讨论。

二、redis服务器时钟漂移问题

如果redis服务器的机器时钟发生了向前跳跃,就会导致这个key过早超时失效,比如说客户端1拿到锁后,key的过期时间是12:02分,但redis服务器本身的时钟比客户端快了2分钟,导致key在12:00的时候就失效了,这时候,如果客户端1还没有释放锁的话,就可能导致多个客户端同时持有同一把锁的问题。

三、单点实例安全问题

如果redis是单master模式的,当这台机宕机的时候,那么所有的客户端都获取不到锁了,为了提高可用性,可能就会给这个master加一个slave,但是因为redis的主从同步是异步进行的,可能会出现客户端1设置完锁后,master挂掉,slave提升为master,因为异步复制的特性,客户端1设置的锁丢失了,这时候客户端2设置锁也能够成功,导致客户端1和客户端2同时拥有锁。

为了解决Redis单点问题,redis的作者提出了RedLock算法。

RedLock算法

该算法的实现前提在于Redis必须是多节点部署的,可以有效防止单点故障,具体的实现思路是这样的:

1、获取当前时间戳(ms);

2、先设定key的有效时长(TTL),超出这个时间就会自动释放,然后client(客户端)尝试使用相同的key和value对所有redis实例进行设置,每次链接redis实例时设置一个比TTL短很多的超时时间,这是为了不要过长时间等待已经关闭的redis服务。并且试着获取下一个redis实例。

比如:TTL(也就是过期时间)为5s,那获取锁的超时时间就可以设置成50ms,所以如果50ms内无法获取锁,就放弃获取这个锁,从而尝试获取下个锁;

3、client通过获取所有能获取的锁后的时间减去第一步的时间,还有redis服务器的时钟漂移误差,然后这个时间差要小于TTL时间并且成功设置锁的实例数>= N/2 + 1(N为Redis实例的数量),那么加锁成功

比如TTL是5s,连接redis获取所有锁用了2s,然后再减去时钟漂移(假设误差是1s左右),那么锁的真正有效时长就只有2s了;

4、如果客户端由于某些原因获取锁失败,便会开始解锁所有redis实例。

根据这样的算法,我们假设有5个Redis实例的话,那么client只要获取其中3台以上的锁就算是成功了,用流程图演示大概就像这样: 

key有效時間

好了,演算法也介紹完了,從設計上看,毫無疑問,RedLock演算法的想法主要是為了有效防止Redis單點故障的問題,而且在設計TTL的時候也考慮到了伺服器時脈漂移的誤差,讓分散式鎖的安全性提高了不少。

但事實真的是這樣嗎?反正我個人的話感覺效果一般般,

首先第一點,我們可以看到,在RedLock演算法中,鎖的有效時間會減去連接Redis實例的時長,如果這個過程因為網路問題導致耗時太長的話,那麼最終留給鎖的有效時長就會大大減少,客戶端存取共享資源的時間很短,很可能程式處理的過程中鎖就到期了。而且,鎖的有效時間還需要減去伺服器的時鐘漂移,但是應該減多少合適呢,要是這個值設定不好,很容易出現問題。

然後第二點,這樣的演算法雖然考慮到用多節點來防止Redis單點故障的問題,但如果有節點發生崩潰重啟的話,還是有可能出現多個客戶端同時獲取鎖的情況。

假設總共有5個Redis節點:A、B、C、D、E,客戶端1和2分別加鎖

  • 客戶端1成功鎖住了A,B,C,取得鎖成功(但D和E沒有鎖住)。

  • 節點C的master掛了,然後鎖定還沒同步到slave,slave升級到master後遺失了客戶端1加的鎖。

  • 客戶端2這個時候取得鎖,鎖住了C,D,E,取得鎖定成功。

這樣,客戶端1和客戶端2就同時拿到了鎖,程式安全的隱患依然存在。除此之外,如果這些節點裡面某個節點發生了時間漂移的話,也有可能導致鎖的安全問題。

所以說,雖然透過多實例的部署提高了可用性和可靠性,但RedLock並沒有完全解決Redis單點故障存在的隱患,也沒有解決時鐘漂移以及客戶端長時間阻塞而導致的鎖超時失效存在的問題,鎖的安全性隱憂依然存在。

結論

有人可能要進一步問了,那該怎麼做才能保證鎖的絕對安全?

對此我只能說,魚和熊掌不可兼得,我們之所以用Redis作為分散式鎖的工具,很大程度上是因為Redis本身效率高且單進程的特點,即使在高並發的情況下也能很好的保證性能,但很多時候,性能和安全不能完全兼顧,如果你一定要保證鎖的安全性的話,可以用其他的中間件如db、zookeeper來做控制,這些工具能很好的保證鎖的安全,但性能方面只能說是差強人意,否則大家早就用上了。

一般來說,用Redis控制共享資源並且還要求資料安全要求較高的話,最終的保底方案是對業務資料做冪等控制,這樣一來,即使出現多個客戶端獲得鎖的情況也不會影響數據的一致性。當然,也不是所有的場景都適合這麼做,具體怎麼取捨就需要各位看官自己處理啦,畢竟,沒有完美的技術,只有適合的才是最好的。

更多程式相關知識,請造訪:程式設計入門! !

以上是帶你深入了解Redis中的分散式鎖的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:segmentfault.com。如有侵權,請聯絡admin@php.cn刪除