ショッピングカートと注文フローの設計

1. 概要

ショッピングカートと注文機能はeコマースプラットフォームで最も頻繁に使用される2つの機能です。これらは最も多くの問題を引き起こしやすくもあります:在庫超過販売、二重請求、フラッシュセール時の「ハング」状態の注文。

この記事では、ケーススタディの形式で完全な設計を検討します。設計には要件、データモデル、API、処理フロー、および難しい点が含まれます。各難しい点にはSpring Boot 3、PostgreSQL、Redis、Kafkaを使用した解決策が付いています。読み終わったら、この設計フレームワークを適用するか、システム設計のインタビューに持ち込むことができます。

2. 要件

2.1. 機能要件

  • 顧客はカート内の商品(SKU)を追加、編集、削除できます。カートは複数のセッションにまたがって保持されます。
  • チェックアウト時、システムは価格を再計算し、在庫を確認してからPENDING_PAYMENT状態で注文を作成します。
  • 15分以内に支払われなかった注文は自動的にキャンセルされ、在庫に戻されます。
  • 支払い成功後、システムは倉庫管理、メール、ポイント還元用のイベントを発行します。

2.2. 想定スケール

  • 月間アクティブユーザー:1,000万人
  • カート操作:平均2,000 req/s、ピーク20,000 req/s
  • 注文:平均200件/s。フラッシュセール時は5,000件/sに達し、数十のSKUに集中します。

2.3. 制約条件

  • 在庫超過販売禁止。 これはハード制約です。
  • 重複注文の作成禁止。クライアントがタイムアウトで再度リクエストを送信したり、ユーザーが2回クリックした場合。
  • 作成された注文はKafkaが一時的に停止している場合でも、イベントは発行される必要があります。
  • ショッピングカートは「最終的な一貫性」を許容されます。注文と在庫は強い一貫性が必須です。

最後の制約が設計を決定します。ショッピングカートは読み書きが多く重要度が低いため、Redisに配置します。注文と在庫はトランザクションが必要なため、PostgreSQLに配置します。

3. データモデル

ショッピングカートはRedisのHashを使用し、顧客ごとに1つを使用します:

key:   cart:{customerId}
field: sku
value: quantity
TTL:   30 days

カートはSKUと数量のみを格納し、価格は格納しません。価格はいつでも変わる可能性があるため、システムはチェックアウト時に常に価格を再計算します。

トランザクションが必要なデータはPostgreSQLに配置します:

CREATE TABLE inventory (
    sku        VARCHAR(64) PRIMARY KEY,
    available  INT NOT NULL CHECK (available >= 0),
    reserved   INT NOT NULL DEFAULT 0
);

CREATE TABLE orders (
    id               BIGSERIAL PRIMARY KEY,
    customer_id      BIGINT       NOT NULL,
    idempotency_key  VARCHAR(64)  NOT NULL,
    status           VARCHAR(32)  NOT NULL,
    total_amount     BIGINT       NOT NULL,
    expires_at       TIMESTAMPTZ  NOT NULL,
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT now(),
    UNIQUE (customer_id, idempotency_key)
);

CREATE TABLE order_items (
    order_id    BIGINT REFERENCES orders(id),
    sku         VARCHAR(64) NOT NULL,
    quantity    INT         NOT NULL,
    unit_price  BIGINT      NOT NULL,
    PRIMARY KEY (order_id, sku)
);

CREATE TABLE outbox_events (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    event_type   VARCHAR(64) NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false
);

このスキーマのいくつかの注目点:

  • CHECK (available >= 0)は最後の保護層です。コードにバグがある場合でも、データベースは負の在庫をブロックします。
  • UNIQUE (customer_id, idempotency_key)はデータレイヤーレベルで重複注文の作成をブロックします。
  • order_items.unit_priceは注文時点での価格スナップショットです。古い注文は製品価格の変更の影響を受けません。
  • 金額は最小単位(円など)でBIGINTで保存されます。DOUBLEは使用しません。

4. API設計

GET    /api/cart                       -> カートを表示(現在の価格付き)
PUT    /api/cart/items/{sku}           -> body {"quantity": 2}、数量を設定
DELETE /api/cart/items/{sku}           -> SKUをカートから削除
POST   /api/orders                     -> header Idempotency-Key、カートから注文を作成
GET    /api/orders/{id}                -> 注文ステータスを表示

