Redis分散式鎖的實現

Redis分散式鎖的實現

參考

使用 Spring Boot AOP 實現 Web 日誌處理和分散式鎖

Redis分散式鎖正確的實現方法

Spring Boot 項目中使用 Swagger 文檔


問題

Q:AOP如何實現在不修改原有程式碼業務邏輯的前提下統一處理一些內容?

Q:AOP的實現原理?

Q:通過AOP實現分散式鎖的優點?

Q:分散式鎖加鎖為什麼要通過SETNX和EXPIRE達到原子執行的目的?解鎖為什麼要使用Lua腳本來執行?

Q:Spring 默認使用 JDK 動態代理,在需要代理類而不是代理介面的時候,Spring 會自動切換為使用 CGLIB 代理。能自動切換么?

Q:自定義註解的作用?如何自定義註解?什麼時候選擇自定義註解?為什麼要自定義註解?


基於Spring Boot AOP 實現分散式鎖

AOP

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

為什麼要使用 AOP

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

  • Web 層:主要是暴露一些 Restful API 供前端調用。
  • 業務層:主要是處理具體的業務邏輯。
  • 數據持久層:主要負責資料庫的相關操作(增刪改查)。

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

AOP 的核心概念

  • 切面(Aspect) :通常是一個類,在裡面可以定義切入點和通知。(@Aspect修飾的類)
  • 連接點(Joint Point) :被攔截到的點,因為 Spring 只支援方法類型的連接點,所以在 Spring 中連接點指的就是被攔截的到的方法,實際上連接點還可以是欄位或者構造器。
  • 切入點(Pointcut) :對連接點進行攔截的定義(在切面類上被@Pointcut修飾的方法, @Pointcut(“execution(* com.controller.TQueryController.query(..))”) )。
  • 通知(Advice) :攔截到連接點之後所要執行的程式碼,通知分為前置、後置、異常、最終、環繞通知五類。(切面類上被@Before 、 @After 、 @AfterReturning 、 @Around 、 @AfterThrowing 修飾的方法)
  • AOP 代理 :AOP 框架創建的對象,代理就是目標對象的加強。Spring 中的 AOP 代理可以使 JDK 動態代理,也可以是 CGLIB 代理,前者基於介面,後者基於子類。

Spring AOP

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

Spring AOP 相關註解

  • @Aspect : 將一個 java 類定義為切面類。
  • @Pointcut :定義一個切入點,可以是一個規則表達式,比如下例中某個 package 下的所有函數,也可以是一個註解等。
  • @Before :在切入點開始處切入內容。
  • @After :在切入點結尾處切入內容。
  • @AfterReturning :在切入點 return 內容之後切入內容(可以用來對處理返回值做一些加工處理)。
  • @Around :在切入點前後切入內容,並自己控制何時執行切入點自身的內容。
  • @AfterThrowing :用來處理當切入內容部分拋出異常之後的處理邏輯。

其中 @Before 、 @After 、 @AfterReturning 、 @Around 、 @AfterThrowing 都屬於通知。

AOP 順序問題

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

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

基於註解的 AOP 配置

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

官網對 execution 表達式的介紹

   execution(<修飾符模式>?<返回類型模式><方法名模式>(<參數模式>)<異常模式>?)

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

表 1. execution() 表達式解析

標識符 含義
execution() 表達式的主體
第一個 * 符號 表示返回值的類型, * 代表所有返回類型
cn.itweknow.sbaop.controller AOP 所切的服務的包名,即需要進行橫切的業務類
包名後面的 .. 表示當前包及子包
第二個 * 表示類名, * 表示所有類
最後的 .*(..) 第一個 . 表示任何方法名,括弧內為參數類型, .. 代表任何類型

為什麼要使用分散式鎖

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

注意

  1. 互斥性。在任時刻,只有一個客戶端能持有鎖。
  2. 不會發生死鎖。即使有一個客戶端在持有鎖的期間崩潰而沒有主動解鎖,也能保證後續其他客戶端能加鎖。這個其實只要我們給鎖加上超時時間即可。
  3. 具有容錯性。只要大部分的 Redis 節點正常運行,客戶端就可以加鎖和解鎖。
  4. 解鈴還須繫鈴人。加鎖和解鎖必須是同一個客戶端,客戶端自己不能把別人加的鎖給解了。

註解參數解析器

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

可以用個約定的獲取方法更討巧方面。

實例:

pom.xml

        <!--web起步依賴-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <exclusions><!-- 去掉springboot默認配置 -->
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!--添加 AOP 相關依賴-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!--添加 Swagger 依賴-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger2</artifactId>
            <version>2.9.2</version>
        </dependency>
        <!--添加 Swagger UI 依賴-->
        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>2.9.2</version>
        </dependency>

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

切面類

@Component
@Aspect
@Order(100)
@Slf4j
public class DistributeLockAspect {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private AnnotationResolver annotationResolver;

    @Pointcut("execution(* com.self.controller..*.*(..))")
    public void distributeLockCut(){

    }

