ホームページ  >  記事  >  データベース  >  Redis 分散ロックの正しい実装の概要

Redis 分散ロックの正しい実装の概要

尚
転載
2019-12-11 17:20:402752ブラウズ

Redis 分散ロックの正しい実装の概要

分散ロックには通常 3 つの実装方法があります:

1. データベースのオプティミスティック ロック;

2. Redis ベースの分散ロック;

3. ZooKeeper に基づく分散ロック。

この記事では、Redis に基づいて分散ロックを実装する 2 番目の方法を紹介します。インターネット上には、Redis 分散ロックの実装を紹介するさまざまなブログが存在しますが、その実装にはさまざまな問題があるため、読者の誤解を避けるために、このブログでは Redis 分散ロックを正しく実装する方法を詳しく紹介します。

信頼性

まず、分散ロックを確実に利用できるようにするには、少なくともロックの実装が次の条件を満たしていることを確認する必要があります。次の 4 つの条件を同時に満たす:

1. 相互排他性。常に 1 つのクライアントだけがロックを保持できます。

2. デッドロックは発生しません。クライアントがロックをアクティブにロック解除せずにロックを保持している間にクラッシュした場合でも、その後他のクライアントがそのロックをロックできることが保証されます。

3. フォールトトレラント。ほとんどの Redis ノードが正常に実行されている限り、クライアントはロックおよびロック解除を行うことができます。

4. ベルの結び目を解くには、ベルも縛らなければなりません。ロックとロック解除は同じクライアントで行う必要があり、他のクライアントが追加したロックをクライアント自身がロック解除することはできません。

コードの実装

コンポーネントの依存関係

まず、Jedis オープン ソース コンポーネントを導入する必要があります。 Maven: 次のコードを pom.xml ファイルに追加します:

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.9.0</version>
</dependency>

ロック コード

正しい姿勢

トークは安いです、コードを見せてください。最初にコードを示し、次にこの方法で実装する理由をゆっくりと説明します。

public class RedisTool {

    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";

    /**
     * 尝试获取分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @param expireTime 超期时间
     * @return 是否获取成功
     */
    public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {

        String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

        if (LOCK_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

}

ご覧のとおり、ロックに必要なコードは jedis.set(String key, String value, String nxxx) の 1 行だけです。 , String expx, int time )、この set() メソッドには合計 5 つの仮パラメータがあります:

最初のパラメータは key です。key は一意であるため、key をロックとして使用します。

2 つ目は value です。渡すのは requestId です。多くの子供には理解できないかもしれません。ロックとしてのキーがあれば十分ではないでしょうか。なぜ value を使用する必要があるのでしょうか?理由は、上で信頼性について説明したときに、分散ロックはベルのロックを解除するための 4 番目の条件を満たしている必要があり、ベルを持っている人がベルを結んだ人である必要があるためです。ロックを追加すると、ロックを解除するときの基盤ができます。 requestId は、UUID.randomUUID().toString() メソッドを使用して生成できます。

3 番目のパラメータは nxxx です。このパラメータに NX を入力します。これは、SET IF NOT EXIST を意味します。つまり、キーが存在しない場合はセット操作を実行します。キーがすでに存在する場合は、いいえを実行します。操作が実行されます;

4 番目のパラメータは expx です。渡すパラメータは PX です。これは、このキーに有効期限設定を追加することを意味します。特定の時間は 5 番目のパラメータによって決まります。

5 番目のパラメータは時間で、4 番目のパラメータに対応し、キーの有効期限を表します。

一般に、上記の set() メソッドを実行すると、次の 2 つの結果しか得られません: 1. 現在ロックがありません (キーが存在しません)。その後、ロック操作を実行し、ロックの有効期間を設定します。 、値はロックされたクライアントを表します。 2. ロックはすでに存在するため、操作は実行されません。

注意深い子供たちは、ロック コードが信頼性で説明されている 3 つの条件を満たしていることに気づくでしょう:

1. まず、set() で NX パラメータが追加され、キーがすでに存在する場合、関数は正常に呼び出されません。つまり、1 つのクライアントのみがロックを保持でき、相互排他が満たされます。

2. 次に、ロックの有効期限を設定しているため、後でロック所有者がクラッシュしてロックが解除されなかった場合でも、ロックは自動的にロック解除されます (つまり、キーは削除されます)。有効期限に達するとデッドロックが発生します。

3. 最後に、ロックされたクライアントのリクエスト識別を表す requestId に値を代入しているため、クライアントがロックを解除しているときに、それが同じクライアントであるかどうかを検証できます。ここでは Redis スタンドアロン展開のシナリオのみを考慮するため、当面はフォールト トレランスについては考慮しません。

エラー例 1

より一般的なエラー例は、jedis.setnx() と jedis.expire() の組み合わせを使用してロックを実装することです。コードは次のとおりです。

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {

    Long result = jedis.setnx(lockKey, requestId);
    if (result == 1) {
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
        jedis.expire(lockKey, expireTime);
    }

}

setnx() メソッドは SET IF NOT EXIST として機能し、expire() メソッドはロックに有効期限を追加します。一見したところ、結果は前の set() メソッドと同じように見えますが、これらは 2 つの Redis コマンドであるため、アトミックではありません。setnx() の実行後にプログラムが突然クラッシュした場合、ロックは解除されます。有効期限が設定されています。するとデッドロックが発生します。インターネット上でこれを実装する人がいる理由は、jedis の以前のバージョンがマルチパラメータ set() メソッドをサポートしていないためです。

エラー例 2

public static boolean wrongGetLock2(Jedis jedis, String lockKey, int expireTime) {

    long expires = System.currentTimeMillis() + expireTime;
    String expiresStr = String.valueOf(expires);

    // 如果当前锁不存在,返回加锁成功
    if (jedis.setnx(lockKey, expiresStr) == 1) {
        return true;
    }

    // 如果锁存在,获取锁的过期时间
    String currentValueStr = jedis.get(lockKey);
    if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
        // 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间
        String oldValueStr = jedis.getSet(lockKey, expiresStr);
        if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
            // 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才有权利加锁
            return true;
        }
    }
        
    // 其他情况,一律返回加锁失败
    return false;

}

