Redis を使った座席予約システム: SET NX PX による原子的ロック

1. 概要

映画チケット、航空券、またはレストランテーブルの予約システムでは、ユーザーは通常、支払いのために数分間「座席を予約」されます。時間が経ったのに支払いがない場合、座席は自動的に他のユーザーに返されます。この記事では、Redis の SET key value NX PX コマンドを使用して座席を原子的にロック、ロックを自動的に有効期限切れにする方法、および Lua スクリプトでロックを安全にリリースまたは延長する方法について説明します。記事を読み終わったら、Spring Boot 3.x で実行できる SeatHoldService を取得でき、Testcontainers を使用したテストが付属しています。

2. 座席ホールド問題

2 人の顧客がほぼ同時に映画 42 の座席 E7 を選択します。システムは 3 つのことを保証する必要があります:

  • 1 人だけが座席をホールドできます。
  • その人が去った場合、座席は例えば 5 分などの期間の後に自動的に解放されます。
  • 座席をホールドしている人だけが座席をリリースまたは延長できます。

データベースで SELECT ... FOR UPDATE と held_until カラムでこれを行うことができます。この方法はまだ機能しますが、有効期限切れのレコードをクリーンアップするためのジョブが必要です。また、チケット販売開始時の大量の書き込みによってデータベースが簡単にボトルネックになります。Redis はより簡潔に解決します。2 つの理由があります: 「存在しない場合は設定」という操作は原子的であり、キーは TTL を使用して自動的に有効期限切れになるためです。

3. SET NX PX がどのように機能するか

redis-cli で直接試してみます:

redis-cli SET seat-hold:{show:42}:E7 "token-alice" NX PX 300000
redis-cli SET seat-hold:{show:42}:E7 "token-bob" NX PX 300000
redis-cli PTTL seat-hold:{show:42}:E7
OK
(nil)
(integer) 299412

各部分の意味:

  • NX: キーがまだ存在しない場合のみ設定します。Bob の 2 番目のコマンドは nil を返します。Alice が既に座席をホールドしているためです。
  • PX 300000: TTL を 300,000 ミリ秒、つまり 5 分に設定します。時間が経過すると、Redis は自動的にキーを削除します。
  • token-alice の値はホールドトークンで、誰が座席をホールドしているかを識別するためのものです。後のセクションでこのトークンを使用して、ロックを安全にリリースします。

重要な点は、NX と PX が1 つのコマンド内にあるため、原子的に実行されることです。古い SETNX の後に PEXPIRE を実行する方法には危険があります。アプリケーションが 2 つのコマンド間で停止した場合、キーは永遠に存在し、座席は永久にロックされたままになります。

キー内の {show:42} 部分はハッシュタグです。Redis Cluster では、同じハッシュタグを持つすべてのキーが同じスロット上にあります。これにより、セクション 7 で示すように、同じ映画の複数の座席に対して Lua スクリプトを使用できます。

4. Spring Boot 設定

Spring Data Redis の依存関係を追加します。Lettuce がデフォルトのクライアントです:

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

接続設定:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=2s

Spring Boot は自動的に bean StringRedisTemplate を作成します。キーと値は両方とも文字列であり、この問題に適しています。

5. setIfAbsent で座席をホールド

@Service
public class SeatHoldService {

    private final StringRedisTemplate redis;

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

    static String key(long showId, String seatId) {
        return "seat-hold:{show:%d}:%s".formatted(showId, seatId);
    }

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

    public boolean isHeld(long showId, String seatId) {
        return Boolean.TRUE.equals(redis.hasKey(key(showId, seatId)));
    }
}

説明:

  • setIfAbsent(key, value, Duration) は SET key value NX PX <ms> コマンドを生成します。ttl が秒単位の偶数の場合、Spring は PX の代わりに EX を使用する可能性がありますが、意味は変わりません。
  • 座席をホールドするたびに、新しい UUID がトークンとして作成されます。トークンはクライアントに返されます。例えば、API POST /holds の応答の中にあります。クライアントは支払いまたはキャンセル時にトークンを送り返します。
  • setIfAbsent は、パイプラインまたはトランザクション内で実行されるときに null になる可能性のある Boolean を返します。したがって、コードは直接アンボックスする代わりに Boolean.TRUE.equals(ok) を使用して比較します。

コントローラで使用する例:

@PostMapping("/shows/{showId}/seats/{seatId}/hold")
public ResponseEntity<Map<String, String>> hold(@PathVariable long showId,
                                                @PathVariable String seatId) {
    return seatHoldService.hold(showId, seatId, Duration.ofMinutes(5))
            .map(token -> ResponseEntity.ok(Map.of("holdToken", token)))
            .orElseGet(() -> ResponseEntity.status(HttpStatus.CONFLICT).build());
}

後から来たユーザーは 409 Conflict を受け取ります。フロントエンドはこれを使用して「座席は既に選択されている」と報告します。

6. Lua でロックを安全にリリースおよび延長

6.1. DEL を直接使用しない理由

以下のシナリオを見てください:

  1. Alice は TTL 5 分で座席をホールドします。
  2. Alice は支払いページで 5 分以上停止し、キーは自動的に有効期限切れになります。
  3. Bob は座席 E7 を取得します。
  4. Alice が「キャンセル」をクリックすると、サーバーが DEL を呼び出します。Bob の座席が削除されます。

削除する前にトークンを確認する必要があります。GET の後に別のコマンドで DEL を実行する場合、2 つのコマンド間でキーの有効期限が切れて所有者が変わる可能性があります。Lua スクリプトは Redis で原子的に実行されるため、このギャップを排除できます。

6.2. リリースと延長

@Service
public class SeatHoldService {

    private static final RedisScript<Long> RELEASE = new DefaultRedisScript<>("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
              return redis.call('DEL', KEYS[1])
            end
            return 0
            """, Long.class);

    private static final RedisScript<Long> EXTEND = new DefaultRedisScript<>("""
            if redis.call('GET', KEYS[1]) == ARGV[1] then
              return redis.call('PEXPIRE', KEYS[1], ARGV[2])
            end
            return 0
            """, Long.class);

    // ... constructor, key(), hold(), isHeld() は上記の通り

    public boolean release(long showId, String seatId, String token) {
        Long n = redis.execute(RELEASE, List.of(key(showId, seatId)), token);
        return n != null && n == 1L;
    }

    public boolean extend(long showId, String seatId, String token, Duration ttl) {
        Long n = redis.execute(EXTEND, List.of(key(showId, seatId)),
                token, String.valueOf(ttl.toMillis()));
        return n != null && n == 1L;
    }
}

説明:

  • KEYS[1] は座席キー、ARGV[1] はクライアントが送信するトークンです。現在の値がトークンと一致する場合のみ、スクリプトは DEL または PEXPIRE を実行します。
  • スクリプトは成功時に 1 を返し、トークンが一致しないか、キーの有効期限が切れた場合に 0 を返します。
  • DefaultRedisScript は SHA1 を自動的に計算して EVALSHA を呼び出します。Redis がまだスクリプトをキャッシュしていない場合、Spring は自動的に EVAL にフォールバックします。呼び出すたびに再作成されないように、スクリプトを static final として宣言する必要があります。

extend は、ユーザーが支払いゲートウェイに移動し、2 分追加したい場合に役立ちます。extend が false を返す場合は、座席が失われたことが確実で、ユーザーに再度選択するよう通知する必要があります。

7. 複数の座席を同時にホールド

友人のグループが 3 つの座席 E7、E8、E9 を予約します。要件は、3 つすべてをホールドするか、まったくホールドしないかです。hold を 3 回呼び出してから失敗時にロールバックすることは可能ですが、冗長で、他の人が中間の状態を見る可能性があります。Lua スクリプトはこれをより簡潔に処理します:

private static final RedisScript<Long> HOLD_ALL = new DefaultRedisScript<>("""
        for _, k in ipairs(KEYS) do
          if redis.call('EXISTS', k) == 1 then return 0 end
        end
        for _, k in ipairs(KEYS) do
          redis.call('SET', k, ARGV[1], 'PX', ARGV[2])
        end
        return 1
        """, Long.class);

public Optional<String> holdAll(long showId, List<String> seatIds, Duration ttl) {
    String token = UUID.randomUUID().toString();
    List<String> keys = seatIds.stream().map(s -> key(showId, s)).toList();
    Long n = redis.execute(HOLD_ALL, keys, token, String.valueOf(ttl.toMillis()));
    return n != null && n == 1L ? Optional.of(token) : Optional.empty();
}

最初のループは、どの座席がすでにホールドされているかをチェックします。ない場合は、2 番目のループがすべてを同じトークンと同じ TTL で設定します。スクリプト全体が原子的に実行されるため、リクエストがその間に割り込むことはありません。

ハッシュタグ {show:42} のおかげで、同じ映画のすべてのキーが同じスロット上にあります。ハッシュタグがない場合、Redis Cluster はスクリプトが複数のキーにアクセスしようとするとき CROSSSLOT エラーを返します。

8. Testcontainers でテストを作成

テスト依存関係を追加:

<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>

主な動作のテスト:

@SpringBootTest
@Testcontainers
class SeatHoldServiceTest {

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

    @Autowired SeatHoldService service;
    @Autowired StringRedisTemplate template;

    @BeforeEach
    void clean() {
        template.getConnectionFactory().getConnection().serverCommands().flushAll();
    }

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() {
        Optional<String> alice = service.hold(42, "E7", Duration.ofMinutes(5));
        Optional<String> bob = service.hold(42, "E7", Duration.ofMinutes(5));

        assertThat(alice).isPresent();
        assertThat(bob).isEmpty();
    }

    @Test
    void holdExpiresAutomatically() throws InterruptedException {
        service.hold(42, "E7", Duration.ofMillis(200));
        Thread.sleep(400);

        assertThat(service.isHeld(42, "E7")).isFalse();
        assertThat(service.hold(42, "E7", Duration.ofMinutes(5))).isPresent();
    }

    @Test
    void staleTokenCannotReleaseSomeoneElsesHold() {
        String aliceToken = service.hold(42, "E7", Duration.ofMillis(100)).orElseThrow();
        await(200);
        String bobToken = service.hold(42, "E7", Duration.ofMinutes(5)).orElseThrow();

        assertThat(service.release(42, "E7", aliceToken)).isFalse();
        assertThat(service.isHeld(42, "E7")).isTrue();
        assertThat(service.release(42, "E7", bobToken)).isTrue();
    }

    @Test
    void holdAllIsAllOrNothing() {
        service.hold(42, "E8", Duration.ofMinutes(5));

        assertThat(service.holdAll(42, List.of("E7", "E8", "E9"), Duration.ofMinutes(5))).isEmpty();
        assertThat(service.isHeld(42, "E7")).isFalse();
        assertThat(service.isHeld(42, "E9")).isFalse();
    }

    private static void await(long ms) {
        try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}

@ServiceConnection(name = "redis") は、このコンテナが Redis であることを Spring Boot に伝えます。Spring Boot は自動的にホストとポートをコンテナに指し示すように構成します。@DynamicPropertySource は必要ありません。テスト staleTokenCannotReleaseSomeoneElsesHold は、セクション 6.1 のシナリオを再現しています。Alice の古いトークンは Bob のロックを削除できません。

真の競争状況は多くのスレッドでテストすることもできます:

@Test
void hundredConcurrentRequestsYieldExactlyOneWinner() throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        List<Future<Optional<String>>> results = IntStream.range(0, 100)
                .mapToObj(i -> executor.submit(() -> service.hold(42, "E7", Duration.ofMinutes(5))))
                .toList();

        long winners = 0;
        for (var f : results) if (f.get().isPresent()) winners++;
        assertThat(winners).isEqualTo(1);
    }
}

テストは Java 21 の仮想スレッドを使用して 100 個の同時リクエストを送信します。結果は常に正確に 1 つの勝者がいます。

9. よくあるエラーと制限事項

注意: Redis ロックは「ソフト」な保護層にすぎません。チケットの予約を確認する際、booking テーブルの (show_id, seat_id) にユニーク制約が必要です。Redis はフェイルオーバー時にデータを失う可能性があります。レプリカはデータを非同期に受け取るためです。制約がない場合、2 人が同じ座席で確認される可能性があります。

その他の一般的なエラー:

  • SETNX を使用してから EXPIRE を別に使用する。 アプリケーションが 2 つのコマンド間で停止した場合、ロックは期限切れになりません。1 つのコマンドで SET ... NX PX を常に使用してください。
  • トークンを確認せずに DEL でロックをリリースする。 セクション 6.1 のシナリオのように、他の人のロックを誤って削除します。
  • ロックを確認せずにチケット予約を確認する。 予約を書き込む前に、トークンがまだ一致しているかを確認するスクリプトを呼び出すか、release を使用して、結果が true の場合のみ書き込みます。支払いが成功したがロックが失われた場合は、払い戻しフローが必要です。
  • TTL が短すぎるか長すぎる。 短すぎるとユーザーはカード入力中に座席を失います。長すぎるとピーク時に座席が「虚偽に占有」されます。各ステップで extend と組み合わせて TTL を短く保つください。
  • Cluster 実行時にハッシュタグを忘れる。 複数キーのスクリプトは CROSSSLOT エラーになります。
  • アプリケーション時刻を使用してホールド期限を計算する。 Redis に TTL を管理させます。UI がカウントダウンを表示する必要がある場合は、自分で計算する代わりに PTTL を読み取ります。

この方法を使用しない場合:

  • ロック層で絶対的な保証が必要な場合。例えば、背後にデータベース制約層がない金融リソース。非同期レプリケーションを使用する単一の Redis はそのような保証を提供しません。
  • トラフィックが少なく、データベースで十分に対応できる場合。held_until カラムとユニーク制約は、追加の Redis を操作するよりも簡単な場合があります。
  • ホールド状態を長期間保存する必要があり、調整する場合。Redis キーは自動的に消滅するため、個別にイベントを記録する必要があります。

10. 結論

SET key value NX PX コマンドは、1 回の呼び出しで原子的で自動的に期限切れになる座席ロックを提供します。ランダムトークンと Lua スクリプトと組み合わせることで、他の人のロックを誤って削除することなくロックをリリースまたは延長できます。また、all-or-nothing スタイルで複数の座席をホールドできます。Redis Cluster を実行するときはハッシュタグを使用してください。最後に、チケット予約を確認する際の最後の保護層として、常にデータベースとユニーク制約を使用してください。

redisspring-bootdistributed-lockjavaconcurrency