Javaはredisに基づいて分散ロックを実現する

7721 ワード

この記事は以下のとおりです.https://yq.aliyun.com/articles/307547
分布式ロックには一般的に3つの実現方式が存在し、1、データベースを通じた楽観的なロック;2、redis  3、ZooKeeper.
本文は第2の方式を紹介して、redisに基づいて分布式ロックを実現して、実はネット上に多くのコードがあって、しかしすべて多少異なった問題が存在して、それらはまったく分布ロックの要求を満たすことができません.このブログでは、Redis分散ロックを正しく実装する方法について詳しく説明します.信頼性はまず、分散ロックが利用可能であることを保証するために、少なくともロックの実装が次の4つの条件を満たすことを保証しなければならない:反発性.任意の時点で、ロックを保持できるクライアントは1つしかありません.デッドロックは発生しません.ロックを保持している間にクラッシュし、アクティブにロックを解除しなくても、後続の他のクライアントがロックを追加できることを保証します.許容誤差がある.ほとんどのRedisノードが正常に動作している限り、クライアントはロックとロック解除を行うことができます.ベルを解くにはベルを結ぶ人が必要だ.ロックとロック解除は同じクライアントでなければなりません.クライアントは自分で他の人のロックを解除することはできません.コード実装コンポーネント依存性まず我々はMavenを通じてJedisオープンソースコンポーネントを導入するpom.xmlファイルには、次のコードが追加されます.

    redis.clients
    jedis
    2.9.0

ロックコード正しい姿勢Talk is cheap,show me the code.まずコードを示し、なぜこのように実現されたのかを説明します.
    /**
     *
     * @param lockKey     key
     * @param requestId    
     * @param acquireTimeout           ,    
     * @param expireTime       ,    
     * @return
     */
    public boolean tryLock(String lockKey,String requestId,long acquireTimeout,long expireTime){

        long end = System.currentTimeMillis() + acquireTimeout;
        while(System.currentTimeMillis() < end){
            String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);

            if (LOCK_SUCCESS.equals(result)) {
                return true;
            }
            try {
                //       ,  10ms  
                Thread.sleep(10);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                log.error("RedisLock    ",e);
                return false;
            }
        }
        return false;
    }

ロックをかけるとコードが1行表示されますset(String key,String value,String nxxx,String expx,int time)、このset()メソッドには5つのパラメータがあります.
1つ目はkeyです.私たちはkeyをロックとして使用します.keyは唯一だからです.
2つ目はvalueです.私たちが伝えているのはrequestIdです.多くの子供靴が分からないかもしれませんが、keyが鍵としてあれば十分ではないでしょうか.どうしてvalueを使うのですか.なぜなら,信頼性について上述した場合,分散ロックが4番目の条件を満たすにはベルを外す必要があり,valueにrequestIdを付与することで,このロックがどのリクエストに加えられたのか,ロックを解除する際に根拠があるからである.requestIdはUUIDを使用することができる.randomUUID().toString()メソッドが生成されます.
3つ目はnxxxです.このパラメータはNXです.SET IF NOT EXISTを意味します.つまり、keyが存在しない場合、set操作を行います.keyがすでに存在する場合、何もしません.XXは存在していることを示してset操作を行う.
4つ目はexpxです.このパラメータはPXで、keyの期限切れを表す単位はmsで、EXは期限切れを表す単位はsです.
5番目はtimeで、4番目のパラメータに呼応してkeyの期限切れを表します.総じて、上記のset()メソッドを実行すると、2つの結果しか得られません:1.現在ロックされていない(keyが存在しない)場合は、ロック操作を行い、ロックに有効期間を設定します.valueはロックされたクライアントを表します.2.ロックが存在し、何もしません.
上記のコードには、取得ロックのタイムアウト時間(acquireTimeout)も加えられており、この時間内にロックの取得を試み続け、タイムアウトがまだ取得されていない場合はfalseに戻る.
心の細い子供靴は、私たちのロックコードが私たちの信頼性に記述されている3つの条件を満たしていることを発見します.まず、set()にはNXパラメータが追加され、keyが存在する場合、関数が正常に呼び出されないことを保証することができます.つまり、ロックを保持し、反発性を満たすクライアントは1つしかありません.次に、ロックの有効期限が設定されているため、ロックの所有者がその後クラッシュしてロックを解除しなくても、ロックも期限切れになると自動的に解除されます(すなわちkeyが削除された)デッドロックは発生しません.最後に、valueをrequestIdとして割り当てたため、ロックされたクライアント要求IDを表し、クライアントがロックを解除したときに同じクライアントであるかどうかを検証することができます.Redisスタンドアロンが配置されたシーンのみを考慮するので、フォールトトレランスはしばらく考慮しません.もちろん、ここではredisマシンを通じて許容誤差の問題を補う.
エラー例1よくあるエラー例はjedissを用いることである.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というエラー例では、問題の発見が困難であり、実装も複雑である.実現構想:jedisを用いる.setnx()コマンドは、keyがロックであり、valueがロックの期限切れであるロックを実現します.実行プロセス:1.setnx()メソッドでロックを試み、現在のロックが存在しない場合はロックに戻ります.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;

}

では、このコードの問題はどこですか.1.クライアント自身が期限切れ時間を生成するため、分散型で各クライアントの時間を同期させる必要がある.2.ロックが期限切れになると、複数のクライアントがjedissを同時に実行する.getSet()メソッドでは、最終的には1つのクライアントしかロックできませんが、このクライアントのロックの有効期限が他のクライアントによって上書きされる可能性があります.3.ロックには所有者IDがありません.つまり、どのクライアントでもロックを解除できます.ロック解除コードの正しい姿勢は、まずコードを示してから、なぜこのように実現されたのかをゆっくり説明します.
    /**
     *         
     * @param lockKey
     * @param requestId
     */
    public boolean tryRelease(String lockKey,String requestId){
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', 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;
    }

ロックを解除するには2行のコードしか必要ありません.最初の行のコード、私たちは簡単なLuaスクリプトコードを書いて、前回このプログラミング言語を見たのはやはり「ハッカーと画家」の中で、今回意外にも使ったとは思わなかった.2行目のコードは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このようなロック解除コードは一見問題なく、私ももう少しでこのように実現するところでしたが、正しい姿勢とは差が少なく、唯一の違いは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のロックが解除される.
本文は主にJavaコードを用いてRedis分散ロックを正しく実現する方法を紹介し,ロックとロック解除についてもそれぞれ2つの比較的古典的なエラー例を示した.実はRedisによって分散ロックを実現するのは難しくなく,信頼性の4つの条件を満たすことを保証すればよい.
分散ロックは主にどんなシーンで使用されますか?同期が必要な場所、例えば1つのデータを挿入するには、データベースに類似のデータがあるかどうかを事前にチェックする必要があります.複数のリクエストが同時に挿入された場合、データベースが類似のデータを返していないと判断した場合、参加することができます.この場合、同期処理が必要ですが、直接データベースのロックテーブルに時間がかかりすぎるため、redis分散ロックを採用し、同時に1つのスレッドでデータを挿入する操作しかできず、他のスレッドは待機しています.
プロジェクト内でRedisがマルチマシンで導入されている場合は、Redissonを使用して分散ロックを実装してみてください.これはRedisが公式に提供しているJavaコンポーネントです.リンクは参照してください.