フラッシュセール設計:1秒で10,000商品を販売してもオーバーセルなし

1. 概要

フラッシュセールは古典的なシステム設計の問題です。数十万人が同じ瞬間に「購入」ボタンを押します。在庫はわずか数千商品です。システムは突然のトラフィック増加に耐える必要があり、絶対に超過販売してはいけません。

このセクションでは具体的なケーススタディを通じて、1秒で10,000商品を販売するシステムについて説明します。読み終わると、要件の定義方法、データモデルとAPIの設計、Redis Luaスクリプトを使った原子的在庫確保、Kafkaを通じた非同期注文書き込み、および支払い期限超過時の在庫返品方法が理解できます。

2. 要件

2.1. 機能要件

  • 各セールイベントは1つ以上の商品を含み、各商品は固定数量と販売価格を持ちます。
  • ログイン済みのユーザーが「購入」をクリックして結果を受け取ります:予約成功、完売、または既に購入済み。
  • 各ユーザーは1セールイベントごとに1商品のみ購入できます。
  • 予約注文は10分以内に支払う必要があります。期限を超えると、他のユーザーが購入できるように在庫に戻します。

2.2. 規模

  • 在庫:10,000商品。
  • 参加ユーザー:約50万人で、ほとんどが最初の1~2秒内にクリック。
  • 予想ピークトラフィック:購入エンドポイントへ毎秒200,000~500,000リクエスト。

そのうち成功するのは10,000件だけです。リクエストの98%以上が「完売」となります。これが重要な点です:システムは安く、素早く拒否する必要があります。

2.3. 制約

  • 在庫切れ状態での販売禁止:有効な注文数は決して10,000を超えません。
  • 過度な在庫保留禁止:予約されたが支払われない商品は返品する必要があります。
  • 通常のeコマースシステムがフラッシュセール中に影響を受けないようにします。

3. 全体アーキテクチャ

単一のリレーショナルデータベースは、同じ行に対して毎秒数千のUPDATEを処理できます。その数は数十万には遠く及びません。したがって、フロー分割を複数層に分け、各層が次の層の負荷を軽減します:

Client ──► CDN (静的セールページ、カウントダウン)
  │
  ▼
API Gateway (ユーザー/IP毎のレート制限、ボット対策)
  │
  ▼
Flash Sale Service ──► Redis (Lua:チェック + 在庫原子的減)
  │                         │ 成功(約10,000回)
  │                         ▼
  │                    Kafka topic "flash-sale-orders"
  │                         │
  ▼                         ▼
202 Accepted          Order Consumer ──► PostgreSQL (最終的な真実のソース)

主な考え方:

  1. Redisはゲートキーパーです。在庫減らす操作はメモリ内で実行され、シングルスレッドなので原子的かつ高速です。
  2. Kafkaはバッファーです。成功するメッセージ約10,000件だけがキューに入ります。コンシューマーはデータベースの速度でDBに書き込みます。
  3. PostgreSQLはセーフティネットです。DB内の制約により、Redisに問題があっても在庫切れ状態での販売がないことを保証します。

4. データモデル

PostgreSQLでは2つのテーブルが必要です:

CREATE TABLE flash_sale_item (
    id         BIGINT PRIMARY KEY,
    sale_id    BIGINT  NOT NULL,
    product_id BIGINT  NOT NULL,
    quantity   INT     NOT NULL,
    sold       INT     NOT NULL DEFAULT 0,
    CONSTRAINT chk_not_oversold CHECK (sold <= quantity)
);

CREATE TABLE flash_sale_order (
    id         UUID PRIMARY KEY,
    item_id    BIGINT      NOT NULL REFERENCES flash_sale_item(id),
    user_id    BIGINT      NOT NULL,
    status     VARCHAR(16) NOT NULL,   -- 保留中、支払済み、期限切れ
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    CONSTRAINT uq_item_user UNIQUE (item_id, user_id)
);

2つの重要な制約:

  • chk_not_oversoldはDB層でsoldがquantityを超えるのを防ぎます。
  • uq_item_userは「ユーザー1人1商品」ルールを適用しながら、Kafkaが同じメッセージを再送信するときにコンシューマーをべき等にします。

Redisでは、各セール商品は2つのキーを使用します:

{item:1001}:stock    -> "10000"          (文字列、残在庫数)
{item:1001}:buyers   -> {userId, ...}    (セット、予約者)

部分文字列{item:1001}はハッシュタグです。おかげで、2つのキーは常にRedis Cluster内の同じスロットに存在します。Luaスクリプトはすべてのキーが同じスロット内にある場合にのみ実行できます。

販売開始前に、ジョブが事前に在庫をRedisに読み込みます:

redis-cli SET "{item:1001}:stock" 10000

5. API

POST /api/flash-sales/items/{itemId}/purchase
Authorization: Bearer <token>

202 Accepted   {"orderId": "c0a8...", "status": "PENDING"}
409 Conflict   {"error": "ALREADY_PURCHASED"}
410 Gone       {"error": "SOLD_OUT"}

GET /api/flash-sales/orders/{orderId}
200 OK         {"orderId": "c0a8...", "status": "PENDING"}

購入エンドポイントは201ではなく202を返します。これは注文がまだDBに書き込まれていないためです。クライアントはorderIdを保持し、GET エンドポイントをポーリングして、注文が支払い可能になるまで待機します。userIdはトークンから取得され、本体からは取得されません。これにより、ユーザーが他の人の代わりに購入するのを防ぎます。

6. Redis Luaを使った原子的在庫確保

依存関係を追加します:

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

最も一般的な間違いは、Javaからの2つの個別のコマンドとしてGETを書き、次にDECRです。2つのコマンド間に、他の数百のスレッドが同じ値1を読み取り、同じくデクリメントしました。結果は負の在庫です。Luaスクリプトはこれを解決します。Redisはスクリプト全体を1つのブロックとして実行し、その間に他のコマンドを挿入しません。

ファイルsrc/main/resources/lua/reserve.lua:

-- KEYS[1] = 在庫、KEYS[2] = 購入者、ARGV[1] = userId
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
  return -2
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
  return -1
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return stock - 1

スクリプトはユーザーが既に購入している場合は-2、在庫がない場合は-1、それ以外は減少後の残在庫数を返します。チェックと在庫減らすは同じステップで発生するため、競合状態がありません。

在庫返品スクリプトsrc/main/resources/lua/release.lua:

-- ユーザーが実際に予約を保持している場合のみ、在庫に戻す
if redis.call('SREM', KEYS[2], ARGV[1]) == 1 then
  return redis.call('INCR', KEYS[1])
end
return -1

条件SREM == 1は、同じユーザーに対する二重返品を防ぎます。これは、期限切れジョブと注文キャンセルスレッドが並行して実行されるときに発生しやすいエラーです。

サービスがスクリプトを呼び出します:

public enum ReserveResult { SUCCESS, SOLD_OUT, ALREADY_PURCHASED }

@Service
public class StockReservationService {

    private static final RedisScript<Long> RESERVE =
            RedisScript.of(new ClassPathResource("lua/reserve.lua"), Long.class);
    private static final RedisScript<Long> RELEASE =
            RedisScript.of(new ClassPathResource("lua/release.lua"), Long.class);

    private final StringRedisTemplate redis;

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

    public ReserveResult reserve(long itemId, long userId) {
        Long result = redis.execute(RESERVE, keys(itemId), String.valueOf(userId));
        if (result == null) throw new IllegalStateException("Redisはnullを返しました");
        if (result == -2) return ReserveResult.ALREADY_PURCHASED;
        if (result == -1) return ReserveResult.SOLD_OUT;
        return ReserveResult.SUCCESS;
    }

    public void release(long itemId, long userId) {
        redis.execute(RELEASE, keys(itemId), String.valueOf(userId));
    }

    private static List<String> keys(long itemId) {
        String tag = "{item:" + itemId + "}";
        return List.of(tag + ":stock", tag + ":buyers");
    }
}

Spring Data RedisはEVALSHAを使用してスクリプトを送信し、Redisがまだスクリプトをキャッシュしていない場合は自動的にEVALにフォールバックします。SHAを自分で管理する必要はありません。

7. Kafkaを通じた非同期注文書き込み

依存関係を追加します:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

7.1. コントローラー:予約してからイベント発行

public record OrderPlaced(UUID orderId, long itemId, long userId) {}

@RestController
@RequestMapping("/api/flash-sales")
public class PurchaseController {

    private final StockReservationService reservation;
    private final KafkaTemplate<String, OrderPlaced> kafka;

    public PurchaseController(StockReservationService reservation,
                              KafkaTemplate<String, OrderPlaced> kafka) {
        this.reservation = reservation;
        this.kafka = kafka;
    }

    @PostMapping("/items/{itemId}/purchase")
    public ResponseEntity<Map<String, Object>> purchase(@PathVariable long itemId,
                                                        @AuthenticationPrincipal Jwt jwt) {
        long userId = Long.parseLong(jwt.getSubject());
        return switch (reservation.reserve(itemId, userId)) {
            case SOLD_OUT -> ResponseEntity.status(410).body(Map.of("error", "SOLD_OUT"));
            case ALREADY_PURCHASED -> ResponseEntity.status(409).body(Map.of("error", "ALREADY_PURCHASED"));
            case SUCCESS -> {
                var event = new OrderPlaced(UUID.randomUUID(), itemId, userId);
                kafka.send("flash-sale-orders", String.valueOf(itemId), event)
                     .whenComplete((ok, ex) -> {
                         if (ex != null) reservation.release(itemId, userId);
                     });
                yield ResponseEntity.accepted()
                        .body(Map.of("orderId", event.orderId(), "status", "PENDING"));
            }
        };
    }
}