絶対数量を使用したPUTはべき等です。複数回呼び出しても同じ結果が得られます。POST /addで+1するという設計の場合、クライアントが再試行するたびに別の商品が追加されます。

POST /api/ordersは本質的にべき等ではないため、クライアントは各「注文」クリックに対してIdempotency-Key(UUID)を生成する必要があります。再試行時、クライアントは同じキーを送信します。

5. RedisでのショッピングカートService

依存関係を追加します:

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

ショッピングカートService:

@Service
public class CartService {

    private static final Duration CART_TTL = Duration.ofDays(30);
    private static final int MAX_ITEMS = 100;

    private final StringRedisTemplate redis;

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

    public void setQuantity(long customerId, String sku, int quantity) {
        String key = "cart:" + customerId;
        HashOperations<String, String, String> ops = redis.opsForHash();
        if (quantity <= 0) {
            ops.delete(key, sku);
            return;
        }
        if (!ops.hasKey(key, sku) && ops.size(key) >= MAX_ITEMS) {
            throw new IllegalStateException("カートに追加できるのは最大 " + MAX_ITEMS + " 個の商品です");
        }
        ops.put(key, sku, String.valueOf(quantity));
        redis.expire(key, CART_TTL);
    }

    public Map<String, Integer> getItems(long customerId) {
        Map<String, String> raw = redis.<String, String>opsForHash().entries("cart:" + customerId);
        return raw.entrySet().stream()
                .collect(Collectors.toMap(Map.Entry::getKey, e -> Integer.parseInt(e.getValue())));
    }
}

説明:

  • ops.put(key, sku, ...)はHash内のフィールドをO(1)複雑度で書き込みます。2つのデバイスが異なるSKUを同時に編集しても上書きされません。カート全体をJSON文字列として保存する場合は上書きされます。
  • expireは書き込みのたびに更新されます。カートは30日間操作がない場合のみ期限切れになります。
  • MAX_ITEMSはボットによるキーサイズの増加を防ぐため、カートサイズを制限します。

hasKeyの後にsizeを呼び出すことはアトミックではありません。2つの並行リクエストは制限を1~2項目超える可能性があります。ショッピングカートの場合、このような小さな不正確さは許容可能です。ただし、このレベルの「最終的な一貫性」を在庫に適用すべきではありません。

注意: Redisは完全に永続的なデータソースではありません。AOF(appendfsync everysec)を有効にして、データ損失を約1秒に制限してください。1秒のショッピングカートデータを失うことは許容可能ですが、注文データを失うことは許容できません。

6. 注文フロー

POST /api/ordersフローは次の手順を実行します:

1. Redisからカートを読む
2. カタログから現在の価格を取得
3. BEGIN transaction (PostgreSQL)
   3a. idempotency_keyで注文をinsert  -> 重複の場合は古い注文を返す
   3b. SKUごとに在庫を引く(条件付きupdate) -> 在庫不足ならROLLBACK
   3c. order_items(価格スナップショット)をinsert
   3d. outbox_events "OrderCreated"をinsert
4. COMMIT
5. ショッピングカートを削除(ベストエフォート)
6. Relayがoutboxを読む -> Kafkaに公開

6.1. 条件付きupdateで在庫超過販売を防止

よくある間違いは、在庫を読んでJavaで比較してから書き込むことです。読み込みと書き込みの間に、別のスレッドが商品を購入する可能性があります。正しい方法は、チェックと減算を1つのSQLステートメントに統合することです:

@Repository
public class InventoryRepository {

    private final JdbcClient jdbc;

    public InventoryRepository(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    public boolean reserve(String sku, int quantity) {
        int updated = jdbc.sql("""
                UPDATE inventory
                   SET available = available - :qty,
                       reserved  = reserved  + :qty
                 WHERE sku = :sku AND available >= :qty
                """)
                .param("qty", quantity)
                .param("sku", sku)
                .update();
        return updated == 1;
    }
}

実行中、UPDATEがデータベース行をロックします。2番目のリクエストは最初のリクエストが完了するのを待たなければなりません。その後、新しい値に対して条件available >= :qtyを再評価します。在庫が不足している場合、updated == 0でメソッドはfalseを返します。SELECT ... FOR UPDATEまたはversionカラムは必要ありません。

6.2. Idempotencyとtransaction

@Service
public class OrderService {

