RedisとRedlockによる分散ロック:マイクロサービスにおけるリソース競合対策の極意

Database tutorial - IT technology blog
Database tutorial - IT technology blog

分散リソースの競合を解決する4つのアプローチ

モノリスからマイクロサービスへの移行はスケーラビリティの課題を解決する一方で、レースコンディション(競合状態)という新たな頭痛の種をもたらします。モノリス時代であれば、Javaの synchronized キーワードやGoのmutexを使うだけで、同一メモリ空間上のリソースを容易にロックできました。しかし、50個のKubernetes Podが10台の物理サーバー上に分散して稼働している現在、ローカルメモリによる排他制御はまったく通用しません。

フラッシュセールのシナリオを想像してみてください。限定100台のiPhone 16 Pro Maxに対して、1秒間に5,000件のリクエストが殺到します。リソースを正確にロックできなければ、在庫数は一瞬でマイナスになってしまうでしょう。この破滅的な事態を防ぐため、私たちには主に4つの選択肢があります。

  • データベースロック(RDBMS): SELECT ... FOR UPDATE による悲観的ロック(Pessimistic Lock)、または version カラムの検証による楽観的ロック(Optimistic Lock)を利用する。
  • Redis単一インスタンス: シングルスレッド特性とアトミックなコマンドを活用する:SET resource_name my_random_value NX PX 30000。
  • Redlockアルゴリズム(マルチマスター): N台の独立したRedisノード(一般的には5ノード)に対して分散ロックを取得し、過半数の合意(Quorum)によって成否を判定する。
  • 分散合意エンジン(ZooKeeper / etcd): ハートビート、リース(Lease)メカニズム、およびPaxosやRaftアルゴリズムに基づき、強い一貫性(CAP定理におけるCP)を保証する。

各ソリューションのメリット・デメリットの比較

各ツールには、想定されている負荷の閾値やリスク許容度が存在します。「鶏を割くのに牛刀を用いるなかれ」と言うように、適切なツール選定が重要です。

1. Database Locking

  • メリット: 実装に馴染みがあり、既存のインフラを活用可能。厳格なACID特性を保証できる。
  • デメリット: DB層で深刻なボトルネックが発生する。トランザクションを長時間保持するとコネクションプール(通常100〜200接続程度)が枯渇し、結果としてシステム全体の連鎖障害(Cascading Failure)を引き起こす恐れがある。

2. 単一ノードRedis(または Master – Replica 構成)

  • メリット: インメモリ動作による圧倒的な高速性(レイテンシ1ms未満)。セットアップも極めてシンプル。
  • デメリット: Redisのレプリケーションは非同期(Asynchronous)です。Masterがロックコマンドを受け取った直後にクラッシュし、Replicaへの同期が間に合わなかった場合、昇格した新Masterにはそのロック情報が存在しません。その結果、2つのクライアントが同時に同一ロックを保持する事態が生じます。

3. Redlockアルゴリズム

  • メリット: 単一障害点(SPOF)を完全に排除できる。1〜2台のノードが突然ダウンしても、過半数(5台のうち3台)のノードが承認すればロックは有効となる。
  • デメリット: 複数の独立したインスタンスを管理・運用する必要がある。複数回のリクエスト送受信によりレイテンシがわずかに増加し、サーバー間のクロックドリフト(時刻のズレ)にも影響を受けやすい。

4. ZooKeeper / etcd

  • メリット: ハートビートとエフェメラルノード(Ephemeral znode / Lease)の仕組みにより極めて堅牢。クライアントのネットワークが切断されると、自動的にロックが解放される。
  • デメリット: 運用が複雑でリソース消費が大きい。スループットは数千ops/s程度にとどまり、Redisの50,000〜100,000 ops/sには遠く及ばない。

自社のユースケースに最適な選択肢は?

冷や汗を流しながらオンコール対応を繰り返してきた経験から、私は以下のような実践的な判断基準にたどり着きました。

  • 単一Redisノード(Luaスクリプト併用): 圧倒的なスループット(数万req/s)が最優先される場合に適しています。仮にリソース競合のすり抜けが起きたとしても(発生確率0.1%未満)、致命的な損害に至らず、日次の締めバッチ処理等でリカバリ可能なシステム向けです。
  • Redlockまたはetcd: 決済フロー、ウォレットの残高引き落とし、クラスタ全体で厳密に1回のみ実行されるべき定期Cronjobなど、厳密な整合性が求められるケースには必須です。

Redisクラスタのストレステスト用にモックデータを作成する際のTips:ユーザーや注文リストのCSVファイルを素早くJSONへ変換したい場合は、toolcraft.app/ja/tools/data/csv-to-json を試してみてください。ブラウザ上でクライアントサイド処理されるため、顧客の機密データが外部に漏洩する心配もありません。

分散ロックの実装ガイド

ステップ1:単一ノードにおけるLuaスクリプトを用いた標準化

