场景设计:美团/饿了么外卖骑手抢单系统

本文讨论通用外卖抢单场景,不代表任何具体平台的内部实现。

1. 题目本质

骑手抢单并不只是“加一个分布式锁”。这个场景同时包含:

  • 一个订单只能被一名骑手成功领取;
  • 大量骑手可能在同一瞬间点击抢单;
  • 抢单接口必须低延迟;
  • 订单、骑手和配送任务的状态必须一致;
  • 重试、重复请求、网络超时不能导致重复接单;
  • 广播范围要合理,不能把所有订单推给所有骑手;
  • 平台还要考虑公平性、履约能力和反作弊;
  • 抢单后骑手失联或超时,需要回收并重新分配。

因此,完整系统至少包含三个问题:

1
2
3
哪些骑手可以看到订单?
谁最终抢到了订单?
抢到以后如何保证履约状态不乱?

2. 需求拆解

2.1 功能需求

  1. 商家出单后生成配送任务;
  2. 根据距离、运力、骑手状态筛选候选骑手;
  3. 将订单推送给候选骑手;
  4. 多名骑手并发抢单,最多一个成功;
  5. 成功骑手获得订单详情和导航信息;
  6. 失败骑手立即收到“已被抢走”;
  7. 超时无人接单时扩大广播范围或转自动派单;
  8. 骑手接单后可到店、取餐、送达;
  9. 异常时支持取消、转单、重新派单。

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
同一个 delivery_order_id 在 ACCEPTED 及其后续状态只能绑定一个有效 rider_id。

数据库层最好再加约束,避免应用 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
grab:orders:{cityId}:{gridId}

骑手 App 不一定只依赖推送,也可以定期拉取附近待抢订单。推送负责提醒,拉取负责恢复和校准,避免因推送丢失导致骑手看不到订单。

6. 核心问题:如何保证只有一个骑手成功

6.1 最简单可靠的数据库条件更新

把数据库作为最终裁决者:

1
2
3
4
5
6
7
8
UPDATE delivery_order
SET rider_id = :riderId,
status = 'ACCEPTED',
accepted_at = NOW(),
version = version + 1
WHERE id = :orderId
AND status IN ('BROADCASTING', 'RESERVED')
AND rider_id IS NULL;

根据受影响行数判断:

1
2
affected_rows = 1 -> 抢单成功
affected_rows = 0 -> 已被别人抢走、订单失效或状态不允许

这条 SQL 本身就是原子比较并更新,不需要先查再改。

错误写法:

1
2
3
SELECT status
if status == WAITING:
UPDATE rider_id

因为查询和更新之间存在竞争窗口,两名骑手可能都读到待抢状态。

6.2 是否需要悲观锁

可以使用:

1
2
3
SELECT * FROM delivery_order
WHERE id = :orderId
FOR UPDATE;

然后判断状态并更新。

但热点订单会让大量请求在数据库行锁上排队,浪费连接和线程。对简单状态竞争,条件更新通常更轻量。

6.3 Redis 原子预占 + 数据库最终确认

在超高并发下,可以先通过 Redis 快速裁剪竞争者:

1
SET grab:lock:{orderId} {riderId}:{requestId} NX PX 3000

只有获得短租约的骑手进入数据库确认流程,其他请求快速失败。

但必须明确:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- KEYS[1] = grab:order:{orderId}
-- ARGV[1] = riderId
-- ARGV[2] = requestId
-- ARGV[3] = expireMs

local status = redis.call('HGET', KEYS[1], 'status')
if status ~= 'OPEN' then
return {0, 'NOT_OPEN'}
end

local current = redis.call('HGET', KEYS[1], 'riderId')
if current then
return {0, 'TAKEN'}
end

redis.call('HSET', KEYS[1],
'status', 'RESERVED',
'riderId', ARGV[1],
'requestId', ARGV[2])
redis.call('PEXPIRE', KEYS[1], ARGV[3])
return {1, 'RESERVED'}

注意:如果整个 Hash 直接过期,可能把订单的其他缓存字段一起删除。生产中通常把“订单展示缓存”和“抢单租约”拆成不同 Key。

8. 幂等设计

8.1 为什么必须幂等

骑手点击一次抢单,可能由于以下原因发出多次请求:

  • App 连点;
  • 客户端超时重试;
  • 网关重试;
  • 网络抖动导致响应丢失;
  • 骑手切换网络;
  • 服务内部补偿重放。

每次请求应携带:

1
request_id / idempotency_key

推荐键:

1
riderId + orderId + clientRequestId

服务端保存结果:

1
idempotency:grab:{riderId}:{requestId} -> SUCCESS/FAILED + result

同一请求再次到达时直接返回第一次结果,而不是重新参与竞争。

8.2 “重复请求”和“再次抢单”要区分

  • 相同 requestId:同一次业务请求的重试,应返回相同结果;
  • requestId:用户再次点击,可重新判断当前订单状态;
  • 已成功骑手再次请求:应返回“已经由你接单”,而不是失败。

9. 数据库事务边界

抢单成功至少涉及:

  1. 更新配送订单;
  2. 更新骑手当前负载;
  3. 写抢单流水;
  4. 生成履约事件;
  5. 通知其他骑手订单已失效。

不能把所有远程 RPC 都放进数据库事务。

推荐本地事务:

1
2
3
4
5
更新配送订单
写 rider_order 关系
写 grab_record
写 outbox_event
提交事务

事务提交后,由 Outbox/CDC 发布事件:

1
OrderAccepted

下游异步处理:

  • 通知其他骑手;
  • 更新骑手画像和运力;
  • 计费;
  • 调度;
  • 风控;
  • 数据分析。
flowchart LR
    TX[本地事务] --> O1[更新配送任务]
    TX --> O2[写抢单流水]
    TX --> O3[写 Outbox]
    O3 --> CDC[CDC/轮询发布器]
    CDC --> MQ[消息队列]
    MQ --> PUSH[通知]
    MQ --> CAP[运力更新]
    MQ --> RISK[风控]
    MQ --> DATA[实时数仓]

10. 骑手状态的一致性

一个骑手可能同时抢多个订单,需要限制:

  • 最大持单数量;
  • 相互冲突的路线;
  • 当前是否暂停接单;
  • 是否被封禁;
  • 车辆能力是否满足订单类型。

如果只锁订单,不校验骑手状态,可能出现一个骑手瞬间抢到几十单。

可以在同一数据库事务内做条件更新:

1
2
3
4
5
6
UPDATE rider_capacity
SET active_order_count = active_order_count + 1,
version = version + 1
WHERE rider_id = :riderId
AND status = 'AVAILABLE'
AND active_order_count < max_order_count;

然后再更新订单。若任一条件失败,整个本地事务回滚。

如果订单和骑手数据位于不同库,则需要重新设计边界:

  • 将“可接单额度”做成配送域内的本地资源;
  • 使用预留额度 + 最终确认;
  • 或通过同一分片策略让相关数据尽量落在同库;
  • 避免为了抢单直接引入重型全局分布式事务。

11. 热点订单与惊群问题

一个高补贴订单可能被数千骑手同时抢,形成热点:

1
一个 orderId -> 一个 Redis Key -> 一行数据库 -> 大量瞬时请求

优化手段:

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
grab_ticket = Sign(orderId, riderId, roundId, expireAt, nonce)

抢单必须携带合法票据,避免非候选骑手直接枚举订单 ID。

14.2 请求签名和设备证明

校验:

  • 设备 ID;
  • App 版本;
  • 请求时间戳;
  • nonce;
  • 完整性证明;
  • TLS 和证书绑定策略;
  • 风险设备指纹。

14.3 公平窗口

部分场景可在极短时间窗口内收集多个意向,再按匹配分排序,而不是严格按到达纳秒决定结果。

但这会增加延迟,是否采用取决于业务目标:

1
极速抢单、公平分配、履约最优,这三者并不总能同时最大化。

14.4 取消惩罚和履约评分

将接单后取消率、超时率纳入候选匹配,不把公平性简单理解成完全随机。

15. API 设计示例

15.1 查询待抢订单

1
GET /api/v1/riders/{riderId}/grab-orders?cursor=xxx

响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"items": [
{
"orderId": 980001,
"merchantName": "示例门店",
"pickupDistanceMeters": 820,
"estimatedIncome": 9.5,
"expireAt": 1785303000000,
"grabTicket": "signed-token"
}
],
"nextCursor": "...",
"serverTime": 1785302900000
}

15.2 抢单

1
2
POST /api/v1/grab-orders/{orderId}/claim
Idempotency-Key: 01K...
1
2
3
4
5
6
7
8
9
{
"riderId": 70001,
"grabTicket": "signed-token",
"clientTime": 1785302901234,
"location": {
"lat": 31.2304,
"lon": 121.4737
}
}

响应状态要区分:

1
2
3
4
5
6
7
8
SUCCESS
ALREADY_ACCEPTED_BY_YOU
TAKEN_BY_OTHER
ORDER_EXPIRED
RIDER_CAPACITY_FULL
INVALID_TICKET
RISK_REJECTED
PROCESSING

不要只返回笼统的 false

16. 数据模型示例

16.1 配送订单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CREATE TABLE delivery_order (
id BIGINT PRIMARY KEY,
biz_order_id BIGINT NOT NULL,
merchant_id BIGINT NOT NULL,
rider_id BIGINT NULL,
status VARCHAR(32) NOT NULL,
broadcast_round INT NOT NULL DEFAULT 0,
lease_id VARCHAR(64) NULL,
accepted_at TIMESTAMP NULL,
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);

CREATE INDEX idx_delivery_status_created
ON delivery_order(status, created_at);

16.2 抢单流水

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE grab_record (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
rider_id BIGINT NOT NULL,
request_id VARCHAR(64) NOT NULL,
result VARCHAR(32) NOT NULL,
reject_reason VARCHAR(64) NULL,
server_received_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
UNIQUE (rider_id, request_id)
);

抢单流水既用于幂等,也用于争议审计和风控分析。

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. 延伸追问

  1. 抢单和自动派单同时发生,如何避免冲突?
  2. 如何支持一名骑手一次接多个顺路订单?
  3. 如何处理商家取消和骑手接单并发?
  4. 城市级 Redis 故障时怎样降级?
  5. 如何设计跨机房容灾而不发生双骑手接单?
  6. “先到先得”和“履约最优”冲突时如何取舍?
  7. 如何发现脚本抢单和 GPS 欺骗?

参考资料


场景设计:美团/饿了么外卖骑手抢单系统
https://allendericdalexander.github.io/2026/07/29/archtect/examples/02-food-delivery-rider-order-grabbing/
作者
AtLuoFu
发布于
2026年7月29日
许可协议