マイクロサービスにおけるRedis分散ロック:同時処理のレースコンディションを防ぐ実装ガイド

Development tutorial - IT technology blog
Development tutorial - IT technology blog

先週、チームで厄介な障害が発生した。注文システムで重複処理が起き、同じ注文の在庫が2回減ってしまったのだ。ログをトレースしたところ、原因は2つのサービスインスタンスが50ms以内に同じリクエストを処理しており、どちらも相手がDBに書き込む前に在庫数を読んでいたことだった。マイクロサービス環境でよくあるレースコンディションだ。

最終的に選んだ解決策はRedis分散ロックだ。シンプルで効果的、それ以来ずっと安定して稼働している。この記事では、チームメンバーへの参考として実装方法をまとめておく。

マイクロサービスにおけるレースコンディションの実態

APIエンドポイント/api/orders/createがあり、ロードバランサーが3つのインスタンスにリクエストを振り分けているとしよう。ユーザーが「注文する」ボタンをダブルクリックすると、ほぼ同時に2つのリクエストが異なるインスタンスにヒットする。両方が同じタイミングで在庫チェックのロジックを実行し始める:

# インスタンスAとインスタンスBがほぼ同時にこのコードを実行する
stock = db.query("SELECT stock FROM products WHERE id = ?", product_id)
if stock > 0:
    db.execute("UPDATE products SET stock = stock - 1 WHERE id = ?", product_id)
    create_order(product_id, user_id)

インスタンスAはstock = 1を読み取り、インスタンスBも同じくstock = 1を読み取る。両方が「在庫あり」と判断し、両方が注文を作成してしまう。結果:stock = -1となり、1つの商品に対して2つの注文確認がユーザーに届くことになる。

シングルサーバーであればデータベーストランザクションと行ロックで対処できる。しかし複数のサービス、複数のインスタンスが複数のサーバーで動くとなると、より上位レイヤーでのロック機構が必要になる。それが分散ロックだ。

Redis分散ロックの仕組み

クリティカルな処理を行う前に、サービスはRedis上でロックキーを「獲得」しなければならない。獲得できれば処理を行い、完了後にリリースする。他のインスタンスが保持中で獲得できなければ、待機またはリトライする。シンプルに聞こえるが、肝心なのはRedisがこのロック獲得ステップを完全にアトミックに保証する点だ。

Redisはこれを実現するためにSET NX PXコマンドを提供している:

# SET key value NX PX milliseconds
# NX = キーが存在しない場合のみセット
# PX = ミリ秒単位のTTL

SET order:lock:product-123 "unique-token-abc" NX PX 5000
# ロック取得成功時はOKを返す
# キーが既に存在する場合はnilを返す(他のサービスが保持中)

このコマンドはアトミックだ。100個のインスタンスが同時に呼び出しても、SETが成功するのは1つだけ。これが2つのコマンドでチェックしてからセットする方式との決定的な違いで、その2コマンドの隙間こそがレースコンディションの入り口になる。

TTLはオプションではない。システムが永久デッドロックに陥るのを防ぐための仕組みだ。ロックを取得した直後にサービスがクラッシュしてリリースできなかった場合、Redisは設定した時間が経過すると自動的にロックを削除する。TTLがなければ、クラッシュしたサービス1つがパイプライン全体を麻痺させかねない

PythonでRedis分散ロックを実装する

インストール

pip install redis

RedisLockクラス

import redis
import uuid
import time

