フードデリバリーシステムの設計: 注文、店舗、配達員とリアルタイム状態

1. 概要

フードデリバリーアプリケーションには、1つの注文に影響を与える3つの当事者があります: 注文を出す顧客、それを確認するレストラン、そして配達を担当する配達員です。これら3つのアクションはすべて同時に発生し、モバイルネットワークは不安定で、お昼時に集中します。

この記事では、設計プロセス全体を説明します: 要件、データモデル、API、ステートマシン、配達員への割り当て時に重複を避ける方法、データベースとKafkaの間で整合性を保つ方法、そしてリアルタイム状態を顧客にプッシュする方法です。完了後、具体的な設計フレームワークが得られ、それを応用するか、システム設計インタビューで使用することができます。

2. 要件

2.1. 機能要件

  • 顧客はメニューを表示し、注文し、支払い、ステータスと配達員の位置を追跡します。
  • レストランは注文を受け取り、確認または拒否し、その後、料理が準備完了したことを通知します。
  • 配達員はオンラインステータスを有効にし、定期的に位置を送信し、提案された注文を受け入れまたは拒否し、ピックアップと配達を更新します。
  • 顧客はレストランが調理を開始する前に注文をキャンセルできます。

2.2. 規模と制約

計算の基礎を持つために、次の数字を想定します:

注文:     1,000,000 注文/日、ピーク時間 (11時~13時) は約 30% を占める
          → ピーク時平均 ~42 注文/秒、スパイク時 ~200 注文/秒
配達員:   50,000 配達員がオンライン、4秒ごとに位置を送信
          → ~12,500 位置書き込み/秒
追跡:     顧客がトラッキング画面を開く → 数十万のリアルタイム接続

これらの数字から、3つの主な制約があります:

  • 注文は強い一貫性が必要です。 注文を失ってはいけず、1つの注文を2人の配達員に割り当ててはいけません。
  • 配達員の位置は弱い一貫性で構いません。 1つの位置点を失っても問題ありませんが、書き込みは非常に高速である必要があります。位置はリレーショナルデータベースに書き込みません。
  • 注文の配置はべき等である必要があります。 モバイルネットワークはクライアントがリクエストを再送信する可能性があります。

3. 全体的なアーキテクチャ

 顧客 / レストラン / 配達員アプリ
          │  REST + SSE/WebSocket
          ▼
     APIゲートウェイ (レート制限、認証)
          │
 ┌────────┼───────────────┬──────────────────┐
 ▼        ▼               ▼                  ▼
Order   Restaurant     Dispatch           Tracking
Service Service        Service            Service
 │ PostgreSQL           │ Redis GEO         │ Redis (位置、pub/sub)
 │ + outbox             │                   │
 └──────────► Kafka (order-events) ◄────────┘

Order Serviceは注文の情報源です。他のサービスはKafkaのイベントに反応します。配達員の位置はRedisにのみ存在します。

以下の例では、次の依存関係を使用します:

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

4. データモデル

CREATE TABLE orders (
    id              BIGSERIAL PRIMARY KEY,
    customer_id     BIGINT      NOT NULL,
    restaurant_id   BIGINT      NOT NULL,
    shipper_id      BIGINT,
    status          VARCHAR(20) NOT NULL,
    total_amount    NUMERIC(12,0) NOT NULL,
    idempotency_key VARCHAR(64) NOT NULL,
    version         INT         NOT NULL DEFAULT 0,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    UNIQUE (customer_id, idempotency_key)
);

CREATE TABLE order_items (
    order_id  BIGINT REFERENCES orders(id),
    dish_id   BIGINT NOT NULL,
    dish_name VARCHAR(200) NOT NULL,
    price     NUMERIC(12,0) NOT NULL,
    quantity  INT NOT NULL
);

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

CREATE INDEX idx_orders_restaurant_active ON orders (restaurant_id, status);
CREATE INDEX idx_outbox_unpublished ON outbox (id) WHERE published = false;

注目すべき点がいくつかあります:

  • UNIQUE (customer_id, idempotency_key) はクライアントがリクエストを再送信した場合、重複する注文を防ぎます。
  • version は多くの当事者が同じ注文を更新する場合、オプティミスティックロックに使用されます。
  • order_items は注文時点での dish_name と price を保存します。レストランが後で価格を変更しても、古い請求書は影響を受けません。
  • outbox テーブルはセクション8の一貫性に使用されます。

5. 注文ステートマシン

注文は有効なステップに従ってのみ進行できます。このルールを複数のサービスに if で分散させるのではなく、1か所でエンコードする必要があります:

public enum OrderStatus {
    PLACED, CONFIRMED, PREPARING, READY_FOR_PICKUP, PICKED_UP, DELIVERED, CANCELLED;

