T-CREATOR

Redis 分散ロック設計:Redlock のリスクと安全な代替・運用ルール

Redis 分散ロック設計:Redlock のリスクと安全な代替・運用ルール

分散システムでは、複数のサーバーが同時に同じリソースにアクセスすることを防ぐために「分散ロック」が必要になります。Redis を使った分散ロックは高速で実装も比較的簡単なため、多くのシステムで採用されていますね。

しかし、Redis の分散ロックアルゴリズム「Redlock」には、実は見過ごせないリスクが潜んでいることをご存知でしょうか。本記事では、Redlock の仕組みと問題点を詳しく解説し、より安全な代替案と運用ルールをご紹介します。分散ロックの設計で悩んでいる方や、既存システムの見直しを検討している方にとって、実践的なガイドラインとなる内容です。

背景

分散システムにおけるロックの必要性

分散システムでは、複数のアプリケーションサーバーが同時に稼働しています。例えば、在庫管理システムで複数のサーバーが同時に「在庫数を減らす」処理を実行すると、データの整合性が崩れてしまうでしょう。

このような問題を防ぐために、一度に 1 つのサーバーだけが特定の処理を実行できるようにする仕組みが「分散ロック」です。分散ロックは、データベースのトランザクションだけでは解決できない、複数ステップにまたがる処理の排他制御に使われますね。

Redis を分散ロックに使う理由

Redis は以下の特徴から、分散ロックの実装に広く使われています。

#特徴説明
1高速性インメモリデータベースなので応答速度が非常に速い
2アトミック操作SET NX EX などのアトミックなコマンドを提供
3TTL 機能キーに有効期限を設定でき、デッドロックを防げる
4シンプルな実装数行のコードでロック機構を実装できる

以下の図は、Redis を使った基本的な分散ロックの動作フローを示しています。

mermaidsequenceDiagram
    participant App1 as アプリ1
    participant App2 as アプリ2
    participant Redis as Redis

    App1->>Redis: SET lock_key unique_id NX EX 10
    Redis-->>App1: OK(ロック取得成功)
    App2->>Redis: SET lock_key unique_id NX EX 10
    Redis-->>App2: NULL(ロック取得失敗)
    Note over App1: 排他処理を実行
    App1->>Redis: DEL lock_key(ロック解放)
    Redis-->>App1: OK
    App2->>Redis: SET lock_key unique_id NX EX 10
    Redis-->>App2: OK(ロック取得成功)

この図から、App1 がロックを取得している間は App2 が待機し、ロック解放後に処理を開始できることがわかります。

Redlock アルゴリズムの登場

単一の Redis サーバーでは、そのサーバーがダウンするとロック機能全体が停止してしまいます。この問題を解決するために、Redis の作者である Salvatore Sanfilippo 氏が提案したのが「Redlock」アルゴリズムです。

Redlock は複数の独立した Redis インスタンス(通常は 5 つ)を使い、過半数(3 つ以上)からロックを取得できた場合にのみ、ロックの取得成功とみなす仕組みですね。

課題

Redlock の仕組みと理論

Redlock は以下のステップでロックを取得します。

Redlock のロック取得手順

  1. 現在時刻を取得
  2. すべての Redis インスタンスに対して順番にロックを試行
  3. 各インスタンスに短いタイムアウトを設定してロックを要求
  4. 過半数のインスタンスからロックを取得できたか確認
  5. 取得にかかった時間がロックの有効期限より十分短いか検証

以下は Redlock の基本的な動作を示す図です。

mermaidflowchart TB
    start["ロック取得開始"] --> time1["現在時刻を記録"]
    time1 --> redis1["Redis1へロック要求"]
    redis1 --> redis2["Redis2へロック要求"]
    redis2 --> redis3["Redis3へロック要求"]
    redis3 --> redis4["Redis4へロック要求"]
    redis4 --> redis5["Redis5へロック要求"]
    redis5 --> check["過半数取得できた?"]
    check -->|Yes| valid["有効時間は十分?"]
    check -->|No| fail["ロック取得失敗"]
    valid -->|Yes| success["ロック取得成功"]
    valid -->|No| fail
    fail --> release["取得したロックを解放"]

