Spring BootのRedisで分散ロックを実装する

1. 概要

アプリケーションが複数のインスタンスで実行される場合、synchronizedやReentrantLockでは不十分です。これらのロックは1つのJVM内でのみ機能します。この記事では、Redisを使って分散ロックを構築する2つの方法(Spring Data Redisで自分で実装する方法とRedissonライブラリを使う方法)について説明します。この記事を読み終わると、ロックを安全に取得・解放する方法、ロックのテストを書く方法、そしてRedisロックが十分に安全でない場合について理解できるようになります。

2. 分散ロックが必要な理由

OrderServiceが3つのpodで実行されている場合を想定してください。2人の顧客が同時に在庫の最後の商品を注文します。2つのリクエストが異なるpodに到達します。両者がstock = 1を読み取り、同時に在庫を減らしてしまい、結果として在庫超過売却になります。

別の例として、@Scheduledジョブが午前2時にレポートを生成します。3つのインスタンスがあると、ジョブは3回実行されて、顧客は同じメールを3回受け取ります。

どちらの場合でも、すべてのインスタンスが参照できるロックが必要です。Redisがこの役割に適している理由は3つあります:

  • Redisはコマンドを単一のスレッドで順序に処理するため、各コマンドはアトミックです。
  • キーにTTLを設定できるため、ロックを保持しているプロセスがクラッシュした場合、ロックは自動的に期限切れになります。
  • 低レイテンシーで、ほとんどのシステムにはすでにRedisが導入されています。

3. 原理:SET NX PX コマンド

メカニズム全体は単一のコマンドに基づいています:

redis-cli SET lock:product:42 "c1f9-token" NX PX 10000

各部分の意味は以下の通りです:

  • NX:キーがまだ存在しない場合のみ書き込みます。これが「ロック取得」のステップです。
  • PX 10000:キーは10秒後に自動的に期限切れになります。これにより、プロセスがクラッシュした場合、ロックは永遠にハングすることはありません。
  • "c1f9-token":ロック取得のたびに生成されるランダムな一意の値。この値はロックの所有者を識別するために使用されます。

このコマンドを2回連続で実行すると、以下のような結果が得られます:

OK
(nil)

1回目はOKが返され、ロック取得に成功したことを意味します。2回目は(nil)が返されます。キーがすでに存在しているためです。

注意: SETNXを使ってから別のコマンドでEXPIREを呼び出さないでください。2つのコマンド間でプロセスがクラッシュすると、キーは期限切れにならず、デッドロックが発生します。

4. Spring Data Redisで自分で実装する

4.1. 依存関係

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

application.propertiesで接続を設定します:

spring.data.redis.host=localhost
spring.data.redis.port=6379

4.2. ロック取得と解放

@Component
public class RedisLock {

    private static final DefaultRedisScript<Long> RELEASE_SCRIPT = new DefaultRedisScript<>("""
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
            """, Long.class);

    private final StringRedisTemplate redis;

    public RedisLock(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public Optional<String> tryLock(String key, Duration ttl) {
        String token = UUID.randomUUID().toString();
        Boolean acquired = redis.opsForValue().setIfAbsent(key, token, ttl);
        return Boolean.TRUE.equals(acquired) ? Optional.of(token) : Optional.empty();
    }

    public boolean unlock(String key, String token) {
        Long deleted = redis.execute(RELEASE_SCRIPT, List.of(key), token);
        return deleted != null && deleted == 1L;
    }
}

説明:

  • setIfAbsent(key, token, ttl)は正確にSET key token NX PX ttlコマンドを生成します。結果がtrueの場合、ロック取得に成功しました。
  • tryLockは成功時にtokenを返します。このtokenは後でロックを解放するために保持する必要があります。
  • unlockはLuaスクリプトを実行します。スクリプトはキーの値があなたのtokenと一致することを確認し、一致する場合のみキーを削除します。

なぜ直接DELを呼び出さないのでしょうか?次のシナリオを考えてみてください。インスタンスAが10秒のTTLでロックを取得しますが、処理に12秒かかります。10秒目で、ロックの期限が切れ、インスタンスBがロックを取得します。12秒目で、AはDELを呼び出し、Bのロックを削除してしまいます。この時点でインスタンスCが入ることができ、BとCが並行して実行されます。

GETとDELに分けても、2つのコマンド間には依然として競合状態が存在します。Luaスクリプトはredisによってアトミックに実行されるため、その競合状態は排除されます。

4.3. Serviceでの使用

public record Order(long productId, int quantity) {}

@Service
public class OrderService {

    private final RedisLock redisLock;
    private final InventoryRepository inventory;

    public OrderService(RedisLock redisLock, InventoryRepository inventory) {
        this.redisLock = redisLock;
        this.inventory = inventory;
    }

    public void placeOrder(Order order) {
        String key = "lock:product:" + order.productId();
        String token = redisLock.tryLock(key, Duration.ofSeconds(10))
                .orElseThrow(() -> new IllegalStateException("Sản phẩm đang được xử lý, vui lòng thử lại"));
        try {
            inventory.decrease(order.productId(), order.quantity());
        } finally {
            redisLock.unlock(key, token);
        }
    }
}

注意すべき3つのポイントがあります:

  • ロックキーはリソースごとに詳細化する必要があります。ここではlock:product:{id}です。すべての製品に対して共通のロックを使用すると、すべての注文が順序付けられてしまいます。
  • unlockは常にfinallyブロック内に配置して、例外が発生してもロックが解放されるようにします。
  • この例はロック取得に失敗するとfail-fastします。待機したい場合は、短いbackoffで数回再試行することができます。無限のwhile(true)ループを使用しないでください。

InventoryRepositoryはあなたのプロジェクト内の任意のrepositoryです。例えば、Spring Data JPAのrepositoryです。

5. Redissonを使用する

自分で実装すると、メカニズムをよく理解できますが、いくつかの機能が不足しています。処理時間が長い場合のロック更新、再入可能性、pollingの代わりにpub/subを使ったロック待機などです。Redissonはこれらをすべて提供しています。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.45.1</version>
</dependency>

プロジェクトが使用しているSpring Bootと互換性のある最新版を確認してください。このstarterはspring.data.redis.*の設定を読み込み、RedissonClientbeanを自動生成します。

@Service
public class DailyReportJob {

    private static final Logger log = LoggerFactory.getLogger(DailyReportJob.class);
    private final RedissonClient redisson;

    public DailyReportJob(RedissonClient redisson) {
        this.redisson = redisson;
    }

    @Scheduled(cron = "0 0 2 * * *")
    public void generate() throws InterruptedException {
        RLock lock = redisson.getLock("lock:daily-report");
        if (!lock.tryLock(0, TimeUnit.SECONDS)) {
            log.info("別のインスタンスがレポートを実行中です。スキップします");
            return;
        }
        try {
            log.info("日レポート生成を開始");
            // データクエリ、ファイル出力、メール送信など...
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

ジョブが3つのインスタンスで実行される場合、ログは以下のようになります:

[pod-1] 日レポート生成を開始
[pod-2] 別のインスタンスがレポートを実行中です。スキップします
[pod-3] 別のインスタンスがレポートを実行中です。スキップします

重要なポイント:

  • tryLock(0, TimeUnit.SECONDS)は待機しません。ロックが保持されている場合、関数は即座にfalseを返します。
  • leaseTimeを渡さない場合、Redissonはwatchdogを有効にします。ロックはデフォルト30秒のTTL(lockWatchdogTimeout)を持ち、スレッドがロックを保持している限り、約10秒ごとに更新されます。JVMがクラッシュすると、更新が停止し、ロックは自動的に期限切れになります。
  • 特定のleaseTimeを指定してtryLock(wait, lease, unit)を呼び出すと、watchdogは無効になります。ロックはlease後に正確に期限切れになります。作業が完了しているかどうかに関わらず。
  • RLockはスレッドに結合され、再入可能です。別のスレッドからunlock()を呼び出すと、IllegalMonitorStateExceptionが発生します。これはCompletableFutureまたはリアクティブコードを使用する場合に特に注意が必要です。

注意: RLockはスレッドに結合されているため、virtual threadやスレッドを途中で切り替えるフレームワークを使用する場合は注意が必要です。ロックは同じスレッド上で取得および解放される必要があります。

6. Testcontainersでテストを書く

Redisロックは実際のRedis上でテストする必要があり、mockを使用すべきではありません。以下のテスト依存関係を追加してください(バージョンはSpring Bootによって管理されます):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@DataRedisTest
@Import(RedisLock.class)
@Testcontainers
class RedisLockTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired
    RedisLock redisLock;

    @Autowired
    StringRedisTemplate template;

    @Test
    void givenConcurrentRequests_whenTryLock_thenOnlyOneSucceeds() throws Exception {
        List<Callable<Optional<String>>> tasks = IntStream.range(0, 20)
                .<Callable<Optional<String>>>mapToObj(i -> () -> redisLock.tryLock("lock:concurrent", Duration.ofSeconds(5)))
                .toList();

        try (ExecutorService executor = Executors.newFixedThreadPool(20)) {
            long acquired = executor.invokeAll(tasks).stream()
                    .map(Future::resultNow)
                    .filter(Optional::isPresent)
                    .count();

            assertThat(acquired).isEqualTo(1);
        }
    }

    @Test
    void givenWrongToken_whenUnlock_thenLockIsKept() {
        String token = redisLock.tryLock("lock:owner", Duration.ofSeconds(5)).orElseThrow();

        assertThat(redisLock.unlock("lock:owner", "not-my-token")).isFalse();
        assertThat(template.hasKey("lock:owner")).isTrue();

        assertThat(redisLock.unlock("lock:owner", token)).isTrue();
        assertThat(template.hasKey("lock:owner")).isFalse();
    }
}

説明:

  • @DataRedisTestはRedis設定部分のみをロードするため、テストは高速です。@Import(RedisLock.class)は、テスト対象のbeanをcontextに追加します。
  • @ServiceConnection(name = "redis")は、StringRedisTemplateを起動したばかりのコンテナに自動的にポイントします。ホストとポートを手動で宣言する必要はありません。
  • 最初のテストは20個のスレッドが1つのロックを競争させ、正確に1つのスレッドだけが成功することを確認しています。
  • 2番目のテストはLuaスクリプトが正しく機能することを確認します。トークンが間違っている場合はロックを削除できず、トークンが正しい場合は削除できます。

7. 頻出するエラーと制限

7.1. TTLが処理時間より短い

これが最も一般的なエラーです。処理がまだ実行中にロックの期限が切れ、別のインスタンスが介入します。原因は遅いコードだけではありません。GCの一時停止、ネットワークの遅延、またはコネクションプールの待機もTTLを超過させる可能性があります。

2つの対処方法があります。1つ目は、Redissonのwatchdogを使用することです。2つ目は、TTLに余裕を持たせることです。例えば、p99処理時間の数倍にします。

7.2. ロックが絶対的な正確性を保証しない

Redisレプリケーションは非同期です。masterがロックを受け入れるが、replicaに複製する前にクラッシュするとします。replicaが新しいmasterに昇格され、そこにはロックキーが存在しません。この時点で、別のクライアントが同じロックを取得できます。

Redlockアルゴリズム(N個の独立したノードの多数派からロックを取得する)はこのリスクを低減します。しかし、Redlockはクロックと遅延の仮定に依存しているため、依然として議論の余地があります。Redissonでは、RedissonRedLockは現在非推奨とマークされています。

データエラーが深刻な結果を引き起こす場合、例えば2回の扣除は、ロックだけに頼らないでください。フェンシングトークンと組み合わせてください。ロックを取得するたびに、増分する番号を取得します(例えばINCR lock:product:42:fence)。ストレージレイヤーは古いトークンを持つ書き込みを拒否します:

UPDATE inventory
SET stock = stock - :qty, last_fence = :fence
WHERE product_id = :id AND last_fence < :fence;

更新ステートメントが0行を更新した場合、より新しいトークンを持つプロセスが既に書き込みました。古いリクエストは拒否されます。

7.3. その他のエラー

  • すべてのリソースに対して共通のキーを使用すると、スループットが大幅に低下します。
  • finallyを忘れると、ロックはTTLの期限切れ時にのみ解放されます。
  • ロックを保持していないときにRedissonのunlock()を呼び出す(期限切れのため)。呼び出す前にisHeldByCurrentThread()をチェックしてください。
  • backoffなしで継続的にロック取得を再試行すると、Redisがリクエストに埋もれます。

7.4. 使用しない方が良い場合

  • アプリケーションが単一のインスタンスのみの場合:ReentrantLockで十分で、はるかに単純です。
  • データが関係データベースにある場合:楽観的ロック(@Version)、SELECT ... FOR UPDATE、またはunique制約の方が通常はより安全です。それらはデータと同じトランザクション内にあります。
  • 完全な正確性が必要な場合:ZooKeeperやetcdなどのコンセンサスシステムとフェンシングトークンを組み合わせた使用を検討してください。
  • 各メッセージが1回だけ処理される必要があるだけの場合:べき等キーまたはメッセージブローカーのコンシューマーグループの方が通常はロックより適切です。

8. まとめ

RedisとDistributed Lockは3つの原則に基づいています。atomicなSET NX PXコマンドでロックを取得し、各ロック取得に一意のトークンを付与し、トークンチェック付きのLuaスクリプトでロックを解放します。StringRedisTemplateで数十行の自分の実装も可能ですし、watchdog、再入可能性、効率的な待機メカニズムをさらに備えたRedissonを使用することもできます。

Redisロックは多くの場合、重複処理を回避するのに役立ちますが、絶対的な保証ではないことに注意してください。重要な操作の場合、ロックをフェンシングトークンまたはデータベースレイヤーの制約と組み合わせる必要があります。さらに、常にTestcontainersで実際のRedis上のロックをテストしてください。

redisdistributed-lockspring-bootredissonconcurrency