Redis分佈式鎖

Redis之分佈式鎖的實現方案 - 如何優雅地實現分佈式鎖(JAVA)

博客地址 https://blog.piaoruiqing.com/2019/05/19/redis分佈式鎖/html

關鍵詞

  • 分佈式鎖: 是控制分佈式系統之間同步訪問共享資源的一種方式。
  • spring-data-redis: Spring針對redis的封裝, 配置簡單, 提供了與Redis存儲交互的抽象封裝, 十分優雅, 也極具擴展性, 推薦讀一讀源碼
  • Lua: Lua 是一種輕量小巧的腳本語言, 可在redis執行.

前言

本文闡述了Redis分佈式鎖的一種簡單JAVA實現及優化進階, 實現了自動解鎖、自定義異常、重試、註解鎖等功能, 嘗試用更優雅簡潔的代碼完成分佈式鎖.java

需求

  • 互斥性: 在分佈式系統環境下, 一個鎖只能被一個線程持有.
  • 高可用: 不會發生死鎖、即便客戶端崩潰也可超時釋放鎖.
  • 非阻塞: 獲取鎖失敗即返回.

方案

Redis具備極高的性能, 且其命令對分佈式鎖支持友好, 藉助SET命令便可實現加鎖處理.git

SETgithub

  • EX seconds -- Set the specified expire time, in seconds.
  • PX milliseconds -- Set the specified expire time, in milliseconds.
  • NX -- Only set the key if it does not already exist.
  • XX -- Only set the key if it already exist.

實現

簡單實現

作法爲set if not exist(若是不存在則賦值), redis命令爲原子操做, 因此單獨使用set命令時不用擔憂併發致使異常.redis

具體代碼實現以下: (spring-data-redis:2.1.6)spring

依賴引入

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <version>2.1.4.RELEASE</version>
</dependency>

配置RedisTemplate

@Bean
@ConditionalOnMissingBean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {

    StringRedisSerializer keySerializer = new StringRedisSerializer();
    RedisSerializer<?> serializer = new StringRedisSerializer();
    StringRedisTemplate template = new StringRedisTemplate();
    template.setConnectionFactory(factory);
    template.setKeySerializer(keySerializer);
    template.setHashKeySerializer(keySerializer);
    template.setValueSerializer(serializer);
    template.setHashValueSerializer(serializer);
    template.afterPropertiesSet();
    return template;
}

簡單的分佈式鎖實現

/**
 * try lock
 * @author piaoruiqing
 * 
 * @param key       lock key
 * @param value     value
 * @param timeout   timeout
 * @param unit      time unit
 * @return 
 */
public Boolean tryLock(String key, String value, long timeout, TimeUnit unit) {      

    return redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit);
}

以上代碼即完成了一個簡單的分佈式鎖功能: shell

其中redisTemplate.opsForValue().setIfAbsent(key, value, timeout, unit); 即爲執行redis命令:數據庫

redis> set dlock:test-try-lock a EX 10 NX
OK
redis> set dlock:test-try-lock a EX 10 NX
null

早期版本spring-data-redis分佈式鎖實現及注意事項

方法 Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit);是在2.1版本中新增的, 早期版本中setIfAbsent沒法同時指定過時時間, 若先使用 setIfAbsent再設置key的過時時間, 會存在產生死鎖的風險, 故舊版本中須要使用另外的寫法進行實現. 以 spring-data-redis:1.8.20爲例
/**
 * try lock
 * @author piaoruiqing
 * 
 * @param key       lock key
 * @param value     value
 * @param timeout   timeout
 * @param unit      time unit
 * @return 
 */
public Boolean tryLock(String key, String value, long timeout, TimeUnit unit) {

    return redisTemplate.execute(new RedisCallback<Boolean>() {
        @Override
        public Boolean doInRedis(RedisConnection connection) throws DataAccessException {

            JedisCommands commands = (JedisCommands)connection.getNativeConnection();
            String result = commands.set(key, value, "NX", "PX", unit.toMillis(timeout));

            return "OK".equals(result);
        }
    });
}

spring-data-redis:1.8.20默認redis客戶端爲jedis, 可經過getNativeConnection直接調用jedis方法進行操做. 新舊版本實現方式最終效果相同.併發

優化進階

基於AOP實現分佈式鎖註解工具 - 不只能用, 並且好用

優化一 (自動解鎖及重試)

自動解鎖、重試: 上一節針對分佈式鎖的簡單實現可知足基本需求, 但仍有較多可優化改進之處, 本小節將針對分佈式鎖自動解鎖及重試進行優化

分佈式鎖抽象類

實現 AutoCloseable接口, 可以使用 try-with-resource方便地完成自動解鎖.
/**
 * distributed lock
 * @author piaoruiqing
 *
 * @since JDK 1.8
 */
abstract public class DistributedLock implements AutoCloseable {

    private final Logger LOGGER = LoggerFactory.getLogger(getClass());

    /**
     * release lock
     * @author piaoruiqing
     */
    abstract public void release();

    /*
     * (non-Javadoc)
     * @see java.lang.AutoCloseable#close()
     */
    @Override
    public void close() throws Exception {

        LOGGER.debug("distributed lock close , {}", this.toString());

        this.unlock();
    }
}

封裝Redis分佈式鎖

RedisDistributedLock是Redis分佈式鎖的抽象, 繼承了 DistributedLock並實現了unlock接口.
/**
 * redis distributed lock
 *
 * @author piaoruiqing
 * @date: 2019/01/12 23:20
 *
 * @since JDK 1.8
 */
public class RedisDistributedLock extends DistributedLock {
    
    private RedisOperations<String, String> operations;
    private String key;
    private String value;
    
    private static final String COMPARE_AND_DELETE =        // (一)
        "if redis.call('get',KEYS[1]) == ARGV[1]\n" +
        "then\n" +
        "    return redis.call('del',KEYS[1])\n" +
        "else\n" +
        "    return 0\n" +
        "end";
    
    /**
     * @param operations
     * @param key
     * @param value
     */
    public RedisDistributedLock(RedisOperations<String, String> operations, String key, String value) {
        this.operations = operations;
        this.key = key;
        this.value = value;
    }
    /*
     * (non-Javadoc)
     * @see com.piaoruiqing.demo.distributed.lock.DistributedLock#release()
     */
    @Override
    public void release() {                                    // (二)
        List<String> keys = Collections.singletonList(key);
        operations.execute(new DefaultRedisScript<String>(COMPARE_AND_DELETE), keys, value);
    }
    /*
     * (non-Javadoc)
     * @see java.lang.Object#toString()
     */
    @Override
    public String toString() {
        return "RedisDistributedLock [key=" + key + ", value=" + value + "]";
    }
}
  • (一): 經過Lua腳本進行解鎖, 使對比鎖的值+刪除成爲原子操做, 確保解鎖操做的正確性. 簡單來講就是防止刪了別人的鎖.
    例如: 線程A方法未執行完畢時鎖超時了, 隨後B線程也獲取到了該鎖(key相同), 但此時若是A線程方法執行完畢嘗試解鎖, 若是不比對value, 那麼A將刪掉B的鎖, 這時候C線程又能加鎖, 業務將產生更嚴重的混亂.(不要過度依賴分佈式鎖, 在數據一致性要求較高的狀況下, 數據庫層面也要進行必定的處理, 例如惟一鍵約束、事務等來確保數據的正確)
  • (二): 使用RedisOperations執行Lua腳本進行解鎖操做.
  • 可參閱redis官方文檔

加鎖方法實現

/**
 * @author piaoruiqing
 * @param key           lock key
 * @param timeout       timeout
 * @param retries       number of retries
 * @param waitingTime   retry interval
 * @return
 * @throws InterruptedException
 */
public DistributedLock acquire(String key, long timeout, int retries, long waitingTime) throws InterruptedException {
    final String value 
        = RandomStringUtils.randomAlphanumeric(4) + System.currentTimeMillis(); // (一)
    do {
        Boolean result 
            = stringRedisTemplate.opsForValue().setIfAbsent(key, value, timeout, TimeUnit.MILLISECONDS); // (二)
        if (result) {
            return new RedisDistributedLock(stringRedisTemplate, key, value);
        }
        if (retries > NumberUtils.INTEGER_ZERO) {
            TimeUnit.MILLISECONDS.sleep(waitingTime);
        }
        if(Thread.currentThread().isInterrupted()){
            break;
        }
    } while (retries-- > NumberUtils.INTEGER_ZERO);

    return null;
}
  • (一): 鎖值要保證惟一, 使用4位隨機字符串+時間戳基本可知足需求
    注: UUID.randomUUID()在高併發狀況下性能不佳.
  • (二): 嘗試加鎖, 代碼中是2.1版本的作法, 早起版本參考上一節的實現.

此代碼已經能夠知足自動解鎖和重試的需求了, 使用方法:dom

// 根據key加鎖, 超時時間10000ms, 重試2次, 重試間隔500ms
try(DistributedLock lock = redisLockService.acquire(key, 10000, 2, 500);){
    // do something
}

但還能夠再優雅一點, 將模板代碼封裝起來, 可支持Lambda表達式:

/**
 * lock handler
 * @author piaoruiqing
 *
 * @since JDK 1.8
 */
@FunctionalInterface                // (一)
public interface LockHandler<T> {

    /**
     * the logic you want to execute
     * 
     * @author piaoruiqing
     *
     * @return
     * @throws Throwable
     */
     T handle() throws Throwable;    // (二)
}
  • (一): 定義函數式接口, 將業務邏輯放入Lambda表達式使代碼更加簡潔.
  • (二): 業務中的異常不建議在分佈式鎖中處理, 直接拋出來更合理.

使用LockHandler完成加鎖的實現:

public <T> T tryLock(String key, LockHandler<T> handler, long timeout, boolean autoUnlock, int retries, long waitingTime) throws Throwable {
    try (DistributedLock lock = this.acquire(key, timeout, retries, waitingTime);) {
        if (lock != null) {
            LOGGER.debug("get lock success, key: {}", key);
            return handler.handle();
        }
        LOGGER.debug("get lock fail, key: {}", key);
        return null;
    }
}

此時能夠經過比較優雅的方式使用分佈式鎖來完成編碼:

@Test
public void testTryLock() throws Throwable {
    final String key = "dlock:test-try-lock";
    AnyObject anyObject = redisLockService.tryLock(key, () -> {
        // do something
        return new AnyObject();
    }, 10000, true, 0, 0);
}

[版權聲明]
本文發佈於樸瑞卿的博客, 非商業用途容許轉載, 但轉載必須保留原做者樸瑞卿 及連接:blog.piaoruiqing.com. 若有受權方面的協商或合做, 請聯繫郵箱: piaoruiqing@gmail.com.

優化二 (自定義異常)

自定義異常: 前文中針對分佈式鎖的封裝可知足多數業務場景, 可是考慮這樣一種狀況, 若是業務自己會返回 NULL當前的實現方式可能會存在錯誤的處理, 由於獲取鎖失敗也會返回 NULL. 避免返回 NULL當然是一種解決方式, 但沒法知足全部的場景, 此時支持自定義異常或許是個不錯的選擇.

實現起來很容易, 在原代碼的基礎之上增長onFailure參數, 若是鎖爲空直接拋出異常便可.

加鎖方法實現

public <T> T tryLock(String key, LockHandler<T> handler, long timeout, boolean autoUnlock, int retries, long waitingTime, Class<? extends RuntimeException> onFailure) throws Throwable {    // (一)
    try (DistributedLock lock = this.getLock(key, timeout, retries, waitingTime);) {
        if (lock != null) {
            LOGGER.debug("get lock success, key: {}", key);
            return handler.handle();
        }
        LOGGER.debug("get lock fail, key: {}", key);
        if (null != onFailure) {
            throw onFailure.newInstance();    // (二)
        }
        return null;
    }
}
  • (一): Class<? extends RuntimeException>限定onFailure必須是RuntimeException或其子類. 筆者認爲使用RuntimeException在語義上更容易理解. 若有須要使用其餘異常也何嘗不可(如獲取鎖失敗須要統一處理等狀況).
  • (二): 反射

優化三 (優雅地使用註解)

結合APO優雅地使用註解完成分佈式鎖:

定義註解

爲了減少篇幅摺疊部分註釋
/**
 * distributed lock
 * @author piaoruiqing
 * @date: 2019/01/12 23:15
 *
 * @since JDK 1.8
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DistributedLockable {

    /** timeout of the lock */
    long timeout() default 5L;
    
    /** time unit */
    TimeUnit unit() default TimeUnit.MILLISECONDS;
    
    /** number of retries */
    int retries() default 0;

    /** interval of each retry */
    long waitingTime() default 0L;

    /** key prefix */
    String prefix() default "";

    /** parameters that construct a key */
    String[] argNames() default {};

    /** construct a key with parameters */
    boolean argsAssociated() default true;

    /** whether unlock when completed */
    boolean autoUnlock() default true;

    /** throw an runtime exception while fail to get lock */
    Class<? extends RuntimeException> onFailure() default NoException.class;

    /** no exception */
    public static final class NoException extends RuntimeException {

        private static final long serialVersionUID = -7821936618527445658L;

    }
}
  • timeout: 超時時間
  • unit: 時間單位
  • retries: 重試次數
  • waitingTime: 重試間隔時間
  • prefix: key前綴, 默認爲包名+類名+方法名
  • argNames: 組成key的參數

註解可以使用在方法上, 須要注意的是, 本文註解經過spring AOP實現, 故對象內部方法間調用將無效.

切面實現

/**
 * distributed lock aspect
 * @author piaoruiqing
 * @date: 2019/02/02 22:35
 *
 * @since JDK 1.8
 */
@Aspect
@Order(10)    // (一)
public class DistributedLockableAspect implements KeyGenerator {    // (二)
    
    private final Logger LOGGER = LoggerFactory.getLogger(getClass());
    @Resource
    private RedisLockClient redisLockClient;
    /**
     * {@link DistributedLockable}
     * @author piaoruiqing
     */
    @Pointcut(value = "execution(* *(..)) && @annotation(com.github.piaoruiqing.dlock.annotation.DistributedLockable)")
    public void distributedLockable() {}
    
    /**
     * @author piaoruiqing
     *
     * @param joinPoint
     * @param lockable
     * @return
     * @throws Throwable
     */
    @Around(value = "distributedLockable() && @annotation(lockable)")
    public Object around(ProceedingJoinPoint joinPoint, DistributedLockable lockable) throws Throwable {
        long start = System.nanoTime();
        final String key = this.generate(joinPoint, lockable.prefix(), lockable.argNames(), lockable.argsAssociated()).toString();
        Object result = redisLockClient.tryLock(
            key, () -> {
                return joinPoint.proceed();
            }, 
            lockable.unit().toMillis(lockable.timeout()), lockable.autoUnlock(), 
            lockable.retries(), lockable.unit().toMillis(lockable.waitingTime()),
            lockable.onFailure()
        );
        long end = System.nanoTime();
        LOGGER.debug("distributed lockable cost: {} ns", end - start);
        return result;
    }
}
  • (一): 切面優先級
  • (二): KeyGenerator爲自定義的key生成策略, 使用 prefix+argName+arg做爲key, 具體實現見源碼.

此時能夠經過註解的方式使用分佈式鎖, 這種方式對代碼入侵較小, 且簡潔.

@DistributedLockable(
    argNames = {"anyObject.id", "anyObject.name", "param1"},
    timeout = 20, unit = TimeUnit.SECONDS, 
    onFailure = RuntimeException.class
    )
public Long distributedLockableOnFaiFailure(AnyObject anyObject, String param1, Object param2, Long timeout) {
    try {
        TimeUnit.SECONDS.sleep(timeout);
        LOGGER.info("distributed-lockable: " + System.nanoTime());
    } catch (InterruptedException e) {
    }
    return System.nanoTime();
}

擴展

分佈式鎖的實現有多種方式, 可根據實際場景和需求選擇不一樣的介質進行實現:

  • Redis: 性能高, 對分佈式鎖支持友好, 實現簡單, 多數場景下表現較好.
  • Zookeeper: 可靠性較高, 對分佈式鎖支持友好, 實現較複雜但有現成的實現可使用.
  • 數據庫: 實現簡單, 可以使用樂觀鎖/悲觀鎖實現, 性能通常, 高併發場景下不推薦

結語

本文闡述了Redis分佈式鎖的JAVA實現, 完成了自動解鎖、自定義異常、重試、註解鎖等功能, 源碼見地址.

本實現還有諸多能夠優化之處, 如:

  • 重入鎖的實現
  • 優化重試策略爲訂閱Redis事件: 訂閱Redis事件能夠進一步優化鎖的性能, 可經過wait+notifyAll來替代文中的sleep.

篇幅有限, 後續再行闡述.

參考文獻

[版權聲明]
本文發佈於樸瑞卿的博客, 非商業用途容許轉載, 但轉載必須保留原做者樸瑞卿 及連接:blog.piaoruiqing.com. 若有受權方面的協商或合做, 請聯繫郵箱: piaoruiqing@gmail.com.

相關文章
相關標籤/搜索