Redlock: 複数のRedisノード間の分散ロックと議論

1. 概要

複数のサービスインスタンスが同じリソース(例えばOrderの在庫を減らすこと)を処理する場合、分散ロックが必要です。単一のRedisノード上でのロックは簡単ですが、そのノードが停止するとロックが消えてしまいます。Redlockは、Redisの作者(antirez)が提案した、複数の独立したRedisノード間でロックを取得するアルゴリズムです。

この記事では、Redlockの動作原理、Jedisを使った実装方法、Testcontainersでのテストの書き方を説明します。最後の部分では、Martin KleppmannとantIrezの議論を分析します。読み終わると、Redlockがいつ十分であるか、そしていつ別のツールを選択すべきかがわかるようになります。

2. 単一のRedisノードが不十分な理由

基本的なロックは、単一ノード上で1つのコマンドだけで済みます:

SET lock:order:42 "c3a1-uuid" NX PX 30000

NXはキーがまだ存在しない場合のみ設定することを意味します。PX 30000は30秒のTTLを設定し、クライアントが停止した場合にロックが自動的に失効するようにします。値はランダムトークンで、ロックの所有者が識別できます。

問題は可用性にあります。マスター–レプリカのレプリケーションを使用する場合、次のシナリオが発生する可能性があります:

  1. クライアントAはマスター上でロックを取得します。
  2. マスターはレプリカにキーをレプリケートする前に停止します(Redisのレプリケーションは非同期です)。
  3. レプリカが新しいマスターにプロモートされますが、そこにはロックキーがありません。
  4. クライアントBが同じロックを取得できます。

この場合、AとBは両方ともロックを保持しています。Redlockはこのシナリオに対処するために生まれました。

3. Redlockアルゴリズム

Redlockは、独立した N個のRedisノードを使用し、レプリケーションやクラスタリングはありません。通常N = 5です。クライアントは次の手順に従ってロックを取得します:

  1. 開始時刻を記録します。
  2. SET key token NX PX ttlを順番にすべてのNノードに送信します。各リクエストには短いタイムアウト(数十ms)があり、ノードが停止している場合にクライアントがハングしないようにします。
  3. 経過時間を計算します。以下の両方の条件が満たされる場合、ロック取得は成功と見なされます:
    • 少なくともN/2 + 1個のノード(クォーラム)でロックを取得する、
    • 経過時間がTTLより少ない。
  4. ロックの実際の有効時間はttl - elapsed - driftで、driftはノード間のクロックスキューを補正します。
  5. 失敗した場合、クライアントはすべてのノードにunlockコマンドを送信します。ロック取得に失敗したと思われるノードも含めます。

コアアイデアは、2つのクライアントが同じ期間内に大多数のノードを同時に占有できないということです。

4. JedisでのRedlock実装

4.1. 依存関係

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.1.0</version>
</dependency>

