订单追踪和快递员实时位置系统设计

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 按位置合并订单,或保存路线用于对账等功能。

system-designspring-bootkafkaredisrealtime