🔐

Redis分散ロックに潜む10の落とし穴

に公開

表紙

日常の開発において、Redis の分散ロックをよく使用して、同時リクエスト時のデータ読み書き問題を解決しています。しかし、Redis 分散ロックの使用には多くの落とし穴も存在します。この記事では、Redis 分散ロックにおける 10 の落とし穴について解説します。

1. 原子性のない処理(setnx + expire)

Redis の分散ロックを実装するとなると、多くの方がまず思い浮かべるのが setnx + expire コマンドです。つまり、まず setnx でロックを取得し、取得できたら expire でロックに有効期限を設定するという方法です。

擬似コードは以下の通りです:

if (jedis.setnx(lock_key,lock_value) == 1) { // ロック取得
    jedis.expire(lock_key, timeout); // 有効期限の設定
    doBusiness // ビジネスロジックの処理
}

このコードには落とし穴があります。setnxexpire は別々のコマンドであり、原子操作ではありません。もし setnx によるロック取得直後に expire 実行直前でプロセスがクラッシュや再起動した場合、そのロックは「永遠に生き続ける」ことになり、他のスレッドがロックを取得できなくなります。

2. 他のクライアントにロックが上書きされる(setnx + 有効期限を value に設定)

ロックが例外で解放されない問題を解決するため、有効期限を setnx の value に入れるという方法が提案されています。ロック取得に失敗した場合、value の値と現在時刻を比較して、有効期限が過ぎていればロックが切れていると判断します。以下は擬似コードです:

long expireTime = System.currentTimeMillis() + timeout; // 現在時刻 + 設定されたタイムアウト
String expireTimeStr = String.valueOf(expireTime); // 文字列に変換

// ロックが存在しない場合、ロック取得成功
if (jedis.setnx(lock_key, expireTimeStr) == 1) {
        return true;
}

// ロックがすでに存在する場合、有効期限を取得
String oldExpireTimreStr = jedis.get(lock_key);

// 有効期限が現在時刻より過去なら、すでに期限切れと判断
if (oldExpireTimreStr != null && Long.parseLong(oldExpireTimreStr) < System.currentTimeMillis()) {

    // ロック期限切れ:以前の値を取得して新しい値に置換(getSetコマンドの詳細は公式参照)
    String oldValueStr = jedis.getSet(lock_key, expireTimeStr);

    if (oldValueStr != null && oldValueStr.equals(oldExpireTimreStr)) {
      // マルチスレッドの競合を考慮し、値が一致したスレッドのみロック取得可
      return true;
    }
}

// それ以外はロック取得失敗
return false;

この実装方法にも落とし穴があります。ロックが期限切れとなったタイミングで、複数のクライアントが同時に jedis.getSet() を実行すると、最終的に 1 つのクライアントだけがロックを取得できますが、そのロックの有効期限が他のクライアントによって上書きされる可能性があります。

3. 有効期限の設定忘れ

以前、コードレビュー中に次のような分散ロック実装を見かけました:

try{
  if(jedis.setnx(lock_key,lock_value) == 1){// ロック取得
     doBusiness // ビジネスロジックの処理
     return true; // ロック成功後、処理を完了し結果を返す
  }
  return false; // ロック失敗
} finally {
    unlock(lockKey); // ロック解放
}

このコードの問題点は、有効期限の設定を忘れていることです。実行中にマシンが突然ダウンした場合、finally ブロックが実行されず、ロックが削除されない可能性があります。このような事態を防ぐため、必ず lockKey に有効期限を設定する必要があります。分散ロックを使用する際は、有効期限の設定が必須です。

4. ビジネス処理後にロックを解放し忘れる

Redis の set コマンドの拡張パラメータを使って分散ロックを実装する人も多いです。

set コマンドの拡張パラメータ:

  • NX:キーが存在しない場合のみ設定に成功。最初のクライアントのみがロックを取得可能。
  • EX seconds:有効期限を秒単位で指定。
  • PX milliseconds:有効期限をミリ秒単位で指定。
  • XX:キーが存在する場合にのみ値を設定。

次のような擬似コードを書く人もいます:

if(jedis.set(lockKey, requestId, "NX", "PX", expireTime)==1){ // ロック取得
   doBusiness // ビジネスロジックの処理
   return true; // ロック成功、処理完了後に戻る
}
return false; // ロック失敗

一見、問題ないように見えますが、よく考えるとロック解放を忘れています!ロック取得後、毎回タイムアウトまでロックが保持されるのは非効率です。ビジネス処理が終わったらすぐにロックを解放すべきです。

正しい例:

try{
  if(jedis.set(lockKey, requestId, "NX", "PX", expireTime)==1){// ロック取得
     doBusiness // ビジネスロジックの処理
     return true; // 処理完了後に結果を返す
  }
  return false; // ロック失敗
} finally {
    unlock(lockKey); // ロック解放
}

5. B のロックが A によって解放された

以下の擬似コードを見てみましょう:

try{
  if(jedis.set(lockKey, requestId, "NX", "PX",expireTime)==1){// ロック取得
     doBusiness // ビジネスロジックの処理
     return true; // 処理完了後に結果を返す
  }
  return false; // ロック失敗
} finally {
    unlock(lockKey); // ロック解放
}

このコードのどこが問題でしょう?

並行シナリオを想定すると:A と B という 2 つのスレッドが Redis の lockKey にロックを試みます。A が先にロックを取得し(たとえば 3 秒の有効期限)、処理が 3 秒以上かかっている間にロックが期限切れになり、Redis が lockKey を自動的に解放します。このタイミングで B がロックを取得して処理を始めると、ちょうど A も処理を終え、ロックを解放しようとしますが、その時点で lockKey はすでに B のものです。結果、A が B のロックを解放してしまいます。

正しい方法は、ロック取得時にそのスレッド専用の一意な requestId をセットし、ロック解放時にその ID が一致するかどうかを確認することです。

try{
  if(jedis.set(lockKey, requestId, "NX", "PX",expireTime)==1){// ロック取得
     doBusiness // ビジネスロジックの処理
     return true; // 処理完了後に結果を返す
  }
  return false; // ロック失敗
} finally {
    if (requestId.equals(jedis.get(lockKey))) { // 自分のrequestIdか確認
      unlock(lockKey); // ロック解放
    }
}

6. 原子性のないロック解放

上記のコードにも落とし穴があります:

if (requestId.equals(jedis.get(lockKey))) { // 自分のrequestIdか確認
      unlock(lockKey); // ロック解放
}

ここでは、現在のスレッドが取得したロックかどうかを判定する操作と、ロックを解放する操作が分離されており、原子操作ではありません。もし unlock(lockKey) 実行時にロックがすでに期限切れで、別のクライアントのロックに変わっていた場合、他人のロックを解放してしまう可能性があります。

この一貫性問題を避けるために、ロックの解放は原子的に行う必要があります。これには Redis + Lua スクリプトを使うのが有効です。以下はその例です:

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

7. ロック期限切れ、業務処理が完了していない

ロック取得後にタイムアウトが発生すると、Redis は自動的にロックを解放します。しかしこの時、業務処理がまだ完了していない可能性があります。どうすればいいでしょうか?

一部の開発者は「ロックの有効期限を少し長めに設定すればいい」と考えるかもしれません。しかし、もう少し良い方法を考えてみましょう。ロックを取得したスレッドに対して、定期的にロックの存在をチェックし、有効期限を延長する守護スレッド(watchdog)を立ち上げる、という仕組みが使えます。

現在、オープンソースのフレームワークである Redisson はこの問題を解決しています。

Redisson では、スレッドがロックを取得した瞬間に、「watch dog(番犬)」というバックグラウンドスレッドが起動します。このスレッドは 10 秒ごとにチェックを行い、スレッドがまだロックを保持していれば、その key の有効期限を継続的に延長してくれます。

つまり、Redisson はロックが期限切れで自動解放される前に、それを防止する仕組みを持っているのです。これにより、「業務処理がまだ完了していないのにロックが切れてしまう」という問題が解決されます。

8. Redis 分散ロックと @Transactional の併用が無効になる

以下の擬似コードを見てみましょう:

@Transactional
public void updateDB(int lockKey) {
  boolean lockFlag = redisLock.lock(lockKey);
  if (!lockFlag) {
    throw new RuntimeException("しばらくしてから再試行してください");
  }
  doBusiness // 業務ロジックの処理
  redisLock.unlock(lockKey);
}

このコードでは、トランザクション内で Redis の分散ロックを使用しています。updateDB メソッドが呼び出されると、まずトランザクションが開始され、その後にロックが取得されます。メソッド終了後は、まず Redis のロックが解放され、最後にトランザクションがコミットされます。

