外卖系统设计:订单、商户、骑手和实时状态
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 channeltrack: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 处理,优先速度。一旦将这两类数据分开,这个问题的大部分难点就变得更容易处理了。