リアルタイム注文追跡とシッパー位置情報システムの設計

1. 概要

フードデリバリーや配送の際、顧客は注文の状態とシッパーの現在位置をマップ上で知りたいと思っています。本記事では、要件からコード実装まで、そのようなシステムを設計します。データモデル、API、処理フロー、そして3つの難題—注文受け取り時の競合状態、データ一貫性、バーストトラフィック—を説明します。

本記事を読み終わると、参考となるアーキテクチャとSpring Boot 3.xの実装例が得られ、本番環境への導入を開始できます。

2. 要件

2.1. 機能要件

  • シッパーは定期的にアプリからGPS位置情報を送信します。
  • 顧客はほぼリアルタイムで注文状態とシッパー位置をエンドツーエンド遅延3秒未満で表示できます。
  • シッパーが注文を受け取ります。各注文は正確に1人のシッパーにのみ割り当てられます。
  • 注文状態はライフサイクルに従います: PENDING → ASSIGNED → PICKED_UP → DELIVERING → DELIVERED。一部の状態から CANCELLED に遷移可能です。
  • 決済と紛争処理のため、状態遷移履歴を記録します。

2.2. スケール推定

指標 値
ピーク時間のオンラインシッパー数 50,000
位置情報送信周期 4秒
位置情報書き込みスループット ~12,500 msg/s
トラッキング画面を開いているユーザー数 ~200,000接続
ピーク時間の新規注文数 ~300注文/s

2.3. 制約

  • 位置情報は一時的なデータです。数ポイント欠落しても問題ありませんが、古い位置が新しい位置を上書きしてはいけません。
  • 注文状態は重要なデータです。データ損失や順序の乱れがあってはいけません。
  • モバイルネットワークは不安定なため、アプリは再送と一括送信を行います。

ここから1つの重要な決定が生まれます: 2つのフローを分離する。位置情報フローは高スループットが必要で、データ欠落が許容されます。状態フローはトランザクション性と耐久性が必要です。

3. 全体アーキテクチャ

App shipper ──HTTP batch──> Location API ──> Kafka [shipper-locations]
                                                   │
                                                   ▼
                                        Location Consumer ──> Redis (最新位置情報, GEO)
                                                   │
                                                   └──> Redis Pub/Sub [order:{id}:loc]
                                                               │
App khách <──────── SSE ──────── Tracking Gateway <────────────┘

App shipper ──HTTP──> Order Service ──> PostgreSQL (orders, order_events, outbox)
                                              │
                                              └──> Outbox relay ──> Kafka [order-events]

各コンポーネントが専門化されています:

  • Kafka は位置情報書き込みの高負荷を吸収し、keyを shipperId にすることでシッパーごとに順序を保証します。
  • Redis は各シッパーの最新位置情報のみを保持します。GPSポイント1つ1つを関係データベースに書き込みません。
  • PostgreSQL は注文状態の単一の情報源です。
  • SSE はサーバーからクライアントへの一方向データ配信です。WebSocketより簡単で、このユースケースに十分です。

4. データモデル

4.1. 注文用PostgreSQL

CREATE TABLE orders (
    id          BIGSERIAL PRIMARY KEY,
    customer_id BIGINT      NOT NULL,
    shipper_id  BIGINT,
    status      VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    version     INT         NOT NULL DEFAULT 0,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_events (
    id         BIGSERIAL PRIMARY KEY,
    order_id   BIGINT      NOT NULL REFERENCES orders(id),
    from_status VARCHAR(20),
    to_status  VARCHAR(20) NOT NULL,
    actor_id   BIGINT      NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE outbox (
    id           BIGSERIAL PRIMARY KEY,
    aggregate_id BIGINT      NOT NULL,
    payload      JSONB       NOT NULL,
    published    BOOLEAN     NOT NULL DEFAULT false,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_outbox_unpublished ON outbox(id) WHERE published = false;

version カラムは楽観的ロックに使用します。order_events テーブルは追記のみで、更新も削除もしません。outbox テーブルはイベント損失を防ぎます。詳細は7章で説明します。

4.2. 位置情報用Redis

shipper:loc:{shipperId}         HASH  {lat, lng, ts}       TTL 120s
shippers:geo:{city}             GEO   member = shipperId
shipper:active-order:{shipperId} STRING orderId

TTL120秒により、シッパーが接続を失うと自動的にシステムから削除されます。別途クリーンアップジョブは不要です。GEO keyは都市ごとに分離されているため、GEOSEARCHで最寄りシッパーを探す際に全国をスキャンする必要がありません。

5. API

POST /api/v1/shippers/{shipperId}/locations      body: [ {lat,lng,ts}, ... ]  -> 202
POST /api/v1/orders/{orderId}/claim              -> 200 | 409
POST /api/v1/orders/{orderId}/transitions        body: {to: "PICKED_UP"}  -> 200 | 409
GET  /api/v1/orders/{orderId}                    -> 状態 + 現在位置
GET  /api/v1/orders/{orderId}/track              -> text/event-stream

位置情報APIは 202 Accepted を返します。これはデータをKafkaに送信したらすぐに応答するためです。このAPIはバッチで受け取り、アプリが電波を失った間のポイントをまとめて送信できます。

6. 位置情報処理フロー

以下のdependencyが必要です:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</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>

Kafka用のJSONシリアライザ設定:

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: 1
    consumer:
      group-id: location-consumer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: "com.nonla.tracking"

acks: 1 は意図的な選択です。リーダーBroker障害時に数ポイント失われるのは許容できますが、そのかわり書き込み遅延が acks: all より低くなります。

6.1. 位置情報の受け取り

public record LocationUpdate(long shipperId, double lat, double lng, long ts) {}

@RestController
@RequestMapping("/api/v1/shippers")
public class LocationController {

    private final KafkaTemplate<String, LocationUpdate> kafka;

    public LocationController(KafkaTemplate<String, LocationUpdate> kafka) {
        this.kafka = kafka;
    }

    @PostMapping("/{shipperId}/locations")
    public ResponseEntity<Void> push(@PathVariable long shipperId,
                                     @RequestBody List<LocationUpdate> batch) {
        batch.stream()
             .filter(u -> u.shipperId() == shipperId)
             .forEach(u -> kafka.send("shipper-locations", String.valueOf(shipperId), u));
        return ResponseEntity.accepted().build();
    }
}

2つの重要なポイント:

  • keyは shipperId。 同じシッパーのすべてのポイントが同じpartitionに入り、送信順序が保証されます。
  • filter は本体の shipperId が異なるポイントを除外します。実装では、JWT から shipperId を取得し、本体のデータを信頼しないべきです。

6.2. 最新位置を記録し、古いポイントを破棄

partitionの順序性だけでは不十分です。アプリが古いバッチを再送信し、そのバッチが新しいポイントより後に到着する可能性があります。consumerは ts を比較し、新しいポイントのみ記録する必要があります。比較と書き込みはアトミックである必要があるため、Lua scriptを使用します:

@Component
public class LocationConsumer {

    // 新しいポイントのみ記録するLua script
    private static final RedisScript<Long> UPSERT_IF_NEWER = RedisScript.of("""
        local cur = redis.call('HGET', KEYS[1], 'ts')
        if cur and tonumber(cur) >= tonumber(ARGV[3]) then return 0 end
        redis.call('HSET', KEYS[1], 'lat', ARGV[1], 'lng', ARGV[2], 'ts', ARGV[3])
        redis.call('EXPIRE', KEYS[1], 120)
        return 1
        """, Long.class);

    private final StringRedisTemplate redis;

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

    @KafkaListener(topics = "shipper-locations", concurrency = "6")
    public void onLocation(LocationUpdate u) {
        Long updated = redis.execute(UPSERT_IF_NEWER,
                List.of("shipper:loc:" + u.shipperId()),
                String.valueOf(u.lat()), String.valueOf(u.lng()), String.valueOf(u.ts()));

        if (updated == null || updated == 0) return; // 古いポイント、スキップ

        redis.opsForGeo().add("shippers:geo:hcm", new Point(u.lng(), u.lat()),
                String.valueOf(u.shipperId()));

        String orderId = redis.opsForValue().get("shipper:active-order:" + u.shipperId());
        if (orderId != null) {
            redis.convertAndSend("order:" + orderId + ":loc",
                    "{\"lat\":%s,\"lng\":%s,\"ts\":%d}".formatted(u.lat(), u.lng(), u.ts()));
        }
    }
}

処理フロー:

  • scriptは古いポイント以上のポイントの場合 0 を返します。この場合、consumerはGEOと顧客への配信をスキップします。
  • GEOADD は経度、緯度の順で受け取ります。これは非常に一般的なエラーです。
  • システムはシッパーが注文を持つときのみpublishします。アイドル状態のシッパーはトラフィックを生成しません。

注意: 上記scriptは1つのkeyのみにアクセスするため、Redis Clusterで動作します。GEOADDをscriptに含める場合、すべてのkeyが同じslotにある必要があります。このときはハッシュタグ {hcm}:loc:42 と {hcm}:geo のようなものを使用してください。

6.3. SSEでクライアントに配信

@RestController
public class TrackingController implements MessageListener {

    private final Map<String, Set<SseEmitter>> subscribers = new ConcurrentHashMap<>();

    @GetMapping(value = "/api/v1/orders/{orderId}/track",
                produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter track(@PathVariable String orderId) {
        SseEmitter emitter = new SseEmitter(Duration.ofMinutes(30).toMillis());
        Set<SseEmitter> set = subscribers.computeIfAbsent(orderId, k -> ConcurrentHashMap.newKeySet());
        set.add(emitter);
        Runnable cleanup = () -> set.remove(emitter);
        emitter.onCompletion(cleanup);
        emitter.onTimeout(cleanup);
        emitter.onError(e -> cleanup.run());
        return emitter;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String channel = new String(message.getChannel());   // order:123:loc
        String orderId = channel.split(":")[1];
        String body = new String(message.getBody());
        subscribers.getOrDefault(orderId, Set.of()).forEach(e -> {
            try {
                e.send(SseEmitter.event().name("location").data(body));
            } catch (IOException ex) {
                e.completeWithError(ex);
            }
        });
    }
}

listenerをpattern order:*:loc で登録します:

@Bean
RedisMessageListenerContainer container(RedisConnectionFactory cf, TrackingController listener) {
    var c = new RedisMessageListenerContainer();
    c.setConnectionFactory(cf);
    c.addMessageListener(listener, new PatternTopic("order:*:loc"));
    return c;
}

クライアントは以下のようなイベントを受け取ります:

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

Gatewayはステートレスなため、任意の数のインスタンスでスケールできます。シッパーのSSE接続を保有するインスタンスがPub/Subメッセージをキャッチしてクライアントにプッシュします。200,000接続の場合、virtual threads (spring.threads.virtual.enabled=true) を有効にするか、WebFluxに移行して、各接続がplatform threadを占有しないようにします。

7. 難題と解決策

7.1. 競合: 2人のシッパーが同じ注文を受け取る

新規注文は近くの複数のシッパーに表示されます。2人が同時に「受け取る」をクリックする可能性があります。間違った方法は状態を読んで PENDING をチェックしてから書き込むことです。その間に隙間があるためです。正しい方法は条件付きUPDATEを使用することです:

@Service
public class OrderClaimService {

    private final JdbcTemplate jdbc;

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

    @Transactional
    public boolean claim(long orderId, long shipperId) {
        int rows = jdbc.update("""
            UPDATE orders
               SET shipper_id = ?, status = 'ASSIGNED', version = version + 1, updated_at = now()
             WHERE id = ? AND status = 'PENDING'
            """, shipperId, orderId);
        if (rows == 0) return false; // 他のシッパーが既に受け取っている

        jdbc.update("INSERT INTO order_events(order_id, from_status, to_status, actor_id) VALUES (?, 'PENDING', 'ASSIGNED', ?)",
                orderId, shipperId);
        jdbc.update("INSERT INTO outbox(aggregate_id, payload) VALUES (?, ?::jsonb)",
                orderId, "{\"orderId\":%d,\"status\":\"ASSIGNED\",\"shipperId\":%d}".formatted(orderId, shipperId));
        return true;
    }
}

PostgreSQLは UPDATE 実行中に行をロックします。2番目のシッパーは待機し、その後条件 status = 'PENDING' を再評価して、もう合致していないことを発見し、rows == 0 を受け取ります。controllerは 409 Conflict を返し、アプリは「注文は既に誰かが受け取っています」というメッセージを表示します。distributed lockは不要です。

7.2. 一貫性: state machineとoutbox

すべての状態遷移は有効である必要があります。遷移ルールをenum内にコード化します:

public enum OrderStatus {
    PENDING, ASSIGNED, PICKED_UP, DELIVERING, DELIVERED, CANCELLED;

    public boolean canTransitionTo(OrderStatus next) {
        return switch (this) {
            case PENDING    -> next == ASSIGNED || next == CANCELLED;
            case ASSIGNED   -> next == PICKED_UP || next == CANCELLED;
            case PICKED_UP  -> next == DELIVERING;
            case DELIVERING -> next == DELIVERED;
            case DELIVERED, CANCELLED -> false;
        };
    }
}

主な動作のテスト:

class OrderStatusTest {

    @Test
    void shouldAllowHappyPath() {
        assertThat(OrderStatus.PENDING.canTransitionTo(OrderStatus.ASSIGNED)).isTrue();
        assertThat(OrderStatus.DELIVERING.canTransitionTo(OrderStatus.DELIVERED)).isTrue();
    }

    @Test
    void shouldRejectCancelAfterPickup() {
        assertThat(OrderStatus.PICKED_UP.canTransitionTo(OrderStatus.CANCELLED)).isFalse();
    }

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

状態遷移時に、WHERE id = ? AND version = ? という条件を使用します。行が更新されない場合、誰かが先に注文を変更しています。この場合、409 を返し、クライアントに再ロードさせます。

Outboxパターン は「DBへの書き込みは成功したがKafkaへの送信は失敗」という問題を解決します。イベントはordersと同じトランザクション内に outbox テーブルに書き込まれます。relayが定期的に未publish行を読み込んでKafkaに送信します:

@Scheduled(fixedDelay = 500)
@Transactional
public void relay() {
    var rows = jdbc.queryForList(
        "SELECT id, aggregate_id, payload::text AS payload FROM outbox WHERE published = false ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED");
    for (var r : rows) {
        kafka.send("order-events", r.get("aggregate_id").toString(), (String) r.get("payload")).join();
        jdbc.update("UPDATE outbox SET published = true WHERE id = ?", r.get("id"));
    }
}

FOR UPDATE SKIP LOCKED 句により、複数のrelayが並行実行でも同じ行を処理しません。relayは at-least-once を保証します: 送信後だがpublicationマークより前に停止した場合、イベントは再送信されます。したがって、order-events のconsumerはidempotentである必要があります。例えば、処理済み version より小さい以上のイベントをスキップします。

order-events のconsumerは shipper:active-order:{shipperId} keyを設定または削除する場所でもあります。これにより、位置情報フローは配信する注文がわかります。

7.3. バーストトラフィック

昼間の食事時間、大雨、またはセール期間には、数分で負荷が3~5倍に増加する可能性があります。以下の対応方法があります:

  • Kafkaがバッファとして機能。 consumerが遅い場合、lagが増加しますがAPIはまだ素早く応答します。partitionを事前に増やします(例: 48)。実行中にpartitionを増やすと、keyに基づく順序がシャッフルされます。
  • 古いポイントを破棄。 lagが大きい場合、30秒以上古いGPSポイントは表示値がありません。consumerはすぐスキップできます: if (System.currentTimeMillis() - u.ts() > 30_000) return;。
  • 適応的送信周期。 サーバーはヘッダー X-Next-Interval: 8 のような提案を返し、システム過負荷時にアプリが送信周期を拡大できます。静止しているシッパーはより低い周期で送信できます。
  • クライアント側の集約。 アプリが2~3ポイントを1つのrequestにまとめます。HTTPリクエスト数が大幅に減る一方、Kafkaメッセージ数は同じです。
  • シッパーごとのrate limit。 各シッパーを最大1request/秒に制限して、不正なアプリやクライアントのスパムをブロックします。
  • リソースの分離。 位置情報フローはRedisクラスタと個別のインスタンスグループで実行されます。このフローが停止してもorder receivingと状態更新は正常に動作します。

8. よくあるエラーと非推奨パターン

  • すべてのGPSポイントをPostgreSQLに記録。 12,500 insert/s とindexはすぐにdatabaseのボトルネックになります。対象データを保存する必要がある場合、別のconsumerを低い頻度でTimescaleDBやobject storageなどの適切なstoreに書き込みします。
  • 注文受け取りに分散ロック(Redisson)を使用。 このアプローチはUPDATE条件を使用するより複雑でエラーが起こりやすいです。
  • requestの到着順序を信頼。 モバイルネットワークは順序を保証しません。常に ts を比較し、状態は version を比較する必要があります。
  • 重要なデータにRedis Pub/Subを使用。 Pub/SubはFire-and-forget型です。subscriberが接続を失うとメッセージは失われます。これは位置情報には問題ありませんが、状態イベントには不適切です。状態イベントはKafkaを経由する必要があります。クライアントがSSEに再接続するときは、GET /orders/{id} を呼び出して最新スナップショットを取得します。

注意: システムに数百のシッパーしかいない場合、このアーキテクチャはやり過ぎです。単一のserviceが位置情報をRedisに書き込み、クライアントが5秒ごとにpollingするだけで十分です。実際の規模に基づいてKafkaとgateway SSEを追加してください。

9. まとめ

本記事は、注文追跡とシッパー位置情報システムの設計を、コア原則に基づいて行いました: 一時的なデータを重要なデータから分離する。

  • 位置情報はKafkaを経由してRedisに入ります。システムは欠落を許容しますが、Luaスクリプトでtimestampを比較して古いポイントの上書きをブロックします。
  • 注文状態はPostgreSQLに存在します。競合はUPDATE条件で解決し、一貫性はstate machineとoutbox patternで保証されます。
  • バーストトラフィックはKafkaによって吸収され、古いポイントの破棄、適応的な送信周期、およびリソース分離と組み合わせられます。

このフレームワークから、ETA計算、GEOSEARCHによる注文マッチング、対象処理の為の移動軌跡の記録など、さらに多くの機能を構築できます。

system-designspring-bootkafkaredisrealtime