4.2. Redlockクラス

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.exceptions.JedisException;
import redis.clients.jedis.params.SetParams;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public class Redlock {

    private static final String UNLOCK_SCRIPT = """
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end
        """;
    private static final double CLOCK_DRIFT_FACTOR = 0.01;

    private final List<JedisPool> nodes;
    private final int quorum;

    public Redlock(List<JedisPool> nodes) {
        this.nodes = nodes;
        this.quorum = nodes.size() / 2 + 1;
    }

    public Optional<LockHandle> tryLock(String resource, long ttlMillis) {
        String token = UUID.randomUUID().toString();
        long start = System.nanoTime();

        int acquired = 0;
        for (JedisPool pool : nodes) {
            if (lockInstance(pool, resource, token, ttlMillis)) {
                acquired++;
            }
        }

        long elapsed = (System.nanoTime() - start) / 1_000_000;
        long drift = (long) (ttlMillis * CLOCK_DRIFT_FACTOR) + 2;
        long validity = ttlMillis - elapsed - drift;

        if (acquired >= quorum && validity > 0) {
            return Optional.of(new LockHandle(resource, token, validity));
        }
        unlockAll(resource, token);
        return Optional.empty();
    }

    public void unlock(LockHandle handle) {
        unlockAll(handle.resource(), handle.token());
    }

    private boolean lockInstance(JedisPool pool, String resource, String token, long ttl) {
        try (Jedis jedis = pool.getResource()) {
            return "OK".equals(jedis.set(resource, token, SetParams.setParams().nx().px(ttl)));
        } catch (JedisException e) {
            return false; // ノードが停止またはタイムアウト: 取得できなかったと見なす
        }
    }

    private void unlockAll(String resource, String token) {
        for (JedisPool pool : nodes) {
            try (Jedis jedis = pool.getResource()) {
                jedis.eval(UNLOCK_SCRIPT, List.of(resource), List.of(token));
            } catch (JedisException ignored) {
                // ノードが応答しない場合、キーはTTLで自動失効する
            }
        }
    }

    public record LockHandle(String resource, String token, long validityMillis) {}
}

主要なポイント:

  • quorum = N/2 + 1。3ノードの場合はクォーラムは2です。5ノードの場合はクォーラムは3です。
  • lockInstanceは例外をキャッチしてfalseを返します。1つのノードが停止しても、投票数が減るだけで、操作全体が破損することはありません。
  • validityはロックを保持していると見なすことが実際に許可される時間です。この時間は常にTTLより小さいです。
  • Unlockはatomicに「トークンを比較してから削除する」操作を実行するためにLuaスクリプトを使用しています。GETしてからDELを2つの別のコマンドで実行した場合、別のクライアントが取得したばかりのロックを誤って削除する可能性があります。
  • 失敗した場合、unlockAllはすべてのノードに送信します。SETコマンドがサーバー側では成功しているが、クライアント側でレスポンスがタイムアウトしている可能性があるためです。

4.3. 短いタイムアウトでノードを初期化する

import redis.clients.jedis.JedisPoolConfig;

List<JedisPool> pools = List.of(
        new JedisPool(new JedisPoolConfig(), "redis-1", 6379, 50),
        new JedisPool(new JedisPoolConfig(), "redis-2", 6379, 50),
        new JedisPool(new JedisPoolConfig(), "redis-3", 6379, 50));
Redlock redlock = new Redlock(pools);

最後のパラメータは50msのタイムアウトです。タイムアウトをデフォルトの2秒のままにすると、ハングしているノードがロックのTTLのほぼすべてを「消費」してしまう可能性があります。

4.4. InventoryServiceでの使用

public class InventoryService {

    private final Redlock redlock;
    private final InventoryRepository repository;

    public InventoryService(Redlock redlock, InventoryRepository repository) {
        this.redlock = redlock;
        this.repository = repository;
    }

    public boolean reserve(String sku, int quantity) {
        var handle = redlock.tryLock("lock:sku:" + sku, 10_000);
        if (handle.isEmpty()) {
            return false; // 呼び出し側はbackoffで再試行します
        }
        try {
            int stock = repository.findStock(sku);
            if (stock < quantity) return false;
            repository.updateStock(sku, stock - quantity);
            return true;
        } finally {
            redlock.unlock(handle.get());
        }
    }
}

Unlockは常にfinally内にあります。ロック内のコードはvalidityMillis内に完了する必要があります。操作が長くなる可能性がある場合は、ロック更新メカニズム(ウォッチドッグ)が必要であるか、タスクを分割する必要があります。

5. Testcontainersでのテスト

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.20.1</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <version>3.26.3</version>
    <scope>test</scope>
</dependency>
@Testcontainers
class RedlockTest {

    @Container static GenericContainer<?> r1 = redis();
    @Container static GenericContainer<?> r2 = redis();
    @Container static GenericContainer<?> r3 = redis();