初心者によく見られるミスとして、SETNX を実行した後に EXPIRE を呼び出すパターンがあります。この方法は非常に危険です。2つのコマンドの合間でプロセスが強制終了された場合、キーの有効期限が設定されずに残り続け、恒久的なデッドロックに陥ります。必ずアトミックなオプションを付与した SET コマンドを使用してください。

import uuid
import time
import redis

client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

def acquire_lock(lock_name, acquire_timeout=10, lock_timeout=10):
    """
    lock_timeout: 自動解放されるまでの最大ロック保持時間(秒)
    acquire_timeout: ロック取得をリトライし続ける最大待機時間(秒)
    """
    identifier = str(uuid.uuid4())  # 他プロセスのロックを誤って解放するのを防ぐ
    lock_key = f"lock:{lock_name}"
    end_time = time.time() + acquire_timeout
    
    while time.time() < end_time:
        # nx=True: キーが存在しない場合のみセット, ex=lock_timeout: X秒後に自動消滅
        if client.set(lock_key, identifier, nx=True, ex=lock_timeout):
            return identifier
        time.sleep(0.05)  # ビジーウェイトによるCPU高負荷を防止
    return False

ロックを解放する際、直接 DEL コマンドを実行することは絶対に避けてください。タスクの処理が長引いてロックの有効期限(10秒など)を超えてしまった場合、元のロックは自動消滅し、すでに別のクライアントが新しいロックを取得している可能性があります。そこで安易に DEL を呼ぶと、他人のロックを誤って削除してしまいます。アトミックなLuaスクリプトを用いて、ロックの所有権を正しく確認した上で解放しましょう。

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

def release_lock(lock_name, identifier):
    lock_key = f"lock:{lock_name}"
    result = client.eval(RELEASE_LOCK_LUA, 1, lock_key, identifier)
    return result == 1

ステップ2:マルチノード構成のRedlockへのアップグレード

さらに高可用性と安全性を高めたい場合は、5台の独立したRedisノード(Master-Replica構成やClusterシャーディングではなく、完全に独立した単一インスタンス)を構築します。Redlockアルゴリズムは、以下の4つのステップで動作します。

  1. 開始時刻(T1)を記録する。
  2. 5台のノードに対して、極めて短いタイムアウト(5〜50ms程度)を設定して順次ロック取得リクエストを送信する。これは、ダウンしているノードの応答待ちでプロセスが長時間ブロックされるのを防ぐため。
  3. 経過時間を計算する:elapsed_time = now() - T1。過半数(5台のうち3台以上)のノードからロックを取得でき、かつ有効時間 Validity Time = TTL - elapsed_time - clock_drift が0より大きい場合にのみ、ロック取得成功と判定する。
  4. 3台以上のノードからロックを取得できなかった場合、あるいは有効期限が切れた場合は、直ちに全5台のノードに対してロック解放リクエストを送信する。

Pythonライブラリのインストール:

pip install redlock-py

実践的なサンプルコード:

from redlock import Redlock
import time

# 5台の独立したサーバー上のRedisノードへ接続
dlm = Redlock([
    {"host": "10.0.0.1", "port": 6379, "socket_timeout": 0.1},
    {"host": "10.0.0.2", "port": 6379, "socket_timeout": 0.1},
    {"host": "10.0.0.3", "port": 6379, "socket_timeout": 0.1},
    {"host": "10.0.0.4", "port": 6379, "socket_timeout": 0.1},
    {"host": "10.0.0.5", "port": 6379, "socket_timeout": 0.1},
], retry_count=3, retry_delay=0.2)

resource_key = "order:payment:20261103"
# 10,000ms(10秒間)ロックを保持
my_lock = dlm.lock(resource_key, 10000)

if my_lock:
    try:
        print("ロックを取得しました。ウォレットの残高を引き落とし中...")
        time.sleep(2)  # 重要な決済処理ロジックを実行
    finally:
        dlm.unlock(my_lock)
        print("ロックを安全に解放しました。")
else:
    print("リソースがビジー状態です。しばらく経ってから再試行してください!")

本番運用で命運を分ける3つの鉄則

  • Watchdogメカニズム(TTLの自動延長): Java GCによるStop-the-worldやスロークエリによって、処理時間が10秒を超えてしまうケースがあります。この場合、処理の完了前にロックが自動解放されてしまいます。RedissonのWatchdogのように、プロセスが生存している間はバックグラウンドスレッドで定期的にTTLを自動延長する仕組みを導入しましょう。
  • クロックドリフト(時刻のズレ)への警戒: Redlockは実時間に基づいてTTLを計算します。サーバー間のシステムクロックがわずか500msズレるだけでも、アルゴリズムの判定が破綻するリスクがあります。全サーバーで必ずNTP等による時刻同期を有効化してください。
  • フェンシングトークン(Fencing Token): 厳格な金融系業務などでは、単純な分散ロックだけでは不十分です。ロック付与サーバーは、ロックごとに単調増加するトークン(1, 2, 3…)を発行する必要があります。ストレージへの書き込み時、データベースは以前に記録されたトークン以下の値を持つトランザクションをすべて拒否するように制御します。
Share: