订单追踪和快递员实时位置系统设计
1. 概述
点外卖或下单送货时,用户想知道订单处于什么状态,快递员在地图上的位置。本文从需求到代码设计这样一个系统。涵盖数据模型、API、处理流程,以及三个难点:抢单并发、数据一致性和突发流量。
读完本文,你将获得一个参考架构和 Spring Boot 3.x 代码片段,可用于开始实施。
2. 需求
2.1. 功能需求
- 快递员从应用定期发送 GPS 位置。
- 用户近实时查看订单状态和快递员位置,延迟低于 3 秒。
- 快递员接单。每个订单只能分配给一个快递员。
- 订单状态遵循生命周期:
PENDING → ASSIGNED → PICKED_UP → DELIVERING → DELIVERED。某些状态可转换为CANCELLED。 - 保存状态历史用于对账和投诉处理。
2.2. 规模估算
| 指标 | 数值 |
|---|---|
| 峰值在线快递员 | 50,000 |
| 位置上报周期 | 4 秒 |
| 位置写入速率 | ~12,500 msg/s |
| 打开追踪屏幕的用户 | ~200,000 个连接 |
| 峰值新订单 | ~300 个/秒 |
2.3. 约束条件
- 位置是临时数据。丢失几个点是可接受的,但不能显示用旧位置覆盖新位置。
- 订单状态是关键数据。不能丢失且不能乱序。
- 移动网络不稳定,所以应用会重试和批量发送。
由此得出一个关键决策:分离两个流。位置流需要高吞吐并容忍丢失。状态流需要事务性和持久性。
3. 整体架构
快递员应用 ──HTTP batch──> 位置 API ──> Kafka [shipper-locations]
│
▼
位置 Consumer ──> Redis (最新位置, GEO)
│
└──> Redis Pub/Sub [order:{id}:loc]
│
用户应用 <──────── SSE ──────── 追踪网关 <────────────┘
快递员应用 ──HTTP──> 订单服务 ──> PostgreSQL (orders, order_events, outbox)
│
└──> Outbox relay ──> Kafka [order-events]
每个组件各司其职:
- Kafka 吸收位置写入的负载,并通过使用
shipperId作为 key 来保持每个快递员的顺序。 - Redis 仅保存每个快递员的最新位置。系统不将每个 GPS 点写入关系数据库。
- PostgreSQL 是订单状态的事实来源。
- SSE 从服务器单向推送数据到客户端。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 表确保事件可靠地发送到 Kafka。第 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
120 秒的 TTL 使断线快递员自动从系统消失。无需单独的清理任务。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>
<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 leader 宕机时丢失几个 GPS 点是可以接受的,换取比 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();
}
}
两点需要注意:
- Key 是
shipperId。 同一快递员的所有点落入同一分区,使 consumer 按发送顺序处理它们。 - 过滤器
filter丢弃shipperId与路径不符的点。实际上,应从 JWT 获取shipperId而非信任请求体。
6.2. 写入最新位置,丢弃旧点
分区内顺序还不够。应用在恢复网络后可能重新发送旧批,该批次可能在更新的点之后到达。因此 consumer 必须比较 ts 并仅在点更新时写入。比较和写入需要原子性,所以使用 Lua 脚本:
@Component
public class LocationConsumer {
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()));
}
}
}
处理流程如下:
- 如果点比存储的点更旧或相等,脚本返回
0。此时 consumer 停止,不更新 GEO 也不推送给客户端。 GEOADD命令的参数顺序是经度在前,纬度在后。这是常见的错误。- 系统仅在快递员有订单时发布。空闲快递员不产生扇出流量。
注意: 上述脚本仅涉及一个 key,因此可在 Redis Cluster 上运行。如果要将
GEOADD合并到脚本中,key 必须在同一 slot。此时使用 hash tag 如{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);
}
});
}
}
注册 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}
网关是无状态的,可以水平扩展任意数量的实例。持有客户端 SSE 连接的实例将通过 Pub/Sub 接收消息并推送下去。对于 200,000 个连接,应启用虚拟线程(spring.threads.virtual.enabled=true)或切换到 WebFlux,使每个连接无需占用一个平台线程。
7. 难点与解决方案
7.1. 并发冲突:两个快递员同时抢单
新订单显示给附近的多个快递员。两人可能同时点击“接单”。错误的做法是先读状态、检查 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 期间锁定行。第二个快递员必须等待,然后重新评估条件 status = 'PENDING',发现不再匹配并得到 rows == 0。控制器返回 409 Conflict,应用显示“订单已被接取”。无需分布式锁。
7.2. 一致性:状态机与 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 发送失败”的问题。事件在同一个事务中写入 outbox 表和 orders。定期的中继读取未发布的行并推送到 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 子句允许多个中继并行运行而不处理重复行。中继保证至少一次:如果在发送后但标记 published 前宕机,事件会重新发送。因此 order-events 的 consumer 必须是幂等的。例如,consumer 忽略 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;。 - 自适应发送周期。 服务器返回建议 header,如
X-Next-Interval: 8,当系统过载时让应用拉长发送周期。静止的快递员发送频率更低。 - 客户端聚合。 应用将 2-3 个点合并到一个请求。HTTP 请求数大幅减少,Kafka 消息数保持不变。
- 按快递员限流。 限制每个快递员最多 1 请求/秒,防止故障应用或虚假客户端滥用。
- 资源隔离。 位置流运行在独立的 Redis 集群和实例组上。如果位置流宕机,接单和状态更新仍继续运行。
8. 常见错误和何时不应这样做
- 每个 GPS 点都写入 PostgreSQL。 12,500 insert/s 加索引会很快导致数据库窒息。如果需要保存路线用于对账,让另一个 consumer 以较低频率将批量数据写入适当的存储如 TimescaleDB 或对象存储。
- 用分布式锁(Redisson)来抢单。 这比对事实来源执行有条件 UPDATE 更复杂且更容易出错。
- 相信请求到达顺序。 移动网络不保证顺序。必须始终比较
ts,对于状态则比较version。 - 关键数据用 Redis Pub/Sub。 Pub/Sub 是即发即忘。Subscriber 掉线即丢失消息。对位置还好,但对状态事件不适合。状态事件应通过 Kafka。客户端重新连接 SSE 时,调用
GET /orders/{id}获取最新快照。
注意: 如果系统只有几百个快递员,这个架构过度设计了。单个服务写入 Redis 位置,结合客户端每 5 秒轮询一次就够了。当实际数据强制你这么做时,再添加 Kafka 和 SSE 网关。
9. 结论
本文按核心原则设计了订单追踪和快递员位置系统:分离临时数据和关键数据。
- 位置流经 Kafka 后进入 Redis。系统容忍丢失,但通过 Lua 脚本比较时间戳来防止旧点覆盖。
- 订单状态存于 PostgreSQL。并发冲突通过有条件 UPDATE 解决,一致性通过状态机和 outbox 模式保持。
- 突发流量通过 Kafka 吸收,结合丢弃旧点、自适应发送周期和资源隔离。
基于这个框架,可以扩展 ETA 计算、通过 GEOSEARCH 按位置合并订单,或保存路线用于对账等功能。