ボスが命じた再入可能分散ロックがついに実現〜

ボスが命じた再入可能分散ロックがついに実現〜

[[330080]]

この記事はWeChatの公開アカウント「Program Pass」から転載したもので、著者は下記のXiaohei兄弟です。この記事を転載する場合は成旭通社の公式アカウントまでご連絡ください。

やり直すことは、再発明するよりも常に簡単です

現在、他社の実装システム(以下、旧システム)を自社のシステム(以下、新システム)に完全統合するプロジェクトに取り組んでいます。そのためには、相手側が実装した機能を自分のシステムにも完全に実装する必要があります。

旧システムのままの商店はまだ数多く存在します。既存の販売者のエクスペリエンスに影響を与えないようにするために、新しいシステムによって提供される外部インターフェースは、以前のものと一致している必要があります。最後に、システムが完全に切り替えられた後、機能は新しいシステムでのみ実行されるため、古いシステムのデータを新しいシステムに完全に移行する必要があります。

もちろん、このプロジェクトを始める前に、私はこれらを予想していました。このプロセスは難しいだろうと思っていましたが、それほど難しいとは思いませんでした。当初は半年以上も待ち時間があったので、まだ時間はたっぷりあると思っていましたが、今は少しずつ埋めていかなければならない大きな穴のように感じています。

ああ、あまり言うと涙が出そうなので、もう文句は言いません。次回は完成後に皆さんと私の本当の体験を振り返りたいと思います。

本文に戻りますが、前回の記事「Redis 分散ロック」では、Redis をベースにした分散ロックを実装しました。この分散ロックの基本的な機能に問題はありませんが、再入可能機能が欠けているため、この記事では、Brother Hei が再入可能な分散ロックを実装する方法を紹介します。

この記事では、以下の内容を取り上げます。

  • 再入性
  • ThreadLocalベースの実装
  • Redisハッシュに基づく実装

再入性

再入可能ロックについて言えば、まずは Wikipedia の再入性の説明を見てみましょう。

プログラムまたはサブルーチンが「いつでも中断され、その後オペレーティング システムが別のコードの実行をスケジュールし、そのコードがエラーなしでサブルーチンを呼び出す」場合、それは再入可能 (再入可能) と呼ばれます。つまり、サブルーチンの実行中に、実行スレッドがサブルーチンに入って再度実行し、設計上の期待を満たす結果を得ることができます。複数のスレッドを同時に実行する場合のスレッド セーフとは異なり、再入可能性は、単一のスレッドが実行中の場合、同じサブルーチンに再び入っても安全であることを強調しています。

スレッドがコードの一部を実行し、ロックを正常に取得して実行を続行すると、ロックされたコードに再び遭遇します。再入可能性により、スレッドは実行を継続できますが、再入可能性がない場合は、実行を継続する前にロックが解放されるのを待機し、再度ロックを正常に取得する必要があります。

Java コードを使用して再入可能性について説明します。

  1. パブリック同期void a() {
  2. b();
  3. }
  4.  
  5. パブリック同期void b(){
  6. // 合格
  7. }

スレッド X がメソッド a でロックを取得した後、メソッド b の実行を継続すると仮定します。この時点で再入が不可能な場合、スレッドはロックが解放されるまで待機し、再度ロックを競合する必要があります。

ロックは明らかにスレッド X によって所有されていますが、ロックを取得する前に、スレッド X 自身がロックを解放するのを待つ必要があります。これはとても奇妙に見えます。自分を解放するよ〜

再入性により、この厄介な問題を解決できます。スレッドがロックを取得した後、将来ロック メソッドに遭遇すると、ロックの数を直接 1 増やしてからメソッド ロジックを実行します。ロックメソッドを終了した後、ロック回数が 1 減少します。ロック回数が 0 になると、ロックは完全に解除されます。

再入可能ロックの最大の特徴は、ロックが追加された回数を計算するカウント機能であることがわかります。したがって、分散環境で再入可能ロックを実装する必要がある場合は、ロックの数もカウントする必要があります。

分散再入可能ロックを実装する方法は 2 つあります。

  • ThreadLocalベースの実装
  • Redisハッシュに基づく実装

まず、ThreadLocal の実装を見てみましょう。

ThreadLocalベースの実装

実装

Java の ThreadLocal を使用すると、各スレッドが独自のインスタンスのコピーを持つことができます。この機能を使用すると、スレッドが再入する回数をカウントできます。

次に、ThreadLocal グローバル変数 LOCKS とメモリ ストレージ Map インスタンス変数を定義します。

  1. プライベート静的ThreadLocal<Map<String, Integer >> LOCKS = ThreadLocal.withInitial(HashMap::new);

各スレッドは、ThreadLocal を通じて独自の Map インスタンスを取得できます。マップ内のキーにはロックの名前が格納され、値にはロックが再入力された回数が格納されます。

ロックコードは次のとおりです。

  1. /**
  2. * 再入可能ロック
  3. *
  4. * @param lockName ロック名。重要なリソースを競う必要性を表す
  5. * @param リクエストの一意の識別子。uuid はこの値に基づいて再入が可能かどうかを判断するために使用できます。
  6. * @paramleasingTime ロック解除時間
  7. * @param unit ロック解除時間単位
  8. * @戻る 
  9. */
  10. パブリックブール型 tryLock(String lockName, String request, longleasingTime, TimeUnit unit) {
  11. Map<String, Integer > counts = LOCKS.get();
  12. counts.containsKey(lockName) の場合 {
  13. counts.put(lockName, counts.get(lockName) + 1);
  14. 戻る 真実;
  15. }それ以外{
  16. redisLock.tryLock(lockName, request, リース時間, 単位) の場合 {
  17. counts.put(lockName, 1);
  18. 戻る 真実;
  19. }
  20. }
  21. 戻る 間違い;
  22. }

「ps: redisLock#tryLock は前回の記事で実装した分散ロックです。公開アカウントの外部リンクは直接飛べないので、「Program Master」に従って分散ロックに返信するとソースコードが手に入ります。

ロック メソッドは、まず現在のスレッドがすでにロックを所有しているかどうかを判断します。すでに所有している場合は、ロックの再入回数を直接 1 増やします。

ロックがまだ所有されていない場合は、Redis でロックを試みます。ロックが正常に取得された後、再入回数に 1 を加算します。

ロックを解除するコードは次のとおりです。

  1. /**
  2. * ロック解除には異なるスレッドプールの判断が必要
  3. *
  4. * @param ロック名
  5. * @param リクエスト
  6. */
  7. パブリックvoid unlock(String lockName, String request) {
  8. Map<String, Integer > counts = LOCKS.get();
  9. counts.getOrDefault(lockName, 0) <= 1の場合{
  10. counts.remove(lockName);
  11. ブール値の結果 = redisLock.unlock(lockName, request);
  12. if (!結果) {
  13. throw new IllegalMonitorStateException( "ロックのロックを解除しようとしましたが、lockName:+" + lockName + " によってロックされていません。リクエスト: "  
  14. + リクエスト);
  15. }
  16.  
  17. }それ以外{
  18. counts.put(lockName, counts.get(lockName) - 1);
  19. }
  20. }

ロックを解除するときは、まず再入回数を決定します。 1 より大きい場合は、ロックがスレッドによって所有されていることを意味するため、ロック再入回数を 1 減らすだけです。

現在の再入可能回数が 1 以下の場合は、まず Map 内のロックに対応するキーを削除し、次に Redis 内のロックを解放します。

ここで注意すべきは、ロックがスレッドによって所有されておらず、直接ロック解除される場合、再入回数も 1 以下となり、今度は直接ロック解除できない可能性があるということです。

「ThreadLocal を使用する場合は、メモリ リーク、コンテキスト データ文字列の使用などの問題を防ぐために、内部ストレージ インスタンス変数を適切なタイミングでクリーンアップすることを忘れないでください。次回は、ThreadLocal を使用して記述された最近のバグについてお話ししましょう。

関連する質問

ThreadLocal を使用して再入回数をローカルに記録するのは非常にシンプルで効率的ですが、いくつか問題もあります。

有効期限の問題

上記のロック コードから、ロックを再入力すると、ローカル カウントのみが 1 増加することがわかります。これにより、業務の実行時間が長くなると、Redis の有効期限が切れてロックが解除される状況が発生する可能性があります。

再度ロックに入ると、ローカルにはまだデータが存在するため、ロックが保持されたままであると考えられますが、これは実際の状況と一致しません。

ローカルで有効期限を長くしたい場合は、ローカルと Redis の有効期限の一貫​​性も考慮する必要があり、コードが非常に複雑になります。

異なるスレッド/プロセスは再入可能

狭義では、再入は同じスレッドにのみ適用されますが、実際のビジネスでは、異なるアプリケーション スレッドが同じロックに再入できることが求められる場合があります。

ThreadLocal ソリューションは同じスレッドの再エントリのみを満たすことができますが、異なるスレッド/プロセス間の再エントリの問題を解決することはできません。

異なるスレッド/プロセスの再エントリの問題は、次の Redis ハッシュ ソリューションを使用して解決する必要があります。

Redis ハッシュベースの再入可能ロック

実装

ThreadLocal ソリューションでは、Map を使用してロックを再入力できる回数を記録します。また、Redis はキーと値のペアを格納できるデータ構造である Hash (ハッシュ テーブル) も提供します。したがって、Redis ハッシュを使用してロック再入回数を保存し、Lua スクリプトを使用してロジックを判断することができます。

ロックされた Lua スクリプトは次のとおりです。

  1. ---- 1は真を表す 
  2. ---- 0は偽を意味します 
  3.  
  4. (redis.call( 'exists' , KEYS[1]) == 0)の場合 
  5. redis.call( 'hincrby' , キー[1], ARGV[2], 1);
  6. redis.call( 'pexpire' , KEYS[1], ARGV[1]);
  7. 1 を返します
  8. 終わり;
  9. (redis.call( 'hexists' , KEYS[1], ARGV[2]) == 1)場合 
  10. redis.call( 'hincrby' , キー[1], ARGV[2], 1);
  11. redis.call( 'pexpire' , KEYS[1], ARGV[1]);
  12. 1 を返します
  13. 終わり;
  14. 0を返します

「KEYS:[lock]、ARGV[1000、uuid]の場合

Lua 言語に慣れていなくても心配しないでください。上記のロジックは比較的単純です。

ロック コードは、まず Redis の exists コマンドを使用して、現在のロックが存在するかどうかを判断します。

ロックが存在しない場合は、hincrby を直接使用してキー uuid を持つロック ハッシュ テーブルを作成し、ハッシュ テーブル内のキー uuid を 0 に初期化してから、再度 1 を追加し、最後に有効期限を設定します。

現在のロックが存在する場合は、hexists を使用して、キー uuid が現在のロックに対応するハッシュ テーブルに存在するかどうかを判断します。存在する場合は、hincrby を使用して再度 1 を追加し、最後に有効期限を再度設定します。

最後に、上記の 2 つのロジックが一致しない場合は、直接戻ります。

ロックコードは次のとおりです。

  1. // 初期化コード
  2.  
  3. 文字列 lockLuaScript = IOUtils.toString(ResourceUtils.getURL( "classpath:lock.lua" ).openStream(), Charsets.UTF_8);
  4. lockScript = 新しい DefaultRedisScript<>(lockLuaScript、Boolean.class);
  5.  
  6. /**
  7. * 再入可能ロック
  8. *
  9. * @param lockName ロック名。重要なリソースを競う必要性を表す
  10. * @param リクエストの一意の識別子。uuid はこの値に基づいて再入が可能かどうかを判断するために使用できます。
  11. * @paramleasingTime ロック解除時間
  12. * @param unit ロック解除時間単位
  13. * @戻る 
  14. */
  15. パブリックブール型 tryLock(String lockName, String request, longleasingTime, TimeUnit unit) {
  16. 長い内部ロックリース時間 = unit.toMillis(リース時間);
  17. stringRedisTemplateを返します実行(lockScript、Lists.newArrayList(lockName)、String.valueOf(internalLockLeaseTime)、リクエスト)。
  18. }

「Spring-Boot 2.2.7.リリース

Lua スクリプトのロック ロジックを理解していれば、Java コードの実装は非常に簡単です。 SpringBoot が提供する StringRedisTemplate を直接使用できます。

ロック解除された Lua スクリプトは次のとおりです。

  1. -- ハッシュセットの再入可能キーの値が0に等しいかどうかを判定します 
  2. -- 0の場合は、再入可能キーが存在しないことを意味します 
  3. (redis.call( 'hexists' , KEYS[1], ARGV[1]) == 0)場合 
  4. nilを返します
  5. 終わり;
  6. -- 現在の再入可能回数を計算する 
  7. ローカルカウンター = redis.call( 'hincrby' , KEYS[1], ARGV[1], -1);
  8. -- 0以下の場合はロック解除可能 
  9. (カウンタ>0)ならば 
  10. 0を返します
  11. それ以外 
  12. redis.call( 'del' , キー[1]);
  13. 1 を返します
  14. 終わり;
  15. nilを返します

まず、hexists を使用して、Redis ハッシュ テーブルに特定のフィールドが含まれているかどうかを判断します。

ロックに対応するハッシュ テーブルが存在しない場合、またはキー uuid がハッシュ テーブルに存在しない場合は、直接 nil が返されます。

存在する場合、現在のロックはそれによって保持されていることを意味します。まず、hincrby を使用して再入回数を 1 減らし、計算後に再入回数を決定します。 0 以下の場合は、del を使用してロックを削除します。

ロック解除の Java コードは次のとおりです。

  1. // 初期化コード:
  2.  
  3.  
  4. 文字列 unlockLuaScript = IOUtils.toString(ResourceUtils.getURL( "classpath:unlock.lua" ).openStream(), Charsets.UTF_8);
  5. unlockScript = 新しい DefaultRedisScript<>(unlockLuaScript、Long.class);
  6.  
  7. /**
  8. * ロック解除
  9. *キーの再入力回数が1回より多い場合は、キーの再入力回数を1回減らします。<br>
  10. * lua スクリプトのロック解除の意味:<br>
  11. * 1: ロック解除成功を表します<br>
  12. * 0: ロックが解除されず、再入回数が 1 回減少することを示します <br>
  13. * nil: 他のスレッドがロック解除を試みていることを表します <br>
  14. * <p>
  15. * DefaultRedisScript<Boolean> を使用する場合、Spring-data-redis eval 型変換のため、<br>
  16. * Redis が Nil を一括で返すと、デフォルトでfalseに変換され、ロック解除のセマンティクスに影響するため、次のように使用します。<br>
  17. * デフォルトRedisScript<Long>
  18. * <p>
  19. * 具体的な変換コードについては、以下を参照してください。<br>
  20. * JedisScriptReturnConverter<br>
  21. *
  22. * @param lockName ロック名
  23. * @param リクエストの一意の識別子。uuidを使用できます。
  24. * @throws IllegalMonitorStateException ロックを解除する前にロックしてください。ロックされている場合、ロックを解除するとこのエラーが発生します
  25. */
  26. パブリックvoid unlock(String lockName, String request) {
  27. 長い結果 = stringRedisTemplate。実行(unlockScript、Lists.newArrayList(lockName)、リクエスト)。
  28. // 値が返されない場合は、他のスレッドがロック解除を試みていることを意味します
  29. 結果がnull場合
  30. throw new IllegalMonitorStateException( "ロックのロックを解除しようとしましたが、lockName:+" + lockName + " によってロックされていません。リクエスト: "  
  31. + リクエスト);
  32. }
  33. }

ロック解除コードの実行方法はロックの場合と似ていますが、ロック解除の実行結果の戻り値の型が Long である点が異なります。ここでロックと同様にブール値が使用されていない理由は、ロック解除の Lua スクリプトでは、3 つの戻り値が次の意味を持つためです。

  • 1はロック解除が成功し、ロックが解除されたことを意味します
  • 0は再入回数が1減少することを意味する
  • nullは他のスレッドがロック解除を試み失敗したことを意味します

戻り値に Boolean が使用されている場合、Spring-data-redis は型変換中に null を false に変換します。これは論理的な判断に影響するため、戻り値の型は Long にする必要があります。

次のコードは JedisScriptReturnConverter からのものです。

関連する質問

Spring-data-redis の低バージョンの問題

Spring-Boot が接続クライアントとして Jedis を使用し、Redis Cluster クラスター モードを使用する場合は、spring-boot-starter-data-redis バージョン 2.1.9 以上を使用する必要があります。そうでない場合、実行中に次のエラーがスローされます。

  1. org.springframework.dao.InvalidDataAccessApiUsageException: EvalSha クラスタ環境ではサポートされません

現在のアプリケーションが spring-data-redis をアップグレードできない場合は、問題はありません。次の方法を使用すると、ネイティブ Jedis 接続を直接使用して Lua スクリプトを実行できます。

ロックコードを例に挙げます。

  1. パブリックブール型 tryLock(String lockName, String reentrantKey, longleasingTime, TimeUnit unit) {
  2. 長い内部ロックリース時間 = unit.toMillis(リース時間);
  3. ブール値の結果 = stringRedisTemplate。実行((RedisCallback<Boolean>)接続-> {
  4. オブジェクト innerResult = eval( connection .getNativeConnection(), lockScript, Lists.newArrayList(lockName), Lists.newArrayList(String.valueOf(internalLockLeaseTime), reentrantKey));
  5. 戻る 変換(innerResult);
  6. });
  7. 結果を返します
  8. }
  9.  
  10. プライベートオブジェクトeval(Object nativeConnection、RedisScript redisScript、最終的なリスト<String>キー、最終的なリスト<String>引数) {
  11.  
  12. オブジェクト innerResult = null ;
  13. // クラスタモードとシングルポイントモードのスクリプト実行方法は同じですが、共通のインターフェースがないため、個別にしか実行できません。
  14. // クラスター
  15. if (nativeConnection インスタンス JedisCluster) {
  16. innerResult = evalByCluster((JedisCluster) nativeConnection、redisScript、キー、引数);
  17. }
  18. // 単一ポイント
  19. そうでない場合 (nativeConnection instanceof Jedis) {
  20. innerResult = evalBySingle((Jedis) nativeConnection、redisScript、キー、引数);
  21. }
  22. innerResultを返します
  23. }

データ型変換の問題

Jedis ネイティブ接続を使用して Lua スクリプトを実行すると、データ型変換の落とし穴に遭遇する可能性があります。

Jedis#eval がオブジェクトを返すことがわかります。 Lua スクリプトの戻り値に基づいて、関連する変換を実行する必要があります。これには、Lua データ型を Redis データ型に変換することが含まれます。

次に、主に Lua データを Redis に変換するときに陥りやすいいくつかのルールについて説明します。

1. Lua の数値と Redis のデータ型変換

Lua の数値型は倍精度浮動小数点数ですが、Redis は整数型のみをサポートしているため、変換プロセスでは小数点以下の桁が破棄されます。

2. Lua ブール値と Redis 型変換

この変換はトラブルに巻き込まれやすいです。 Redis にはブール型がないため、Lua の true は Redis の整数 1 に変換されます。Lua では、false は整数に変換されず、null に変換されてクライアントに返されます。

3. Lua nil と Redis の型変換

Lua の nil は空の値とみなされ、Java の null と同等です。 Lua では、条件式に nil が現れると、false として扱われます。

したがって、Lua nil もクライアントに null を返します。

その他の変換ルールは比較的簡単です。以下を参照してください。

http://doc.redisfans.com/script/eval.html

要約する

再入可能な分散ロックの鍵は、ロックの再入をカウントすることです。この記事では主に 2 つの解決策を紹介します。 1 つは ThreadLocal 実装に基づいており、実装が簡単で、より効率的に実行されます。ただし、ロックの有効期限の問題に対処する場合、コードの実装はより複雑になります。

別のソリューションでは、Redis ハッシュ データ構造を使用して ThreadLocal の欠陥を解決しますが、コードの実装は少し難しく、Lua スクリプトといくつかの Redis コマンドに精通している必要があります。また、spring-data-redis などのツールを使用して Redis を操作する場合、意図せずさまざまな問題に遭遇する可能性があります。

ヘルプ

https://www.sofastack.tech/blog/sofa-jraft-rheakv-distributedlock/

https://tech.meituan.com/2016/09/29/distributed-system-mutually-exclusive-idempotence-cerberus-gtis.html

<<:  Cloud Native Computing Foundationがテクノロジーレーダーを導入し、継続的デリバリーツールのFluxとHelmが広く採用される

>>:  SaaS プロバイダーのセキュリティを確認するための 10 のプラン

推薦する

Mafengwo ビッグデータ プラットフォームにおける Kafka クラスターの最適化とアプリケーション拡張

Kafka は人気のあるメッセージ キュー ミドルウェアです。大量のデータをリアルタイムで処理でき、...

Akamai: ネットワーク セキュリティの新たなトレンドを理解する

[51CTO.comより引用] 現在、サイバーセキュリティ市場は急速な発展期を迎えており、中国はサイ...

エッジコンピューティングサービスの利点は何ですか?

エッジコンピューティングサービスは、近年進化を遂げた新しい用語と言えます。エッジコンピューティングの...

友好的なリンクを共有したり交換したりする際に注意すべきいくつかの側面(I)

コンテンツは王様、外部リンクは女王様です。この文はウェブマスター業界で最もよく知られています。多くの...

純粋に技術的な乾物共有: 分散トランザクション処理方法の概要

プロジェクト開発では、分散トランザクションを処理する必要があることがよくあります。たとえば、データベ...

クラウド導入が進むにつれ、ITチームはビジネスアドバイザーに

クラウド コンピューティングは、データ駆動型開発をサポートするインフラストラクチャになりました。今日...

オンプレミスからクラウドへ: エンタープライズ近代化の 3 つのフェーズ

新型コロナウイルス感染症のパンデミックにより、多くの企業は予想よりも早く従来のテクノロジーの負担に対...

なぜ www なしの Baidu スナップショットの方がランクが高いのでしょうか?

最近、Baidu ランキングの Web ページ スナップショットのほとんどに www がないことが判...

推奨: UltraVPS-3 Euro/Xen/1g RAM/30g HDD/1T トラフィック

1G メモリを搭載した XEN VPS に月額 3 ユーロを支払うことについてどう思いますか?実は税...

プライベートクラウドには明確な「価値提案」が必要です

現在、貿易摩擦が未解決であるにもかかわらず、大企業はデジタルビジネス変革をサポートするために新しいテ...

銀行はオンラインショッピングの顧客を欲しがっている:オンライン小売業者との頻繁な協力の効果は不透明

他の銀行が公式サイトの「プライベートドメイン」内に「淘宝のような」金融モールを開設しているのに比べ、...

Baidu の「サイト構文クエリの問題について」発表イベント

昨日百度が発表した「サイト構文クエリの問題について」という発表を見て、多くの罪のない個人ウェブマスタ...

Spring Boot 2.x 基本チュートリアル: JTA を使用した分散トランザクションの実装

[[380215]] Spring Boot プロジェクトでは、複数のデータ ソースに接続するのが非...

外部リンクの効果を高めるにはどうすればいいでしょうか?

外部リンクはオフサイト最適化効果を実現するための主な方法の1つと言えます。さらに、関連する外部リンク...

EasyStack は、ガートナーの OpenStack 競争環境レポートで世界トップ 8 社にランクインしました。

最近、ガートナーは「競争環境: OpenStack ディストリビューションおよびサポート サービス市...