この順序には問題があります。なぜなら:

  • **Spring の AOP(アスペクト指向プログラミング)**は、updateDB メソッドの前にトランザクションを開始します。
  • ロックで保護されるコードブロックが、トランザクションの中で実行されます。
  • コードブロックが完了した時点では、トランザクションはまだコミットされていませんが、ロックはすでに解放されています
  • そのため、他のスレッドがロックを取得し、保護されたコードブロックを実行した時には、最新のデータがまだコミットされていない可能性があります

このように、トランザクションがまだ終了していないのにロックが解放されるという不整合が発生します。

解決方法:

ロックは updateDB メソッドが呼び出される前に取得する必要があります。すなわち、トランザクションが開始される前にロックを取得することで、スレッドセーフな実行が保証されます。

9. ロックの再入(Reentrant Lock)

前述した Redis 分散ロックの実装は、再入可能ではありません

再入不可能とは、現在のスレッドがすでにあるメソッドでロックを取得していた場合、そのメソッド内で再度同じロックを取得しようとするとブロックされてしまい、再びロックを得ることができないということです。つまり、同じスレッドが同じロックを 2 回取得できないのです。

多くの業務シナリオでは、再入不可能なロックで十分ですが、特定のシナリオでは再入可能なロックが必要になることもあります。そのため、自身の業務シナリオに応じて、再入可能な分散ロックが必要かどうかを判断することが重要です

再入可能なロックを実現するには、Redis で次の 2 点を解決する必要があります:

  1. 現在ロックを保持しているスレッド情報をどのように保存するか
  2. ロックの取得回数(再入回数)をどのように管理するか

このような再入可能な分散ロックは、JDK の ReentrantLock の設計思想を参考にして実装できます。ただし、もっと簡単なのは、Redisson フレームワークを使うことです。Redisson は、再入可能ロックをネイティブにサポートしています。

10. Redis のマスター・スレーブ構成による落とし穴

Redis で分散ロックを実装する際には、マスター・スレーブ構成による問題にも注意が必要です。Redis は通常、クラスタ構成で運用されます。

たとえば、スレッド A が Redis のマスター上でロックを取得したとします。しかし、そのロックに関連する key がまだスレーブノードに同期されていない状態で、マスターノードが障害を起こすと、スレーブが昇格して新しいマスターになります

この時、スレッド B が昇格した新マスターから同じ key のロックを取得できることになり、スレッド A と B が同時に同じロックを持っている状態が発生します。これではロックの安全性が破綻します。

解決方法:

この問題を解決するために、Redis の作者である antirez が提案したのが、Redlock という高度な分散ロックアルゴリズムです。

Redlock の基本的な考え方:

  • 複数の Redis マスターノードを独立してデプロイし、それらが同時に障害を起こさないようにする。
  • これらのマスターノード間には、データの同期関係が存在しない(完全に独立)。
  • 各マスターノードで、Redis 単体と同じ方法でロックを取得・解放する。

Redlock の実装手順(例:5 台の Redis マスター):

  1. 5 台の Redis マスターに対し、順番にロックをリクエストする。
  2. 各ノードにはタイムアウト時間を設け、それを超えた場合はスキップする。
  3. 5 台中、3 台以上でロックを取得できた場合かつ、全体の操作時間がロック有効時間より短ければ、ロック成功とみなす。
  4. ロック取得に失敗した場合、すでに取得したロックをすべて解放する。

私たちはLeapcell、バックエンド・プロジェクトのホスティングの最適解です。

Leapcell

Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:

複数言語サポート

  • Node.js、Python、Go、Rustで開発できます。

無制限のプロジェクトデプロイ

  • 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。

比類のないコスト効率

  • 使用量に応じた支払い、アイドル時間は課金されません。
  • 例: $25で6.94Mリクエスト、平均応答時間60ms。

洗練された開発者体験

  • 直感的なUIで簡単に設定できます。
  • 完全自動化されたCI/CDパイプラインとGitOps統合。
  • 実行可能なインサイトのためのリアルタイムのメトリクスとログ。

簡単なスケーラビリティと高パフォーマンス

  • 高い同時実行性を容易に処理するためのオートスケーリング。
  • ゼロ運用オーバーヘッド — 構築に集中できます。

ドキュメントで詳細を確認!

Try Leapcell

Xでフォローする:@LeapcellHQ


ブログでこの記事を読む

Discussion