注意すべき点:

  • リクエスト「完売」は、Redisへの1回の呼び出しだけで戻ります。これはリクエストの98%の経路です。
  • Kafkaの送信に失敗した場合、releaseを呼び出して在庫に返品します。この補償ステップがないと、商品は永遠に「幽霊予約」のままになります。
  • メッセージのキーはitemIdです。同じ商品のすべての注文は同じパーティションに入るため、順序通りに処理されます。

Producerは、メッセージが失われないようにacks=allと冪等性を有効にすべきです:

spring.kafka.producer.acks=all
spring.kafka.producer.properties.enable.idempotence=true
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer

7.2. コンシューマー:DBがセーフティネット

@Component
public class OrderConsumer {

    private final JdbcTemplate jdbc;

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

    @Transactional
    @KafkaListener(topics = "flash-sale-orders", groupId = "order-writer")
    public void handle(OrderPlaced event) {
        int inserted = jdbc.update("""
                INSERT INTO flash_sale_order (id, item_id, user_id, status)
                VALUES (?, ?, ?, 'PENDING')
                ON CONFLICT DO NOTHING
                """, event.orderId(), event.itemId(), event.userId());
        if (inserted == 0) return; // メッセージが重複、既に処理済み

        int updated = jdbc.update("""
                UPDATE flash_sale_item SET sold = sold + 1
                WHERE id = ? AND sold < quantity
                """, event.itemId());
        if (updated == 0) {
            throw new IllegalStateException("商品 " + event.itemId() + " のDB在庫が枯渇しました");
        }
    }
}

ON CONFLICT DO NOTHINGがコンシューマーをべき等にします。Kafkaは少なくとも1回の配信を保証するため、同じメッセージは2回到着する可能性があります。2回目は行を挿入できず、早期に終了します。

UPDATE ... WHERE sold < quantityはDB層の原子的な条件です。Redisがずれている場合(たとえば誰かが手動で間違った値を読み込んだ)、ステートメントは0を返します。トランザクションはロールバックし、注文は作成されません。無限再試行の代わりに、エラーメッセージをデッドレターキューにプッシュしRedisで在庫を返す、エラーハンドラーを設定すべきです。

コンシューマーは約10,000メッセージしか受け取らず、パーティションによって順序通りに書き込むため、DBは毎秒数千のトランザクションを処理します。このレベルは完全に正常です。

8. 支払い期限超過と在庫返品

期限切れの注文をスキャンする定期ジョブ:

@Component
public class ExpiredOrderJob {

    private final JdbcTemplate jdbc;
    private final StockReservationService reservation;

    public ExpiredOrderJob(JdbcTemplate jdbc, StockReservationService reservation) {
        this.jdbc = jdbc;
        this.reservation = reservation;
    }

    @Scheduled(fixedDelay = 30_000)
    @Transactional
    public void expire() {
        List<long[]> expired = jdbc.query("""
                UPDATE flash_sale_order SET status = 'EXPIRED'
                WHERE status = 'PENDING' AND created_at < now() - interval '10 minutes'
                RETURNING item_id, user_id
                """, (rs, i) -> new long[]{rs.getLong(1), rs.getLong(2)});

        for (long[] row : expired) {
            jdbc.update("UPDATE flash_sale_item SET sold = sold - 1 WHERE id = ?", row[0]);
            reservation.release(row[0], row[1]);
        }
    }
}

UPDATE ... RETURNINGはステータスを変更し、1つのステートメントで注文リストを取得します。2つのインスタンスが同時にジョブを実行する場合、各行は最初の実行後に条件status = 'PENDING'が満たされなくなるため、1つのインスタンスのみが更新します。

注意点:Redisのリリースはトランザクション内にありますが、そのトランザクションの一部ではありません。リリース後にトランザクションがロールバックされると、Redisは1単位余剰になります。実際には、TransactionSynchronization.afterCommitを使用するなど、コミット後にリリースを分離すべきです。Kafkaを通じて「OrderExpired」イベントを発行することもできます。ここの簡潔版は説明目的のみです。

9. テスト:在庫切れ状態での販売禁止

