天天看點

Redis分布式鎖的實作

AOP 的全稱為 Aspect Oriented Programming,譯為面向切面程式設計。實際上 AOP 就是通過預編譯和運作期動态代理實作程式功能的統一維護的一種技術。在不同的技術棧中 AOP 有着不同的實作,但是其作用都相差不遠,我們通過 AOP 為既有的程式定義一個切入點,然後在切入點前後插入不同的執行内容,以達到在不修改原有代碼業務邏輯的前提下統一處理一些内容(比如日志處理、分布式鎖)的目的。

在實際的開發過程中,我們的應用程式會被分為很多層。通常來講一個 Java 的 Web 程式會擁有以下幾個層次:

Web 層:主要是暴露一些 Restful API 供前端調用。

業務層:主要是處理具體的業務邏輯。

資料持久層:主要負責資料庫的相關操作(增删改查)。

雖然看起來每一層都做着全然不同的事情,但是實際上總會有一些類似的代碼,比如日志列印和安全驗證等等相關的代碼。如果我們選擇在每一層都獨立編寫這部分代碼,那麼久而久之代碼将變的很難維護。是以我們提供了另外的一種解決方案: AOP。這樣可以保證這些通用的代碼被聚合在一起維護,而且我們可以靈活的選擇何處需要使用這些代碼。

切面(Aspect) :通常是一個類,在裡面可以定義切入點和通知。(<code>@Aspect</code>修飾的類)

連接配接點(Joint Point) :被攔截到的點,因為 Spring 隻支援方法類型的連接配接點,是以在 Spring 中連接配接點指的就是被攔截的到的方法,實際上連接配接點還可以是字段或者構造器。

切入點(Pointcut) :對連接配接點進行攔截的定義(在切面類上被<code>@Pointcut</code>修飾的方法, @Pointcut("execution(* com.controller.TQueryController.query(..))") )。

通知(Advice) :攔截到連接配接點之後所要執行的代碼,通知分為前置、後置、異常、最終、環繞通知五類。(切面類上被<code>@Before</code> 、 <code>@After</code> 、 <code>@AfterReturning</code> 、 <code>@Around</code> 、 <code>@AfterThrowing</code> 修飾的方法)

AOP 代理 :AOP 架構建立的對象,代理就是目标對象的加強。Spring 中的 AOP 代理可以使 JDK 動态代理,也可以是 CGLIB 代理,前者基于接口,後者基于子類。

Spring 中的 AOP 代理還是離不開 Spring 的 IOC 容器,代理的生成,管理及其依賴關系都是由 IOC 容器負責,Spring 預設使用 JDK 動态代理,在需要代理類而不是代理接口的時候,Spring 會自動切換為使用 CGLIB 代理,不過現在的項目都是面向接口程式設計,是以 JDK 動态代理相對來說用的還是多一些。

<code>@Aspect</code> : 将一個 java 類定義為切面類。

<code>@Pointcut</code> :定義一個切入點,可以是一個規則表達式,比如下例中某個 <code>package</code> 下的所有函數,也可以是一個注解等。

<code>@Before</code> :在切入點開始處切入内容。

<code>@After</code> :在切入點結尾處切入内容。

<code>@AfterReturning</code> :在切入點 return 内容之後切入内容(可以用來對處理傳回值做一些加工處理)。

<code>@Around</code> :在切入點前後切入内容,并自己控制何時執行切入點自身的内容。

<code>@AfterThrowing</code> :用來處理當切入内容部分抛出異常之後的處理邏輯。

其中 <code>@Before</code> 、 <code>@After</code> 、 <code>@AfterReturning</code> 、 <code>@Around</code> 、 <code>@AfterThrowing</code> 都屬于通知。

在實際情況下,我們對同一個接口做多個切面,比如日志列印、分布式鎖、權限校驗等等。這時候我們就會面臨一個優先級的問題,這麼多的切面該如何告知 Spring 執行順序呢?這就需要我們定義每個切面的優先級,我們可以使用 <code>@Order(i)</code> 注解來辨別切面的優先級, <code>i</code> 的值越小,優先級越高。假設現在我們一共有兩個切面,一個 <code>WebLogAspect</code> ,我們為其設定 <code>@Order(100)</code> ;而另外一個切面 <code>DistributeLockAspect</code> 設定為 <code>@Order(99)</code> ,是以 <code>DistributeLockAspect</code> 有更高的優先級,這個時候執行順序是這樣的:在 <code>@Before</code> 中優先執行 <code>@Order(99)</code> 的内容,再執行 <code>@Order(100)</code> 的内容。而在 <code>@After</code> 和 <code>@AfterReturning</code> 中則優先執行 <code>@Order(100)</code> 的内容,再執行 <code>@Order(99)</code> 的内容,可以了解為先進後出的原則。