    @Around(value = "distributeLockCut() && @annotation(distributeLock)")
    public Object doDistributeLockAround(ProceedingJoinPoint joinPoint, DistributeLock distributeLock) throws Exception {
        String key = annotationResolver.resolver(joinPoint, distributeLock.key());
        String keyValue = getLock(key, distributeLock.timeOut(), distributeLock.timeUnit());
        if (StringUtil.isNullOrEmpty(keyValue)) {
            // 獲取鎖失敗。
            return BaseResponse.addError(ErrorCodeEnum.OPERATE_FAILED, "請勿頻繁操作");
        }
        // 獲取鎖成功
        try {
            return joinPoint.proceed();
        } catch (Throwable throwable) {
            return BaseResponse.addError(ErrorCodeEnum.SYSTEM_ERROR, "系統異常");
        } finally {
            // 釋放鎖。
            unLock(key, keyValue);
        }

    }

    /**
     * 獲取鎖
     * @param key       鎖的key
     * @param timeout   鎖超時時間
     * @param timeUnit  時間單位
     *
     * @return 鎖的值
     */
    private String getLock(String key, long timeout, TimeUnit timeUnit) {
        try {
            String value = UUID.randomUUID().toString();
            Boolean lockStat = stringRedisTemplate.execute((RedisCallback<Boolean>)connection ->
                    connection.set(key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8")),
                            Expiration.from(timeout, timeUnit), RedisStringCommands.SetOption.SET_IF_ABSENT));
            if (!lockStat) {
                // 獲取鎖失敗。
                return null;
            }
            return value;
        } catch (Exception e) {
            log.error("獲取分散式鎖失敗,key={}", key, e);
            return null;
        }
    }

    /**
     * 釋放鎖
     *
     * @param key    鎖的key
     * @param value  獲取鎖的時候存入的值
     */
    private void unLock(String key, String value) {
        try {
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            boolean unLockStat = stringRedisTemplate.execute((RedisCallback<Boolean>) connection ->
                    connection.eval(script.getBytes(), ReturnType.BOOLEAN, 1,
                            key.getBytes(Charset.forName("UTF-8")), value.getBytes(Charset.forName("UTF-8"))));
            if (!unLockStat) {
                log.error("釋放分散式鎖失敗,key={},已自動超時,其他執行緒可能已經重新獲取鎖", key);
            }
        } catch (Exception e) {
            log.error("釋放分散式鎖失敗,key={}", key, e);
        }
    }
}

註解

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DistributeLock {
    /**
     * 鎖名稱
     */
    String key();
    /**
     * 超時時間
     */
    long timeOut() default 3;
    /**
     * 時間單位
     */
    TimeUnit timeUnit() default TimeUnit.HOURS;

}

測試類

@RequestMapping("/post-test")
@DistributeLock(key = "post_test_#{baseRequest.channel}", timeOut = 10)
public BaseResponse postTest(@RequestBody @Valid BaseRequest baseRequest, BindingResult bindingResult) {
    try {
        Thread.sleep(10000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return BaseResponse.addResult();
}

基於Spring AOP 實現分散式鎖——Jedis

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

pom.xml文件加入下面的程式碼:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

加鎖程式碼

正確程式碼

public class RedisTool {

    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 嘗試獲取分散式鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖
     * @param requestId 請求標識
     * @param expireTime 超期時間
     * @return 是否獲取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

}

可以看到,我們加鎖就一行程式碼: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()方法就只會導致兩種結果:

  1. 當前沒有鎖(key不存在),那麼就進行加鎖操作,並對鎖設置個有效期,同時value表示加鎖的客戶端。

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

錯誤示例1

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

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在這裡程式突然崩潰,則無法設置過期時間,將發生死鎖
        jedis.expire(lockKey, expireTime);
    }
}

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

錯誤示例2

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

public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {
    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);
    // 如果當前鎖不存在,返回加鎖成功
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }
    // 如果鎖存在,獲取鎖的過期時間
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // 鎖已過期,獲取上一個鎖的過期時間,並設置現在鎖的過期時間
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考慮多執行緒並發的情況,只有一個執行緒的設置值和當前值相同,它才有權利加鎖
            return true;
        }
    }
    // 其他情況,一律返回加鎖失敗
    return false;
}

這段程式碼的錯誤之處在於:

  1. 由於是客戶端自己生成過期時間,所以需要強制要求分散式下每個客戶端的時間必須同步。
  2. 當鎖過期的時候,如果多個客戶端同時執行jedis.getSet()方法,那麼雖然最終只有一個客戶端可以加鎖,但是這個客戶端的鎖的過期時間可能被其他客戶端覆蓋。
  3. 鎖不具備擁有者標識,即任何客戶端都可以解鎖。

解鎖程式碼

正確程式碼

public class RedisTool {
    private static final Long RELEASE_SUCCESS = 1L;
    /**
     * 釋放分散式鎖
     * @param jedis Redis客戶端
     * @param lockKey 鎖
     * @param requestId 請求標識
     * @return 是否釋放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;
    }
}

可以看到,我們解鎖只需要兩行程式碼就搞定了!第一行程式碼,我們寫了一個簡單的Lua腳本程式碼,第二行程式碼,我們將Lua程式碼傳到jedis.eval()方法里,並使參數KEYS[1]賦值為lockKey,ARGV[1]賦值為requestId。eval()方法是將Lua程式碼交給Redis服務端執行。

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

錯誤示例1

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

public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
    jedis.del(lockKey);
}

錯誤示例2

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

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
    // 判斷加鎖與解鎖是不是同一個客戶端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此時,這把鎖突然不是這個客戶端的,則會誤解鎖.鎖過期情況
        jedis.del(lockKey);
    }
}

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

總結

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

Tags: