外卖系统设计:订单、商户、骑手和实时状态

1. 概述

一个外卖应用涉及三方对订单的操作:客户下单、商户确认、骑手配送。这三个操作并发进行,在不稳定的移动网络上,而且集中在午餐时段。

这篇文章将介绍完整的设计过程:需求、数据模型、API、状态机、如何向骑手分单而不重复、如何保持数据库和 Kafka 的一致性,以及如何向客户推送实时状态。读完这篇,你会有一个具体的设计框架可以应用或用于系统设计面试。

2. 需求

2.1. 功能需求

  • 客户查看菜单、下单、付款、跟踪订单状态和骑手位置。
  • 商户收到订单、确认或拒绝、然后报告菜肴已准备好。
  • 骑手启用在线状态、定期发送位置、接受或拒绝订单邀请、更新已取货和已送达。
  • 客户可以在商户开始烹饪前取消订单。

2.2. 规模和约束

假设以下数字作为计算基础:

订单:        100 万订单/天,午餐时段(11:00–13:00)约占 30%
              → 午餐高峰平均约 42 单/秒,突发可达 200 单/秒
骑手:        5 万在线骑手,每 4 秒发送一次位置
              → 每秒约 1.25 万次位置写入
实时追踪:    客户打开追踪屏幕 → 数十万个实时连接

从这些数字可以看出三个主要约束:

  • 订单必须强一致性。 不能丢单,也不能将一张单分配给两个骑手。
  • 骑手位置可以接受最终一致性。 丢失一个位置点没关系,但写入必须非常快。我们不会将位置写入关系型数据库。
  • 下单必须幂等。 移动网络经常导致客户端重新发送请求。

3. 总体架构

 客户端 / 商户 / 骑手 App
          │  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 语句:

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。最后两个状态没有后续状态。

测试来验证这些规则:

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 使用操作名称设计(/confirm、/accept),而不是通用的 PATCH status=...。这样更容易检查权限:只有订单所属商户可以调用 /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;
    }
}

数据库确保只有一条 UPDATE 语句能匹配条件。获胜方得到 true。失败方得到 false 并返回 HTTP 409 及最新状态,让应用显示“商户已确认订单”。

7.2. 向骑手分单

这是最难的部分。我们需要找到离商户最近的骑手,并确保每张单只分配给一个骑手。

步骤 1:使用 Redis GEO 存储位置。 每次骑手发送位置时,我们将其写入一个排序集合 GEO 并刷新一个 heartbeat key:

@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 集合中的成员不会自动过期。因此,带 30 秒 TTL 的 key shipper:alive:* 用于过滤已关闭应用或网络中断的骑手。定期任务可能会将死骑手从 shipper:geo 中 ZREM 出去。

在每秒 1.25 万次写入的情况下,单个 Redis 实例仍然能处理得很好。当扩展到多个城市时,你可以按区域分片:shipper:geo:hcm、shipper:geo:hn。

步骤 2:依次邀请骑手,每人一份邀请。 Dispatch Service 依次向离得最近的骑手发送邀请。每份邀请有 20 秒的时限。为了防止一个骑手同时被邀请两张单,我们用 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 确保即使两个骑手同时点击“接受”(例如由于旧邀请超时并被重新发送),也只有一个人能赢。

注意: 不要用 Redis 锁作为分配订单的真实来源。Redis 可能会故障转移并丢失锁。此时,数据库中的约束是最后的保护层。

8. 数据库和 Kafka 之间的一致性

创建订单后,Order Service 必须发出 OrderPlaced 事件以便商户收到通知。如果你写数据库然后调用 kafkaTemplate.send(),服务可能会在两步之间崩溃。结果是订单已存在但商户永远不会知道。这就是双写问题。

解决方案是事务 outbox:在与订单相同的事务中将事件写入 outbox 表。

@Transactional
public long placeOrder(PlaceOrderCommand cmd) {
    long orderId = orderRepository.insert(cmd); // 重复 key 时抛出 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。控制器捕获此错误,找到旧订单并返回它,而不是创建新订单。

一个 relay 读取 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 允许多个 relay 实例运行而不会处理重复的记录。消息的 key 是 orderId,所以同一订单的所有事件落入同一 partition 并保持顺序。

Relay 保证了至少一次交付。如果服务在 send 之后但 commit 之前崩溃,事件会被重新发送。因此,consumer 必须是幂等的,例如存储已处理的 event_id 或使用 UPDATE ... WHERE status = ... 就像第 7.1 部分那样。

9. 向客户推送实时状态

客户需要看到两种更新:订单状态(少、重要)和骑手位置(多、可以错过)。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);
            }
        });
    }
}

每张订单都有一个 emitter 列表(客户可能在多个设备上打开)。push 发送一个具有名称的事件,例如 status 或 location。客户端收到:

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

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

运行多个实例时,客户的 SSE 连接只存在于一个实例上。有两种方式让事件到达正确的地方:

  • 订单状态: 每个 Tracking 实例用自己的 group.id 消费 order-events,所以每个实例都接收所有事件,只推送给自己持有的 emitter。
  • 骑手位置: 当订单处于 PICKED_UP 状态时,Tracking 将位置发布到 Redis channel track:order:{id}。持有连接的实例订阅该 channel。我们不会通过 Kafka 推送每秒 1.25 万条消息,因为只有一小部分被查看。

为了减少负载,你可以汇总位置:客户每 4–5 秒只需要一个点,然后在地图上自己计算中间的移动。

10. 午餐时段的流量突发

午餐时间或运行促销活动时,流量可能增加 5–10 倍。一些技术有助于系统保持稳定:

  • 缓存菜单。 菜单读多写少。你可以在 Redis 中按 restaurant_id 缓存,并在商户修改菜肴时清除缓存。大部分“查看商户”请求都不会接触数据库。
  • 在网关处按用户和商户进行速率限制。 你也可以暂停接收已超载的商户的订单(订单超过 N 张处于 PLACED 状态),而不是让客户等待 40 分钟。
  • 将写入与处理分离。 下单 API 只写 orders 和 outbox 然后立即返回。通知、计算送货费、推荐优惠券等通过 Kafka 异步运行。
  • 批量分单。 当需求超过供给时,每 5–10 秒进行一次批量匹配而不是立即邀请每个人,会得到更好的结果。系统也可以将两张来自同一商户的单分配给一个骑手。
  • 受控降级。 当 Redis Tracking 变慢时,优先保持下单流程,暂停位置推送。客户宁可地图停留几秒也不想下不了单。

11. 常见错误

  • 将骑手位置写入 PostgreSQL。 每秒 1.25 万次写入,每次更新一行,会导致表膨胀和锁争用。你应该只在需要对帐时以批量方式将历史行程推送到 Kafka 或数据仓库。
  • 先 SELECT 检查状态再 UPDATE。 两条语句之间总有一个窗口允许竞态条件。你应该将条件放在 WHERE 子句中。
  • 在 @Transactional 中发送 Kafka 但没有 outbox。 事务回滚但消息已发出,或反之亦然。
  • 使用没有超时的单个 SseEmitter。 悬挂连接逐渐积累并耗尽资源。你应该设置超时,让客户通过 GET /api/orders/{id} 自动重新连接以获取最新状态。

注意: 具有 Kafka、Redis GEO 和 outbox 的多服务架构只在足够大的规模上才值得。对于每天只有几百张订单的系统,一个 Spring Boot 单体、一个 PostgreSQL 和 5 秒的轮询已经足够,而且易于运维得多。

12. 总结

在这篇文章中,你从需求到实现设计了一个外卖系统。有四个主要观点:

  • 用清晰的状态机对订单建模。
  • 用带条件的 UPDATE 和乐观锁来解决并发。
  • 用 Redis GEO 结合数据库约束来分单。
  • 通过事务 outbox 保持与 Kafka 的一致性。

贯穿始终的原则是根据一致性需求对数据分类。订单需要强一致性所以在关系型数据库中。骑手位置接受丢失所以通过 Redis 处理,优先速度。一旦将这两类数据分开,这个问题的大部分难点就变得更容易处理了。

system-designspring-bootrediskafkacase-study