ホームページ  >  記事  >  データベース  >  Redis 分散ロックの落とし穴は何ですか?

Redis 分散ロックの落とし穴は何ですか?

PHPz
PHPz転載
2023-06-03 12:03:241355ブラウズ

1 非アトミック操作

redis 分散ロックを使用する場合、最初に考えるのは setNx コマンドかもしれません。

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

簡単です。3 掛ける 5 を 2 で割れば、コードを書くことができます。

このコードは確かに正常にロックできますが、何か問題は見つかりましたか?

ロック操作 とそれに続く タイムアウト期間の設定 は別個の 非アトミック操作 です。

ロックは成功したがタイムアウトを設定できない場合、lockKey は永久に有効になります。同時実行性の高いシナリオで、多数の lockKey が正常にロックされても失敗しない場合は、redis メモリ領域の不足に直接つながる可能性があります。

それでは、アトミック性を保証するロック コマンドはあるのでしょうか?

答えは「はい、以下をご覧ください。」

2 ロックを解除するのを忘れました

前述のとおり、setNx コマンドを使用したロック操作とタイムアウトの設定は別個のものであり、アトミックな操作ではありません。

redis には、複数のパラメーターを指定できる set コマンドもあります。

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

その中に:

  • lockKey: ロックの識別

  • requestId: リクエストID

  • NX: キーが存在しない場合にのみキーが設定されます。

  • #PX: キーの有効期限をミリ秒単位で設定します。

  • expireTime: 有効期限

set コマンドはアトミックな操作、ロックと設定です。タイムアウトはコマンド1つで簡単に解決できます。

set コマンドを使用してロックすると、表面上は問題ないようです。しかし、よく考えてみるとロックした後、毎回タイムアウト時間経過後にロックを解除するのは少々無理があるでしょうか?ロック後、時間内にロックを解除しないと、多くの問題が発生します。

#分散ロックのより合理的な使用法は次のとおりです:

  • 手動ロック

  • ビジネス オペレーション

  • 手動によるロックの解放

  • ##手動によるロックの解放が失敗すると、タイムアウトに達し、redis が自動的にロックを解放します。

一般的なフローチャートは次のとおりです。

Redis 分散ロックの落とし穴は何ですか?

次に問題は、どのようにリリースするかです。ロック?

疑似コードは次のとおりです。

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

ビジネス コードの例外をキャッチし、

finally でロックを解放する必要があります。言い換えれば、コードの実行が成功するか失敗するかに関係なく、ロックを解放する必要があります。

この時点で、友人の中には、ロックが解除されたときにシステムが再起動されたり、ネットワークが切断されたり、コンピュータ ルームにブレークポイントが設定されたりした場合、ロックの解除も失敗するのではないかと尋ねる人もいるかもしれません。 ?

この小さな確率の問題は実際に存在するため、これは良い質問です。

しかし、以前にロックのタイムアウトを設定したことを覚えていますか?異常事態が発生してロックの解除に失敗した場合でも、設定したタイムアウト後にredisにより自動的にロックが解除されます。

しかし、最終的にロックインを解除するだけで十分なのでしょうか?

3 他の人のロックを解除する

前の質問に答えるのは親切なジェスチャーですが、ロックを解除する方法も重要であるため、最終的にロックを解除するだけでは十分ではありません。 ######どうしたの?

回答: マルチスレッドのシナリオでは、他の誰かのロックが解放される可能性があります。

一部の友人は反論するかもしれません。マルチスレッドのシナリオで、スレッド A がロックを取得するとします。しかし、スレッド A がロックを解放しない場合、現時点ではスレッド B はロックを取得できません。他人を解放する?ロック理論?

回答: スレッド A とスレッド B の両方が lockKey を使用してロックしている場合。スレッド A はロックを正常に取得しましたが、そのビジネス関数の実行時間がタイムアウト設定を超えました。このとき、redis は自動的に lockKey ロックを解除します。この時点で、スレッド B は lockKey を正常にロックし、ビジネス操作を実行できます。まさにこの時点で、スレッド A はビジネス関数の実行を終了し、finally メソッドで lockKey を解放します。問題はありませんか? スレッド B のロックがスレッド A によって解放されます。

