初识分布式锁(三):Redis分布式锁原理及Redisson框架实战

879 阅读13分钟

写作不易,点赞收藏关注一键三连,以便下次再看,感谢支持~

前两篇文章咱们聊到了如何采用SQL数据库及Zookeeper实现相应的分布式锁。

初识分布式锁(二):ZooKeeper分布式锁原理浅析及实战案例

初识分布式锁(一)

今天咱们再来聊聊如何采用redis实现相应的分布式锁,以及这种实现与前两种方式实现的差异性。

Redis常见命令

在介绍分布式锁之前,我们先来了解一下redis的常用命令:

1、SET key value [EX seconds] [PX milliseconds] [NX|XX],将字符串值 value 关联到 key 。如果 key 已经持有其他值, SET就覆写旧值,无视类型。从 Redis 2.6.12 版本开始, SET命令的行为可以通过一系列参数来修改:

  • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value
  • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value
  • XX :只在键已经存在时,才对键进行设置操作。

2、EXPIRE key seconds,为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。

3、SETEX key seconds value,将值 value 关联到 key ,并将 key 的生存时间设为 seconds (以秒为单位)。

这个命令类似于以下两个命令:

SET key value
EXPIRE key seconds  # 设置生存时间

SETEX命令与SET + EXPIRE命令的区别主要在于,SETEX命令可以保持原子性,而SET+EXPIRE属于两条命令,难以保持其原子性。

4、DEL key [key ...],删除给定的一个或多个 key

5、SETNX key value,将 key 的值设为 value ,当且仅当 key 不存在。若给定的 key 已经存在,则 SETNX 不做任何动作。

分布式锁最关键的主要几个命令我都罗列在上面了~如果还有不清楚或者没有提及的命令,可以点开这个文章进行查找。

表情包

Lua脚本

紧接着还需要介绍一个redis里面比较不常见的内容,lua脚本。

一般我们需要操作redis的时候,都是需要进入到redis客户端,通过一个一个的命令进行编辑输入,从而完成相应的redis操作。

这样的方式操作起来相对方便,而且都是及时反馈,在命令数量较少、操作简单的时候十分友好。

但是如果当需要执行的命令很多、而且命令可能有前后依赖的时候,那么采用这样一个个命令输入的方式就显得十分不友好了。

为此,redis特意引入了lua脚本,用户可以向服务器发送 lua 脚本来执行自定义动作,获取脚本的响应数据。

而且另外一个特点是,Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。这个也是lua脚本相比较于单条命令不断执行的优势之一。

分布式锁原理浅析

redis实现分布式锁,主要有两种方式:1、基于redis命令实现;2、基于lua脚本实现。

基于redis命令实现

实现的逻辑主要梳理如下:

  1. 当线程进入程序时候,采用SETNX命令往缓存中设置key值,如果设置成功,证明此时加锁成功。
  2. 当线程退出程序的时候,采用DEL命令将key值删除,从而实现解锁。
SETNX key value # 加锁
# 实现相应的业务代码逻辑
DEL key value # 解锁 

但是这样明显存在一个问题,如果一个线程在加锁期间,因为某些特殊原因挂掉了,没有进行解锁,此时就会产生【死锁】,从而严重影响整个系统的性能。

因此在加锁后我们还需要采用EXPIRE命令,为相应的KEY值添加上过期时间从而避免死锁的产生。

SETNX key value # 加锁
EXPIRE key seconds # 设置过期时间
# 实现相应的业务代码逻辑
DEL key value # 解锁 

问题是不是到此就解决了呢?显然并没有!

之前我们说过由于加锁及设置过期时间的代码是两个命令,而redis在执行两个命令的时候并不能保证原子性,因此又可能出现在执行SETNX命令的时候,出现宕机,这样还是出现了死锁!

因此,在redis,对set命令进行了拓展,我们可以将上述的代码替换成下述的代码。

SET key value EX seconds NX # 设置锁的超时时间,且当key存在时直接返回。
  # 实现相应的业务代码逻辑
DEL key value # 解锁

尽管如此,锁重入仍是个难题,因为我们采用了NX参数,因此难以实现锁的重入;

img

基于lua脚本实现

相反,得益于lua脚本的执行时的原子性,lua脚本能较好的解决上述的种种问题。

用lua脚本实现的加锁代码大致流程如下所示:

image-20220208155903924.png

lua脚本实现解锁的主要流程如下所示:

image-20220208162148247.png

更详细的代码解析,在Redisson源码浅析中我们会分析到。

但需要注意的一点是,锁的过期时间设定是一门难题,设置时间长了,锁久久不释放影响性能;设置短了,业务代码还没执行完锁就释放了,没法限制其他线程的代码执行。比较巧妙的是,现有的框架里面已经有使用守护线程的方式(看门狗)来自动延长过期时间,从而简化使用的门槛。

代码实战

这次代码实战,我们采用Redission实现分布式锁,其实redission框架对分布式锁的封装相对完善,只需要很少的代码就可以实现对应分布式加锁及解锁。

首先,我们写一个配置类,用于加载我们对应的容器到spring中,这里需要注意的一点是,@Bean注解会默认使用方法名作为容器名字,要确保咱们的方法名与要加载的容器名字一致,当然也可以使用@Bean(value = "redissionClient")来显式的指定容器的名字。

@Configuration
public class RedisConfig {

  //这里在application.yml中填写你对应的redis的ip:port
    @Value("${redis.address}")
    private String redisAddress;

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(redisAddress);
        return Redisson.create(config);
    }
}

在将对应的容器注入到Spring的框架后,我们调用redission的关键方法getLock获取对应的锁。紧接着可以对这个锁调用相应的tryLock方法进行上锁,这里的上锁是个多态方法,主要区别如下所示:

// 不填写参数,即时获取锁,如果锁不可用则直接返回false。
boolean tryLock(); 
// 在给定时间内获取对应的锁(如果线程没有被中断)
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; 

这里我们采用简单的方法实现,直接采用tryLock()改造对应的代码内容,改造后的代码如下:

@Resource
RedissionClient redissionClient;

public Boolean deductProduct(ProductPO productPO){
  //首先获取分布式的锁
  RLock lock = redissonClient.getLock("deductProduct");
  try{
    LOGGER.info("分布式锁加锁!");
    //尝试对redis的分布式锁进行加锁
    boolean success = lock.tryLock(30, TimeUnit.SECONDS);
    if (!success){
      //加锁失败,直接返回
      return false;
    }
    LOGGER.info("查找商品的数据为 :"+ JSON.toJSONString(productPO));
    Example example = new Example(ProductPO.class);
    Example.Criteria criteria = example.createCriteria();
    criteria.andEqualTo("skuId", productPO.getSkuId());
    List<ProductPO> productPOS = productMapper.selectByExample(example);
    if (CollectionUtils.isEmpty(productPOS)){
        throw new RuntimeException("当前商品不存在");
    }
    for (ProductPO selectProductPO: productPOS){
      //对对应的sku进行数量扣减
      Integer number = selectProductPO.getNumber();
      LOGGER.info("当前商品的数量为:"+number);
      if (number<=0){
        //小于等于0时,不进行扣减
        continue;
      }
      selectProductPO.setNumber(number-productPO.getNumber());
      productMapper.updateByPrimaryKey(selectProductPO);
    }
  }finally {
    //最后一定记得释放锁资源
    LOGGER.info("分布式锁释放!");
    lock.unlock();
  }
  return true;
}

随后运行咱们的代码就可以得到相应的结果啦:

截屏2022-01-26 下午5.04.45.png

截屏2022-01-26 下午5.04.58.png

源码浅析

加锁源码

对tryLock(),即加锁的代码进行分析。

boolean success = lock.tryLock();

深入到关键的源码层面,其主要代码如下:

private RFuture<Boolean> tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
  RFuture<Boolean> acquiredFuture;
  if (leaseTime != -1) {
    /*关键代码*/
    acquiredFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
  } else {
    /*关键代码*/
    acquiredFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
                                       TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
  }
  CompletionStage<Boolean> f = acquiredFuture.thenApply(acquired -> {
    //如果成功获取锁
    if (acquired) {
      if (leaseTime != -1) {
        // 明确指定了租约时间,则更新类相应的租约时间即可
        internalLockLeaseTime = unit.toMillis(leaseTime);
      } else {
        // 否则将当前的ThreadId保存到一个相应的ConcurrentMap中,
        // 开启守护线程,定期刷新对应线程ID持有锁的过期时间。避免出现锁过期被释放的问题
        scheduleExpirationRenewal(threadId);
      }
    }
    return acquired;
  });
  return new CompletableFutureWrapper<>(f);
}

获取锁的命令中,可以看到比较关键的代码是tryLockInnerAsync。

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
  //这个命令的逻辑相对清晰,首先判断当前的key值是否存在
  //==0则代表哈希的key不存在,则此时新增哈希的key及field对象
  //==1则代表哈希key及对应的field对象存在,刷新其过期时间,同时会返回其剩余的超时时间。
  return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
                        "if (redis.call('exists', KEYS[1]) == 0) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                        "end; " +
                        "return redis.call('pttl', KEYS[1]);",
                        Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}

我们追入相应的代码中可以看到,Redission底层的源码是采用lua脚本的方式执行的。其中有一些关键的参数及命令列举如下:

  • KEYS[1],是Collections.singletonList(getName()),表示分布式锁的key,即REDLOCK_KEY;

  • ARGV[1],是internalLockLeaseTime,即锁的租约时间,默认30s;

  • ARGV[2],是getLockName(threadId),是获取锁时set的唯一值,即UUID+threadId

  • "pexpire",为设置键的超时时间,对一个已经存在的键重复使用会刷新过期时间。

  • "hincrby",则是对哈希对象中某个field对象进行原子增加或减少。

  • "pttl",则是返回当前键的过期时间。

  • "exists",判断当前的key值是否存在。

值得关注的还有scheduleExpirationRenewal里的源码:

protected void scheduleExpirationRenewal(long threadId) {
  ExpirationEntry entry = new ExpirationEntry();
  ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
  if (oldEntry != null) {
    //往key对应的哈希结构中添加新的ThreadId
    oldEntry.addThreadId(threadId);
  } else {
    //同上
    entry.addThreadId(threadId);
    try {
      renewExpiration();//开启守护线程,关键代码
    } finally {
      if (Thread.currentThread().isInterrupted()) {
        cancelExpirationRenewal(threadId);
      }
    }
  }
}

private void renewExpiration() {
  ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
  if (ee == null) {
    return;
  }
	//启动一个定时任务 
  Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
    @Override
    public void run(Timeout timeout) throws Exception {
      ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
      if (ent == null) {
        return;
      }
      Long threadId = ent.getFirstThreadId(); // 获取对应key的第一个线程Id
      if (threadId == null) {
        return;
      }
      RFuture<Boolean> future = renewExpirationAsync(threadId); // 在此处采用脚本的方式更新对应的过期时间
      future.whenComplete((res, e) -> {
        if (e != null) {
          log.error("Can't update lock " + getRawName() + " expiration", e);
          EXPIRATION_RENEWAL_MAP.remove(getEntryName());
          return;
        }
        if (res) {
          //如果更新成功,那么此时重复进入此方法,再次更新。
          renewExpiration();
        } else {
          //否则取消更新
          cancelExpirationRenewal(null);
        }
      });
    }
  }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
  ee.setTimeout(task);
}

由此分析下来,整个加锁的流程就相对清晰了。流程主要为:

1、首先判断是否存在这个键

  • 返回0则代表哈希的key不存在,则此时新增哈希的key及field对象;
  • 返回1则代表哈希key及对应的field对象存在,刷新其过期时间,同时会返回其剩余的超时时间。

2、如果加锁成功了,根据租约时间会有不同的策略。

  • 如果指定了过期时间,那么不会开启守护线程,而是任由锁超时后自动释放
  • 如果没有指定过期时间,那么此时会开启一个守护线程,持续去更新对应线程ID的redis锁时间。

解锁源码

解锁的关键代码主要如下:

@Override
public RFuture<Void> unlockAsync(long threadId) {
  /*解锁关键代码*/
  RFuture<Boolean> future = unlockInnerAsync(threadId);
  CompletionStage<Void> f = future.handle((opStatus, e) -> {
    //解锁成功后,需要解锁对应的watchDog机制,即关闭掉对应的自动延时机制。
    cancelExpirationRenewal(threadId);
    if (e != null) {
      throw new CompletionException(e);
    }
    if (opStatus == null) {
      IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                                                                            + id + " thread-id: " + threadId);
      throw new CompletionException(cause);
    }
    return null;
  });
  return new CompletableFutureWrapper<>(f);
}

追入unlockInnerAsync中进行查看:

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
  //如果当前不存在对应的分布式锁,直接返回
  //否则将对应的key/field对象的计数-1(针对重入锁)
  //如果此时计数>0,就再次刷新相应锁过期时间
  //否则直接删除锁,并向对应的频道通知。
  return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
                        "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                        "return nil;" +
                        "end; " +
                        "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                        "if (counter > 0) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                        "return 0; " +
                        "else " +
                        "redis.call('del', KEYS[1]); " +
                        "redis.call('publish', KEYS[2], ARGV[1]); " +
                        "return 1; " +
                        "end; " +
                        "return nil;",
                        Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}

主要的相关参数罗列如下:

  • KEYS[1],是getRawName(),表示分布式锁的key;
  • KEYS[2],是getChannelName(),这里会将分布式的key与固定的前缀进行组合,用于将解锁的消息发送到特定的频道。
  • ARGV[1],是LockPubSub.UNLOCK_MESSAGE,即发送的消息类型,此处为【解锁】;
  • ARGV[2],是internalLockLeaseTime,即锁的租约时间,默认30s;
  • ARGV[3],是getLockName(threadId),是获取锁时set的唯一值,即UUID+threadId。
  • Publish,命令用于将信息发送到指定的频道。(Ps:关于redis发布订阅的介绍,可以看这里)

看到这里我产生了一些疑惑,为啥我们前置都没有进行消息的监听,这里却做了解锁消息的广播呢?为此我又查阅了一遍源码,发现原来【tryLock()】不会去监听相应的频道消息,但是【tryLock(long waitTime, long leaseTime, TimeUnit unit)】方法,却会监听对应的消息。

@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
  ...
    //监听相应的消息
  CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
  ...
}

在进行了订阅之后,线程会进入自旋。只有当其余线程释放了占用的锁并会广播了解锁消息后,监听器接收到解锁消息,并释放信号量,才会会唤醒阻塞在这里的其余线程。(不禁大喊一声:“作者牛逼。”)

由此梳理下来,整个解锁流程也相对清晰了。主要为:

1、首先判断是否存在这个键(lua脚本)

  • 不存在,直接返回。
  • 存在,锁线程计数-1。

2、如果计数扣减成功,根据计数会有不同的策略。

  • 锁线程计数大于0,意味着此时锁处于重入状态,刷新过期时间并退出。
  • 如果计数小于等于0,删除对应的锁,同时发送广播消息提醒其余的锁进行争抢。

3、最后,解锁成功后,还需要暂停相应的【看门狗机制】,关闭相应的自动延时任务。

优劣性分析

优势

1、基于缓存实现,性能较好,

2、lua脚本方式实现,拓展性好,可支持锁重入、订阅/发布等多个功能。

3、现有框架已实现,开箱即用。

4、支持watchDog自动延时功能。

缺点

1、不保持高一致性,不能保证分布式下每个redis中保存的内容每时每刻完全一致。因此读写时,需要读取同一个redis实例。

2、而且在主从情况下,往主redis实例写入后,主实例还没来得及同步到从实例就挂掉了,导致了从实例可以再次进行加锁,出现了多个服务器同时加锁的情况,有兴趣的可以进一步了解REDLOCK算法。

参考文献

使用Redis分布式锁的一系列问题以及解决方案

Redisson实现Redis分布式锁的N种姿势

Redis应用详解(一)分布式锁

Redis 命令参考

Redis分布式锁面临的问题和解决方案