多個AOP執行順序是按棧先進後出的原則。

使用注解一方面可以減少我們的配置,另一方面注解在編譯期間就可以驗證正确性,查錯相對比較容易,而且配置起來也相當友善。相信大家也都有所了解,我們現在的 Spring 項目裡面使用了非常多的注解替代了之前的 xml 配置。

其中除了傳回類型模式、方法名模式和參數模式外,其它項都是可選的。這個解釋可能有點難了解,下面我們通過一個具體的例子來了解一下。在 <code>WebLogAspect</code> 中我們定義了一個切點,其 <code>execution</code> 表達式為 <code>* cn.itweknow.sbaop.controller..*.*(..)</code> ,下表為該表達式比較通俗的解析:

辨別符

含義

<code>execution()</code>

表達式的主體

第一個 <code>*</code> 符号

表示傳回值的類型, <code>*</code> 代表所有傳回類型

<code>cn.itweknow.sbaop.controller</code>

AOP 所切的服務的包名,即需要進行橫切的業務類

包名後面的 <code>..</code>

表示目前包及子包

第二個 <code>*</code>

表示類名, <code>*</code> 表示所有類

最後的 <code>.*(..)</code>

第一個 <code>.*</code> 表示任何方法名,括号内為參數類型, <code>..</code> 代表任何類型

我們程式中多多少少會有一些共享的資源或者資料,在某些時候我們需要保證同一時間隻能有一個線程通路或者操作它們。在傳統的單機部署的情況下,我們簡單的使用 Java 提供的并發相關的 API 處理即可。但是現在大多數服務都采用分布式的部署方式,我們就需要提供一個跨程序的互斥機制來控制共享資源的通路,這種互斥機制就是我們所說的分布式鎖。

互斥性。在任時刻,隻有一個用戶端能持有鎖。

不會發生死鎖。即使有一個用戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他用戶端能加鎖。這個其實隻要我們給鎖加上逾時時間即可。

具有容錯性。隻要大部分的 Redis 節點正常運作,用戶端就可以加鎖和解鎖。

解鈴還須系鈴人。加鎖和解鎖必須是同一個用戶端,用戶端自己不能把别人加的鎖給解了。

由于注解屬性在指定的時候隻能為常量,我們無法直接使用方法的參數。而在絕大多數的情況下分布式鎖的 key 值是需要包含方法的一個或者多個參數的,這就需要我們将這些參數的位置以某種特殊的字元串表示出來,然後通過參數解析器去動态的解析出來這些參數具體的值,然後拼接到 <code>key</code> 上。在本教程中我也編寫了一個參數解析器 <code>AnnotationResolver</code> 。需要的讀者可以 檢視源碼 。

可以用個約定的擷取方法更讨巧方面。

執行個體:

pom.xml

Redis配置參考Springboot整合redis使用RedisTemplate.

切面類

注解

測試類

分布式鎖一般有資料庫樂觀鎖(服務端是叢集,資料庫是單例或者讀寫分離庫)、基于Redis的分布式鎖以及基于ZooKeeper的分布式鎖三種實作方式。

pom.xml檔案加入下面的代碼:

正确代碼

可以看到,我們加鎖就一行代碼:jedis.set(String key, String value, String nxxx, String expx, int time),這個set()方法一共有五個形參:

第一個為key,我們使用key來當鎖,因為key是唯一的。

第二個為value,我們傳的是requestId,很多童鞋可能不明白,有key作為鎖不就夠了嗎,為什麼還要用到value?原因就是我們在上面講到可靠性時,分布式鎖要滿足第四個條件解鈴還須系鈴人,通過給value指派為requestId,我們就知道這把鎖是哪個請求加的了,在解鎖的時候就可以有依據。requestId可以使用UUID.randomUUID().toString()方法生成。