この図から、Redlock が複数段階の検証を経てロックの取得を判断していることがわかります。

Redlock の重大な問題点

一見堅牢に見える Redlock ですが、分散システムの専門家である Martin Kleppmann 氏が 2016 年に重大な欠陥を指摘しました。

タイミング依存性の問題

Redlock は「時間」に依存したアルゴリズムです。しかし、分散システムでは以下の理由で時間の正確性を保証できません。

#問題影響
1GC(ガベージコレクション)の停止アプリケーションが数秒間停止する可能性
2ネットワーク遅延パケット遅延により応答が遅れる
3時刻のずれNTP による時刻同期のズレ
4プロセスの一時停止OS のスケジューリングによる遅延

具体的な破綻シナリオ

以下のシナリオで Redlock が破綻します。

mermaidsequenceDiagram
    participant Client1 as クライアント1
    participant Redis1 as Redis1-3
    participant Redis2 as Redis4-5
    participant Client2 as クライアント2

    Client1->>Redis1: ロック取得(3/5成功)
    Note over Client1: ロック取得成功と判断
    Note over Client1: GCが発生し10秒停止
    Note over Redis1: TTL切れでロック自動解放
    Client2->>Redis1: ロック取得(5/5成功)
    Client2->>Redis2: ロック取得(5/5成功)
    Note over Client2: ロック取得成功と判断
    Note over Client1,Client2: 両方がロックを持っている状態
    Note over Client1: GC終了、処理を実行
    Note over Client2: 処理を実行
    Note over Client1,Client2: データ競合発生

この図から、GC による停止がロックの安全性を破壊することが明確にわかりますね。

フェンシングトークンの欠如

真に安全な分散ロックには「フェンシングトークン」が必要です。これは単調増加する番号で、リソースへのアクセス時にトークンを検証することで、古いロック保持者からのアクセスを拒否できます。

しかし、Redlock にはこの仕組みが組み込まれていません。

Martin Kleppmann 氏の批判

Kleppmann 氏は自身のブログ記事で以下のように指摘しています。

「Redlock は、安全性(safety)と活性(liveness)のどちらも保証できない。安全性が必要なら Zookeeper のような合意アルゴリズムを使うべきで、単なる効率性のためなら単一 Redis で十分だ」

この批判は、Redlock が「中途半端」であることを示唆しています。高い安全性が必要なら不十分で、単純な効率性だけなら過剰に複雑だということですね。

Redlock が適さないユースケース

以下の要件がある場合、Redlock は使用すべきではありません。

  • 金融取引など、絶対にデータ不整合が許されない処理
  • 長時間(数秒以上)かかる処理のロック
  • クリティカルセクションで外部 API を呼び出す処理
  • 厳密な順序保証が必要な処理

解決策

単一 Redis インスタンスでの分散ロック

多くの場合、複雑な Redlock ではなく、単一の Redis インスタンスで十分です。

シンプルなロック実装

以下は Node.js での基本的な実装例です。

typescript// Redis クライアントのインポート
import { createClient } from 'redis';

// Redis クライアントの初期化
const redis = createClient({
  url: 'redis://localhost:6379'
});
typescript// ロック取得関数の実装
async function acquireLock(
  lockKey: string,
  uniqueId: string,
  ttlSeconds: number
): Promise<boolean> {
  // SET NX EX コマンドでアトミックにロックを取得
  // NX: キーが存在しない場合のみセット
  // EX: 有効期限を秒単位で設定
  const result = await redis.set(lockKey, uniqueId, {
    NX: true,
    EX: ttlSeconds
  });

  return result === 'OK';
}

この実装では、SET コマンドの NX オプションでアトミック性を保証し、EX オプションで自動的にロックを解放します。

