Redis实现分布式锁详解

Redis实现分布式锁的演进

1, setNx 命令(不用)

分布式锁(悲观锁)

if (jedis.setnx(lockKey, val) == 1) {
   jedis.expire(lockKey, timeout);
}

不足:加锁操作和后面的设置超时时间是分开的,并非原子操作。假如加锁成功,但是设置超时时间失败了,该 lockKey 就变成永不失效。

假如在高并发场景中,有大量的 lockKey 加锁成功了,但不会失效,有可能直接导致 redis 内存空间不足。

redis 中还有 set 命令,该命令可以指定多个参数

String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
if ("OK".equals(result)) {
    return true;
}
return false;

其中:

lockKey:锁的标识

requestId:请求 id

NX:只在键不存在时,才对键进行设置操作。

PX:设置键的过期时间为 millisecond 毫秒。

expireTime:过期时间

set 命令是原子操作,加锁和设置超时时间

不足:加锁之后,每次都要达到了超时时间才释放锁,会不会有点不合理?加锁后,如果不及时释放锁,会有很多问题。

分布式锁更合理的用法是:

手动加锁

业务操作

手动释放锁

如果手动释放锁失败了,则达到超时时间,redis 会自动释放锁。

手动释放锁 需要解锁操作不能仅依赖用户控制,系统级别也要给出保底方案:定时解锁

try{
  String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  if ("OK".equals(result)) {
      return true;
  }
  return false;
} finally {
    unlock(lockKey);
} 

需要捕获业务代码的异常,然后在 finally 中释放锁。换句话说就是:无论代码执行成功或失败了,都需要释放锁。

此时,有些朋友可能会问:假如刚好在释放锁的时候,系统被重启了,或者网络断线了,或者机房断点了,不也会导致释放锁失败?

这是一个好问题,因为这种小概率问题确实存在。但还记得前面我们给锁设置过超时时间吗?

即使出现异常情况造成释放锁失败,但到了我们设定的超时时间,锁还是会被 redis 自动释放。

不足:有可能释放了别人的锁

假如线程 A 和线程B,都使用 lockKey 加锁。线程 A 加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间。这时候,redis 会自动释放 lockKey 锁。

此时,线程 B 就能给 lockKey 加锁成功了,接下来执行它的业务操作。恰好这个时候,线程 A 执行完了业务功能,接下来,在 finally 方法中释放了锁 lockKey。这不就出问题了,线程 B 的锁,被线程 A 释放了。

try {
  String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
  if ("OK".equals(result)) {
    if(!exists(path)) {
       mkdir(path);
    }
    return true;
  }
} finally{
    unlock(lockKey,requestId);
}  
return false;

大量失败请求

如果有 1 万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的 9999 个请求都会失败。

在秒杀场景下,会有什么问题?

答:每 1 万个请求,有 1 个成功。再 1 万个请求,有 1 个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。

如何解决这个问题呢?

此外,还有一种场景:比如,有两个线程同时上传文件到 sftp,上传文件前先要创建目录。

假设两个线程需要创建的目录名都是当天的日期,比如:20210920,如果不做任何控制,直接并发的创建目录,第二个线程必然会失败。

这时候有些朋友可能会说:这还不容易,加一个 redis 分布式锁就能解决问题了,此外再判断一下,如果目录已经存在就不创建,只有目录不存在才需要创建。

显然第二个请求,肯定是不能返回失败的,如果返回失败了,这个问题还是没有被解决。如果文件还没有上传成功,直接返回成功会有更大的问题。头疼,到底该如何解决呢?

使用自旋锁

try {
  Long start = System.currentTimeMillis();
  while(true) {
     String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
     if ("OK".equals(result)) {
        if(!exists(path)) {
           mkdir(path);
        }
        return true;
     }

     long time = System.currentTimeMillis() - start;
      if (time>=timeout) {
          return false;
      }
      try {
          Thread.sleep(50);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
  }
} finally{
    unlock(lockKey,requestId);
}  
return false;

在规定的时间,比如 500 毫秒内,自旋不断尝试加锁(说白了,就是在死循环中,不断尝试加锁),如果成功则直接返回。

如果失败,则休眠 50 毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。

锁重入问题

redis 分布式锁是互斥的。假如我们对某个 key 加锁了,如果该 key 对应的锁还没失效,再用相同 key 去加锁,大概率会失败。

没错,大部分场景是没问题的。为什么说是大部分场景呢?

因为还有这样的场景:假设在某个请求中,需要获取一颗满足条件的菜单树或者分类树。我们以菜单为例,这就需要在接口中从根节点开始,递归遍历出所有满足条件的子节点,然后组装成一颗菜单树。

需要注意的是菜单不是一成不变的,在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下,每次都可能获取最新的数据,这里可以加 redis 分布式锁。

加 redis 分布式锁的思路是对的。但接下来问题来了,在递归方法中递归遍历多次,每次都是加的同一把锁。

递归第一层当然是可以加锁成功的,但递归第二层、第三层…第 N 层,不就会加锁失败了?

递归方法中加锁的伪代码如下:

private int expireTime = 1000;

public void fun(int level,String lockKey,String requestId){
  try{
     String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
     if ("OK".equals(result)) {
        if(level<=10){
           this.fun(++level,lockKey,requestId);
        } else {
           return;
        }
     }
     return;
  } finally {
     unlock(lockKey,requestId);
  }
}

如果你直接这么用,看起来好像没有问题。但最终执行程序之后发现,等待你的结果只有一个:出现异常。

因为从根节点开始,第一层递归加锁成功,还没释放锁,就直接进入第二层递归。因为锁名为 lockKey,并且值为 requestId 的锁已经存在,所以第二层递归大概率会加锁失败,然后返回到第一层。第一层接下来正常释放锁,然后整个递归方法直接返回了。

递归方法其实只执行了第一层递归就返回了,其他层递归由于加锁失败,根本没法执行。

那么这个问题该如何解决呢?

使用可重入锁。

redisson 框架为例,它的内部实现了可重入锁的功能。

伪代码如下:

private int expireTime = 1000;

public void run(String lockKey) {
  RLock lock = redisson.getLock(lockKey);
  this.fun(lock,1);
}

public void fun(RLock lock,int level){
  try{
      lock.lock(5, TimeUnit.SECONDS);
      if(level<=10){
         this.fun(lock,++level);
      } else {
         return;
      }
  } finally {
     lock.unlock();
  }
}

redisson 可重入锁的实现原理。

加锁主要是通过以下脚本实现的:
if (redis.call(‘exists’, KEYS[1]) == 0)
then
redis.call(‘hset’, 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]);

其中:

KEYS[1]:锁名

ARGV[1]:过期时间

ARGV[2]:uuid + “:” + threadId,可认为是 requestId

先判断如果锁名不存在,则加锁。接下来,判断如果锁名和 requestId 值都存在,则使用 hincrby 命令给该锁名和 requestId 值计数,每次都加 1。

注意一下,这里就是重入锁的关键,锁重入一次值就加 1。如果锁名存在,但值不是 requestId,则返回过期时间。

释放锁主要是通过以下脚本实现的:

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

先判断如果锁名和 requestId 值不存在,则直接返回。如果锁名和 requestId 值存在,则重入锁减 1。

如果减 1 后,重入锁的 value 值还大于 0,说明还有引用,则重试设置过期时间。如果减 1 后,重入锁的 value 值还等于 0,则可以删除锁,然后发消息通知等待线程抢锁。

读写锁

众所周知,加锁的目的是为了保证,在并发环境中读写数据的安全性,即不会出现数据错误或者不一致的情况。

但在绝大多数实际业务场景中,一般是读数据的频率远远大于写数据。而线程间的并发读操作是并不涉及并发安全问题,我们没有必要给读操作加互斥锁,只要保证读写、写写并发操作上锁是互斥的就行,这样可以提升系统的性能。

我们以 redisson 框架为例,它内部已经实现了读写锁的功能。

读锁的伪代码如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock(“readWriteLock”);
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
//业务操作
} catch (Exception e) {
log.error(e);
} finally {
rLock.unlock();
}

写锁的伪代码如下:
RReadWriteLock readWriteLock = redisson.getReadWriteLock(“readWriteLock”);
RLock rLock = readWriteLock.writeLock();
try {
rLock.lock();
//业务操作
} catch (InterruptedException e) {
log.error(e);
} finally {
rLock.unlock();
}

将读锁和写锁分开,最大的好处是提升读操作的性能,因为读和读之间是共享的,不存在互斥性。

而我们的实际业务场景中,绝大多数数据操作都是读操作。所以,如果提升了读操作的性能,也就会提升整个锁的性能。

下面总结一个读写锁的特点:

读与读是共享的,不互斥

读与写互斥

写与写互斥

锁分段

此外,为了减小锁的粒度,比较常见的做法是将大锁:分段。

在 java 中 ConcurrentHashMap,就是将数据分为 16 段,每一段都有单独的锁,并且处于不同锁段的数据互不干扰,以此来提升锁的性能。

放在实际业务场景中,我们可以这样做:比如在秒杀扣库存的场景中,现在的库存中有 2000 个商品,用户可以秒杀。为了防止出现超卖的情况,通常情况下,可以对库存加锁。如果有 1W 的用户竞争同一把锁,显然系统吞吐量会非常低。

为了提升系统性能,我们可以将库存分段,比如:分为 100 段,这样每段就有 20 个商品可以参与秒杀。

在秒杀的过程中,先把用户 id 获取 hash 值,然后除以 100 取模。模为 1 的用户访问第 1 段库存,模为 2 的用户访问第 2 段库存,模为 3 的用户访问第 3 段库存,后面以此类推,到最后模为 100 的用户访问第 100 段库存。

锁超时问题

我在前面提到过,如果线程 A 加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间,这时候 redis 会自动释放线程 A 加的锁。

我们加锁的目的是:为了防止访问临界资源时,出现数据异常的情况。比如:线程 A 在修改数据 C 的值,线程 B 也在修改数据 C 的值,如果不做控制,在并发情况下,数据 C 的值会出问题。

为了保证某个方法,或者段代码的互斥性,即如果线程 A 执行了某段代码,是不允许其他线程在某一时刻同时执行的,我们可以用 synchronized 关键字加锁。

但这种锁有很大的局限性,只能保证单个节点的互斥性。如果需要在多个节点中保持互斥性,就需要用 redis 分布式锁。

如果达到了超时时间,但业务代码还没执行完,需要给锁自动续期。

我们可以使用 TimerTask 类,来实现自动续期的功能:
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//自动续期逻辑
}
}, 10000, TimeUnit.MILLISECONDS);

获取锁之后,自动开启一个定时任务,每隔 10 秒钟,自动刷新一次过期时间。这种机制在 redisson 框架中,watch dog,

当然自动续期功能,我们还是优先推荐使用 lua 脚本实现,比如:
if (redis.call(‘hexists’, KEYS[1], ARGV[2]) == 1) then
redis.call(‘pexpire’, KEYS[1], ARGV[1]);
return 1;
end;
return 0;

需要注意的地方是:在实现自动续期功能时,还需要设置一个总的过期时间,可以跟 redisson 保持一致,设置成 30 秒。如果业务代码到了这个总的过期时间,还没有执行完,就不再自动续期了。

自动续期的功能是获取锁之后开启一个定时任务,每隔 10 秒判断一下锁是否存在,如果存在,则刷新过期时间。如果续期 3 次,也就是 30 秒之后,业务方法还是没有执行完,就不再续期了。

主从复制的问题

假设 redis 现在用的主从模式,1 个 master 节点,3 个 slave 节点。master 节点负责写数据,slave 节点负责读数据。
redis 加锁操作,都在 master 上进行,加锁成功后,再异步同步给所有的 slave。

突然有一天,master 节点由于某些不可逆的原因,挂掉了。这样需要找一个 slave 升级为新的 master 节点,假如 slave1 被选举出来了。
如果有个锁 A 比较悲催,刚加锁成功 master 就挂了,还没来得及同步到 slave1。

这样会导致新 master 节点中的锁 A 丢失了。后面,如果有新的线程,使用锁 A 加锁,依然可以成功,分布式锁失效了。

redisson 框架为了解决这个问题,提供了一个专门的类:RedissonRedLock,使用了 Redlock 算法。

RedissonRedLock 解决问题的思路如下:

需要搭建几套相互独立的 redis 环境,假如我们在这里搭建了 5 套。

每套环境都有一个 redisson node 节点。

多个 redisson node 节点组成了 RedissonRedLock。

环境包含:单机、主从、哨兵和集群模式,可以是一种或者多种混合。

RedissonRedLock 加锁过程如下:

获取所有的 redisson node 节点信息,循环向所有的 redisson node 节点加锁,假设节点数为 N,例子中 N 等于 5。

如果在 N 个节点当中,有 N/2+1 个节点加锁成功了,那么整个 RedissonRedLock 加锁是成功的。

如果在 N 个节点当中,小于 N/2+1 个节点加锁成功,那么整个 RedissonRedLock 加锁是失败的。

如果中途发现各个节点加锁的总耗时,大于等于设置的最大等待时间,则直接返回失败。

从上面可以看出,使用 Redlock 算法,确实能解决多实例场景中,假如 master 节点挂了,导致分布式锁失效的问题。

但也引出了一些新问题,比如:

需要额外搭建多套环境,申请更多的资源,需要评估一下成本和性价比。

如果有 N 个 redisson node 节点,需要加锁 N 次,最少也需要加锁 N/2+1 次,才知道 redlock 加锁是否成功。显然,增加了额外的时间成本,有点得不偿失。

由此可见,在实际业务场景,尤其是高并发业务中,RedissonRedLock 其实使用的并不多。在分布式环境中,CAP 是绕不过去的。

CAP 指的是在一个分布式系统中:

一致性(Consistency)

可用性(Availability)

分区容错性(Partition tolerance)

这三个要素最多只能同时实现两点,不可能三者兼顾。

如果你的实际业务场景,更需要的是保证数据一致性。那么请使用 CP 类型的分布式锁,比如:zookeeper,它是基于磁盘的,性能可能没那么好,但数据一般不会丢。

如果你的实际业务场景,更需要的是保证数据高可用性。那么请使用 AP 类型的分布式锁,比如:redis,它是基于内存的,性能比较好,但有丢失数据的风险。

其实,在我们绝大多数分布式业务场景中,使用 redis 分布式锁就够了,真的别太较真。因为数据不一致问题,可以通过最终一致性方案解决。但如果系统不可用了,对用户来说是暴击一万点伤害。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值