外卖订单分发系统设计
外卖订单分发系统 的核心不是“写一个最优匹配算法”——题目里已经说明分配算法是现成的,只需要调用。真正要设计的是:在订单和骑手数据都很大的情况下,如何持续构造合适的候选集,如何每 30 秒刷新未分配订单,如何把调用算法这件事做成一个稳定、可扩展、可观测的分布式调度系统。
本文按照系统设计面试的方式展开:先算清楚量级,再拆核心链路,最后讨论分区、数据模型、一致性和高可用。
题目输入:
| 输入 | 规模 |
|---|---|
| 外卖订单数据 | 1000 QPS |
| 骑手位置数据 | 300 万骑手,每个骑手每 10s 上报一次 |
| 刷新要求 | 每 30s 全量更新一次未分配订单给骑手 |
| 分配算法 | 已有,只需要调用 |
| 算法输入要求 | 一批订单 + 一批骑手,二者在一定经纬度范围内 |
本文脉络:
- 一、题目理解:系统重点不是算法,而是候选集与调度
- 二、容量估算:订单流和位置流到底有多大
- 三、整体架构:订单池、骑手位置索引、分区调度
- 四、地理分区:如何把经纬度范围变成可计算的批次
- 五、30 秒分发主流程:从未分配订单到推送骑手
- 六、数据模型与存储设计
- 七、一致性、幂等与并发控制
- 八、高可用、降级与可观测性
- 九、面试追问与总结
一、题目理解:系统重点不是算法,而是候选集与调度
面试里遇到“外卖订单分发系统”,很多人会马上开始讲匹配算法:距离、骑手负载、商家出餐时间、顺路单、ETA、收益最大化。但这个题目已经明确说了:
分配算法现成,只需要调用即可。
所以我们要把重点放在算法外面的工程系统上:
| 问题 | 系统要解决什么 |
|---|---|
| 订单持续进入 | 1000 QPS 新订单,如何进入未分配订单池 |
| 骑手位置持续变化 | 300 万骑手每 10s 上报一次,如何维护实时位置 |
| 算法要求局部输入 | 如何为每批订单找到一定经纬度范围内的候选骑手 |
| 每 30s 全量刷新 | 如何保证未分配订单每 30s 至少被重新分发一次 |
| 分布式执行 | 如何避免重复分配、漏分配、热点区域打爆 |
一句话概括:算法负责“怎么分”,系统负责“把哪些订单和哪些骑手放到一起,让算法能高效、稳定地分”。
二、容量估算:订单流和位置流到底有多大
2.1 订单数据
订单输入是 1000 QPS:
每秒新订单:1000 |
实际业务中订单不会全天均匀,午晚高峰可能是平均值的数倍。系统设计时要按高峰冗余考虑,例如按 3~5 倍峰值准备:
高峰订单 QPS:3000 ~ 5000 |
2.2 骑手位置数据
骑手总量 300 万,每个骑手 10 秒上报一次位置:
平均定位 QPS = 3,000,000 / 10 = 300,000 QPS |
这比订单流大得多。位置流的设计重点是:
- 写入吞吐要高;
- 只保留最新位置;
- 过期位置要自动失效;
- 查询要按地理范围快速取候选骑手。
2.3 30 秒全量刷新意味着什么
“每 30s 全量更新一次没有分配的订单给骑手”不等于每 30s 把所有订单和所有骑手做一次全局匹配。那样复杂度太高,也没有业务意义。
合理理解是:
系统每 30 秒扫描一轮未分配订单,把它们按地理区域分批,为每批订单找附近骑手,然后调用现成分配算法,输出新的分配结果。
核心是分区批处理,而不是全局大批处理。
三、整体架构:订单池、骑手位置索引、分区调度
整体系统可以拆成五层:
| 模块 | 职责 |
|---|---|
| 订单接入层 | 接收新订单、订单取消、订单状态变更 |
| 骑手位置接入层 | 接收 30 万 QPS 级别的位置上报 |
| 热数据索引层 | 维护未分配订单池和骑手实时位置索引 |
| 分区调度层 | 每 30s 按地理分区扫描未分配订单,生成算法调用批次 |
| 分配执行层 | 调用现成算法,写入分配结果,推送给骑手 |
这里最关键的是中间两层:热数据索引层和分区调度层。
订单和骑手都不能直接全量丢给算法。系统必须先做一层“候选集构造”:
未分配订单 → 按地理网格分桶 |
四、地理分区:如何把经纬度范围变成可计算的批次
算法要求输入是一批订单和一批骑手,并且二者在一定经纬度范围内。这个要求天然适合用地理网格处理。
常见做法有两种:
| 方案 | 说明 | 适用 |
|---|---|---|
| Geohash | 把经纬度编码成字符串前缀,相同前缀代表相近区域 | 实现简单,适合 Redis / KV |
| 固定网格 Cell | 按经纬度切成规则网格,例如 1km x 1km | 更可控,适合自定义调度 |
本文用“固定网格 Cell”来讲,因为更容易解释调度逻辑。
4.1 订单入池
新订单进入后,根据商家或取餐点经纬度计算所属网格:
cellId = geoToCell(merchantLat, merchantLng) |
然后写入对应网格的未分配订单池:
unassigned_orders:{cityId}:{cellId} |
订单状态变化时,如果订单被取消、已分配、已超时,也要从池子里移除或标记不可分配。
4.2 骑手位置索引
骑手每 10s 上报位置,只需要保留最新位置:
rider_location:{riderId} = { |
同时把骑手放入所在网格的实时索引:
active_riders:{cityId}:{cellId} |
骑手移动时,要从旧 cell 移到新 cell。为了避免频繁删除失败,可以用“最新位置版本号”或“更新时间校验”,查询时过滤过期数据。
4.3 候选范围扩展
对某个订单 cell,不能只找同一个 cell 的骑手。外卖配送通常要看半径,比如 2km、3km 或动态半径。
候选骑手范围可以这样扩展:
订单所在 cell |
扩圈规则可以根据区域密度动态调整:
- 市中心:同 cell + 周边 8 个 cell 可能足够;
- 郊区:骑手少,需要扩大半径;
- 高峰期:订单多,可以缩小候选范围控制算法压力;
- 夜间:骑手少,可以适当扩大范围。
五、30 秒分发主流程:从未分配订单到推送骑手
一个完整调度周期可以分成 7 步:
调度器按城市和网格切分任务
每 30s 扫描一轮活跃城市和有未分配订单的 cell。领取分区锁
每个{cityId, cellId}同一时间只允许一个调度 worker 处理,避免重复调用算法。拉取未分配订单
从unassigned_orders:{cityId}:{cellId}中取出订单批次,例如每批 200~1000 单。查询候选骑手
根据 cell 和扩展半径,从附近骑手索引里取可接单骑手,过滤离线、满载、位置过期的骑手。调用现成分配算法
输入订单列表和骑手列表,输出订单到骑手的匹配结果。写入分配结果
用 CAS / 状态机更新订单状态,确保一个订单只会被一个骑手分到。推送给骑手并记录事件
推送分配结果,同时写订单分配日志,便于排查和回放。
注意,这里的“每 30s 全量更新”不是由一个巨大任务完成,而是由很多分区任务并行完成:
city_1_cell_001 → worker A |
这样才能横向扩展。
六、数据模型与存储设计
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} |
Sorted Set 的好处是可以按时间或优先级取订单,避免老订单一直被新订单挤掉。
6.3 骑手位置索引
骑手位置是高频写、按范围读的数据:
rider_location:{riderId} |
为了控制脏数据:
- 骑手位置设置 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 |
只有更新成功,才算分配成功。如果返回 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 降级策略
如果系统压力过大,可以分层降级:
- 缩小候选骑手范围,减少算法输入规模;
- 限制每个 cell 每轮最大订单数,老订单优先;
- 算法超时时使用简单兜底策略,例如最近骑手优先;
- 非核心区域降低刷新频率,例如从 30s 放宽到 60s;
- 对异常订单进入人工/离线补偿队列。
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 按地理网格生成任务,构造候选集后调用现成算法,再用状态机和幂等机制写回结果。只要分区、索引、调度和一致性设计清楚,这个系统就能在高并发下稳定运行。