テストする最も重要な動作は:1,000人が100商品を競争するとき、正確に100人が成功する必要があります。Testcontainersを使用して本物のRedisを実行します。

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@Testcontainers
class StockReservationServiceTest {

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

    @Test
    void whenThousandUsersCompeteForHundredItems_thenExactlyHundredSucceed() {
        var factory = new LettuceConnectionFactory(
                redisContainer.getHost(), redisContainer.getMappedPort(6379));
        factory.afterPropertiesSet();
        var template = new StringRedisTemplate(factory);
        var service = new StockReservationService(template);
        template.opsForValue().set("{item:1}:stock", "100");

        var successes = new AtomicInteger();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            for (long userId = 0; userId < 1_000; userId++) {
                long id = userId;
                executor.submit(() -> {
                    if (service.reserve(1, id) == ReserveResult.SUCCESS) {
                        successes.incrementAndGet();
                    }
                });
            }
        }

        assertThat(successes.get()).isEqualTo(100);
        assertThat(template.opsForValue().get("{item:1}:stock")).isEqualTo("0");
        assertThat(service.reserve(1, 0)).isEqualTo(ReserveResult.ALREADY_PURCHASED);
    }
}

仮想スレッドエグゼキューターとのtry-with-resourcesブロックはすべてのタスクが完了するまで待機します。予想結果:

successes = 100
stock     = "0"

Luaスクリプトを個別のGETとDECRに置き換えると、このテストは不安定に失敗し、在庫はしばしば負の値になります。

10. 難しい点と一般的なエラー

Redis上のホットキー。

すべてのリクエストが同じキー{item:1001}:stockを叩きます。1つのRedisノードは約毎秒100,000コマンドを処理します。より高い負荷では、2つの方法があります:

  • 在庫のシャーディング:10,000を10個のキー{item:1001:0}…{item:1001:9}に分割、各キーは1,000で、異なるノードに位置します。リクエストはシャードをランダムに選択し、尽きたら別のシャードを試します。この場合、「ユーザー1人1商品」ルールはuserId % 10に基づいてユーザーを固定シャードにルーティングするか、DB層で確認する必要があります。
  • ローカル完売フラグ:RedisがSOLD_OUTを報告する場合、各インスタンスはメモリ内にAtomicBooleanをトグルしてからはRedisを呼び出さずに拒否します。

Redis前の急激な負荷。

ゲートウェイでのレート制限、キャプチャ、または「仮想キューイング」により、ほとんどのボットをカットできます。セールページはCDN上の静的ページである必要があります。「購入」ボタンは時間になってのみ有効化でき、購入URLはスクリプトによる早期クリックを減らすためにその時点で返されます。

Redisデータ喪失。

Redisがフェイルオーバーし、レプリカが最後のDECRコマンドを受け取れない場合、Redis上の在庫は実際より高い可能性があります。この場合、DB内の条件sold < quantityが機能します。このセーフティネットを決して削除しないでください。

最終的な一貫性。

ユーザーは202を受け取りますが、注文はまだDBに入っていません。UIは「処理中」ステータスを表示してポーリングする必要があり、「購入成功」を即座に報告してはいけません。

注意: 在庫行でSELECT ... FOR UPDATEをフラッシュセールの主要なメカニズムとして使用しないでください。これは一貫性の観点から正しいですが、すべてのトランザクションが同じ行ロックを待機キューに入ります。スループットは数百から数千リクエスト/秒に低下し、接続プールが枯渇して、通常のeコマースシステム全体を引きずり下ろします。

注意: Redis + Kafka アーキテクチャは、実際の負荷が大きい場合にのみ価値があります。セールがわずか数百リクエスト/秒の場合、PostgreSQLの単一のUPDATE ... WHERE sold < quantityステートメントで十分です。これはより単純で、2つのデータストア間の同期の問題がありません。

11. まとめ

フラッシュセール設計は1つの原則を中心に回ります:各層で負荷をフィルタリングし、厳密な制約を持つ単一の真実のソースを保つ。この記事では、以下を見てきました:

  • Redis Luaスクリプトが在庫を原子的にチェックして減少させ、1回のラウンドトリップで「完売」を拒否します。
  • Kafkaはバッファーとして機能し、DBは正確な数の成功した注文のみを受け取ります。
  • PostgreSQL のUNIQUE、CHECK、UPDATE ... WHERE sold < quantityが負の在庫販売と重複メッセージ処理に対するセーフティネットとして機能します。
  • Kafka送信失敗時または注文期限切れ時の補償メカニズム、二重返品ブロック付き。

実装する際は、セクション9のような競合テストから始めてください。その後、最初のセールを始める前に、実際のピークに近い数値でロードテストを実行してください。

system-designrediskafkaspring-bootconcurrency