Redis分佈式鎖的實現(基於Spring AOP+自定義註解)

        最近工作涉及到一個需求是關於禁止重複操作的後端校驗,當時通過一種與業務耦合的redis加鎖方式暫時滿足了功能需求,後來在大佬的指點下將該功能抽離出來單獨做一個組件,公司目前的項目是典型的分佈式系統,自己之前接觸的很少,中途也遇到了一些困難,在此做下總結。

什麼是分佈式鎖

要介紹分佈式鎖,首先要提到與分佈式鎖相對應的是線程鎖、進程鎖。

① 線程鎖:主要用來給方法、代碼塊加鎖。當某個方法或代碼使用鎖,在同一時刻僅有一個線程執行該方法或該代碼段。線程鎖只在同一JVM中有效果,因爲線程鎖的實現在根本上是依靠線程之間共享內存實現的,比如synchronized是共享對象頭,顯示鎖Lock是共享某個變量(state)。

② 進程鎖:爲了控制同一操作系統中多個進程訪問某個共享資源,因爲進程具有獨立性,各個進程無法訪問其他進程的資源,因此無法通過synchronized等線程鎖實現進程鎖。

分佈式鎖:當多個進程不在同一個系統中,用分佈式鎖控制多個進程對資源的訪問。
 

爲什麼需要分佈式鎖(即要解決什麼問題)

        隨着業務越來越複雜,應用服務都會朝着分佈式、集羣方向部署,而分佈式CAP原則告訴我們,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分區容錯性),三者不可得兼。實踐中一般滿足CP或AP。

很多場景中,需要使用分佈式事務、分佈式鎖等技術來保證數據最終一致性。有的時候,我們需要保證某一方法同一時刻只能被一個線程執行。   

        在單機環境中,多個線程對共享變量進行訪問時,我們可以簡單的通過Java自身的同步操作來協調同一時刻對變量的串行訪問。然而在分佈式環境中,進程的獨立性,進程之間無法訪問相互之間的資源,無法像之前那樣的方式實現進程鎖,故需要一個獨立的中心節點,以協調多個系統對共享變量的訪問,所有進程在訪問該變量時,都從同一個地方進行取值並控制,從而實現在類似於單機環境中同步控制的效果。      

需要滿足什麼條件

① 可以保證在分佈式部署的應用集羣中,同一個方法在同一時間只能被一臺機器上的一個線程執行
② 這把鎖要是一把可重入鎖(避免死鎖)
③ 這把鎖最好是一把阻塞鎖
④ 有高可用的獲取鎖和釋放鎖功能
⑤ 獲取鎖和釋放鎖的性能要好

實現方式

主要有三種實現方式:①基於數據庫(樂觀鎖);②基於緩存(redis,memcached等)③基於zookeeper

本次實踐主要選用第二種,基於redis進行實現。

實現思路

粗粒度的大致思路:

① 在對共享變量(key)操作前,判斷該變量值在redis中是否有對應的key

② 若無,則設置到redis中,並設置該key的超時時間,返回true;若有,則說明該變量已經被別的線程/進程持有,不做任何動作,返回false

大致的思路相對簡單,實際實現會遇到很多困難,如實現方式,加鎖位置,操作原子性,死鎖問題,性能問題等。

實現方案

以下方式沒有誰好誰壞之分,需要看實際的業務情況取捨,性能,或是跟業務代碼的耦合度,代碼結構設計等等

① 以服務的方式提供分佈式鎖

  這種方式主要是將加鎖解鎖操作封裝成一個服務提供給業務使用,將鎖服務耦合到業務中,能夠較細粒度的控制好變量的訪問,實現較爲靈活,性能相對較好,但是技術需求對於業務代碼的入侵相對較多。

② 以切面的方式提供分佈式鎖

主要通過自定義註解,集成spring提供的aop切面,在方法執行前後進行攔截,對要加鎖的變量進行協調,這種方式對業務代碼的入侵較低,可以方便的對業務的加鎖操作進行擴展。不過隨之帶來的有性能問題,因爲在方法攔截後需要對當前方法進行反射,從而獲取當前註解變量和方法參數,反射是一種耗性能的操作方式。

自定義註解+Spring AOP+Redis實現分佈式鎖

本次採用第二種方式,通過自定義註解+Spring AOP+Redis實現

① 自定義註解 RedisLock

package cn.jyycode.annotition;

import java.lang.annotation.*;

/** redis鎖
 * @author [email protected]
 * @date 2019/1/29 19:23
 * @since 1.0.0
 */

@Documented
@Target({ElementType.METHOD,ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {

    /**
     * 要鎖定的屬性參數值(key值)索引
     * @return
     */
    int argsIndex();

}

這裏的argsIndex是指形參中參數的索引值,即第0個參數,第1個參數,第2個參數......

因爲我們需要獲取加鎖的變量值(非固定鎖),這個值在方法參數中提供(若要加鎖的對象在業務邏輯中提供而不是方法參數,就應當採用以服務的方式提供分佈式鎖而不是現在這種方法),所以本方式也是具有它的侷限性的。

② RedisLockAspect

@Slf4j
@Aspect
@Component
public class RedisLockAspect {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

集成spring aop的方式,聲明RedisLock註解的攔截切面。並將redis服務通過spring bean的方式注入進來。

③ 設置超時時間

    /**
     * 設置key超時時間,默認爲5s
     */
    private static final int LOCK_EXPIRE_SECONDS = 5;

具體超時時間根據業務需求而定了,這裏默認5s

④ 設置攔截切點

/**
     * 要鎖定的操作切點
     */
    @Pointcut("@annotation(cn.jyycode.annotition.RedisLock)")
    public void lockAspect() {
    }

對註解了RedisLock的方法進行攔截

⑤ 方法執行前

 /**
     * 用於攔截操作,在方法執行前執行
     *
     * @param joinPoint 切點
     */
    @Before(value = "lockAspect()")
    public void before(JoinPoint joinPoint) throws Throwable {
        this.handle(joinPoint);
    }

對攔截的方法進行代理增強,進行相關邏輯的操作

⑥ 加鎖判斷

    /**
     * redis分佈式鎖
     *
     * @param joinPoint 連接點
     * @return
     */
    private void handle(JoinPoint joinPoint) throws Throwable {
        RedisLock annotation = this.getAnnotation(joinPoint);
        Object[] args = joinPoint.getArgs();
        boolean result = false;
        try {
            key = String.valueOf(args[Optional.ofNullable(annotation.argsIndex()).orElse(0)]);
            result = this.lock(key, LOCK_EXPIRE_SECONDS);
            if (!result) {
                log.info("【鎖定失敗】:{}", "要操作的屬性值已被鎖定");
                throw new RedisLockException("【鎖定失敗】:要操作的屬性值已被鎖定");
            } else {
                log.info("【成功加鎖】");
            }
        } catch (Exception e) {
        }
    }

1. 獲取方法上的註解,將要加鎖對象的參數索引值取出,通過切入點的參數數組獲取要加鎖對象的值;

2. 執行lock加鎖操作,若加鎖成功則返回true,失敗則返回false

⑦ 加鎖操作(重點)

    /**
     * 加鎖
     *
     * @param key
     * @param expire 過期時間,單位秒
     * @return true:加鎖成功,false:加鎖失敗
     */
    private boolean lock(String key, int expire) {
        String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, String.valueOf(expire));
        stringRedisTemplate.expire(key, expire, TimeUnit.SECONDS);
        if (oldValue != null) {
            return false;
        }
        return true;
    }

1.對redis進行getAndSet操作,該操作是原子性的,獲取舊值並設置新值

2.對獲取的舊值進行判斷,若爲空,說明是第一次加鎖(即前面沒線程/進程進來設置value值),則表明加鎖成功,返回true;若舊值不爲空,則說明上一個線程/進程已經在使用該變量,並且還在使用或是未超時,則本次應當返回加鎖失敗(雖然設了新值進去,但是)返回false

⑧ 獲取方法上的註解

    /**
     * 獲取方法上的註解
     *
     * @param joinPoint 連接點
     * @return 返回方法註解
     * @throws Exception
     */
    private RedisLock getAnnotation(JoinPoint joinPoint) {
        Method method = null;
        try {
            method = Optional.ofNullable(((MethodSignature) joinPoint.
                    getSignature()).
                    getMethod()).orElse(null);
        } catch (Exception ignore) {
            log.info("【反射當前方法失敗】:{}", ignore);
            ignore.printStackTrace();
            throw new RedisLockException("【反射當前方法失敗】");
        }
        if (method == null) {
            throw new RedisLockException("【獲取方法註解異常】");
        }
        return method.getAnnotation(RedisLock.class);
    }

⑨ 自定義異常

/** redis鎖異常
 * @author [email protected]
 * @date 2019/1/30 10:00
 * @since 1.0.0
 */
@NoArgsConstructor
public class RedisLockException extends RuntimeException {

    public RedisLockException(String message){
        super(message);
    }
}

用於統一處理此類異常

存在的問題及不足(時間倉促,待完善)

① 上述寫的很粗糙,需要完善,大致完善點如下

② 對上面寫的內容進行補充和review

③ 需要更合乎邏輯的加鎖處理(如鎖失效條件,超時還是自己手動解鎖等),操作原子性等

④ 潛在的死鎖問題分析

⑤ 本次是悲觀鎖實現,性能差,需要尋求樂觀鎖實現(配合分佈式事務)

⑥ 實踐spring官方提供的一種解決方案redisson

⑦ 遇到的問題總結及改進方式,例utils->自定義註解+aop攔截,方式的轉變,非固定鎖值的獲取(方法註解,參數註解等),異常處理(空指針,數組越界,邊界條件等)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章