typescript// 安全なロック解放関数(Luaスクリプト使用)
async function releaseLock(
  lockKey: string,
  uniqueId: string
): Promise<boolean> {
  // Luaスクリプトで値を確認してから削除
  // 自分が取得したロックのみ解放できるようにする
  const script = `
    if redis.call("GET", KEYS[1]) == ARGV[1] then
      return redis.call("DEL", KEYS[1])
    else
      return 0
    end
  `;

  const result = await redis.eval(script, {
    keys: [lockKey],
    arguments: [uniqueId]
  });

  return result === 1;
}

Lua スクリプトを使うことで、値の確認と削除をアトミックに実行できます。これにより、他のクライアントのロックを誤って解放してしまう事態を防げますね。

使用例

typescript// ロックを使った排他処理の実装
async function processWithLock() {
  const lockKey = 'inventory:product:123';
  // ユニークIDは UUID などを使用
  const uniqueId = crypto.randomUUID();
  const ttl = 10; // 10秒

  try {
    // ロック取得を試行
    const acquired = await acquireLock(lockKey, uniqueId, ttl);

    if (!acquired) {
      console.log('ロックを取得できませんでした');
      return;
    }

    console.log('ロック取得成功');
    // クリティカルセクション: 排他制御が必要な処理
    await updateInventory();

  } finally {
    // 必ずロックを解放
    await releaseLock(lockKey, uniqueId);
    console.log('ロック解放完了');
  }
}

この例では、try-finally ブロックを使用してロックの解放を保証しています。

単一 Redis の利点

#利点説明
1シンプル実装と運用が簡単
2低レイテンシ複数インスタンスへの問い合わせが不要
3デバッグ容易動作の追跡と問題の特定が簡単
4コスト削減1つのインスタンスのみで済む

Redis Sentinel による高可用性

単一 Redis の弱点は可用性です。これを解決するのが Redis Sentinel による監視とフェイルオーバーです。

mermaidflowchart LR
    App["アプリケーション"] -->|接続| Sentinel1["Sentinel 1"]
    App -->|接続| Sentinel2["Sentinel 2"]
    App -->|接続| Sentinel3["Sentinel 3"]
    Sentinel1 -->|監視| Master["Redis Master"]
    Sentinel2 -->|監視| Master
    Sentinel3 -->|監視| Master
    Master -->|レプリケーション| Replica1["Redis Replica 1"]
    Master -->|レプリケーション| Replica2["Redis Replica 2"]

    style Master fill:#ff9999
    style Sentinel1 fill:#99ccff
    style Sentinel2 fill:#99ccff
    style Sentinel3 fill:#99ccff

Sentinel はマスターの障害を検知し、自動的にレプリカを新しいマスターに昇格させます。

Sentinel 構成での注意点

Sentinel を使用する場合も、レプリケーションの遅延により一時的に複数のクライアントがロックを取得する可能性があります。これは以下の理由で発生します。

  1. マスターでロックを取得
  2. レプリカへの同期前にマスターが故障
  3. レプリカがマスターに昇格(ロック情報なし)
  4. 別のクライアントがロック取得

完全な安全性が必要な場合は、後述する合意ベースのシステムを検討すべきでしょう。

Redisson ライブラリの活用

Java/JVM 環境では、Redisson というライブラリが高度なロック機能を提供します。

Redisson の特徴

java// Redisson クライアントの設定
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonExample {
    public static RedissonClient createClient() {
        Config config = new Config();
        // 単一サーバー構成
        config.useSingleServer()
            .setAddress("redis://localhost:6379")
            .setConnectionPoolSize(10)
            .setConnectionMinimumIdleSize(5);

        return Redisson.create(config);
    }
}

Redisson は設定も簡潔で、接続プールの管理も自動的に行います。

java// Redisson の分散ロック使用例
import org.redisson.api.RLock;
import java.util.concurrent.TimeUnit;

public class DistributedLockExample {
    private final RedissonClient redisson;

    public void processWithLock() throws InterruptedException {
        // ロックオブジェクトの取得
        RLock lock = redisson.getLock("inventory:product:123");

        try {
            // ロック取得を試行(最大10秒待機、30秒でTTL)
            boolean acquired = lock.tryLock(10, 30, TimeUnit.SECONDS);

            if (!acquired) {
                System.out.println("ロック取得失敗");
                return;
            }

            // クリティカルセクション
            updateInventory();

        } finally {
            // ロックの解放(自分が保持している場合のみ)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

Redisson は内部でウォッチドッグ機構を実装しており、処理が長引いた場合に自動的にロックの TTL を延長します。これにより、処理中にロックが期限切れになる問題を回避できますね。

Redisson の高度な機能

java// フェアロック:先着順でロックを取得
RLock fairLock = redisson.getFairLock("myFairLock");

// 読み書きロック:複数の読み取りを許可
RReadWriteLock rwLock = redisson.getReadWriteLock("myRWLock");
RLock readLock = rwLock.readLock();
RLock writeLock = rwLock.writeLock();

// セマフォ:同時実行数を制限
RSemaphore semaphore = redisson.getSemaphore("mySemaphore");
semaphore.tryAcquire(3, TimeUnit.SECONDS);

これらの機能により、複雑な排他制御も実装できます。

Zookeeper による合意ベースのロック

厳密な安全性が必要な場合は、Apache Zookeeper のような合意アルゴリズム(Paxos/Zab)を使ったシステムが適切です。

Zookeeper の仕組み

mermaidflowchart TB
    Client["クライアント"] -->|ロック要求| ZK["Zookeeper クラスタ"]
    ZK --> Leader["Leader"]
    Leader --> Follower1["Follower 1"]
    Leader --> Follower2["Follower 2"]

    subgraph ZookeeperCluster["Zookeeper クラスタ(Quorum)"]
        Leader
        Follower1
        Follower2
    end

    Leader -.->|過半数の合意| Follower1
    Leader -.->|過半数の合意| Follower2

Zookeeper は過半数のノードが合意した場合のみデータを確定するため、ネットワーク分断時でも一貫性を保証します。

Zookeeper のロック実装例

javascript// Node.js での Zookeeper クライアント使用例
const zookeeper = require('node-zookeeper-client');

// Zookeeper クライアントの作成
function createZKClient() {
  const client = zookeeper.createClient('localhost:2181', {
    sessionTimeout: 30000,
    retries: 3
  });

  client.connect();
  return client;
}
javascript// Zookeeper でのロック取得関数
async function acquireZKLock(client, lockPath) {
  return new Promise((resolve, reject) => {
    // エフェメラルシーケンシャルノードを作成
    // クライアント切断時に自動削除される
    client.create(
      lockPath + '/lock-',
      Buffer.from(''),
      zookeeper.CreateMode.EPHEMERAL_SEQUENTIAL,
      (error, path) => {
        if (error) {
          reject(error);
          return;
        }

        // 作成されたノードのパスを返す
        resolve(path);
      }
    );
  });
}

エフェメラルノードは、クライアントのセッションが切れると自動的に削除されるため、デッドロックを防げます。

javascript// 最小シーケンス番号の確認とロック取得
async function checkAndWaitForLock(client, lockPath, myPath) {
  const children = await getChildren(client, lockPath);

  // シーケンス番号でソート
  children.sort();

  const mySequence = myPath.split('/').pop();

  // 自分が最小のシーケンス番号なら、ロック取得成功
  if (children[0] === mySequence) {
    return true;
  }

  // そうでなければ、前のノードを監視して待機
  const myIndex = children.indexOf(mySequence);
  const watchPath = lockPath + '/' + children[myIndex - 1];

  // 前のノードの削除を待つ
  await watchNode(client, watchPath);

  return checkAndWaitForLock(client, lockPath, myPath);
}

この実装では、各クライアントが順番待ちの列に並び、自分の順番が来るまで待機します。

Zookeeper の利点と欠点

#項目内容
1利点: 強い一貫性Paxos/Zab による厳密な合意保証
2利点: フェンシング単調増加する zxid でフェンシングトークンを実現
3利点: デッドロック回避エフェメラルノードによる自動クリーンアップ
4欠点: 複雑性運用とメンテナンスが Redis より複雑
5欠点: レイテンシディスクへの書き込みが発生し Redis より遅い

etcd による分散ロック

Kubernetes のバックエンドとして有名な etcd も、優れた分散ロック機能を提供します。

etcd の特徴

typescript// etcd クライアントの初期化
import { Etcd3 } from 'etcd3';

const client = new Etcd3({
  hosts: ['localhost:2379'],
  dialTimeout: 3000
});
typescript// etcd でのロック取得と使用
async function processWithEtcdLock() {
  const lock = client.lock('inventory/product/123');

  try {
    // ロックを取得(最大10秒待機)
    await lock.acquire();
    console.log('ロック取得成功');

    // クリティカルセクション
    await updateInventory();

  } finally {
    // ロックを解放
    await lock.release();
  }
}

etcd のロックは内部的にリース機構を使い、クライアントが定期的にハートビートを送ることでロックを保持します。

etcd のリース機構

typescript// 明示的なリース管理の例
async function lockWithLease() {
  // 10秒間有効なリースを作成
  const lease = client.lease(10);

  // リースにキーを関連付け
  await client.put('lock/myresource')
    .value('locked')
    .lease(lease)
    .exec();

  // リースのキープアライブを開始(自動延長)
  lease.on('lost', () => {
    console.log('リース失効:ロックが解放されました');
  });

  // 処理実行
  await doWork();

  // リースを取り消し(ロック解放)
  await lease.revoke();
}

リースのキープアライブにより、プロセスが停止した場合でも自動的にロックが解放されます。

各ソリューションの比較

以下の表で、各分散ロックソリューションを比較します。

#ソリューション一貫性パフォーマンス運用複雑度推奨ケース
1単一 Redis非常に高効率性重視、可用性は Sentinel で補完
2Redlock推奨しない
3Redisson低〜中Java 環境での実用的な選択
4Zookeeper厳密な一貫性が必要
5etcdKubernetes 環境で統合

具体例

ケーススタディ 1:EC サイトの在庫管理

要件

  • 複数のアプリケーションサーバーが在庫を更新
  • 在庫の過剰販売は絶対に避けたい
  • 購入処理は 1 秒以内に完了することが多い

設計判断

このケースでは、データの一貫性が極めて重要です。しかし、処理時間が短く、Redis の高速性を活かせるため、以下の設計を選択します。

mermaidflowchart TB
    User["ユーザー"] -->|購入リクエスト| LB["ロードバランサー"]
    LB --> App1["アプリ1"]
    LB --> App2["アプリ2"]
    LB --> App3["アプリ3"]

    App1 -->|ロック取得| Redis["Redis Sentinel"]
    App2 -->|ロック取得| Redis
    App3 -->|ロック取得| Redis

    App1 -->|在庫更新| DB[("PostgreSQL")]
    App2 -->|在庫更新| DB
    App3 -->|在庫更新| DB

    Redis -->|フェイルオーバー| Sentinel["Sentinel クラスタ"]

実装コード

typescript// 在庫管理用のロック機能
interface PurchaseRequest {
  productId: string;
  quantity: number;
  userId: string;
}

class InventoryService {
  private redis: RedisClientType;
  private db: Database; // PostgreSQLクライアント

  constructor(redis: RedisClientType, db: Database) {
    this.redis = redis;
    this.db = db;
  }
}
typescript// 在庫減少処理(分散ロック使用)
async purchaseProduct(request: PurchaseRequest): Promise<boolean> {
  const lockKey = `inventory:lock:${request.productId}`;
  const lockId = crypto.randomUUID();
  const lockTTL = 5; // 5秒でタイムアウト

  try {
    // ロック取得を試行
    const acquired = await this.acquireLock(lockKey, lockId, lockTTL);
    if (!acquired) {
      throw new Error('ロック取得失敗:他の処理が実行中です');
    }

    // データベーストランザクション開始
    await this.db.beginTransaction();

    // 在庫確認
    const inventory = await this.db.query(
      'SELECT quantity FROM inventory WHERE product_id = $1 FOR UPDATE',
      [request.productId]
    );

    if (inventory.quantity < request.quantity) {
      throw new Error('在庫不足');
    }

    // 在庫減少
    await this.db.query(
      'UPDATE inventory SET quantity = quantity - $1 WHERE product_id = $2',
      [request.quantity, request.productId]
    );

    // 注文記録作成
    await this.db.query(
      'INSERT INTO orders (user_id, product_id, quantity) VALUES ($1, $2, $3)',
      [request.userId, request.productId, request.quantity]
    );

    // コミット
    await this.db.commit();
    return true;

  } catch (error) {
    // ロールバック
    await this.db.rollback();
    throw error;

  } finally {
    // ロック解放
    await this.releaseLock(lockKey, lockId);
  }
}

この実装では、Redis のロックと PostgreSQL のトランザクションを組み合わせています。FOR UPDATE により、データベースレベルでも行ロックを取得していますね。

typescript// ヘルパー関数:ロック取得
private async acquireLock(
  key: string,
  id: string,
  ttl: number
): Promise<boolean> {
  const result = await this.redis.set(key, id, {
    NX: true,
    EX: ttl
  });
  return result === 'OK';
}

// ヘルパー関数:ロック解放
private async releaseLock(key: string, id: string): Promise<void> {
  const script = `
    if redis.call("GET", KEYS[1]) == ARGV[1] then
      return redis.call("DEL", KEYS[1])
    else
      return 0
    end
  `;

  await this.redis.eval(script, {
    keys: [key],
    arguments: [id]
  });
}

運用ルール

  1. ロック TTL は処理時間の 2〜3 倍に設定:通常 1 秒の処理なら TTL は 3〜5 秒
  2. リトライロジックの実装:ロック取得失敗時は指数バックオフでリトライ
  3. 監視とアラート:ロック取得失敗率を監視し、閾値を超えたらアラート
  4. データベースとの二重ロックFOR UPDATE で DB レベルでも保護

ケーススタディ 2:バッチ処理の排他制御

要件

  • 定期実行されるデータ集計バッチ
  • 複数サーバーで同時に実行されてはいけない
  • 処理時間は 5〜30 分と変動が大きい

設計判断

処理時間が長いため、固定 TTL では対応が難しいです。Redisson のウォッチドッグ機構を活用します。

java// バッチ処理クラス(Redisson使用)
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.util.concurrent.TimeUnit;

public class DataAggregationBatch {
    private final RedissonClient redisson;
    private final DataAggregationService service;

    public DataAggregationBatch(
        RedissonClient redisson,
        DataAggregationService service
    ) {
        this.redisson = redisson;
        this.service = service;
    }
}
java// バッチ実行メソッド
public void execute() {
    RLock lock = redisson.getLock("batch:data-aggregation");

    try {
        // ロック取得を試行(待機なし)
        // leaseTime = -1 でウォッチドッグ有効化
        boolean acquired = lock.tryLock(0, -1, TimeUnit.SECONDS);

        if (!acquired) {
            System.out.println("他のサーバーで実行中のため、スキップします");
            return;
        }

        System.out.println("バッチ処理を開始します");

        // 長時間の処理を実行
        // ウォッチドッグが自動的にTTLを延長
        service.aggregateData();

        System.out.println("バッチ処理が完了しました");

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        System.err.println("バッチ処理が中断されました");
    } finally {
        // ロックを保持している場合のみ解放
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

Redisson のウォッチドッグは、デフォルトで 30 秒ごとにロックの TTL を延長します。これにより、処理時間が変動しても安全ですね。

障害時の挙動

java// バッチサーバーがクラッシュした場合のシミュレーション
public class BatchFailureScenario {
    public static void demonstrateFailover() {
        // サーバー1: バッチ実行中にクラッシュ
        // → Redisson のセッションが切れる
        // → ウォッチドッグが停止
        // → TTL 経過後にロックが自動解放(約30秒)

        // サーバー2: 次回のスケジュール実行時
        // → ロックが解放されているため取得成功
        // → バッチ処理を開始
    }
}

この仕組みにより、サーバークラッシュ時でも最大 30 秒程度でバッチが再実行可能になります。

ケーススタディ 3:金融システムの送金処理

要件

  • 送金処理の重複実行は絶対に許されない
  • ネットワーク障害やサーバー障害があっても一貫性を保証
  • 処理時間は 1〜3 秒

設計判断

厳密な安全性が必要なため、etcd を採用します。フェンシングトークンも活用しましょう。

typescript// 送金サービス(etcd 使用)
import { Etcd3, Lease } from 'etcd3';

interface TransferRequest {
  transferId: string;
  fromAccount: string;
  toAccount: string;
  amount: number;
}

class TransferService {
  private etcd: Etcd3;
  private db: Database;

  constructor(etcd: Etcd3, db: Database) {
    this.etcd = etcd;
    this.db = db;
  }
}
typescript// フェンシングトークンを使った送金処理
async executeTransfer(request: TransferRequest): Promise<void> {
  const lockKey = `transfer:lock:${request.transferId}`;
  const lease = this.etcd.lease(10); // 10秒リース

  try {
    // 分散ロックを取得
    const lock = this.etcd.lock(lockKey).lease(lease);
    await lock.acquire();

    // リースIDをフェンシングトークンとして取得
    const fencingToken = await lease.grant();

    console.log(`ロック取得: フェンシングトークン=${fencingToken}`);

    // リースのキープアライブ開始
    const keepAlive = lease.keepaliveOnce();

    // 送金処理を実行(フェンシングトークン付き)
    await this.processTransfer(request, fencingToken);

    console.log('送金処理完了');

  } finally {
    // リースを明示的に取り消し
    await lease.revoke();
  }
}
typescript// フェンシングトークンを検証する送金処理
private async processTransfer(
  request: TransferRequest,
  fencingToken: string
): Promise<void> {
  await this.db.beginTransaction();

  try {
    // データベースに記録されている最後のトークンを確認
    const lastToken = await this.db.query(
      'SELECT last_fencing_token FROM transfer_locks WHERE transfer_id = $1',
      [request.transferId]
    );

    // 古いトークンでのアクセスを拒否
    if (lastToken && lastToken >= fencingToken) {
      throw new Error('古いフェンシングトークン: 処理を中止');
    }

    // 送金元の残高確認
    const balance = await this.db.query(
      'SELECT balance FROM accounts WHERE account_id = $1 FOR UPDATE',
      [request.fromAccount]
    );

    if (balance < request.amount) {
      throw new Error('残高不足');
    }

    // 送金元から減額
    await this.db.query(
      'UPDATE accounts SET balance = balance - $1 WHERE account_id = $2',
      [request.amount, request.fromAccount]
    );

    // 送金先に加算
    await this.db.query(
      'UPDATE accounts SET balance = balance + $1 WHERE account_id = $2',
      [request.amount, request.toAccount]
    );

    // フェンシングトークンを更新
    await this.db.query(
      `INSERT INTO transfer_locks (transfer_id, last_fencing_token)
       VALUES ($1, $2)
       ON CONFLICT (transfer_id)
       DO UPDATE SET last_fencing_token = $2`,
      [request.transferId, fencingToken]
    );

    await this.db.commit();

  } catch (error) {
    await this.db.rollback();
    throw error;
  }
}

フェンシングトークンにより、たとえネットワーク遅延で古いリクエストが遅れて到着しても、データベースレベルで拒否できます。

障害シナリオと安全性

mermaidsequenceDiagram
    participant S1 as サーバー1
    participant etcd as etcd
    participant DB as データベース
    participant S2 as サーバー2

    S1->>etcd: ロック取得(トークン=100)
    etcd-->>S1: OK
    Note over S1: 処理開始
    Note over S1: ネットワーク遅延発生
    Note over etcd: リースタイムアウト
    etcd->>etcd: ロック自動解放
    S2->>etcd: ロック取得(トークン=101)
    etcd-->>S2: OK
    S2->>DB: 送金実行(トークン=101)
    DB-->>S2: OK(トークン更新)
    Note over S1: ネットワーク回復
    S1->>DB: 送金実行(トークン=100)
    DB-->>S1: ERROR(古いトークン)
    Note over DB: データの一貫性が保たれる

この図から、フェンシングトークンがどのように二重処理を防ぐかが明確にわかります。

運用上のベストプラクティス

監視とアラート

以下の指標を監視しましょう。

#監視項目閾値例アラート内容
1ロック取得失敗率5%以上競合が多発している可能性
2ロック保持時間予想の3倍以上処理が異常に遅延
3ロックタイムアウト発生数1時間に10回以上TTL設定の見直しが必要
4Redis/etcd の可用性99.9%未満クラスタの健全性確認

ログ記録

typescript// 詳細なログ記録の実装
class DistributedLockLogger {
  async acquireLockWithLogging(
    lockKey: string,
    uniqueId: string,
    ttl: number
  ): Promise<boolean> {
    const startTime = Date.now();

    console.log({
      event: 'lock_acquire_attempt',
      lockKey,
      uniqueId,
      ttl,
      timestamp: new Date().toISOString()
    });

    const acquired = await this.acquireLock(lockKey, uniqueId, ttl);
    const duration = Date.now() - startTime;

    console.log({
      event: acquired ? 'lock_acquired' : 'lock_failed',
      lockKey,
      uniqueId,
      duration,
      timestamp: new Date().toISOString()
    });

    return acquired;
  }
}

詳細なログにより、問題発生時の原因究明が容易になります。

リトライ戦略

typescript// 指数バックオフによるリトライ
async function acquireLockWithRetry(
  lockKey: string,
  maxRetries: number = 3
): Promise<boolean> {
  for (let i = 0; i < maxRetries; i++) {
    const uniqueId = crypto.randomUUID();
    const acquired = await acquireLock(lockKey, uniqueId, 10);

    if (acquired) {
      return true;
    }

    // 指数バックオフ: 100ms, 200ms, 400ms...
    const delay = Math.pow(2, i) * 100;
    await sleep(delay);
  }

  return false;
}

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

まとめ

Redis を使った分散ロックは、高速で実装も簡単なため魅力的です。しかし、本記事で解説したように、Redlock アルゴリズムには重大な安全性の問題があります。

重要なポイントを整理すると、以下の通りです。

  • Redlock は推奨されない:タイミング依存性により、厳密な安全性を保証できません
  • 単一 Redis で十分なケースが多い:効率性が目的なら、単一インスタンスと Sentinel の組み合わせがシンプルで効果的です
  • 厳密な安全性が必要なら合意ベースのシステムを:Zookeeper や etcd はフェンシングトークンと強い一貫性を提供します
  • Java 環境では Redisson が実用的:ウォッチドッグ機構により、処理時間が変動する場合でも安全に動作します

選択の指針としては、以下を参考にしてください。

  1. 効率性と低レイテンシが重要で、短時間の処理 → 単一 Redis + Sentinel
  2. Java/JVM 環境で実用的なバランスを求める → Redisson
  3. 絶対にデータ不整合が許されない → Zookeeper または etcd

分散ロックは、システムアーキテクチャの重要な要素です。要件を正しく理解し、適切なツールを選択することで、安全で効率的なシステムを構築できるでしょう。既存システムで Redlock を使用している場合は、この記事を参考に代替案の検討をお勧めします。

関連リンク