Redisによる分散ロックの実装:SETNX、Redlock、そしてその論争の深掘り
Grace Collins
Solutions Engineer · Leapcell

はじめに
分散システムの世界では、複数の独立したプロセス間で共有リソースを管理することは、重大な課題です。適切な同期メカニズムがなければ、同時アクセスはデータ破損、一貫性のない状態、予期せぬ動作につながる可能性があります。分散ロックは、これらの共有リソースを保護するための基本的なプリミティブとして登場し、一度に1つのプロセスのみがクリティカルセクションにアクセスできるようにします。Redisは、その驚異的な速度のインメモリデータストアと汎用性の高いコマンドにより、このようなロックの実装に人気のある選択肢となっています。しかし、Redisを用いた堅牢で信頼性の高い分散ロックへの道は、単純なSETNX
アプローチから、Redlockのようなより複雑なアルゴリズムまで、ニュアンスに富んでおり、それぞれが独自の強み、弱み、そして特に激しい議論を伴います。本稿では、Redisを用いた分散ロックの実践に踏み込み、基盤となるメカニズム、一般的な落とし穴、そしてベストプラクティスを形成する継続的な論争を探求します。
分散ロックのコアコンセプトの理解
Redis固有の実装に飛び込む前に、分散ロックに関わる主要な概念を基礎的に理解しましょう。
- 相互排他性(Mutual Exclusion): ロックの最も重要な特性であり、任意の時点で1つのクライアントのみがロックを保持し、クリティカルセクションにアクセスできることを保証します。
- デッドロックフリー(Deadlock Freedom): システムは、2つ以上のプロセスがリソース解放を互いに無限に待ち、行き詰まりにつながる状態に陥るべきではありません。
- ライブネス/耐障害性(Liveness / Fault Tolerance): クライアントがロックを保持中にクラッシュまたはエラーを発生させた場合、システムは最終的に回復し、他のクライアントがロックを取得できるようにするべきです。これには、タイムアウトやリース機構がしばしば伴います。
- パフォーマンス(Performance): ロックメカニズムは、最小限のオーバーヘッドで、分散アプリケーションのボトルネックにならないようにするべきです。
それでは、Redisがこれらの概念をどのように促進するか、基本的なアプローチからより洗練されたソリューションへと進みながら探求しましょう。
SETNXによるシンプルな分散ロック
Redisで分散ロックを実装する最も簡単な方法は、SETNX
(SET if Not eXists)コマンドを活用することです。このコマンドは、キーがまだ存在しない場合にのみキーを設定します。
メカニズム:
- クライアントは、
SETNX my_lock_key my_client_id
を実行してロックの取得を試みます。 SETNX
が1を返した場合、クライアントはロックを正常に取得しました。my_client_id
は、デバッグやロック所有権の検証(ただし、基本的なミューテックスには厳密には必要ない場合が多い)に役立つクライアントの一意の識別子にすることができます。SETNX
が0を返した場合、別のクライアントが既にロックを保持しており、現在のクライアントは待機して再試行するか、他のアクションを実行する必要があります。- ロックを解放するには、クライアントは単純にキーを削除します:
DEL my_lock_key
。
コード例(概念的なPython):
import redis import time r = redis.Redis(host='localhost', port=6379, db=0) LOCK_KEY = "my_resource_lock" CLIENT_ID = "client_A_123" def acquire_lock_setnx(resource_name, client_id, timeout=10): start_time = time.time() while time.time() - start_time < timeout: if r.setnx(resource_name, client_id): print(f"{client_id} acquired lock on {resource_name}") return True time.sleep(0.1) # Wait and retry print(f"{client_id} failed to acquire lock on {resource_name}") return False def release_lock_setnx(resource_name, client_id): # This is problematic for safety, see explanation below if r.get(resource_name).decode('utf-8') == client_id: r.delete(resource_name) print(f"{client_id} released lock on {resource_name}") return True return False # Usage demonstration # if acquire_lock_setnx(LOCK_KEY, CLIENT_ID): # try: # print(f"{CLIENT_ID} is performing critical operation...") # time.sleep(2) # Simulate work # finally: # release_lock_setnx(LOCK_KEY, CLIENT_ID)
基本的なSETNX
の限界:
SETNX
アプローチはシンプルですが、決定的な欠点があります:適切な有効期限の欠如。クライアントがロックを取得してから、それを解放する前にクラッシュした場合、ロックキーはRedisに無期限に残ったままになり、恒久的なデッドロックにつながります。
有効期限によるSETNX
の強化
デッドロック問題を解決するために、SETNX
とEXPIRE
または、より堅牢なアトミックSET
コマンドによる有効期限メカニズムを組み合わせることができます。
SETNX
とEXPIRE
の使用(問題あり):
# Problematic sequence: not atomic if r.setnx(resource_name, client_id): r.expire(resource_name, 30) # Set expiration for 30 seconds return True
このシーケンスには競合状態があります。クライアントはロックを取得(SETNX
が1を返す)してからEXPIRE
を実行する前にクラッシュした場合、ロックは再び永続的になります。
アトミックSET
コマンド:
Redis 2.6.12は、SET
コマンドに複合引数を導入し、SET key value NX EX seconds
をアトミックに実行できるようにしました。これは、基本的な有効期限付きロックの推奨される方法です。
import redis import time import uuid r = redis.Redis(host='localhost', port=6379, db=0) LOCK_KEY = "my_atomic_resource_lock" def acquire_lock_atomic_set(resource_name, expire_time_seconds, client_id): # SET key value NX EX seconds # NX: Only set the key if it does not already exist. # EX: Set the specified expire time, in seconds. if r.set(resource_name, client_id, nx=True, ex=expire_time_seconds): print(f"{client_id} acquired lock on {resource_name} with expiration") return True return False def release_lock_atomic_set(resource_name, client_id): # Use LUA script for atomic read-and-delete to prevent deleting # a lock set by another client (due to original lock expiring). lua_script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ script = r.register_script(lua_script) if script(keys=[resource_name], args=[client_id]): print(f"{client_id} released lock on {resource_name}") return True else: print(f"{client_id} failed to release lock (not owner or already expired)") return False # Usage demonstration # client_id = str(uuid.uuid4()) # if acquire_lock_atomic_set(LOCK_KEY, 30, client_id): # try: # print(f"{client_id} is performing critical operation...") # time.sleep(5) # finally: # release_lock_atomic_set(LOCK_KEY, client_id) # else: # print(f"Another client holds the lock.")
解放に関する重要な考慮事項: ロックを解放する際には、解放を試みているクライアントが実際にそれを取得したクライアントであることを確認することが重要です。そうでなければ、クライアントは(意図的または悪意をもって)別のクライアントが保持しているロックを削除してしまう可能性があります。これは、元のロックが期限切れになり、そのクリティカルセクション中に別のクライアントがそれを再取得した場合に発生します。上記のLuaスクリプトは、削除前に原子的に値をチェックすることで、これを正しく処理します。
Redlockアルゴリズムの導入
適切な有効期限を持つ単一のRedisインスタンスでのSET ... NX EX
は、多くのシナリオで合理的な分散ロックセマンティクスを提供しますが、単一障害点が存在します。Redisインスタンスがダウンした場合(すぐに回復しなかったり、データが失われたりした場合)、保持されているすべてのロックが失われ、相互排他性の喪失につながります。ここで、Salvatore Tridici(Redisの作成者)によって設計された分散ロックアルゴリズムであるRedlockが登場します。
Redlockの目標:
Redlockは、複数の独立したRedisインスタンス全体で、より堅牢で耐障害性の高い分散ロックを提供することを目指しています。コアアイデアは、単一のRedisインスタンスではなく、多数のRedisインスタンスでロックを取得することです。
Redlockアルゴリズムの手順:
N個の独立したRedisマスターインスタンスがあり、クライアントはresource_name
とvalidity_time
(ロックが有効と見なされる期間)を持つロックを取得する必要があります。
- ランダム値の生成: クライアントは、後でロックを安全に解放するために使用されるランダムで一意の値(例:大きなランダム文字列またはUUID)を生成します。
- インスタンスでの取得(並列): クライアントは、可能な限り並列に、N個のRedisインスタンスすべてで(または過半数を取得するまで)、ロックの取得(
SET resource_name my_rand_value NX PX validity_time_milliseconds
)を試みます。各取得試行には短いタイムアウト(例:数百ミリ秒)を使用する必要があります。 - ロック取得時間の計算: クライアントは、ロック取得プロセスを開始した時刻(
start_time
とする)を記録します。 - 過半数と有効性のチェック:
- クライアントは、
start_time
から現在時刻までの経過時間を計算します。 - クライアントが過半数(N/2 + 1)のインスタンスでロックを取得でき、かつ経過時間が
validity_time
未満の場合、クライアントはロックを正常に取得したことになります。 - ロックの有効な
validity_time
は、取得中に経過した時間によって短縮されます。
- クライアントは、
- 解放または再試行:
- ロックが正常に取得された場合、クライアントはクリティカルセクションの処理に進むことができます。
- ロックが正常に取得されなかった場合(過半数に達しない、または
validity_time
を過ぎた場合)、クライアントは取得できたすべてのインスタンスでロックを解放しようと試みる必要があります。これはクリーンアップのために不可欠です。
- ロックの延長(オプション): クライアントが初期
validity_time
よりも長い時間が必要な場合、同じrand_value
を使用して新しいvalidity_time
で取得プロセスを再実行することにより、ロックの延長を試みることができます。
コード例(概念的なPython、簡潔さのために簡略化):
import redis import time import uuid # Assume multiple Redis instances REDIS_INSTANCES = [ redis.Redis(host='localhost', port=6379, db=0), # redis.Redis(host='localhost', port=6380, db=0), # redis.Redis(host='localhost', port=6381, db=0), ] MAJORITY = len(REDIS_INSTANCES) // 2 + 1 LOCK_KEY = "my_redlock_resource" def acquire_lock_redlock(resource_name, lock_ttl_ms): my_id = str(uuid.uuid4()) acquired_count = 0 start_time = int(time.time() * 1000) # Milliseconds for r_conn in REDIS_INSTANCES: try: # Use PX for milliseconds if r_conn.set(resource_name, my_id, nx=True, px=lock_ttl_ms): acquired_count += 1 except redis.exceptions.ConnectionError: # Handle connection errors pass end_time = int(time.time() * 1000) elapsed_time = end_time - start_time if acquired_count >= MAJORITY and elapsed_time < lock_ttl_ms: print(f"Redlock acquired by {my_id} on {acquired_count} instances.") return my_id, lock_ttl_ms - elapsed_time # Return actual validity else: # If not acquired or validity expired, release locks we might have acquired for r_conn in REDIS_INSTANCES: lua_script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ script = r_conn.register_script(lua_script) script(keys=[resource_name], args=[my_id]) print(f"Redlock not acquired by {my_id}. Acquired count: {acquired_count}") return None, 0 def release_lock_redlock(resource_name, my_id): for r_conn in REDIS_INSTANCES: lua_script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """ script = r_conn.register_script(lua_script) script(keys=[resource_name], args=[my_id]) print(f"Redlock released by {my_id}.")
Redlockをめぐる論争
Redlockの洗練された設計にもかかわらず、特に分散システム専門家から、かなりの議論と批判の対象となっています。最も顕著な批判は、Martin Kleppmann(「Designing Data-Intensive Applications」の著者)によるものです。
主な批判:
-
「より強力な」安全保証を*提供しない:Kleppmannは、Redlockは実際には、適切な永続化とフェンシングを備えた単一のRedisインスタンスよりも安全なメカニズムを提供しないと主張しています。
- クロックスキューとシステム時間: Redlockは、分散システムでは信頼性が非常に低い、異なるマシンやインスタンス間での同期された時間の概念に依存しています。クロックが大幅にずれると、クライアントは、別のインスタンスの観点からは既に期限切れになっているロックを取得したと信じるかもしれませんが、その逆も同様です。
- 実行の一時停止(GC、ネットワーク遅延、コンテキストスイッチ): プロセスがRedlockを取得し、その後長い一時停止(例:長いガベージコレクションサイクル、オペレーティングシステムスケジューラの一時停止、ネットワークパーティション)を経験した場合、ロックは一部またはすべてのRedisインスタンスで期限切れになる可能性があります。プロセスが再開すると、まだロックを保持していると信じ、クリティカルセクションを続行するかもしれませんが、別のクライアントが既にロックを取得しており、相互排他性を侵害します。
- フェンシングトークンの欠如: Redlockには、「フェンシングトークン」(各ロック取得試行に関連付けられた単調増加する番号)がありません。フェンシングトークンは、保護されたリソースに渡されると、リソースが古い、期限切れのロックホルダーからの操作を拒否することを許可します。これがないと、期限切れのロックを持つクライアントは、リソースがトークンの有効性をチェックしない限り、共有リソースに書き込むことができます。これは、おそらくRedlockの、遅延に直面した場合の真に安全性を保証する上での最も重大な欠陥です。
-
複雑さとメリットの比較: Redlockのために複数のRedisインスタンスをセットアップおよび管理する追加の複雑さと、ロック取得を調整するオーバーヘッドは、それが実際に提供する安全保証によって正当化されない可能性があるということです。これは、分散システムの実世界での障害モードを考慮すると、特に当てはまります。
-
実行可能な代替手段: 評論家はしばしば、Apache ZooKeeperやetcdなどのシステムによって実装されたPaxosやRaftのような、分散調整とロックのためのより堅牢で理論的に健全なソリューションを指摘します。これらは、ネットワークパーティション、クロックスキュー、ノード障害などを強力な一貫性保証とともに本質的に扱います。
Redlockが(どのような「安全性」のために)潜在的に役立つ場合?
批判にもかかわらず、Redlockはライブネスに役立ちます。1つのRedisインスタンスがダウンしても、ロックは引き続き取得および解放でき、システム全体の停止を防ぎます。しかし、機械の一時停止やクロックスキューに直面した際の相互排他性の強化を主張することは、フェンシングトークンなしでは大いに議論の余地があります。多くのユースケースでは、一時的な競合バグが許容されるか、システムがそのようなイベントから正常に回復できる場合、適切なアプリケーションレベルのセーフガード(例:冪等性、再試行)を備えた単一のRedisインスタンスとSET ... NX EX
が十分で、よりシンプルである可能性があります。
結論
Redisを用いた分散ロックの実装は、基本的なSETNX
からマルチインスタンスのRedlockアルゴリズムまで、さまざまな選択肢を提供します。アトミックな有効期限(SET ... NX EX
)と組み合わせたSETNX
は、多くの一般的なシナリオでシンプルかつ効果的なソリューションを提供しますが、単一障害点であることには変わりありません。Redlockは、複数のRedisインスタンスにロック状態を分散させることで耐障害性を強化しようとし、より優れたライブネス保証を提供します。しかし、その安全性に関する主張、特に機械の一時停止やクロックスキューに対するものは、分散システム専門家から厳しく異議が唱えられており、フェンシングトークンメカニズムなしでは、適切に管理された単一インスタンス設定よりも強力な相互排他性を提供しない可能性を示唆しています。最終的に、ロック戦略の選択は、一貫性、可用性、および許容される複雑さと潜在的な障害モードのトレードオフに関する特定のアプリケーションの要件に大きく依存します。絶対的な相互排他性と任意の遅延に対する耐性を必要とするクリティカルセクションについては、ZooKeeperやetcdのような堅牢なコンセンサスシステムを検討することが、より信頼性の高いパスであることがよくあります。