    static GenericContainer<?> redis() {
        return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
    }

    static Redlock newClient() {
        return new Redlock(Stream.of(r1, r2, r3)
                .map(c -> new JedisPool(new JedisPoolConfig(),
                        c.getHost(), c.getMappedPort(6379), 200))
                .toList());
    }

    @Test
    void secondClientCannotAcquireWhileFirstHoldsLock() {
        Redlock clientA = newClient();
        Redlock clientB = newClient();

        var lockA = clientA.tryLock("lock:order:42", 5_000);
        var lockB = clientB.tryLock("lock:order:42", 5_000);

        assertThat(lockA).isPresent();
        assertThat(lockA.get().validityMillis()).isLessThan(5_000);
        assertThat(lockB).isEmpty();

        clientA.unlock(lockA.get());
        assertThat(clientB.tryLock("lock:order:42", 5_000)).isPresent();
    }

    @Test
    void stillAcquiresWhenOneNodeIsDown() {
        Redlock client = newClient();
        r3.stop();

        assertThat(client.tryLock("lock:order:99", 5_000)).isPresent();
    }
}

最初のテストは相互排除性をチェックします: Aがロックを保持している間、Bは失敗し、Aがアンロックした直後に取得できます。2番目のテストは、3ノード中1つが失敗した場合でもRedlockが機能することを示しています。残りの2ノードがクォーラムに十分だからです。このテストはコンテナを停止するので、最後に配置するか、別のクラスに分離してください。

6. Redissonの場合はどうでしょうか?

実際には、Redlockを自分で書く人はほとんどいません。Redissonは以前、独立したクライアントから複数のRLockを受け取るRedissonRedLockを提供していました。

注意: 最近のRedissonバージョンでは、RedissonRedLockとRedissonMultiLockが非推奨とマークされています。Redissonは通常のRLock(自動更新ウォッチドッグを備えており、レプリカに同期できる)、またはフェンシングトークンが必要な場合はRFencedLockを推奨しています。APIを選択する前に、使用しているバージョンを確認してください。

最も人気のあるライブラリさえもRedlockを放棄したという事実は、議論の部分へと導きます。

7. 議論: KleppmannとantiRez

7.1. Martin Kleppmannの議論

Designing Data-Intensive Applicationsの著者であるKleppmannは、Redlockが正確性が必要なユースケースには安全ではないことを指摘しています。主な理由は2つあります。

プロセスの一時停止。 次のシナリオを見てください:

t=0     クライアントAがロックを取得 (TTL 10s)
t=1     Aは15秒間のGC stop-the-worldに遭遇
t=10    すべてのノードでロックが失効
t=11    クライアントBがロックを取得し、DBにデータを書き込む
t=16    Aが再開し、まだロックを保持していると思い、Bのデータを上書き

TTLベースのロックアルゴリズムでは、このシナリオを防ぐことはできません。書き込む直前にvalidityをチェックしても、チェック直後に一時停止が発生する可能性があるため、役に立ちません。一時停止の原因はGCだけではありません。ページフォルト、VM移行、またはネットワーク遅延も同じ効果を引き起こします。

クロックの依存性。 Redlockはノードのクロックがほぼ同じ速度で動作することを想定しています。1つのノードのクロックがジャンプする場合(例えば、NTPの大幅な調整や管理者による手動時刻変更)、そのノード上のキーが早期に失効する可能性があります。5ノード固有のシナリオ:

Aはノード1、2、3でロックを取得
ノード3のクロックがジャンプ前に進む、ノード3のキーが失効
Bはノード3、4、5でロックを取得
=> AとBは両方ともクォーラムを保持

Kleppmannの結論: 安全にするには、ロックに伴う単調増加する数値であるフェンシングトークンが必要です。Redlockはこれを提供しません。なぜなら、そのトークンはランダムな文字列だからです。

7.2. フェンシングトークンの形式