    private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
        PLACED,           Set.of(CONFIRMED, CANCELLED),
        CONFIRMED,        Set.of(PREPARING, CANCELLED),
        PREPARING,        Set.of(READY_FOR_PICKUP),
        READY_FOR_PICKUP, Set.of(PICKED_UP),
        PICKED_UP,        Set.of(DELIVERED),
        DELIVERED,        Set.of(),
        CANCELLED,        Set.of()
    );

    public boolean canTransitionTo(OrderStatus next) {
        return TRANSITIONS.get(this).contains(next);
    }
}

TRANSITIONS は各ステータスから有効な次のステータスをリストします。顧客は PLACED または CONFIRMED でのみキャンセルできます。レストランが調理を開始した後 (PREPARING)、注文は CANCELLED に戻ることはできません。最後の2つのステータスには次のステップがありません。

このルールをテストします:

class OrderStatusTest {

    @Test
    void customerCanCancelBeforePreparing() {
        assertThat(OrderStatus.PLACED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
        assertThat(OrderStatus.CONFIRMED.canTransitionTo(OrderStatus.CANCELLED)).isTrue();
    }

    @Test
    void cannotCancelOnceKitchenStarted() {
        assertThat(OrderStatus.PREPARING.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
    }

    @Test
    void deliveredIsTerminal() {
        assertThat(OrderStatus.values())
            .noneMatch(OrderStatus.DELIVERED::canTransitionTo);
    }
}

6. API

POST  /api/orders                         顧客が注文を配置 (ヘッダー Idempotency-Key)
GET   /api/orders/{id}                    注文の詳細を表示
POST  /api/orders/{id}/cancel             顧客がキャンセル
POST  /api/restaurant/orders/{id}/confirm レストランが確認
POST  /api/restaurant/orders/{id}/ready   レストランが準備完了を通知
PUT   /api/shippers/me/location           配達員が位置を送信 {lat, lng}
POST  /api/shippers/me/offers/{orderId}/accept   配達員が注文を受け入れ
POST  /api/orders/{id}/pickup | /deliver  配達員が更新
GET   /api/orders/{id}/stream             SSE: ステータス + 配達員の位置

APIは一般的な PATCH status=... ではなく、アクション (/confirm, /accept) で設計されています。このアプローチにより、権限チェックが簡単になります: その注文のレストランのみが /confirm を呼び出すことができ、割り当てられた配達員のみが /pickup を呼び出すことができます。

7. 競合の処理

7.1. オプティミスティックロックによるステータス更新

顧客がキャンセルボタンを押して、レストランが確認ボタンを押すとします。両方が PLACED を見ると、両方が有効だと思います。これを解決するために、条件付き UPDATE を使用します:

@Repository
public class OrderRepository {

    private final JdbcClient jdbc;

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

    public boolean transition(long orderId, OrderStatus from, OrderStatus to, int version) {
        if (!from.canTransitionTo(to)) {
            throw new IllegalStateException(from + " -> " + to + " は無効です");
        }
        int updated = jdbc.sql("""
                UPDATE orders SET status = :to, version = version + 1
                WHERE id = :id AND status = :from AND version = :version
                """)
            .param("to", to.name())
            .param("id", orderId)
            .param("from", from.name())
            .param("version", version)
            .update();
        return updated == 1;
    }
}

データベースは、version 条件に一致する UPDATE の1つだけを確保します。勝者は true を受け取ります。敗者は false を受け取り、最新のステータスとともにHTTP 409を返すため、アプリは「レストランが注文を確認しました」と表示できます。

7.2. 配達員への注文割り当て

ここが最も難しい部分です。レストラン近くの配達員を見つけ、各注文が1人の配達員にのみ割り当てられることを確認する必要があります。

ステップ1: Redis GEOを使用して位置を保存します。 配達員が位置を送信するたびに、ソートセットのGEOに書き込み、ハートビートキーを更新します:

@Service
public class ShipperLocationService {

    private final StringRedisTemplate redis;

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

    public void update(long shipperId, double lat, double lng) {
        String id = String.valueOf(shipperId);
        redis.opsForGeo().add("shipper:geo", new Point(lng, lat), id);
        redis.opsForValue().set("shipper:alive:" + id, "1", Duration.ofSeconds(30));
    }

    public List<String> findNearby(double lat, double lng) {
        var args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs()
            .includeDistance().sortAscending().limit(20);
        var results = redis.opsForGeo().search("shipper:geo",
            GeoReference.fromCoordinate(lng, lat),
            new Distance(3, Metrics.KILOMETERS), args);
        return results.getContent().stream()
            .map(r -> r.getContent().getName())
            .filter(id -> Boolean.TRUE.equals(redis.hasKey("shipper:alive:" + id)))
            .toList();
    }
}

Point は経度を最初に、緯度を後に受け取ります。これは非常に一般的なバグです。GEOセット内のメンバーは自動的には期限切れになりません。したがって、TTLが30秒の shipper:alive:* キーは、アプリをオフにするか、信号を失った配達員をフィルタリングするために使用されます。定期的なジョブは、shipper:geo から廃止されたメンバーを ZREM できます。

12,500の書き込み/秒で、単一のRedisインスタンスは十分に対応できます。複数の都市に拡張する場合は、地域でシャードします: shipper:geo:hcm, shipper:geo:hn。

ステップ2: 各配達員に1つずつ招待を送ります。 Dispatch Serviceは、最も近い配達員から順に招待を送ります。各招待には20秒のタイムアウトがあります。配達員が同時に2つの招待を受けないようにするために、SET NX でロックします:

public boolean reserveShipperForOffer(String shipperId, long orderId) {
    Boolean ok = redis.opsForValue().setIfAbsent(
        "offer:shipper:" + shipperId, String.valueOf(orderId), Duration.ofSeconds(20));
    return Boolean.TRUE.equals(ok);
}

false の場合、配達員は別の招待を見ています。次の人に移ります。

ステップ3: データベースで注文割り当てを確定します。 Redisロックは調整に役立つだけです。最終的な決定はデータベースにある必要があります:

public boolean assignShipper(long orderId, long shipperId) {
    return jdbc.sql("""
            UPDATE orders SET shipper_id = :shipperId, version = version + 1
            WHERE id = :orderId AND shipper_id IS NULL
              AND status IN ('CONFIRMED', 'PREPARING', 'READY_FOR_PICKUP')
            """)
        .param("shipperId", shipperId)
        .param("orderId", orderId)
        .update() == 1;
}

shipper_id IS NULL 条件は、2人の配達員が同時に「受け入れ」をクリックした場合 (たとえば、古い招待がタイムアウトして再送信された場合)、1人だけが勝つことを確認します。

注: Redisロックを注文割り当ての情報源として使用しないでください。Redisはフェイルオーバーでき、ロックを失う可能性があります。データベースの制約は最後の防御層です。

8. データベースとKafkaの間の一貫性

注文を作成した後、Order Serviceは OrderPlaced イベントを発行し、レストランに通知する必要があります。しかし、データベースに書き込んでから kafkaTemplate.send() を呼び出すと、その間にサービスがクラッシュする可能性があります。その場合、注文は存在しますがレストランは通知を受けません。これが dual write 問題です。

解決策は transactional outbox です: 注文と同じトランザクション内で outbox テーブルにイベントを書き込みます。

@Transactional
public long placeOrder(PlaceOrderCommand cmd) {
    long orderId = orderRepository.insert(cmd); // 重複キーの場合 DuplicateKeyException をスロー
    jdbc.sql("""
            INSERT INTO outbox (aggregate_id, event_type, payload)
            VALUES (:id, 'OrderPlaced', CAST(:payload AS JSONB))
            """)
        .param("id", orderId)
        .param("payload", toJson(cmd, orderId))
        .update();
    return orderId;
}

両方のレコードは一緒にコミットまたはロールバックされます。クライアントが同じ Idempotency-Key で再送信する場合、UNIQUE 制約は DuplicateKeyException をスロー します。コントローラはこのエラーをキャッチして、古い注文を見つけ、新しい注文を作成する代わりにそれを返します。

リレーはoutboxを読み込み、Kafkaにプッシュします:

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

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

FOR UPDATE SKIP LOCKED は、複数のリレーインスタンスが重複レコードを処理することなく実行できるようにします。メッセージのキーは orderId なので、同じ注文のすべてのイベントは同じパーティションに落ち、正しい順序を保ちます。

リレーは at-least-once を保証します。サービスが send 後だがコミット前に死ぬと、イベントは再送信されます。したがって、コンシューマーはべき等である必要があり、たとえば処理済みの event_id を保存するか、セクション7.1の UPDATE ... WHERE status = ... を使用します。

9. 顧客へのリアルタイム状態

顧客は2種類の更新を確認する必要があります: 注文ステータス (少ない、重要) と配達員の位置 (多い、スキップ可能)。Server-Sent Eventsは、データフローがサーバーからクライアントへの一方向だけであるため、十分に適切な選択です:

@RestController
public class OrderStreamController {

    private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>();

    @GetMapping(value = "/api/orders/{id}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter stream(@PathVariable long id) {
        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
        emitters.computeIfAbsent(id, k -> new CopyOnWriteArrayList<>()).add(emitter);
        emitter.onCompletion(() -> emitters.get(id).remove(emitter));
        emitter.onTimeout(emitter::complete);
        return emitter;
    }

    public void push(long orderId, String eventName, Object data) {
        emitters.getOrDefault(orderId, List.of()).forEach(e -> {
            try {
                e.send(SseEmitter.event().name(eventName).data(data));
            } catch (IOException ex) {
                e.completeWithError(ex);
            }
        });
    }
}

各注文にはエミッターのリストがあります (顧客は複数のデバイスで開く可能性があります)。push は status や location などの名前を持つイベントを送信します。クライアント側では:

event:status
data:{"orderId":1024,"status":"PICKED_UP"}

event:location
data:{"lat":10.7769,"lng":106.7009}

複数インスタンスを実行している場合、顧客のSSE接続は1つのインスタンスにのみ存在します。イベントが正しい場所に到達するための2つの方法があります:

  • 注文ステータス: 各Tracking インスタンスは group.id を持つ order-events を消費しているため、すべてのインスタンスは十分なイベントを受け取り、自分が保持しているエミッターにのみプッシュします。
  • 配達員の位置: 注文が PICKED_UP ステータスにある場合、Tracking はRedisチャネル track:order:{id} に位置をパブリッシュします。接続を保持しているインスタンスがそのチャネルをサブスクライブします。12,500メッセージ/秒をKafkaを通じてプッシュするのではなく、一部の小さな部分が表示されるだけです。

負荷を軽減するために、位置をグループ化できます: クライアントは4~5秒ごとに1つのポイントを取得し、マップ上で自動的にモーションを補間します。

10. ピーク時間での負荷スパイク

昼食時間または販促キャンペーン中に、トラフィックは5〜10倍増加する可能性があります。システムを安定させるためのいくつかの技術:

  • メニューをキャッシュします。 メニューは読み取られることが多く、書き込みは少ないです。Redisでレストランごとのメニューをキャッシュし、レストランがディッシュを編集したときにキャッシュを無効化します。ほとんどの「レストランを表示」リクエストはデータベースに到達しません。
  • ユーザーごと、レストランごとにレート制限をゲートウェイで適用します。お店が過負荷の場合 (PLACED ステータスの注文が N を超える場合)、そのお店での新規注文の受け入れを一時的に停止して、顧客が40分待つことがないようにすることもできます。
  • 書き込みパスを処理パスから分離します。 注文APIは orders と outbox だけを書き込んでから戻ります。通知、送料計算、プロモーション提案はKafkaを介して非同期で実行されます。
  • ロット単位でディスパッチします。 需要が供給を超える場合、5~10秒ごとに注文と配達員をマッチングすることで (バッチマッチング)、すぐに1人ずつ招待するより良い結果が得られます。システムは2つの注文を同じレストランから同じ配達員にマージすることもできます。
  • 制御された低下。 Tracking が遅い場合、注文配置フローの優先度を保持し、位置更新プッシュを一時的に中止します。顧客は、注文できないことよりも数秒間マップが止まることを受け入れます。

11. よくある間違い

  • 配達員の位置をPostgreSQLに保存します。 12,500の書き込み/秒、各書き込みは1行を更新し、ブロートとロック競合を引き起こします。必要な場合のみ、移動履歴をバッチ単位でKafkaにプッシュしてデータウェアハウスに保存してください。
  • SELECT でステータスを確認してから UPDATE します。 2つのステートメント間には常に競合状態の隙間があります。WHERE 句に条件を入れる必要があります。
  • トランザクション内でKafkaに送信しますが、outboxなしで実行します。 トランザクションはロールバックしますが、メッセージは既に飛び去っています。あるいはその逆です。
  • タイムアウトのない SseEmitter を使用します。 ハングしている接続が蓄積され、リソースを枯渇させます。タイムアウトを設定して、クライアントが GET /api/orders/{id} を使用して最新のステータスを取得するときに再接続する必要があります。

注: 複数のサービス、Kafka、Redis GEO、outboxを備えたアーキテクチャは、十分に大きな規模でのみ価値があります。1日に数百の注文がある場合は、1つのSpring Bootモノリス、1つのPostgreSQL、5秒のポーリングで十分であり、運用がはるかに簡単です。

12. まとめ

この記事では、要件から実装まで、フードデリバリーシステムを設計しました。4つの主な要点があります:

  • 明確なステートマシンを使用して注文をモデル化します。
  • 条件付き UPDATE とオプティミスティックロックで競合を解決します。
  • Redis GEO をデータベース制約と組み合わせて、割り当てを調整します。
  • transactional outbox でKafkaの一貫性を保ちます。

全体的な原則は、一貫性の要件に従ってデータを分類することです。注文は強い一貫性が必要なため、リレーショナルデータベースにあります。配達員の位置は一貫性の低下を受け入れるため、Redisを通じて移動し、速度を優先します。この2つのタイプが分離されたら、問題のほとんどの難しい点はより扱いやすくなります。

system-designspring-bootrediskafkacase-study