场景设计:美团/饿了么外卖骑手抢单系统
本文讨论通用外卖抢单场景,不代表任何具体平台的内部实现。
1. 题目本质
骑手抢单并不只是“加一个分布式锁”。这个场景同时包含:
- 一个订单只能被一名骑手成功领取;
- 大量骑手可能在同一瞬间点击抢单;
- 抢单接口必须低延迟;
- 订单、骑手和配送任务的状态必须一致;
- 重试、重复请求、网络超时不能导致重复接单;
- 广播范围要合理,不能把所有订单推给所有骑手;
- 平台还要考虑公平性、履约能力和反作弊;
- 抢单后骑手失联或超时,需要回收并重新分配。
因此,完整系统至少包含三个问题:
1 | |
2. 需求拆解
2.1 功能需求
- 商家出单后生成配送任务;
- 根据距离、运力、骑手状态筛选候选骑手;
- 将订单推送给候选骑手;
- 多名骑手并发抢单,最多一个成功;
- 成功骑手获得订单详情和导航信息;
- 失败骑手立即收到“已被抢走”;
- 超时无人接单时扩大广播范围或转自动派单;
- 骑手接单后可到店、取餐、送达;
- 异常时支持取消、转单、重新派单。
2.2 非功能需求
- 抢单核心链路 P99 尽可能低;
- 订单不重分、不漏分;
- 服务重启和消息重复不影响正确性;
- 能承受热点订单瞬时竞争;
- 能审计“谁在什么时候抢单、为什么成功或失败”;
- 核心操作具备明确的状态机和补偿机制。
3. 先设计订单配送状态机
没有状态机,抢单系统很快会变成一堆互相冲突的 if。
stateDiagram-v2
[*] --> CREATED: 配送任务创建
CREATED --> BROADCASTING: 开始广播
BROADCASTING --> RESERVED: 某骑手获得短暂预占
RESERVED --> ACCEPTED: 业务事务确认成功
RESERVED --> BROADCASTING: 预占超时/确认失败
BROADCASTING --> DISPATCHING: 多轮抢单无人领取
DISPATCHING --> ACCEPTED: 系统派单成功
ACCEPTED --> ARRIVED_STORE: 骑手到店
ARRIVED_STORE --> PICKED_UP: 已取餐
PICKED_UP --> DELIVERED: 已送达
ACCEPTED --> CANCELLED: 取消/转单
BROADCASTING --> CANCELLED: 订单取消
DELIVERED --> [*]
CANCELLED --> [*]
核心约束:
1 | |
数据库层最好再加约束,避免应用 Bug 直接破坏业务不变量。
4. 总体架构
flowchart LR
OMS[订单系统] --> TASK[配送任务服务]
TASK --> OUTBOX[(Outbox/CDC)]
OUTBOX --> MQ[事件总线]
MQ --> MATCH[候选骑手匹配服务]
MATCH --> GEO[(骑手位置索引)]
MATCH --> CAP[骑手运力/状态服务]
MATCH --> BOARD[(待抢订单池)]
MATCH --> PUSH[Push/WebSocket 网关]
RIDER[骑手 App] --> GRAB[抢单网关]
GRAB --> CLAIM[抢单核心服务]
CLAIM --> REDIS[(Redis 原子预占)]
CLAIM --> DB[(配送任务数据库)]
CLAIM --> RIDER_STATE[骑手状态服务]
CLAIM --> EVENT[抢单结果事件]
EVENT --> PUSH
EVENT --> BILL[计费/履约/风控]
EVENT --> AUDIT[审计日志]
核心链路最好保持短小:
1 | |
推荐、画像、复杂风控等不应全部同步阻塞在最核心的竞争临界区内。
5. 候选骑手如何选
不能把一个订单广播给整个城市。候选骑手匹配通常考虑:
- 骑手当前位置与商家距离;
- 骑手是否在线、是否可接单;
- 当前持有订单数量;
- 已有路线与新订单的顺路程度;
- 交通工具和配送能力;
- 骑手所属站点、服务区域;
- 预计到店时间;
- 历史履约质量;
- 风控状态;
- 商家出餐时间和订单承诺时效。
5.1 分轮广播
flowchart TD
O[新配送订单] --> R1[第一轮:近距离高匹配骑手]
R1 -->|无人接单 5 秒| R2[第二轮:扩大半径]
R2 -->|仍无人接单| R3[第三轮:更大范围并提高补贴]
R3 -->|仍无人接单| A[转自动派单/人工调度]
这里的时间和半径只是示意,生产参数应动态配置,并结合城市、天气、时段、运力供需进行调整。
5.2 待抢订单池
可以按城市和空间网格组织:
1 | |
骑手 App 不一定只依赖推送,也可以定期拉取附近待抢订单。推送负责提醒,拉取负责恢复和校准,避免因推送丢失导致骑手看不到订单。
6. 核心问题:如何保证只有一个骑手成功
6.1 最简单可靠的数据库条件更新
把数据库作为最终裁决者:
1 | |
根据受影响行数判断:
1 | |
这条 SQL 本身就是原子比较并更新,不需要先查再改。
错误写法:
1 | |
因为查询和更新之间存在竞争窗口,两名骑手可能都读到待抢状态。
6.2 是否需要悲观锁
可以使用:
1 | |
然后判断状态并更新。
但热点订单会让大量请求在数据库行锁上排队,浪费连接和线程。对简单状态竞争,条件更新通常更轻量。
6.3 Redis 原子预占 + 数据库最终确认
在超高并发下,可以先通过 Redis 快速裁剪竞争者:
1 | |
只有获得短租约的骑手进入数据库确认流程,其他请求快速失败。
但必须明确:
Redis 预占只是性能优化,数据库中的配送状态才是最终事实。
因为 Redis 可能发生:
- 主从切换时锁数据丢失;
- 网络分区导致客户端误判;
- 持锁服务宕机;
- 锁过期但数据库事务仍在执行;
- 缓存与数据库状态不一致。
更稳妥的流程:
sequenceDiagram
participant R1 as 骑手 A
participant R2 as 骑手 B
participant API as 抢单服务
participant Redis as Redis
participant DB as 配送数据库
participant MQ as 事件总线
par 并发请求
R1->>API: grab(orderId, requestIdA)
R2->>API: grab(orderId, requestIdB)
end
API->>Redis: 原子 SET NX PX
Redis-->>API: 骑手 A 获得预占
API-->>R2: 抢单失败/处理中
API->>DB: 条件 UPDATE 绑定 riderId
alt 数据库更新成功
DB-->>API: affectedRows = 1
API->>MQ: 发布 OrderAccepted
API-->>R1: 抢单成功
else 数据库更新失败
DB-->>API: affectedRows = 0
API->>Redis: 校验 token 后释放预占
API-->>R1: 订单已失效
end
释放 Redis 锁时不能直接 DEL,必须比较锁值,避免误删已经被下一位持有者获得的新锁。通常使用 Lua 脚本完成“比较并删除”。
7. 更推荐的实现:单次 Lua 原子判定
如果 Redis 中维护待抢状态,可使用 Lua 同时完成:
- 检查订单是否仍可抢;
- 检查骑手是否重复请求;
- 设置预占骑手;
- 设置短 TTL;
- 记录请求 ID。
伪代码:
1 | |
注意:如果整个 Hash 直接过期,可能把订单的其他缓存字段一起删除。生产中通常把“订单展示缓存”和“抢单租约”拆成不同 Key。
8. 幂等设计
8.1 为什么必须幂等
骑手点击一次抢单,可能由于以下原因发出多次请求:
- App 连点;
- 客户端超时重试;
- 网关重试;
- 网络抖动导致响应丢失;
- 骑手切换网络;
- 服务内部补偿重放。
每次请求应携带:
1 | |
推荐键:
1 | |
服务端保存结果:
1 | |
同一请求再次到达时直接返回第一次结果,而不是重新参与竞争。
8.2 “重复请求”和“再次抢单”要区分
- 相同
requestId:同一次业务请求的重试,应返回相同结果; - 新
requestId:用户再次点击,可重新判断当前订单状态; - 已成功骑手再次请求:应返回“已经由你接单”,而不是失败。
9. 数据库事务边界
抢单成功至少涉及:
- 更新配送订单;
- 更新骑手当前负载;
- 写抢单流水;
- 生成履约事件;
- 通知其他骑手订单已失效。
不能把所有远程 RPC 都放进数据库事务。
推荐本地事务:
1 | |
事务提交后,由 Outbox/CDC 发布事件:
1 | |
下游异步处理:
- 通知其他骑手;
- 更新骑手画像和运力;
- 计费;
- 调度;
- 风控;
- 数据分析。
flowchart LR
TX[本地事务] --> O1[更新配送任务]
TX --> O2[写抢单流水]
TX --> O3[写 Outbox]
O3 --> CDC[CDC/轮询发布器]
CDC --> MQ[消息队列]
MQ --> PUSH[通知]
MQ --> CAP[运力更新]
MQ --> RISK[风控]
MQ --> DATA[实时数仓]
10. 骑手状态的一致性
一个骑手可能同时抢多个订单,需要限制:
- 最大持单数量;
- 相互冲突的路线;
- 当前是否暂停接单;
- 是否被封禁;
- 车辆能力是否满足订单类型。
如果只锁订单,不校验骑手状态,可能出现一个骑手瞬间抢到几十单。
可以在同一数据库事务内做条件更新:
1 | |
然后再更新订单。若任一条件失败,整个本地事务回滚。
如果订单和骑手数据位于不同库,则需要重新设计边界:
- 将“可接单额度”做成配送域内的本地资源;
- 使用预留额度 + 最终确认;
- 或通过同一分片策略让相关数据尽量落在同库;
- 避免为了抢单直接引入重型全局分布式事务。
11. 热点订单与惊群问题
一个高补贴订单可能被数千骑手同时抢,形成热点:
1 | |
优化手段:
11.1 限制广播候选数
只推送给最匹配的一小批骑手,而不是无限广播。
11.2 接入层快速失败
在网关或抢单服务中缓存订单已结束状态,后续请求不再进入数据库。
11.3 Redis 预裁剪
用原子预占让绝大多数失败请求在内存层结束。
11.4 请求合并不是核心方案
对于同一订单可以短时间合并状态查询,但真正的抢单请求仍需逐一返回结果,不能简单把不同骑手请求合并为一个。
11.5 结果广播
订单被抢后,立刻向相关骑手推送 ORDER_TAKEN,让客户端移除卡片,减少继续点击产生的无效请求。
12. 推送丢失怎么办
不能假设 WebSocket 或 Push 一定可靠。
骑手端应采用:
1 | |
例如:
- 推送到达:立即更新;
- 推送断开:客户端重连后携带
lastEventSeq; - 服务端补发缺失事件,或让客户端重新拉取当前待抢列表;
- 订单卡片点击前再次以服务端状态为准。
推送只是“提醒”,抢单结果必须由抢单服务返回或查询确认。
13. 超时回收和重新广播
骑手获得短预占后,可能在数据库确认前宕机。预占必须有 TTL,并由定时任务或延迟消息检查。
sequenceDiagram
participant Claim as 抢单服务
participant Lease as 预占租约
participant Delay as 延迟队列
participant DB as 数据库
participant Dispatch as 调度服务
Claim->>Lease: 创建 3 秒预占
Claim->>Delay: 投递 LeaseCheck(orderId, leaseId)
Delay->>DB: 查询最终状态
alt 已 ACCEPTED
DB-->>Delay: 已确认
else 仍 RESERVED/OPEN
Delay->>Lease: 校验租约是否过期
Delay->>Dispatch: 重新广播或自动派单
end
延迟任务必须携带版本号或 lease_id,否则旧的超时任务可能误回收新的预占。
14. 公平性与反作弊
纯“谁网络快谁抢到”可能导致:
- 机房附近或高性能设备长期占优;
- 自动化脚本抢单;
- GPS 模拟和位置欺骗;
- 骑手抢单后高频取消;
- 团伙代抢;
- 内部接口被逆向调用。
可设计:
14.1 服务端资格令牌
订单推送时给候选骑手签发短时 grab_ticket:
1 | |
抢单必须携带合法票据,避免非候选骑手直接枚举订单 ID。
14.2 请求签名和设备证明
校验:
- 设备 ID;
- App 版本;
- 请求时间戳;
- nonce;
- 完整性证明;
- TLS 和证书绑定策略;
- 风险设备指纹。
14.3 公平窗口
部分场景可在极短时间窗口内收集多个意向,再按匹配分排序,而不是严格按到达纳秒决定结果。
但这会增加延迟,是否采用取决于业务目标:
1 | |
14.4 取消惩罚和履约评分
将接单后取消率、超时率纳入候选匹配,不把公平性简单理解成完全随机。
15. API 设计示例
15.1 查询待抢订单
1 | |
响应:
1 | |
15.2 抢单
1 | |
1 | |
响应状态要区分:
1 | |
不要只返回笼统的 false。
16. 数据模型示例
16.1 配送订单
1 | |
16.2 抢单流水
1 | |
抢单流水既用于幂等,也用于争议审计和风控分析。
17. 故障场景推演
17.1 Redis 成功,数据库失败
处理方式:
- 数据库条件更新失败则释放预占;
- 释放时比较锁 token;
- 由租约超时任务兜底;
- 不向骑手返回成功。
17.2 数据库成功,响应丢失
骑手重试同一 requestId,幂等记录返回原成功结果。
17.3 数据库成功,MQ 发布失败
通过 Outbox/CDC 重试发布。不能使用“提交数据库后直接发 MQ,发失败就算了”。
17.4 抢单服务在事务提交后宕机
幂等记录、订单状态和 Outbox 都已提交。重试时查到订单已归该骑手,应返回成功。
17.5 两个机房同时处理同一订单
需要保证同一订单的写请求有唯一裁决点,例如:
- 数据库主节点条件更新;
- 按
orderId路由到固定主分片; - 单 Leader 状态机;
- 严格定义跨地域写入策略。
多活不是让两个地域同时随意改同一行。
18. 监控指标
核心指标包括:
- 抢单请求 QPS、成功率、失败原因分布;
- P50/P95/P99 延迟;
- Redis 预占成功但数据库确认失败比例;
- 幂等命中率;
- 同一订单竞争请求数;
- 广播轮次与最终接单率;
- 从出单到接单的时长;
- 无人接单率;
- 超时回收数量;
- 数据库条件更新冲突率;
- 推送到达率和重连率;
- 骑手接单后取消率。
19. 常见错误回答
错误一:只使用分布式锁
锁解决不了数据库最终状态、消息可靠发布、幂等和骑手容量一致性。
错误二:先查订单再更新
存在明显并发窗口,应使用条件更新或原子状态机。
错误三:Redis 抢到就立即告诉客户端成功
如果数据库落单失败,会产生“客户端成功、系统没有订单”的严重不一致。
错误四:把所有逻辑放进一个大事务
跨 RPC 长事务会占用连接、放大锁等待,并且难以恢复。
错误五:默认推送绝不丢
推送只能加速,不应成为状态正确性的唯一来源。
20. 面试总结话术
我会把系统分为候选匹配、订单广播和原子抢单三部分。候选匹配根据位置、运力、路线和风控筛选骑手,采用分轮广播减少惊群。抢单请求带幂等键和服务端签发的候选票据,核心并发控制使用数据库条件更新作为最终裁决;高并发下可增加 Redis 短租约预裁剪,但不能把 Redis 当唯一事实源。订单、抢单流水和 Outbox 在同一本地事务提交,通知、运力画像和计费通过事件异步完成。对预占超时、响应丢失、MQ 失败和服务宕机分别通过租约、幂等、Outbox 和状态对账兜底。
21. 延伸追问
- 抢单和自动派单同时发生,如何避免冲突?
- 如何支持一名骑手一次接多个顺路订单?
- 如何处理商家取消和骑手接单并发?
- 城市级 Redis 故障时怎样降级?
- 如何设计跨机房容灾而不发生双骑手接单?
- “先到先得”和“履约最优”冲突时如何取舍?
- 如何发现脚本抢单和 GPS 欺骗?
参考资料
- Redis SET: https://redis.io/docs/latest/commands/set/
- Redis Scripting: https://redis.io/docs/latest/develop/programmability/eval-intro/
- Apache Kafka Documentation: https://kafka.apache.org/documentation/
- Transactional Outbox Pattern: https://microservices.io/patterns/data/transactional-outbox.html