Redis分散ロック:10の一般的な誤ちと、それらを回避する方法
Ethan Miller
Product Engineer · Leapcell

日々の開発において、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 // ビジネスロジック }
このコードには大きな落とし穴があります。setnx
と expire
は別々に実行され、アトミックではありません!もし setnx
の実行直後、expire
の実行前にプロセスがクラッシュまたは再起動した場合、ロックは永遠に期限切れになりません。その結果、他のスレッドはロックを取得できなくなります。
2. 別のクライアントのリクエストによって上書きされる (setnx + value を有効期限として使用)
例外によるロックの解放漏れの問題を解決するために、setnx
の値に有効期限のタイムスタンプを入れることを提案する人もいます。ロックの取得に失敗した場合、保存された値を現在のシステム時刻と比較して、ロックが期限切れかどうかを判断できます。疑似コードの実装:
long expireTime = System.currentTimeMillis() + timeout; // 現在時刻 + タイムアウト String expireTimeStr = String.valueOf(expireTime); // 文字列に変換 // ロックが存在しない場合、true を返す if (jedis.setnx(lock_key, expireTimeStr) == 1) { return true; } // ロックが存在する場合、その有効期限を取得する String oldExpireTimeStr = jedis.get(lock_key); // 保存された有効期限が現在時刻よりも小さい場合、期限切れ if (oldExpireTimeStr != null && Long.parseLong(oldExpireTimeStr) < System.currentTimeMillis()) { // ロックが期限切れ。新しい有効期限で上書きを試みる String oldValueStr = jedis.getSet(lock_key, expireTimeStr); if (oldValueStr != null && oldValueStr.equals(oldExpireTimeStr)) { // 同時実行シナリオでは、設定値が古い値と一致するスレッドのみがロックを取得する 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
ブロックに到達しなかった場合、ロックは削除されず、ロック解除が信頼できなくなります。したがって、分散ロックを使用する場合は、常に有効期限を設定してください。
4. ビジネス処理後にロックの解放を忘れる
多くの開発者は、Redisの set
コマンドの拡張パラメータを使用して、分散ロックを実装します。
SET key value
の拡張パラメータ:
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の両方がロックを取得しようとする同時実行シナリオを考えてみてください。スレッドAが最初にロックを取得したとします(3秒で期限切れになるように設定)。もしそのビジネスロジックが遅く、3秒以上かかる場合、Redisは自動的にロックを期限切れにします。次に、スレッドBがロックを取得して実行を開始します。もしスレッドAがタスクを終了し、その後ロックを解放した場合、誤ってスレッドBのロックを解放してしまいます。
正しいアプローチは、ロックを取得するときに一意のリクエスト識別子(例:requestId
)を追加し、識別子が一致する場合にのみロックを解放することです。
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); // ロックを解放 }
確認(get
)と解放(del
)が2つの別々の操作であるため、アトミックではありません。もし 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は watchdog を開始します。これは、10秒ごとにロックを確認するバックグラウンドスレッドです。スレッドがまだロックを保持している場合、watchdogはTTLを延長し続けます。これが、Redissonがビジネスロジックが完了していない場合に過剰なロックの期限切れの問題を解決する方法です。
8. @Transactional
と共に使用すると、Redis分散ロックが無効になる
次の疑似コードを見てください。
@Transactional public void updateDB(int lockKey) { boolean lockFlag = redisLock.lock(lockKey); if (!lockFlag) { throw new RuntimeException("後でもう一度お試しください"); } doBusiness // ビジネスロジック redisLock.unlock(lockKey); }
この場合、Redis分散ロックはトランザクションメソッド内で使用されます。このメソッドが実行されると:
- SpringのAOPによりトランザクションが開始されます。
- Redisロックが取得されます。
- ビジネスロジックが実行された後、Redisロックが解放されます。
- その後、トランザクションがコミットされます。
これにより問題が発生します。ロックはトランザクションがコミットされる前に解放されます。別のスレッドがロックを取得し、そのロジックを実行し、最初のトランザクションによってまだコミットされていない古いデータを読み取る可能性があります。
なぜこれが起こるのでしょうか?
Spring AOPは、updateDB()
が実行される前にトランザクションを開始します。Redisロックは、その後メソッド内で取得されます。メソッドが完了すると、ロックは解放されますが、トランザクションはまだコミットされていません。
正しいアプローチ:トランザクションメソッドに入る前に、トランザクションが開始される前にロックを取得します。そうすることで、ロックで保護されたコードは完全に一貫性のある状態になります。
9. 再入可能ロック
これまで議論してきたRedis分散ロックは、非再入可能です。
非再入可能とは、スレッドがすでにロックを保持しており、もう一度(同じスレッド内で)ロックを取得しようとした場合、ブロックされるか失敗することを意味します。言い換えれば、スレッドは同じロックを一度しか取得できません。
このタイプのロックはほとんどのビジネスケースで機能しますが、一部のシナリオでは再入可能性が必要です。分散ロックを設計する際は、アプリケーションが再入可能分散ロックを必要とするかどうかを検討してください。
Redisで再入可能な動作を実装するには、2つの問題を解決する必要があります。
- 現在どのスレッドがロックを保持しているかを追跡する方法。
- ロックが取得された回数(再入回数)を維持する方法。
再入可能な分散ロックを構築するには、Javaの ReentrantLock
の設計を参照できます。または、再入可能ロックをネイティブでサポートする Redisson を使用することもできます。
10. Redisマスター/スレーブのレプリケーションによって引き起こされる問題
Redis分散ロックを実装する場合、Redisの マスター/スレーブのレプリケーション のセットアップによって引き起こされる問題に注意してください。Redisは多くの場合、クラスタとしてデプロイされます:
スレッドAがマスターノードでロックを取得したが、ロックキーがまだスレーブノードにレプリケートされていないと想像してください。もしマスターノードがダウンした場合、スレーブの1つがマスターに昇格される可能性があります。これで、スレッドBは同じロックキーを取得できます。なぜなら、キーが新しいマスターに存在しないからです。しかし、スレッドAはまだロックを保持していると考えています。これで、両方のスレッドがロックを持っていると考えており、ロックの安全性が損なわれます。
これを解決するために、Redisの作者である antirez は、Redlock と呼ばれるより高度な分散ロックアルゴリズムを提案しました。
Redlockの中核となるアイデア:
高い可用性を確保するために、複数のRedisマスターノードを使用します。これらのノードは完全に独立しており、ノード間のレプリケーションはありません。同じロックロジック(取得/解放)が各マスターに適用されます。
別々のサーバー上に5つのRedisマスターノードがあるとします。Redlockの手順:
- すべての5つのマスターノードでロックの取得を順番に試みます。
- ノードが到達不能な場合(例:ネットワーク遅延)、タイムアウト後にスキップします。
- 5つのノードのうち少なくとも3つでロックの取得に成功し、使用された合計時間がロックのTTLよりも短い場合、ロックは成功したと見なされます。
- 取得に失敗した場合は、以前に取得したすべてのロックを解放します。
Leapcellは、バックエンドプロジェクトをホストするための最高の選択肢です。
Leapcellは、ウェブホスティング、非同期タスク、およびRedisのための次世代のサーバーレスプラットフォームです:
多言語サポート
- Node.js、Python、Go、または Rust で開発。
無制限のプロジェクトを無料でデプロイ
- 使用量に対してのみ支払い - リクエストも料金もありません。
比類のない費用対効果
- アイドル料金なしの従量課金制。
- 例:25ドルで、平均応答時間60msで694万リクエストをサポート。
合理化された開発者エクスペリエンス
- 簡単なセットアップのための直感的なUI。
- 完全に自動化されたCI/CDパイプラインとGitOps統合。
- 実用的なインサイトのためのリアルタイムメトリクスとログ記録。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を処理するための自動スケーリング。
- 運用オーバーヘッドゼロ - 構築に集中するだけ。
ドキュメントで詳細をご覧ください!
Xでフォローしてください:@LeapcellHQ