この種のエラー例は、問題を見つけるのがより難しく、実装もより複雑です。実装のアイデア: jedis.setnx() コマンドを使用してロックを実装します。ここで、key はロック、value はロックの有効期限です。

実行プロセス:

1. setnx() メソッドを使用してロックを試みます。現在のロックが存在しない場合は、ロックを正常に返します。

2. ロックが既に存在する場合は、ロックの有効期限を取得し、現在の時刻と比較します。ロックの有効期限が切れている場合は、新しい有効期限を設定して、ロックを正常に返します。コードは次のとおりです。

それでは、このコードの何が問題なのでしょうか?

1、由于是客户端自己生成过期时间,所以需要强制要求分布式下每个客户端的时间必须同步。 

2、当锁过期的时候,如果多个客户端同时执行jedis.getSet()方法,那么虽然最终只有一个客户端可以加锁,但是这个客户端的锁的过期时间可能被其他客户端覆盖。

3、锁不具备拥有者标识,即任何客户端都可以解锁。

解锁代码

正确姿势

还是先展示代码,再带大家慢慢解释为什么这样实现:

public class RedisTool {

    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockKey 锁
     * @param requestId 请求标识
     * @return 是否释放成功
     */
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {

        String script = "if redis.call(&#39;get&#39;, KEYS[1]) == ARGV[1] then return redis.call(&#39;del&#39;, KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));

        if (RELEASE_SUCCESS.equals(result)) {
            return true;
        }
        return false;

    }

}

可以看到,我们解锁只需要两行代码就搞定了!第一行代码,我们写了一个简单的Lua脚本代码,上一次见到这个编程语言还是在《黑客与画家》里,没想到这次居然用上了。第二行代码,我们将Lua代码传到jedis.eval()方法里,并使参数KEYS[1]赋值为lockKey,ARGV[1]赋值为requestId。eval()方法是将Lua代码交给Redis服务端执行。

那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。那么为什么要使用Lua语言来实现呢?因为要确保上述操作是原子性的。关于非原子性会带来什么问题,可以阅读【解锁代码-错误示例2】 。

那么为什么执行eval()方法可以确保原子性,源于Redis的特性,下面是官网对eval命令的部分解释:

简单来说,就是在eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。

错误示例1

最常见的解锁代码就是直接使用jedis.del()方法删除锁,这种不先判断锁的拥有者而直接解锁的方式,会导致任何客户端都可以随时进行解锁,即使这把锁不是它的。

public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
    jedis.del(lockKey);
}

错误示例2

这种解锁代码乍一看也是没问题,甚至我之前也差点这样实现,与正确姿势差不多,唯一区别的是分成两条命令去执行,代码如下:

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
        
    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }

}

如代码注释,问题在于如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。

更多redis知识请关注redis数据库教程栏目。

以上がRedis 分散ロックの正しい実装の概要の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

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