リアルタイム注文追跡とシッパー位置情報システムの設計
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による注文マッチング、対象処理の為の移動軌跡の記録など、さらに多くの機能を構築できます。