第三個為nxxx,這個參數我們填的是NX,意思是SET IF NOT EXIST,即當key不存在時,我們進行set操作;若key已經存在,則不做任何操作;

第四個為expx,這個參數我們傳的是PX,意思是我們要給這個key加一個過期的設定,具體時間由第五個參數決定。

第五個為time,與第四個參數相呼應,代表key的過期時間。

總的來說,執行上面的set()方法就隻會導緻兩種結果:

目前沒有鎖(key不存在),那麼就進行加鎖操作,并對鎖設定個有效期,同時value表示加鎖的用戶端。

已有鎖存在,不做任何操作。

錯誤示例1

比較常見的錯誤示例就是使用jedis.setnx()和jedis.expire()組合實作加鎖,代碼如下:

setnx()方法作用就是SET IF NOT EXIST,expire()方法就是給鎖加一個過期時間。乍一看好像和前面的set()方法結果一樣,然而由于這是兩條Redis指令,不具有原子性,如果程式在執行完setnx()之後突然崩潰,導緻鎖沒有設定過期時間。那麼将會發生死鎖。網上之是以有人這樣實作,是因為低版本的jedis并不支援多參數的set()方法。

錯誤示例2

這一種錯誤示例就比較難以發現問題,而且實作也比較複雜。實作思路:使用jedis.setnx()指令實作加鎖,其中key是鎖,value是鎖的過期時間。執行過程:1. 通過setnx()方法嘗試加鎖,如果目前鎖不存在,傳回加鎖成功。2. 如果鎖已經存在則擷取鎖的過期時間,和目前時間比較,如果鎖已經過期,則設定新的過期時間,傳回加鎖成功。代碼如下:

這段代碼的錯誤之處在于:

由于是用戶端自己生成過期時間,是以需要強制要求分布式下每個用戶端的時間必須同步。

當鎖過期的時候,如果多個用戶端同時執行jedis.getSet()方法,那麼雖然最終隻有一個用戶端可以加鎖,但是這個用戶端的鎖的過期時間可能被其他用戶端覆寫。

鎖不具備擁有者辨別,即任何用戶端都可以解鎖。

可以看到,我們解鎖隻需要兩行代碼就搞定了!第一行代碼,我們寫了一個簡單的Lua腳本代碼,第二行代碼,我們将Lua代碼傳到jedis.eval()方法裡,并使參數KEYS[1]指派為lockKey,ARGV[1]指派為requestId。eval()方法是将Lua代碼交給Redis服務端執行。

那麼這段Lua代碼的功能是什麼呢?其實很簡單,首先擷取鎖對應的value值,檢查是否與requestId相等,如果相等則删除鎖(解鎖)。那麼為什麼要使用Lua語言來實作呢?因為要確定上述操作是原子性的。那麼為什麼執行eval()方法可以確定原子性,源于Redis的特性,簡單來說,就是在eval指令執行Lua代碼的時候,Lua代碼将被當成一個指令去執行,并且直到eval指令執行完成,Redis才會執行其他指令。

最常見的解鎖代碼就是直接使用jedis.del()方法删除鎖,這種不先判斷鎖的擁有者而直接解鎖的方式,會導緻任何用戶端都可以随時進行解鎖,即使這把鎖不是它的。

這種解鎖代碼乍一看也是沒問題,甚至我之前也差點這樣實作,與正确姿勢差不多,唯一差別的是分成兩條指令去執行,代碼如下:

如代碼注釋,這個代碼的問題在于如果調用jedis.del()方法的時候,這把鎖已經不屬于目前用戶端的時候會解除他人加的鎖。那麼是否真的有這種場景?答案是肯定的,比如用戶端A加鎖,一段時間之後用戶端A解鎖,在執行jedis.del()之前,鎖突然過期了,此時用戶端B嘗試加鎖成功,然後用戶端A再執行del()方法,則将用戶端B的鎖給解除了。

總結

本文介紹的Redis分布式鎖都是用JAVA實作,對于加鎖和解鎖的方法也分别給出了錯誤示例供大家參考。其實想要通過Redis實作分布式鎖難度并不高,隻要能滿足上面給出的四個可靠性條件即可。

使用 Spring Boot AOP 實作 Web 日志處理和分布式鎖 Spring Boot 項目中使用 Swagger 文檔