Redis分佈式鎖解決接口冪等的兩種方案

一、背景

還在爲不瞭解分佈式鎖而煩惱嗎?還在爲衆多微服務接口不冪等而發愁嗎?如果是,並且有興趣同我一起學習,那請接着看本文,通過本文能夠學習到分佈式鎖的基本原理、如何實現分佈式鎖以及使用分佈式鎖解決接口冪等問題。

二、基礎知識

本文是通過使用 Redis 實現分佈式鎖,當然也可用使用各大數據庫,比如 Mysql、Oracle 自持的行級鎖、大廠的 Zookeeper 等方案實現。

  • 分佈式鎖的基本思想
    我們既然稱其爲“鎖”,那就是說只有唯一的一把鑰匙才能將鎖打開,將這種思想放到我們軟件設計上來,那就是在同一時間內只有同一個進程或者線程擁有這個”鑰匙“來鎖住資源,防止其他進程或線程用”鑰匙“鎖住同一個資源。當然,這是一種通俗的理解,在我們軟件工程中,“鎖”是要更復雜,更難以掌握的。

  • Redis 實現分佈式鎖原理
    Redis 主要是利用命令 redis.call()SETNXPEXPIRE 實現分佈式鎖的,但是因爲是兩個分開的命令,單獨執行這兩個命令肯定是非原子性,根據答墨菲定理未來一定會發生非原子的操作。好在一點是的 Redis 可以使用 Lua 腳本將單獨的多個命令統一順序執行,命令 EVAL。通過 EVAL 命名可以執行多個命令,這些命名要麼都成功,要麼都失敗(這就是我們想要的事務的原子性啊)。關於 Lua 腳本如何使用,Redis 官網有示例,可以點擊 Lua 腳本使用 學習。如果覺得 Lua 太難,那就感謝 Redis 幫我們實現了分佈式鎖框架 Redisson 吧,Redisson 實現分佈式鎖。另外 Redisson 幫我們實現了更多細節問題,例如,通過加入 watchdog 監控鎖的狀態,當實例還在運行時自動幫你續約(實際就是通過命令 PEXPIRE 重新設定過期時間)。

三、解決方案

爲了能夠在多場景下複用,避免重複造輪子的現象,我們可以藉助 Spring AOP 技術,通過自定義註解 @ApiIdempotent 來實現,寫好後在打成 jar 放到我們的中央倉庫,在項目上引入 jar ,再在需要控制接口冪等的 Controller 方法上加上我們的註解即可,方便快捷。我這下面自定義一個接口冪等的註解:

/**
 * 自定義接口冪等註解
 * @author ouyang
 * @version 1.0
 * @date 2020/4/20 11:21
 **/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {

    /**
     * 過期時間,單位:ms。 默認2000
     */
    long expire() default 2000;

    /**
     * 重試次數,默認0
     */
    int retryTimes() default 0;

    /**
     * 重試間隔時間,單位:ms,默認100
     */
    long retryInterval() default 100;
}

本註解 @ApiIdempotent 支持自定義鎖時間、重試加鎖次數及重試間隔設置。

/**
 * @author ouyang
 * @version 1.0
 * @date 2020/4/20 11:21
 **/
@Aspect
@Component
public class ApiIdempotentAspect {

    private final Logger logger = LoggerFactory.getLogger(ApiIdempotentAspect.class);
    private final RedisLockUtil redisLockUtil;

    @Autowired
    public ApiIdempotentAspect(RedisLockUtil redisLockUtil) {
        this.redisLockUtil = redisLockUtil;
    }
    @Pointcut("@annotation(com.gridsum.techpub.apiidempotent.annotation.ApiIdempotent)")
    public void apiIdempotentPointCut() { }

    @Around("apiIdempotentPointCut()")
    public Object apiIdempotentAround(ProceedingJoinPoint point) throws Throwable {
    	// TODO lock
    	
        Object result = point.proceed();
        
        // TODO unlock
        
        return result;
    }
}
  • 基於 lua 腳本實現分佈式鎖解決接口冪等方案
/**
 * 鎖
 * @author ouyang
 * @version 1.0
 * @date 2020/4/20 11:21
 **/
@Component
public class RedisLockUtil {
    private final Logger logger = LoggerFactory.getLogger(RedisLockUtil.class);

    private static final String KEY_PREFIX = "apiIdempotent:";

    //定義獲取鎖的lua腳本
    private static final DefaultRedisScript<Long> LOCK_LUA_SCRIPT = new DefaultRedisScript<>(
            "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end"
            , Long.class
    );

    //定義釋放鎖的lua腳本
    private static final DefaultRedisScript<Long> UNLOCK_LUA_SCRIPT = new DefaultRedisScript<>(
            "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return -1 end"
            , Long.class
    );

    private static final Long LOCK_SUCCESS = 1L;
    private static final Long LOCK_EXPIRED = -1L;

    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    public RedisLockUtil(@Qualifier("customRedisTemplate") RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 加鎖
     * @param key 鎖的 key
     * @param value  value ( key + value 必須保證唯一)
     * @param expire key 的過期時間,單位 ms
     * @param retryTimes 重試次數,即加鎖失敗之後的重試次數
     * @param retryInterval 重試時間間隔,單位 ms
     * @return 加鎖 true 成功
     */
    public boolean lock(String key, String value, long expire, int retryTimes, long retryInterval) {
        key = KEY_PREFIX + key;

        logger.info("locking... redisK = {}", key);
        try {
            //執行腳本
            Object result = redisTemplate
                    .execute(LOCK_LUA_SCRIPT, Collections.singletonList(key), value, expire);
            //存儲本地變量
            if(LOCK_SUCCESS.equals(result)) {
                logger.info("locked... redisK = {}", key);
                return true;
            } else {
                //重試獲取鎖
                int count = 0;
                while(count < retryTimes) {
                    try {
                        Thread.sleep(retryInterval);
                        result = redisTemplate
                                .execute(LOCK_LUA_SCRIPT, Collections.singletonList(key), value, expire);
                        if(LOCK_SUCCESS.equals(result)) {
                            logger.info("locked... redisK = {}", key);
                            return true;
                        }
                        logger.warn("{} times try to acquire lock", count + 1);
                        count++;
                    } catch (Exception e) {
                        logger.error("acquire redis occurred an exception", e);
                    }
                }

                logger.info("fail to acquire lock {}", key);
                return false;
            }
        } catch (Throwable e1) {
            logger.error("acquire redis occurred an exception", e1);
        }

        return false;
    }

    /**
     * 釋放KEY
     * @param key   釋放本請求對應的鎖的key
     * @param value 釋放本請求對應的鎖的value
     * @return 釋放鎖 true 成功
     */
    public boolean unlock(String key, String value) {
        key = KEY_PREFIX + key;
        logger.info("unlock... redisK = {}", key);
        try {
            // 使用lua腳本刪除redis中匹配value的key
            Object result = redisTemplate
                    .execute(UNLOCK_LUA_SCRIPT, Collections.singletonList(key), value);
            //如果這裏拋異常,後續鎖無法釋放
            if (LOCK_SUCCESS.equals(result)) {
                logger.info("release lock success. redisK = {}", key);
                return true;
            } else if (LOCK_EXPIRED.equals(result)) {
                logger.warn("release lock exception, key has expired or released");
            } else {
                //其他情況,一般是刪除KEY失敗,返回0
                logger.error("release lock failed");
            }
        } catch (Throwable e) {
            logger.error("release lock occurred an exception", e);
        }

        return false;
    }
}

缺點:
基於 Lua 腳本實現的分佈式鎖,鎖的失效時間是自己設定的,需要根據接口的響應時間評個人經驗設定合理的值,如果設定的失效時間過短,將可能導致該鎖失效。

  • 基於 Redisson 實現分佈式鎖解決接口冪等方案

/**
 * 鎖
 * @author ouyang
 * @version 1.0
 * @date 2020/4/20 11:21
 **/
@Component
public class RedisLockUtil {
    private final Logger logger = LoggerFactory.getLogger(RedisLockUtil.class);

    private final RedissonClient redissonClient;

    @Autowired
    public RedisLockUtil(@Qualifier("customRedisson") RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

   /**
     * 加鎖
     * @param key 鎖的 key
     * @param value  value ( key + value 必須保證唯一)
     * @param expire key 的過期時間,單位 ms
     * @param retryTimes 重試次數,即加鎖失敗之後的重試次數
     * @param retryInterval 重試時間間隔,單位 ms
     * @return 加鎖 true 成功
     */
    public RLock lock(String key, String value, long expire, int retryTimes, long retryInterval) {
        logger.info("locking... redisK = {}", key);
        RLock fairLock = redissonClient.getFairLock(key + ":" +  value);
        try {
            boolean tryLock = fairLock.tryLock(0, expire, TimeUnit.MILLISECONDS);
            if (tryLock) {
                logger.info("locked... redisK = {}", key);
                return fairLock;
            } else {
                //重試獲取鎖
                logger.info("retry to acquire lock: [redisK = {}]", key);
                int count = 0;
                while(count < retryTimes) {
                    try {
                        Thread.sleep(retryInterval);
                        tryLock = fairLock.tryLock(0, expire, TimeUnit.MILLISECONDS);
                        if(tryLock) {
                            logger.info("locked... redisK = {}", key);
                            return fairLock;
                        }
                        logger.warn("{} times try to acquire lock", count + 1);
                        count++;
                    } catch (Exception e) {
                        logger.error("acquire redis occurred an exception", e);
                        break;
                    }
                }

                logger.info("fail to acquire lock {}", key);
            }
        } catch (Throwable e1) {
            logger.error("acquire redis occurred an exception", e1);
        }

        return fairLock;
    }

    /**
     * 釋放KEY
     * @param fairLock 分佈式公平鎖
     * @return 釋放鎖 true 成功
     */
    public boolean unlock(RLock fairLock) {
        try {
            //如果這裏拋異常,後續鎖無法釋放
            if (fairLock.isLocked()) {
                fairLock.unlock();
                logger.info("release lock success");

                return true;
            }
        } catch (Throwable e) {
            logger.error("release lock occurred an exception", e);
        }

        return false;
    }
}

四、實驗

  1. 通過 jmeter 進行壓測(15 線程 * 循環 10 次),結果達到預期。
    在這裏插入圖片描述
    在這裏插入圖片描述

五、說在最後的話

  1. 本文必須保證 key + value 的唯一性。

  2. 如有說錯的地方,還請在下方評論留言(每個評論我都會去認真查看),不吝賜教,我也是一直在學習中。

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