アイデアは、ストレージが古いトークンを持つ書き込みを自動的に拒否することです:

public class FencedInventoryRepository {

    private final JdbcTemplate jdbc;

    public FencedInventoryRepository(JdbcTemplate jdbc) {
        this.jdbc = jdbc;
    }

    public boolean updateStock(String sku, int newStock, long fencingToken) {
        int rows = jdbc.update("""
                UPDATE inventory SET stock = ?, last_token = ?
                WHERE sku = ? AND last_token < ?
                """, newStock, fencingToken, sku, fencingToken);
        return rows == 1;
    }
}

上記のGCシナリオに適用します: Bはトークン34で書き込み、Aが目覚めてトークン33で書き込みます。AのUPDATEは0行に影響し、Bのデータは保持されます。重要な点は、ロックではなく、ストレージが正確性を保証することです。トークンはZooKeeper(zxid)やetcd(revision)などのコンセンサスシステムから生成される必要があります。

7.3. antlrezの反応

antlrezは次の主要なポイントで応答しました:

  • ストレージがすでにトークンの比較と設定をサポートしている場合、ランダムトークン自体をチェックするために使用でき、その場合ロックはほぼ不要になります。
  • Redlockはロック取得後に時間確認ステップがあるため、ロック取得中のネットワーク遅延が検出されます。その後の一時停止は、確かにTTLベースのロックすべてが共通して抱える問題です。
  • クロック仮定は、適切に運用する場合は妥当です: NTPが時刻をいきなりジャンプさせない(slew モードを使用)し、手動で時刻を変更しないことです。

今のところ、どちらの側も「完全に勝利」していません。しかし、コミュニティの大多数はKleppmannの分類に同意しており、これは次のセクションで説明されています。

8. いつ使用し、いつ使用しないか

自分自身に問いかけてください: 2つのクライアントが同時にロックを保持している場合の結果は何ですか?

  • 効率ロック(重複作業を避ける): 重複したレポートメール送信、重複したcron実行、キャッシュの再計算。たまに重複してもかまいません。この場合、SET NX PXを持つ単一のRedisノードで十分です。Redlockはインフラに3~5倍のコストがかかりますが、ほとんど改善はありません。
  • 正確性ロック(エラーはデータを破損): 送金、在庫減少、請求書番号発行。Redlockは不十分です。etcdまたはZooKeeperとフェンシングトークンを使用するか、optimistic locking(JPAの@Version)、SELECT ... FOR UPDATE、またはunique constraintでロジックをデータベースレイヤーに下げるべきです。

注意: Redlockを使用するときの一般的なエラー:

  • Redlockの「ノード」として Redis Cluster またはマスター–レプリカを使用する。ノードは完全に独立している必要があります。
  • 接続タイムアウトをデフォルトのままにしておく(長すぎる)。
  • Luaスクリプトの代わりにDELでアンロックする。トークン検査を実行します。
  • Persistenceを有効にしてもfsync=alwaysを設定しない、またはノード再起動を遅延させない。ノードの再起動とキーの喪失により、2つのクライアントが同じクォーラムを達成する可能性があります。antlrezはノード再起動後、最大TTLと同じ期間待機してから再参加することを推奨しています。

9. 結論

Redlockは、N個の独立したRedisノード間で大多数のロックを取得し、ネットワーク遅延とクロックスキューを補正するための有効時間計算を伴っています。Jedisでの実装、Luaスクリプトでの安全なアンロック、Testcontainersでのテスト方法を説明しました。

Redlockはまだ TTLとクロックに依存しているため、プロセスの一時停止やクロックのジャンプを防ぐことはできません。したがって、重複作業を避けるだけを目的としたロックの場合、単一のRedisノードで十分です。データの正確性を保護するロックの場合、コンセンサスシステムからのフェンシングトークンが必要であるか、データベースにロジックを委ねるべきです。

redisredlock分散ロックjava並行処理