    private final JdbcClient jdbc;
    private final InventoryRepository inventory;

    public OrderService(JdbcClient jdbc, InventoryRepository inventory) {
        this.jdbc = jdbc;
        this.inventory = inventory;
    }

    @Transactional
    public long placeOrder(long customerId, String idempotencyKey, List<OrderLine> lines) {
        Optional<Long> existing = jdbc.sql("""
                INSERT INTO orders (customer_id, idempotency_key, status, total_amount, expires_at)
                VALUES (:c, :k, 'PENDING_PAYMENT', :total, now() + interval '15 minutes')
                ON CONFLICT (customer_id, idempotency_key) DO NOTHING
                RETURNING id
                """)
                .param("c", customerId).param("k", idempotencyKey)
                .param("total", lines.stream().mapToLong(OrderLine::subtotal).sum())
                .query(Long.class).optional();

        if (existing.isEmpty()) {
            return jdbc.sql("SELECT id FROM orders WHERE customer_id = :c AND idempotency_key = :k")
                    .param("c", customerId).param("k", idempotencyKey)
                    .query(Long.class).single();
        }
        long orderId = existing.get();

        for (OrderLine line : lines.stream().sorted(Comparator.comparing(OrderLine::sku)).toList()) {
            if (!inventory.reserve(line.sku(), line.quantity())) {
                throw new OutOfStockException(line.sku());
            }
            jdbc.sql("INSERT INTO order_items VALUES (:o, :s, :q, :p)")
                    .param("o", orderId).param("s", line.sku())
                    .param("q", line.quantity()).param("p", line.unitPrice())
                    .update();
        }

        jdbc.sql("""
                INSERT INTO outbox_events (aggregate_id, event_type, payload)
                VALUES (:id, 'OrderCreated', CAST(:payload AS jsonb))
                """)
                .param("id", orderId)
                .param("payload", "{\"orderId\":" + orderId + ",\"customerId\":" + customerId + "}")
                .update();
        return orderId;
    }
}

public record OrderLine(String sku, int quantity, long unitPrice) {
    long subtotal() { return quantity * unitPrice; }
}

public class OutOfStockException extends RuntimeException {
    public OutOfStockException(String sku) { super("在庫切れ: " + sku); }
}

重要なポイント:

  • ON CONFLICT ... DO NOTHING RETURNING idは挿入成功時のみidを返します。キーが既に存在する場合、結果は空です。その場合、serviceは古い注文を読み込んで返すため、クライアント再試行は正確に1つの注文を受け取ります。
  • OutOfStockExceptionはRuntimeExceptionであるため、@Transactionalはすべてをロールバックします:注文、それまでに減額されたすべてのSKU、およびoutbox。「半分の状態」はありません。
  • SKUのリストは減額の前にソートされます。同じSKU AとBを含む2つの注文は常にAを最初にロックしてからBをロックするため、デッドロックを回避します。
  • 最初のリクエストがロールバックされた場合、キーもロールバックされます。同じキーでクライアント再試行は最初から再実行されます。これが期待される動作です。

6.3. Transactional outbox

@Transactional内でkafkaTemplate.send()を呼び出す場合、2つのエラーのいずれかに遭遇します。Kafkaは正常に送信されたがDBロールバックすると「ゴーストイベント」が生成されます。DBがコミットされたがKafkaがエラーになると、イベントは失われます。Outboxは、イベントを同じトランザクションに書き込み、relayがイベントをKafkaにプッシュすることで解決します。

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
public class OutboxRelay {

    private final JdbcClient jdbc;
    private final KafkaTemplate<String, String> kafka;

    public OutboxRelay(JdbcClient jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publish() throws Exception {
        List<OutboxEvent> events = jdbc.sql("""
                SELECT id, aggregate_id, event_type, payload::text AS payload
                  FROM outbox_events WHERE published = false
                 ORDER BY id LIMIT 100
                 FOR UPDATE SKIP LOCKED
                """).query(OutboxEvent.class).list();

        for (OutboxEvent e : events) {
            kafka.send("orders", String.valueOf(e.aggregateId()), e.payload()).get();
            jdbc.sql("UPDATE outbox_events SET published = true WHERE id = :id")
                    .param("id", e.id()).update();
        }
    }
}

record OutboxEvent(long id, long aggregateId, String eventType, String payload) {}

@Scheduledは@EnableSchedulingが有効な場合のみ実行されます。