この時、スレッドBはトイレで泣きながら気を失っていたに違いないと思いますが、まだもっともらしく話し続けていました。

それでは、この問題をどうやって解決すればいいのでしょうか?

気づいたでしょうか?

set

コマンドを使用してロックする場合、lockKey ロック識別子の使用に加えて、追加パラメータ

requestId が設定されます。requestId を記録する必要があるのはなぜですか? 回答: requestId はロックを解放するときに使用されます。

疑似コードは次のとおりです。

if (jedis.get(lockKey).equals(requestId)) {
    jedis.del(lockKey);
    return true;
}
return false;
ロックを解放するときは、まずロックの値 (以前に設定されていた値は requestId) を取得し、次に決定します。以前に設定した値と同じ値 値が同じかどうか、同じであればロックの削除を許可し、成功を返します。異なる場合は、直接失敗が返されます。

言い換えると、解放できるのは自分が追加したロックのみであり、他の人が追加したロックを解放することはできません。

なぜここで requestId を使用する必要があるのですか? userId は使用できないのですか?

userId が使用される場合、それはリクエストに対して一意ではありません。異なるリクエストで同じ userId が使用される可能性があります。 requestId はグローバルに一意であるため、ロックとロックの解放を混乱させることはありません。

此外,使用lua脚本,也能解决释放了别人的锁的问题:

if redis.call('get', KEYS[1]) == ARGV[1] then 
 return redis.call('del', KEYS[1]) 
else 
  return 0 
end

lua脚本能保证查询锁是否存在和删除锁是原子操作,用它来释放锁效果更好一些。

说到lua脚本,其实加锁操作也建议使用lua脚本:

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)
   redis.call('hincrby', KEYS[1], ARGV[2], 1); 
   redis.call('pexpire', KEYS[1], ARGV[1]); 
  return nil; 
end; 
return redis.call('pttl', KEYS[1]);

这是redisson框架的加锁代码,写的不错,大家可以借鉴一下。

有趣,下面还有哪些好玩的东西?

4 大量失败请求

上面的加锁方法看起来好像没有问题,但如果你仔细想想,如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。

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

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

如何解决这个问题呢?

此外,还有一种场景:

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

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

伪代码如下:

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;

一切看似美好,但经不起仔细推敲。

来自灵魂的一问:第二个请求如果加锁失败了,接下来,是返回失败,还是返回成功呢?

主要流程图如下:

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毫秒,再发起新一轮的尝试。如果到了超时时间,还未加锁成功,则直接返回失败。

好吧,学到一招了,还有吗?

5 锁重入问题

我们都知道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框架为例,它的内部实现了可重入锁的功能。

古时候有句话说得好:为人不识陈近南,便称英雄也枉然。

我说:分布式锁不识redisson,便称好锁也枉然。哈哈哈,只是自娱自乐一下。

由此可见,redisson在redis分布式锁中的江湖地位很高。