class RedisLock:
    def __init__(self, redis_client: redis.Redis, lock_key: str, ttl: int = 5000):
        self.redis = redis_client
        self.lock_key = lock_key
        self.ttl = ttl  # milliseconds
        self.token = str(uuid.uuid4())  # ロックインスタンスごとのユニークトークン

    def acquire(self, retry: int = 3, retry_delay: float = 0.1) -> bool:
        for attempt in range(retry):
            result = self.redis.set(
                self.lock_key,
                self.token,
                nx=True,
                px=self.ttl
            )
            if result:
                return True
            if attempt < retry - 1:
                time.sleep(retry_delay)
        return False

    def release(self):
        # 自分自身のロックのみをリリースするためのLuaスクリプト
        lua_script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
        """
        self.redis.eval(lua_script, 1, self.lock_key, self.token)

    def __enter__(self):
        if not self.acquire():
            raise RuntimeError(f"ロックを取得できません: {self.lock_key}")
        return self

    def __exit__(self, *args):
        self.release()

ロックのリリースにDELを直接呼ぶのではなくLuaスクリプトを使う理由は明確だ。ロックがまさに削除しようとしたタイミングで期限切れになり、別のインスタンスが新しいロックを取得済みの場合、他者のロックを削除してはいけない。スクリプトは削除前にトークンを確認する。アトミックなので、レースコンディションが入り込む隙間がない。

注文処理への適用

import redis

redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)

def create_order(product_id: str, user_id: str):
    lock_key = f"order:lock:{product_id}"

    with RedisLock(redis_client, lock_key, ttl=10000) as lock:
        # 同時に1つのサービスのみがこの処理を実行できる
        stock = db.query("SELECT stock FROM products WHERE id = ?", product_id)

        if stock <= 0:
            raise Exception("在庫切れ")

        db.execute(
            "UPDATE products SET stock = stock - 1 WHERE id = ?",
            product_id
        )
        order_id = db.insert("INSERT INTO orders ...")
        return order_id
    # withブロックを抜けると自動的にロックがリリースされる

ロック取得失敗時の処理

from fastapi import HTTPException

def create_order_endpoint(product_id: str, user_id: str):
    lock_key = f"order:lock:{product_id}"
    lock = RedisLock(redis_client, lock_key, ttl=10000)

    if not lock.acquire(retry=5, retry_delay=0.2):
        raise HTTPException(
            status_code=429,
            detail="現在他のリクエストを処理中です。しばらくしてから再試行してください"
        )

    try:
        result = process_order(product_id, user_id)
        return result
    finally:
        lock.release()  # 例外が発生してもリリースする

実運用での注意点

1. 適切なTTLの設定

TTLが短すぎる → 処理完了前にロックが期限切れになる → レースコンディションが再発する。
TTLが長すぎる → サービスがクラッシュした場合、他のサービスが延々と待ち続けることになる。

使っている計算式:TTL = 推定処理時間 × 3、最低3秒。処理時間が不明な場合は、1〜2日間durationをログに記録してp99を取得し、それを3倍にする。感覚で手動設定するのは避けよう。

2. 適切な粒度のロックキー選択

ロックキーが広すぎると不要なボトルネックが生まれる。1000種類の商品があっても全体で1つのグローバルロックキーしかなければ、同時に処理できる注文は1件だけになってしまう:

# 広すぎる — 全注文をブロックする
lock_key = "order:lock:global"

# 適切 — 特定商品の注文のみブロック
lock_key = f"order:lock:product:{product_id}"

# ビジネスロジックが許すならさらに細かく
lock_key = f"order:lock:product:{product_id}:warehouse:{warehouse_id}"

3. 冪等性キーとの組み合わせ

分散ロックはレースコンディションを防ぐが、タイムアウト後のクライアントリトライによる重複は防げない。この2つの仕組みは相互補完的であり、真のべき等性が必要なシステムでは両方を使うべきだ。

4. Redisでのロック監視

# アクティブな全ロックキーを確認
redis-cli KEYS "order:lock:*"

# ロックの残りTTLを確認
redis-cli TTL "order:lock:product-123"

# ロックの値(トークン)を確認
redis-cli GET "order:lock:product-123"

デバッグ時は、Redisにセットする前にJSONペイロードをtoolcraft.app/ja/tools/developer/json-formatterでフォーマット・確認することが多い。拡張機能のインストール不要、登録なしで即使えるので非常に便利だ。

まとめ

先週の注文重複インシデント以来、Redis分散ロックを導入してから同様の問題は発生していない。完璧なソリューションだからではなく、理解しやすくて本番デプロイに耐えられる信頼性があり、すでにスタックにRedisがあれば追加インフラが不要だからだ。

最大の教訓:複数のサービスが並行稼働する環境でデータベーストランザクションだけで十分と思ってはいけない。分散環境における並行処理はシングルサーバーとは全く異なり、複数のレイヤーで考慮が必要だ。Redisロックは最も一般的な脆弱性の一つを塞ぐ最速の方法だ。

Node.jsを使っているならredlockライブラリを確認してみてほしい。Goならgo-redisが同等の機能を提供している。基本原理はSET NX PXとLuaスクリプトによるリリースで同じ、構文が違うだけだ。

Share: