外卖订单分发系统设计

外卖订单分发系统 的核心不是“写一个最优匹配算法”——题目里已经说明分配算法是现成的,只需要调用。真正要设计的是:在订单和骑手数据都很大的情况下,如何持续构造合适的候选集,如何每 30 秒刷新未分配订单,如何把调用算法这件事做成一个稳定、可扩展、可观测的分布式调度系统。

本文按照系统设计面试的方式展开:先算清楚量级,再拆核心链路,最后讨论分区、数据模型、一致性和高可用。

题目输入:

输入 规模
外卖订单数据 1000 QPS
骑手位置数据 300 万骑手,每个骑手每 10s 上报一次
刷新要求 每 30s 全量更新一次未分配订单给骑手
分配算法 已有,只需要调用
算法输入要求 一批订单 + 一批骑手,二者在一定经纬度范围内

本文脉络:

  • 一、题目理解:系统重点不是算法,而是候选集与调度
  • 二、容量估算:订单流和位置流到底有多大
  • 三、整体架构:订单池、骑手位置索引、分区调度
  • 四、地理分区:如何把经纬度范围变成可计算的批次
  • 五、30 秒分发主流程:从未分配订单到推送骑手
  • 六、数据模型与存储设计
  • 七、一致性、幂等与并发控制
  • 八、高可用、降级与可观测性
  • 九、面试追问与总结

一、题目理解:系统重点不是算法,而是候选集与调度

面试里遇到“外卖订单分发系统”,很多人会马上开始讲匹配算法:距离、骑手负载、商家出餐时间、顺路单、ETA、收益最大化。但这个题目已经明确说了:

分配算法现成,只需要调用即可。

所以我们要把重点放在算法外面的工程系统上:

问题 系统要解决什么
订单持续进入 1000 QPS 新订单,如何进入未分配订单池
骑手位置持续变化 300 万骑手每 10s 上报一次,如何维护实时位置
算法要求局部输入 如何为每批订单找到一定经纬度范围内的候选骑手
每 30s 全量刷新 如何保证未分配订单每 30s 至少被重新分发一次
分布式执行 如何避免重复分配、漏分配、热点区域打爆

一句话概括:算法负责“怎么分”,系统负责“把哪些订单和哪些骑手放到一起,让算法能高效、稳定地分”。

二、容量估算:订单流和位置流到底有多大

2.1 订单数据

订单输入是 1000 QPS:

每秒新订单:1000
每 30 秒新增订单:1000 * 30 = 30,000
每天订单量:1000 * 86400 = 8640 万

实际业务中订单不会全天均匀,午晚高峰可能是平均值的数倍。系统设计时要按高峰冗余考虑,例如按 3~5 倍峰值准备:

高峰订单 QPS:3000 ~ 5000
30 秒新增未分配订单:9 万 ~ 15 万

2.2 骑手位置数据

骑手总量 300 万,每个骑手 10 秒上报一次位置:

平均定位 QPS = 3,000,000 / 10 = 300,000 QPS

这比订单流大得多。位置流的设计重点是:

  • 写入吞吐要高;
  • 只保留最新位置;
  • 过期位置要自动失效;
  • 查询要按地理范围快速取候选骑手。

2.3 30 秒全量刷新意味着什么

“每 30s 全量更新一次没有分配的订单给骑手”不等于每 30s 把所有订单和所有骑手做一次全局匹配。那样复杂度太高,也没有业务意义。

合理理解是:

系统每 30 秒扫描一轮未分配订单,把它们按地理区域分批,为每批订单找附近骑手,然后调用现成分配算法,输出新的分配结果。

核心是分区批处理,而不是全局大批处理。

三、整体架构:订单池、骑手位置索引、分区调度

外卖订单分发系统整体架构

整体系统可以拆成五层:

模块 职责
订单接入层 接收新订单、订单取消、订单状态变更
骑手位置接入层 接收 30 万 QPS 级别的位置上报
热数据索引层 维护未分配订单池和骑手实时位置索引
分区调度层 每 30s 按地理分区扫描未分配订单,生成算法调用批次
分配执行层 调用现成算法,写入分配结果,推送给骑手

这里最关键的是中间两层:热数据索引层分区调度层

订单和骑手都不能直接全量丢给算法。系统必须先做一层“候选集构造”:

未分配订单 → 按地理网格分桶
骑手位置 → 按地理网格建实时索引

调度器每 30 秒:
取某个网格内未分配订单
扩展周边网格找候选骑手
调用分配算法
写回分配结果

四、地理分区:如何把经纬度范围变成可计算的批次

算法要求输入是一批订单和一批骑手,并且二者在一定经纬度范围内。这个要求天然适合用地理网格处理。

常见做法有两种:

方案 说明 适用
Geohash 把经纬度编码成字符串前缀,相同前缀代表相近区域 实现简单,适合 Redis / KV
固定网格 Cell 按经纬度切成规则网格,例如 1km x 1km 更可控,适合自定义调度

本文用“固定网格 Cell”来讲,因为更容易解释调度逻辑。

外卖订单分发的地理网格候选集构造

4.1 订单入池

新订单进入后,根据商家或取餐点经纬度计算所属网格:

cellId = geoToCell(merchantLat, merchantLng)

然后写入对应网格的未分配订单池:

unassigned_orders:{cityId}:{cellId}

订单状态变化时,如果订单被取消、已分配、已超时,也要从池子里移除或标记不可分配。

4.2 骑手位置索引

骑手每 10s 上报位置,只需要保留最新位置:

rider_location:{riderId} = {
lat,
lng,
status,
capacity,
updateTime
}

同时把骑手放入所在网格的实时索引:

active_riders:{cityId}:{cellId}

骑手移动时,要从旧 cell 移到新 cell。为了避免频繁删除失败,可以用“最新位置版本号”或“更新时间校验”,查询时过滤过期数据。

4.3 候选范围扩展

对某个订单 cell,不能只找同一个 cell 的骑手。外卖配送通常要看半径,比如 2km、3km 或动态半径。

候选骑手范围可以这样扩展:

订单所在 cell
+ 一圈相邻 cell
+ 必要时再扩一圈

扩圈规则可以根据区域密度动态调整:

  • 市中心:同 cell + 周边 8 个 cell 可能足够;
  • 郊区:骑手少,需要扩大半径;
  • 高峰期:订单多,可以缩小候选范围控制算法压力;
  • 夜间:骑手少,可以适当扩大范围。

五、30 秒分发主流程:从未分配订单到推送骑手

外卖订单分发 30 秒调度流程

一个完整调度周期可以分成 7 步:

  1. 调度器按城市和网格切分任务
    每 30s 扫描一轮活跃城市和有未分配订单的 cell。

  2. 领取分区锁
    每个 {cityId, cellId} 同一时间只允许一个调度 worker 处理,避免重复调用算法。

  3. 拉取未分配订单
    unassigned_orders:{cityId}:{cellId} 中取出订单批次,例如每批 200~1000 单。

  4. 查询候选骑手
    根据 cell 和扩展半径,从附近骑手索引里取可接单骑手,过滤离线、满载、位置过期的骑手。

  5. 调用现成分配算法
    输入订单列表和骑手列表,输出订单到骑手的匹配结果。

  6. 写入分配结果
    用 CAS / 状态机更新订单状态,确保一个订单只会被一个骑手分到。

  7. 推送给骑手并记录事件
    推送分配结果,同时写订单分配日志,便于排查和回放。

注意,这里的“每 30s 全量更新”不是由一个巨大任务完成,而是由很多分区任务并行完成:

city_1_cell_001 → worker A
city_1_cell_002 → worker B
city_2_cell_010 → worker C
...

这样才能横向扩展。

六、数据模型与存储设计

6.1 订单表

订单主表建议落在数据库中,作为最终状态来源:

字段 说明
order_id 订单 ID
city_id 城市
merchant_lat / merchant_lng 商家位置
user_lat / user_lng 用户位置
status 待分配、已分配、已取消、已完成
assigned_rider_id 分配骑手
dispatch_version 分发版本号
create_time / update_time 时间字段

6.2 未分配订单池

未分配订单池是热数据,适合放 Redis / KV / 内存索引:

Key: unassigned_orders:{cityId}:{cellId}
Value: orderId set / sorted set
Score: createTime 或 priority

Sorted Set 的好处是可以按时间或优先级取订单,避免老订单一直被新订单挤掉。

6.3 骑手位置索引

骑手位置是高频写、按范围读的数据:

rider_location:{riderId}
active_riders:{cityId}:{cellId}

为了控制脏数据:

  • 骑手位置设置 TTL,例如 30s 或 60s;
  • 查询候选骑手时过滤 now - updateTime > 20s 的位置;
  • 骑手离线、满载、休息时从活跃索引移除;
  • 位置更新异步写入索引,允许短暂最终一致。

6.4 分发任务表 / 日志

为了排查问题,建议记录每轮调度:

字段 说明
task_id 调度任务 ID
city_id / cell_id 分区
round_time 所属 30s 轮次
order_count 输入订单数
rider_count 输入骑手数
assigned_count 成功分配数
algorithm_cost_ms 算法耗时
status 成功 / 失败 / 超时

这张表不一定参与在线查询,但对故障定位非常关键。

七、一致性、幂等与并发控制

7.1 如何避免一个订单被重复分配

分区锁只能降低重复执行概率,不能作为唯一保障。真正的保障要落在订单状态更新上。

推荐用状态机 + CAS:

UPDATE orders
SET status = 'ASSIGNED',
assigned_rider_id = ?,
dispatch_version = dispatch_version + 1
WHERE order_id = ?
AND status = 'UNASSIGNED';

只有更新成功,才算分配成功。如果返回 0 行,说明订单已经被其他 worker 分配或取消,本次结果要丢弃。

7.2 如何避免骑手被过量分配

骑手也需要容量控制,例如当前最多背几单、是否已满载、是否接单中。

可以维护骑手接单状态:

rider_capacity:{riderId}

写入分配结果时,同样要做容量扣减的原子校验:

如果 rider 可接单容量 > 0:
扣减容量
分配订单
否则:
本次匹配失败,订单回到未分配池

如果订单更新成功但骑手容量扣减失败,就要做补偿,或者把两者放到同一个事务/原子脚本里处理。

7.3 调度任务幂等

每个调度批次生成唯一 taskId

taskId = cityId + cellId + roundTime + batchNo

算法调用、结果写入、推送都带上 taskId,重复执行时可以识别:

  • 订单已分配:跳过;
  • 推送已发送:不重复推;
  • 调度日志已存在:更新状态而不是插入新记录。

八、高可用、降级与可观测性

8.1 高可用设计

风险 方案
位置上报流量过大 Kafka 削峰,位置消费者水平扩展
Redis 热点 cell cell 拆分、按商圈二级分片、热点迁移
算法服务超时 设置超时和熔断,失败批次延后重试
调度 worker 宕机 分区锁设置 TTL,任务可被其他 worker 接管
数据不一致 订单状态 CAS、调度日志、补偿扫描

8.2 降级策略

如果系统压力过大,可以分层降级:

  1. 缩小候选骑手范围,减少算法输入规模;
  2. 限制每个 cell 每轮最大订单数,老订单优先;
  3. 算法超时时使用简单兜底策略,例如最近骑手优先;
  4. 非核心区域降低刷新频率,例如从 30s 放宽到 60s;
  5. 对异常订单进入人工/离线补偿队列。

8.3 核心监控指标

指标 含义
unassigned_order_count 未分配订单积压
dispatch_round_delay 30s 调度轮次延迟
algorithm_latency_p99 算法调用耗时
candidate_rider_count 候选骑手数量
assign_success_rate 分配成功率
rider_location_freshness 骑手位置新鲜度
duplicate_assign_conflict 重复分配冲突数

其中最关键的是两个指标:

  • 未分配订单积压是否持续上升
  • 调度轮次是否超过 30s 无法完成

如果这两个指标失控,说明系统处理能力已经跟不上订单和位置变化。

九、面试追问与总结

追问一:为什么不能直接把所有未分配订单和所有骑手丢给算法?

因为规模太大。订单每 30s 新增 3 万,高峰可能 10 万级;骑手总量 300 万。全局匹配输入过大,算法耗时和网络传输都会不可控。外卖配送天然有地理局部性,应该按城市、商圈、网格分区处理。

追问二:如果某个区域订单很多、骑手很少怎么办?

可以动态扩大候选范围,同时给该区域提高调度优先级。如果仍然分不出去,要进入业务降级:提高补贴、跨区调度、延迟承诺或人工兜底。这属于调度策略,不是单纯技术问题。

追问三:30 秒一轮会不会太慢?

题目要求是 30s 全量刷新未分配订单。实际系统可以做到“事件触发 + 周期兜底”:

  • 新订单进入时立即尝试一次分配;
  • 每 30s 对未分配订单做全量兜底刷新;
  • 超时订单提高优先级,避免一直沉底。

这样既满足题目要求,也能提升实时性。

追问四:如何保证骑手位置是新的?

位置记录必须带 updateTime,并设置 TTL。查询候选骑手时过滤过期位置,例如超过 20s 未更新就不参与本轮分配。否则系统可能把订单分给已经离线或位置漂移很远的骑手。


总结:外卖订单分发系统的关键不在于手写分配算法,而在于把海量订单和骑手位置组织成可计算的局部批次。订单进入未分配池,骑手进入实时位置索引,调度器每 30s 按地理网格生成任务,构造候选集后调用现成算法,再用状态机和幂等机制写回结果。只要分区、索引、调度和一致性设计清楚,这个系统就能在高并发下稳定运行。