  • FOR UPDATE SKIP LOCKEDにより、複数のrelayインスタンスが同時に同じ行を処理することなく並行実行できます。
  • メッセージキーはaggregateIdです。同じ注文のすべてのイベントは同じパーティションに落ちて、正しい順序を保ちます。
  • .get()はKafkaの確認を待ってからpublishedをマークします。relayが途中で死ぬと、イベントは再度送信される可能性があります。Outboxはat-least-onceを保証するため、consumerはべき等である必要があります。例えば、既に処理されたorderIdを保存します。

6.4. 支払い期限切れ

定期的に期限超過の注文をキャンセルし、在庫を戻すジョブ:

WITH expired AS (
    UPDATE orders SET status = 'CANCELLED'
     WHERE status = 'PENDING_PAYMENT' AND expires_at < now()
    RETURNING id
)
UPDATE inventory i
   SET available = i.available + oi.quantity,
       reserved  = i.reserved  - oi.quantity
  FROM order_items oi
 WHERE oi.order_id IN (SELECT id FROM expired) AND oi.sku = i.sku;

条件status = 'PENDING_PAYMENT'は支払いコールバックとの競合を解決します。コールバックもUPDATE ... WHERE status = 'PENDING_PAYMENT'でステータスを変更する必要があります。最初に更新した方が勝ち、もう一方は0行更新されます。支払いが注文がキャンセルされた後に到着した場合、システムは注文を「復活」させるのではなく払い戻しを発行します。

上記のステートメントは、各注文の各SKUが最大1回のみ出現することを前提としており、主キー(order_id, sku)によって既に保証されています。キャンセルバッチに同じSKUを含む複数の注文がある場合、PostgreSQLは各inventory行に対してのみ1つの行joinを適用します。この場合、GROUP BY skuとSUM(quantity)で事前に集計する必要があります。

7. 突発的な負荷:フラッシュセール

5,000件/sのリクエストが同じSKUを争う場合、すべてがinventoryの1行をロックするために並んでいます。データベースがボトルネックになり、レイテンシーが増加し、他のSKUも引き込まれます。解決策は、Redisで実行されているLuaスクリプトを使用したカウンターで、DBにヒットする前にトラフィックをフィルタリングすることです:

@Component
public class FlashSaleGate {

    private static final RedisScript<Long> TRY_ACQUIRE = RedisScript.of("""
            local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
            local qty = tonumber(ARGV[1])
            if stock >= qty then
                redis.call('DECRBY', KEYS[1], qty)
                return 1
            end
            return 0
            """, Long.class);

    private final StringRedisTemplate redis;

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

    public boolean tryAcquire(String sku, int quantity) {
        Long ok = redis.execute(TRY_ACQUIRE, List.of("flash:stock:" + sku), String.valueOf(quantity));
        return ok != null && ok == 1L;
    }

    public void release(String sku, int quantity) {
        redis.opsForValue().increment("flash:stock:" + sku, quantity);
    }
}

Redisはスクリプトを原子的に実行します。GETとDECRBYの間には何もありません。1,000のスロットと50,000のリクエストで、約1,000のリクエストのみがゲートを通過してOrderServiceに入ります。残りの49,000のリクエストは、DBクエリを消費することなく、即座にHTTP 409「売り切れ」を受け取ります。

フロー全体:

tryAcquire (Redis)  --false-->  409 Sold out
       |true
placeOrder (PostgreSQL、条件付きupdateは変わらない)
       |exception
release (Redis)

Redisはフィルターのみです。真実のソースは依然としてPostgreSQLの条件付きupdateです。Redisが数字からずれている場合、例えば再起動やリリースが実行されないため、最悪のシナリオは数スロット不足販売することです。過剰販売はできません。

その他のサポート対策:

  • 顧客ごとのレート制限。botスパムをブロックします。
  • キューイング: ゲートを通過したリクエストをKafkaにプッシュし、consumerはDB処理速度でリアルタイムに注文を作成し、クライアントGET /api/orders/{id}をポーリングします。この方法はレイテンシーを安定性に変えます。
  • ホットSKUの分離:これらを別のテーブルまたはインスタンスに分離して、通常の注文に影響を与えないようにします。

8. 在庫競合のテスト

最も重要なテストは、複数のスレッドが同時に購入する場合に、システムが在庫を超過販売しないことを証明することです。

@JdbcTest
@Import(InventoryRepository.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class InventoryRepositoryTest {

    @Autowired JdbcClient jdbc;
    @Autowired InventoryRepository inventory;

    @BeforeEach
    void setUp() {
        jdbc.sql("""
                CREATE TABLE IF NOT EXISTS inventory (
                  sku VARCHAR(64) PRIMARY KEY,
                  available INT NOT NULL CHECK (available >= 0),
                  reserved INT NOT NULL DEFAULT 0)
                """).update();
        jdbc.sql("DELETE FROM inventory").update();
        jdbc.sql("INSERT INTO inventory (sku, available) VALUES ('IPHONE-16', 5)").update();
    }

    @Test
    void givenStockOf5_when20ConcurrentBuyers_thenExactly5Succeed() throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Boolean>> results = IntStream.range(0, 20)
                    .mapToObj(i -> executor.submit(() -> inventory.reserve("IPHONE-16", 1)))
                    .toList();

            long success = 0;
            for (Future<Boolean> f : results) {
                if (f.get()) success++;
            }
            assertThat(success).isEqualTo(5);
        }

        Integer left = jdbc.sql("SELECT available FROM inventory WHERE sku = 'IPHONE-16'")
                .query(Integer.class).single();
        assertThat(left).isZero();
    }
}
  • @Transactional(propagation = NOT_SUPPORTED)は@JdbcTestのデフォルトtransactionを無効にします。無効にしない場合、setUpで挿入されたデータはコミットされず、他のスレッドは表示できません。
  • テストはJava 21の仮想スレッドを使用して、20の並行リクエストを作成します。
  • @JdbcTestのデフォルト埋め込みデータベース(H2)はこのチェックで十分です。ON CONFLICTとSKIP LOCKEDをテストするには、Testcontainersを使用してPostgreSQLを使用します。

期待される結果:

success = 5, available = 0

UPDATEステートメントを「JavaでSELECTして比較」タイプに置き換えた場合、このテストはランダムに失敗し、時にはsuccessが7または8に達することがあります。

9. よくあるミスと非推奨ユースケース

  • カート内に価格を保存してチェックアウト時に使用。 悪意のあるユーザーが古い価格を保持したり、ペイロードを編集する可能性があります。常にサーバーから価格を取得します。
  • カートに追加時に即座に在庫を減らす。 ユーザーはカートを放棄することが非常に多いため、在庫が無駄に「ロック」されます。チェックアウト時にのみ保持し、常に有効期限を設定します。
  • @Transactional内でKafkaまたは支払いサービスを呼び出す。 長いトランザクションはロックを長く保持し、一貫性を保証しないリスクもあります。Outboxパターンを使用します。
  • Redisを在庫の真実のソースと見なす。 Redisはフィルター層としてのみ使用すべきです。
  • サービス間でdistributed transaction(2PC/XA)を使用する。 これは複雑で遅いです。イベントベースのsagaと補償ステップ(注文キャンセル、在庫返却)を使用します。

注意: eコマースプラットフォームが1分あたり数十の注文のみの場合は、条件付きupdateとidempotency keyを備えた単一のデータベースで十分です。Redisゲート、Kafkaキューイング、ホットSKU分離は、実測データがDBが実際にボトルネックであることを示している場合にのみ追加する価値があります。

10. 結論

この記事では、ショッピングカートと注文フローを層ごとに設計しました。ショッピングカートはRedis Hash上にあり、高速が必要であり最終的な一貫性を受け入れるため。注文と在庫はPostgreSQL上にあり、強い一貫性が必須です。

3つの重要な技術がそれぞれの難しい点を解決します:

  • 条件付きupdateは在庫超過販売をブロックします。
  • Idempotency keyとUNIQUE制約は重複注文の作成をブロックします。
  • Transactional outboxはイベント損失を防止します。

フラッシュセール時には、Redisで実行されているLuaスクリプトがトラフィックをフィルタリングして、データベースがソース・オブ・トゥルースの役割を果たしながら、データベースが過負荷にならないようにします。最もシンプルなバージョンから始めて、実際の負荷が要求するときに各層を追加できます。

system-designspring-bootrediskafkaケーススタディ