伪代码如下:

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(&#39;exists&#39;, KEYS[1]) == 0) 
then
   redis.call(&#39;hset&#39;, KEYS[1], ARGV[2], 1);        redis.call(&#39;pexpire&#39;, KEYS[1], ARGV[1]); 
   return nil;
end;
if (redis.call(&#39;hexists&#39;, KEYS[1], ARGV[2]) == 1) 
then  
  redis.call(&#39;hincrby&#39;, KEYS[1], ARGV[2], 1); 
  redis.call(&#39;pexpire&#39;, KEYS[1], ARGV[1]); 
  return nil; 
end;
return redis.call(&#39;pttl&#39;, KEYS[1]);

其中:

  • KEYS[1]:锁名

  • ARGV[1]:过期时间

  • ARGV[2]:uuid + ":" + threadId,可认为是requestId

  • 先判断如果锁名不存在,则加锁。

  • 接下来,判断如果锁名和requestId值都存在,则使用hincrby命令给该锁名和requestId值计数,每次都加1。注意一下,这里就是重入锁的关键,锁重入一次值就加1。

  • 如果锁名存在,但值不是requestId,则返回过期时间。

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

if (redis.call(&#39;hexists&#39;, KEYS[1], ARGV[3]) == 0) 
then 
  return nil
end
local counter = redis.call(&#39;hincrby&#39;, KEYS[1], ARGV[3], -1);
if (counter > 0) 
then 
    redis.call(&#39;pexpire&#39;, KEYS[1], ARGV[2]); 
    return 0; 
 else 
   redis.call(&#39;del&#39;, KEYS[1]); 
   redis.call(&#39;publish&#39;, KEYS[2], ARGV[1]); 
   return 1; 
end; 
return nil
  • 先判断如果锁名和requestId值不存在,则直接返回。

  • 如果锁名和requestId值存在,则重入锁减1。

  • 如果减1后,重入锁的value值还大于0,说明还有引用,则重试设置过期时间。

  • 如果减1后,重入锁的value值还等于0,则可以删除锁,然后发消息通知等待线程抢锁。

再次强调一下,如果你们系统可以容忍数据暂时不一致,有些场景不加锁也行,我在这里只是举个例子,本节内容并不适用于所有场景。

6 锁竞争问题

如果有大量需要写入数据的业务场景,使用普通的redis分布式锁是没有问题的。

但如果有些业务场景,写入的操作比较少,反而有大量读取的操作。这样直接使用普通的redis分布式锁,会不会有点浪费性能?

我们都知道,锁的粒度越粗,多个线程抢锁时竞争就越激烈,造成多个线程锁等待的时间也就越长,性能也就越差。

所以,提升redis分布式锁性能的第一步,就是要把锁的粒度变细。

6.1 读写锁

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

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

我们以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();
}

将读锁和写锁分离的主要优点在于提高读取操作的性能,因为读取操作之间是共享的,而不存在互斥关系。而我们的实际业务场景中,绝大多数数据操作都是读操作。所以,如果提升了读操作的性能,也就会提升整个锁的性能。

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

  • 读与读是共享的,不互斥

  • 读与写互斥

  • 写与写互斥

6.2 锁分段

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

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

放在实际业务场景中,我们可以这样做:

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

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

在秒杀过程中,先通过哈希函数获取用户ID的哈希值,然后对100取模。模为1的用户访问第1段库存,模为2的用户访问第2段库存,模为3的用户访问第3段库存,后面以此类推,到最后模为100的用户访问第100段库存。

Redis 分散ロックの落とし穴は何ですか?

如此一来,在多线程环境中,可以大大的减少锁的冲突。以前多个线程只能同时竞争1把锁,尤其在秒杀的场景中,竞争太激烈了,简直可以用惨绝人寰来形容,其后果是导致绝大数线程在锁等待。由于多个线程同时竞争100把锁,等待线程数量减少,因此系统吞吐量提高了。

分段锁虽然能提高系统性能,但也会增加系统复杂度,需要注意。因为它需要引入额外的路由算法,跨段统计等功能。我们在实际业务场景中,需要综合考虑,不是说一定要将锁分段。

7 锁超时问题

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

有些朋友可能会说:到了超时时间,锁被释放了就释放了呗,对功能又没啥影响。

答:错,错,错。对功能其实有影响。

我们通常会对关键资源进行加锁,以避免在访问时产生数据异常。比如:线程A在修改数据C的值,线程B也在修改数据C的值,如果不做控制,在并发情况下,数据C的值会出问题。

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

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

做了这么多铺垫,现在回到正题。

假设线程A加redis分布式锁的代码,包含代码1和代码2两段代码。

Redis 分散ロックの落とし穴は何ですか?

由于该线程要执行的业务操作非常耗时,程序在执行完代码1的时,已经到了设置的超时时间,redis自动释放了锁。而代码2还没来得及执行。

Redis 分散ロックの落とし穴は何ですか?

此时,代码2相当于裸奔的状态,无法保证互斥性。当多个线程访问同一临界资源时,如果存在并发访问,可能会导致数据异常。(PS:我说的访问临界资源,不单单指读取,还包含写入)

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

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

我们可以使用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(&#39;hexists&#39;, KEYS[1], ARGV[2]) == 1) then 
   redis.call(&#39;pexpire&#39;, KEYS[1], ARGV[1]);
  return 1; 
end;
return 0;

需要注意的地方是:在实现自动续期功能时,还需要设置一个总的过期时间,可以跟redisson保持一致,设置成30秒。自动续期将在总的过期时间到达后停止,即使业务代码未完成执行。

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

8 主从复制的问题

上面花了这么多篇幅介绍的内容,对单个redis实例是没有问题的。

but,如果redis存在多个实例。当使用主从复制或哨兵模式,并且基于Redis分布式锁功能进行操作时,可能会遇到问题。

具体是什么问题?

假定当前Redis采用主从复制模式,具有一个主节点和三个从节点。master节点负责写数据,slave节点负责读数据。

Redis 分散ロックの落とし穴は何ですか?

本来是和谐共处,相安无事的。在Redis中,加锁操作是在主节点上执行的,加锁成功后,会异步将锁同步到所有从节点。

突然有一天,master节点由于某些不可逆的原因,挂掉了。

这样需要找一个slave升级为新的master节点,假如slave1被选举出来了。

Redis 分散ロックの落とし穴は何ですか?

如果有个锁A比较悲催,刚加锁成功master就挂了,还没来得及同步到slave1。

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

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

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

RedissonRedLock解决问题的思路如下:

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

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

  • 複数の redisson ノードノードが RedissonRedLock を形成します。

  • 環境には、スタンドアロン、マスター/スレーブ、センチネル、およびクラスター モードが含まれており、これらは 1 つのタイプまたは複数のタイプの混合にすることができます。

#ここではマスター/スレーブを例として取り上げます。アーキテクチャ図は次のとおりです。

Redis 分散ロックの落とし穴は何ですか?

##RedissonRedLock ロック処理は次のとおりです:

  • すべての redisson ノード情報を取得し、ループ内のすべての redisson ノードをロックします (ノード数を N とします) . この例では、N は 5 です。

  • N 個のノードのうち、N/2 個の 1 ノードが正常にロックされた場合、RedissonRedLock 全体のロックが成功したことになります。

  • N 個のノードのうち、ロックに成功したノードが N/2 未満の 1 個の場合、RedissonRedLock ロック全体が失敗します。

  • 各ノードのロックに費やした合計時間が設定された最大待ち時間以上であることが判明した場合は、直接失敗が返されます。

上記からわかるように、Redlock アルゴリズムを使用すると、マスター ノードがハングアップした場合のマルチインスタンス シナリオにおける分散ロックの失敗の問題を実際に解決できます。

しかし、次のようないくつかの新しい疑問も生じます。

  • 複数の追加環境を構築し、より多くのリソースを申請する必要があります。コストと金額に見合った価値。

  • N 個の redisson ノードがある場合、redlock ロックが成功したかどうかを知るために、N 回、少なくとも N/2 回ロックする必要があります。明らかに、追加の時間コストが追加されますが、それは利益に値しません。

実際のビジネス シナリオ、特に同時実行性の高いビジネスでは、RedissonRedLock は実際にはあまり使用されていないことがわかります。

分散環境では、CAP をバイパスできません。

CAP は、分散システムにおける次のことを指します:

  • 一貫性

  • 可用性

  • パーティション耐性

これら 3 つの要素は同時に最大 2 つまでしか達成できません。ポイント、3 つすべてに対処するのは不可能です。 。

実際のビジネス シナリオでは、データの一貫性を確保することがさらに重要です。その場合は、Zookeeper などの CP タイプの分散ロックを使用してください。これはディスクベースであり、パフォーマンスはそれほど良くないかもしれませんが、通常はデータが失われることはありません。

実際のビジネス シナリオがある場合、さらに必要なのは、高いデータ可用性を確保することです。 Redis の AP タイプのロックなど、メモリベースの分散ロックを使用することをお勧めします。パフォーマンスは向上しますが、データ損失の一定のリスクがあります。

以上がRedis 分散ロックの落とし穴は何ですか?の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

声明:
この記事はyisu.comで複製されています。侵害がある場合は、admin@php.cn までご連絡ください。