データ破損を防ぐ:Redis分散ロックの実践ガイド

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

問題点:スケール時にローカルロックが失敗する場合

たった一度のレースコンディション(競合状態)が、成功するはずのプロダクトローンチをカスタマーサポートの悪夢に変えてしまうことがあります。例えば、1分間に2,000件の注文を処理するEコマースプラットフォームを想像してください。限定商品が残りちょうど10個だとします。2人のユーザーが全く同じミリ秒に「購入」ボタンを押し、トラフィックが5つの異なるコンテナに分散されたとしましょう。2つのインスタンスが同時にデータベースを読み取ると、両方が「在庫あり(10個)」と認識し、処理を続行してしまいます。その結果、在庫が10個しかないのに11個売れてしまうことになります。これが分散システムの現実です。

モノリシックなアプリケーションであれば、メモリ内のシンプルなミューテックスやセマフォを使用できます。しかし、マイクロサービスはメモリを共有しません。各インスタンスは独自の隔離された環境で動作します。これらを同期させるには、外部の「レフェリー(審判)」が必要です。本番環境においてこれを正しく実装できるかどうかが、安定したシステムになるか、あるいは断続的でデバッグ困難なデータ破損に悩まされるシステムになるかの分かれ目となります。

Redisはこのタスクにおける業界標準です。非常に高速で、アトミックな操作をネイティブにサポートしており、すでにキャッシュ用途でスタックに組み込まれていることも多いでしょう。

クイックスタート:5分でわかる基本のロック

複雑なアルゴリズムを実装する前に、基本的な構成要素を理解しておく必要があります。RedisのSETコマンドに、2つの重要なフラグを組み合わせて使用します。NX(存在しない場合のみセット)とPX(ミリ秒単位の有効期限付きでセット)です。有効期限はセーフティネットの役割を果たし、ロックを保持したままサービスがクラッシュした場合の「デッドロック」を防ぎます。

以下は、Pythonとredis-pyを使用したクリーンな実装例です。

import redis
import uuid
import time

# Redisインスタンスに接続
client = redis.StrictRedis(host='localhost', port=6379, db=0)

def acquire_basic_lock(lock_name, acquire_timeout=10, lock_timeout=30000):
    identifier = str(uuid.uuid4())
    end = time.time() + acquire_timeout
    
    while time.time() < end:
        # NX: キーが存在しない場合のみセット
        # PX: 恒久的なロックアウトを防ぐため、30秒の有効期限を設定
        if client.set(lock_name, identifier, nx=True, px=lock_timeout):
            return identifier
        time.sleep(0.01) # 再試行する前に10ミリ秒待機
    
    return False

def release_basic_lock(lock_name, identifier):
    # 'get'と'del'を一つのアトミックなステップで実行するためにLuaスクリプトを使用します。
    # これにより、他者が保持しているロックを誤って削除することを防ぎます。
    script = """
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    """
    return client.eval(script, 1, lock_name, identifier)

これはユースケースの90%でうまく機能します。しかし、これは単一のRedisノードに依存しています。そのノードがダウンしたり再起動したりすると、ロックのロジックは消失してしまいます。ミッションクリティカルなタスクには、より高い冗長性が必要です。

Redlockアルゴリズム:数の力による安全性

Redisの生みの親であるAntirez氏によって設計されたRedlockアルゴリズムは、単一障害点の問題を解決します。これは複数の独立したRedisノード(通常は5つ)を使用します。ロックを取得するには、クライアントはこれらのノードの過半数(5つ中少なくとも3つ)からロックを正常に取得する必要があります。

プロセスの仕組み

  1. タイムスタンプ: クライアントは現在の時刻をミリ秒単位で記録します。
  2. スプリント: 同じキーと一意のランダムな値を使用して、すべてのN個のインスタンスに対して順次ロックの取得を試みます。
  3. 投票: クライアントはすべてのノードとの通信にかかった時間を計算します。過半数(少なくとも3つ)のロックを確保でき、かつ費やした合計時間がロックの有効期間内であれば、ロック取得成功です。
  4. 時間の調整: 実際に作業に使える時間は、最初のTTL(生存時間)から取得フェーズにかかった時間を差し引いた残り時間となります。
  5. クリーンアップ: 過半数の取得に失敗した場合は、最初に応答しなかったノードも含め、すべてのノードのロックを即座に解除しなければなりません。

この構成により、単一ノードのクラッシュや、ネットワーク分断によって一部のRedisインスタンスとの通信が途切れた場合でも保護されます。

Pythonによる高度な実装

本番環境向けにRedlockを自作するのは避けましょう。クロックドリフト(時刻のズレ)やネットワークのジッター(揺らぎ)などの微妙なバグが原因で、不具合が生じる可能性があります。代わりに、redlock-pyのようなライブラリを使用してください。以下は、決済処理サービス向けの堅牢なセットアップ例です。

from redlock import Redlock

# レプリカを持つクラスターではなく、独立したノードを使用する
servers = [
    {"host": "redis-node-a", "port": 6379, "db": 0},
    {"host": "redis-node-b", "port": 6379, "db": 0},
    {"host": "redis-node-c", "port": 6379, "db": 0},
]

dlm = Redlock(servers)

def process_payment(order_id):
    lock_key = f"lock:order:{order_id}"
    
    # 10,000ミリ秒(10秒間)ロックの保持を試みる
    my_lock = dlm.lock(lock_key, 10000)
    
    if my_lock:
        try:
            print(f"注文 {order_id} を処理中...")
            # 重要なロジック: データベースの更新やStripe APIの呼び出し
        finally:
            dlm.unlock(my_lock)
    else:
        print("ビジー状態: 別のワーカーが既にこの注文を処理しています。")

フェンシングトークン:最終的なセーフティネット

Redlockであっても完璧ではありません。プロセスがロックのTTLよりも長いガベージコレクション(GC)による停止に遭遇した場合、プロセスが実行中であってもロックが期限切れになる可能性があります。別のワーカーがロックを取得して作業を開始してしまう恐れがあります。

私はこれをフェンシングトークン(Fencing Token)を使用して解決します。ロックが付与されるたびに、インクリメント(増加)するIDを生成します。データベースに書き込む際、クエリにこのトークンを含めます:UPDATE orders SET status='paid' WHERE id=123 AND last_token < :current_token。もし「ゾンビ」化したプロセスがロック失効後に書き込みを試みても、データベースは古いトークンを無視するため、安全が保たれます。

本番環境のための実践的なアドバイス

私は長年、分散システムのトラブルシューティングに携わってきました。以下は、私が守っているルールです。

  • TTLは短く設定する: 面倒だからといってリソースを5分間もロックしないでください。ワーカーが停止した場合、そのリソースはTTLが切れるまで凍結されてしまいます。2〜5秒のロックを使用し、タスクが正常に継続している場合にのみ延長するようにしましょう。
  • 優雅に失敗する(Fail Gracefully): ロックを取得できなかった場合、リクエストをクラッシュさせないでください。指数バックオフ(10ms、50ms、200msと間隔を広げて再試行)を使用するか、ジョブを「再試行」キューに移動させます。
  • 真の独立性: Redisノードが異なる物理ハードウェア上で動作していることを確認してください。5つのノードすべてが同じホスト上のVMであり、そのホストが再起動してしまえば、「分散」ロックは何の意味もなしません。
  • 時刻の同期: Redlockは時間に依存します。NTP(Network Time Protocol)を使用してサーバー間の時刻を同期させてください。さもないと、クロックドリフトによって、ロックの有効期限についてノード間で不一致が生じることになります。

分散ロックは並行性を処理するための優れた方法ですが、スタックに複雑さをもたらすことも事実です。リスクの低いタスクには、まず基本的なSET NXから始めてください。データの衝突によるコストが、複雑なアーキテクチャを維持するコストを上回る場合にのみ、Redlockやフェンシングトークンの導入を検討しましょう。

Share: