第一版是最直觉的做法:订单一进来,查附近可接单的骑手,取最近的那个派给他。这个逻辑在单量少的时候没问题,饭点一到全面暴露。
一是逐单分配只能得到局部最优。三点钟方向来了一单,派给了最近的骑手 A;下一秒同一栋楼又来一单,本来 A 顺路一起带走最划算,但 A 已经被占了,只能派给两公里外的 B。逐单决策看不到「下一单」,必然做出短视的分配。
二是一名骑手被多单同时派中。并发的两个派单请求同时查到骑手 A 空闲,同时派给他,骑手端弹出两个新订单,他接了一个另一个就卡在「已派单未接单」状态。
三是高峰期算不完就积压。逐单处理时每单都要查 GEO、算距离、排序,单量上来后处理速度跟不上进单速度,待派池越来越大,最后订单等十几分钟没人接。
四是调度服务一挂,整个配送就停了。没有任何降级路径。
所以四个设计:批次化批量匹配(看到一批订单和一批骑手,做整体分配)、骑手维度加锁防重复派单、批量处理提升吞吐、降级为抢单模式保底。
核心认知是:派单不是「请求响应」问题,是「资源分配」问题。想清这一点,批量化就是必然选择——分配问题必须看到全局才能做好。
SET NX 加锁,抢到才继续。where status = '待派单',影响行数为 0 说明这单已被别的流程处理了。锁防骑手被重复占用,条件更新防订单被重复派出,两个方向都要防。批次窗口的取舍是这个模块最本质的权衡。窗口大分配优但用户等得久,窗口小响应快但退化成逐单。我们的处理是按时段动态调整——平峰期单量少,窗口开大一点(攒够订单才有批量的意义);高峰期单量密集,窗口可以缩小(3 秒内就能攒到足够多的订单)。参数不是拍的,是压测加观察不同窗口下的平均取送距离定出来的。
为什么不用严格最优的分配算法。匈牙利算法能给出最优解,但复杂度对订单数和骑手数敏感,而我们必须在百毫秒内出结果——超时了订单就要等下一批。而且现实里的「最优」本身是模糊的(各维度权重怎么定就是主观的),追求数学最优的意义有限。贪心加局部交换的解和最优解的差距,远小于「权重设置不同」带来的差距。这个判断能体现工程感。
指派 vs 抢单不是二选一,是主备关系。指派(系统决定派给谁)分配质量高,但依赖调度服务;抢单(广播让骑手抢)质量差但极其可靠。我们用指派为主、抢单为降级。有些平台是反过来的(抢单为主),那取决于骑手是自由职业还是专职——自由骑手更接受抢单,专职骑手需要系统保证单量公平。这个业务差异要能说出来。
踩过的坑:一名骑手被两个区域的批次同时派中。骑手在两个配送区域的交界处,两个区域的批次调度并行跑,各自的 GEO 查询都把他算进了候选集,然后各派了一单给他。骑手端弹出两个订单,接了一个另一个卡死。修法是骑手维度的分布式锁(跨区域全局唯一),抢不到锁的那单回池。教训是:并行的分配流程之间必须有全局互斥点,光靠单个流程内部的算法保证是不够的。
踩过的坑二:接单超时回池后又派给同一个骑手,订单一直派不出去。骑手手机没网或者放兜里没看到,20 秒超时回池;下一批调度他还是分数最高,又派给他,又超时。这单在池子里循环了十几分钟,用户投诉。修法是回池时记录「该骑手对该订单的拒绝/超时」,后续批次把这个组合排除,并且重派次数超过阈值就进异常池转人工或触发加价。任何「失败后重试」的机制都要防止重试给同一个失败源。
没做的部分:没做基于路径规划的精确顺路计算。目前的顺路分是用直线距离的重合度近似的,真实的顺路要看实际道路(隔着一条河的两个点直线很近但路上要绕很远)。接路径规划服务的成本是每次计算都要调外部接口,在批量匹配里要算几百次,成本和延迟都不可接受。
批次决策耗时:在批次开始和分配落定处打时间戳。必须说明批次规模——「50 单 × 200 骑手的矩阵,P95 约 120ms」才是有意义的表述,只说 120ms 没有参考价值。压测时要覆盖高峰的批次规模。
重复派单验证:构造场景——让两个区域的批次同时选中同一个骑手(把骑手位置设在交界处),并发触发调度,检查该骑手最终只被派了一单,另一单正确回池。跑多轮。这类并发正确性用确定性场景验证,不要用比率。
批量匹配的收益:对比「逐单就近分配」和「批量匹配」两种策略下的平均取送距离,在同一批历史订单数据上离线回放对比。要诚实用「有可观察的下降」而不是给一个精确百分比——因为收益高度依赖订单密度(订单越密集顺路机会越多),不同时段不同区域差别很大,报一个固定百分比会被追问穿。
降级验证:故障演练——停掉调度服务,验证自动切到抢单模式、订单能被骑手抢到、调度恢复后自动切回。切回这一步最容易漏。
不要报「配送时长缩短 N 分钟」。配送时长受商家出餐速度、天气、路况、骑手个人影响,调度只是其中一环。把不属于自己的指标往身上揽是最容易被问穿的。可以报的是「平均取送距离」和「订单从进池到派出的时长」,这两个是调度直接决定的。
SET rider:lock:{id} NX EX 30,跨区域全局唯一),抢不到锁的那单回池下批再派。另外还要有订单侧的状态机条件更新(where status = '待派单'),影响行数为 0 说明这单已被别的流程处理。锁防骑手被重复占用,条件更新防订单被重复派出,两个方向都要防。通用教训是:并行的分配流程之间必须有全局互斥点,单个流程内部的算法保证不够。
位置上报是这个系统里写入频率最高的东西,而且它的特点和电商、社区的场景完全相反:写远多于读,数据时效性极短(几秒后就没用了),但历史轨迹又必须留存(用于对账、纠纷、路径优化)。
第一版是最直白的:骑手端每 5 秒上报一次经纬度,服务端每条都写数据库,用户端每 3 秒轮询一次骑手位置。四个问题。
一是写入量压垮数据库。上万骑手每 5 秒一次,每条都 insert,数据库的写入很快到上限,而且表以每天千万行的速度膨胀。
二是 GPS 漂移导致位置乱跳。骑手进了隧道或者高楼间,定位精度骤降,上报的坐标会跳到几百米外甚至另一条街,用户看到骑手在地图上瞬移,轨迹画出来是一堆锯齿。用户的反馈是「骑手怎么跑到别的地方去了」。
三是用户轮询把读也压垮了。用户在等外卖时会反复看骑手到哪了,一个订单在配送期间可能轮询几百次,而这段时间的订单量是上万级。
四是 ETA 一个数字算一次根本不准。下单时算出「预计 30 分钟送达」,但骑手还没接单、商家还没出餐,这个数字完全是猜的。用户看到 30 分钟等了 50 分钟,投诉。
所以四个设计:上报频率自适应(从源头减量)、服务端做漂移过滤、实时位置与历史轨迹分开存、ETA 分段并在关键节点重算。
降低上报频率的代价是位置精度下降。5 秒一次意味着最坏情况位置滞后 5 秒,骑手骑行速度下这可能是几十米的误差。但用户对「骑手在哪条街」的精度需求就是这个量级,没人在意骑手是在门口还是门口前 30 米。取舍依据是「用户实际需要的精度」而不是「技术能做到的精度」。
速度过滤会误杀真实的快速移动。骑手上了高架或者坐地铁(部分平台允许),真实速度可能超过阈值而被误判为漂移。处理办法是阈值设得宽松一点,并且连续多个点都超阈值时接受它们(真实移动是持续的,漂移是孤立的跳变)。方向上宁可漏过一些漂移,不可误杀真实轨迹——因为轨迹是配送距离结算的依据,误杀会导致骑手收入算少。
踩过的坑:补传的旧位置点覆盖了最新位置,用户看到骑手退回去了。骑手过隧道时断网,缓存了一批点,出隧道后一起上报。服务端按接收顺序处理,最后写入的是缓存里最早的那个点,实时位置被改成了几分钟前的位置,用户看到骑手在地图上突然退回去。修法是严格按上报点自带的时间戳过滤:比当前最新点更旧的,只入轨迹不更新实时位置。通用教训是:任何「可能延迟到达」的数据,都要用数据自带的时间戳而不是接收顺序来判定新旧。
踩过的坑二:ETA 在骑手接单时突然从 30 分钟变成 45 分钟,用户炸了。因为下单时的 ETA 用的是乐观估计(假设立刻有骑手接单),实际接单晚了 8 分钟,重算后自然变长。用户的感受是「你们改了承诺」。修法两个:下单时的 ETA 要包含「预计等待接单时长」(用历史数据估,不假设立刻接单);展示用区间而不是精确值,区间上限留足缓冲。ETA 的技术难点不在算得多准,而在预期管理——宁可一开始报得保守,也不要中途往后调。
没做的部分:没做基于实时路况的路径耗时预估。目前用的是路径规划服务返回的常规耗时,没有结合实时拥堵。接实时路况数据的成本高(每次估算都要调外部接口,而 ETA 要频繁重算),当时用「按时段的经验系数」近似。
位置上报的承载量:压测模拟 2 万骑手按配送中频率上报,观察接入层的处理延迟、队列积压、Redis 与冷存储的写入情况。要说明这个数字对应的骑手规模和上报频率——「2 万点/秒」如果不说是多少骑手多高频率,无法判断真实性。
「漂移未再出现」怎么验证:找一批已知有漂移的真实轨迹数据(从线上历史轨迹里捞出有明显瞬移的),跑过滤链前后对比,检查瞬移点是否被正确剔除、正常点是否被误杀。误杀率比漂移剔除率更该关注——因为轨迹是配送距离结算的依据,误杀会让骑手收入算少。
用户侧位置更新延迟:量从「骑手上报」到「用户端图标位置更新」的端到端时间,约 5 秒(主要是推送节流的间隔)。要说明这个延迟是有意设计的节流结果,不是性能问题,并且配合客户端插值后用户感知是连续的。
ETA 准确性怎么衡量:诚实的做法是报「实际送达时间落在预估区间内的比例」,而不是「ETA 误差 X 分钟」。并且要分阶段报——下单时的预估、接单后的预估、取货后的预估,准确性依次提高。只报最后一段的准确性是选择性呈现。
不要报「配送时长缩短」或「ETA 准确率 95%」。前者不是位置模块的成果;后者需要明确定义「准确」(误差在几分钟内算准),随口报一个会被追问口径。
配送履约的链路比电商发货长得多,而且每一步都在物理世界发生,每一步都会出意外:没人接单、骑手迟到、商家出餐慢、地址找不到、联系不上用户、骑手中途手机没电。
第一版的问题是把它当成简单的状态字段更新。
一是状态被乱序覆盖。骑手在地下车库没网,「已到店」和「已取货」两个状态都缓存在本地,出来后一起上报,服务端按接收顺序处理,最后写入的是「已到店」,订单状态退回去了。用户看到骑手已经取货又变成到店,客服也搞不清。
二是异常单没人管就一直挂着。一单派不出去,它就在待派池里躺着,没有任何机制发现「这单已经等了 20 分钟」。直到用户打电话投诉,客服才去查。
三是客诉时说不清当时发生了什么。用户说「骑手根本没来过」,骑手说「我到了打电话没人接」,我们只有订单的当前状态,没有过程记录,无法判断谁说的是真的。
四是干预手段是靠客服提工单让开发改数据库。要重派一单、要给一单加钱吸引骑手,都得找开发。
所以四个设计:状态流转用条件更新且按事件时间归并、每个状态独立超时检测自动进异常池、全链路时间轴留痕、干预动作产品化让运营自助。
where status = 期望值。影响行数为 0 不是异常而是并发的正常结果——说明状态已被别的流程改了,这次操作应该跳过而不是报错。「状态只能前进不能回退」这个约束简化了大量逻辑,但也带来一个真实的限制。骑手误点了「已取货」(实际还没拿到),按规则无法自己改回去。处理办法是提供运营侧的「状态修正」动作(带审批和留痕),而不是允许骑手端随意回退。取舍是:把「回退」从一个常规能力变成一个受控的例外操作——因为允许随意回退会让状态机的所有推理失效。
延迟消息和定时扫描双通道有重复触发的成本。同一个超时可能被两个通道都检测到。靠幂等消化(异常池里同一订单同一超时类型只入一条)。宁可重复检测也不能漏检测——漏一单超时就是一个客诉。
踩过的坑:离线补传导致订单状态退回,客服和用户都懵了。骑手在地下车库连续操作了「已到店」和「已取货」,两个请求都失败进本地队列,出车库后一起补传。服务端按接收顺序处理,结果最后写入的是「已到店」,订单状态从已取货退回到已到店。用户看到骑手已经取货又变回到店,客服查也查不明白。修法是状态定义次序 + 只允许前进 + 按事件时间判定。通用教训是:任何「客户端可能延迟上报」的状态流转,都必须按事件时间而不是接收顺序判定,并且状态要有明确的偏序关系。
踩过的坑二:超时检测只有定时扫描,扫描周期内的超时发现太晚。扫描是每分钟一次,看起来够快,但某次扫描任务因为一个慢查询卡了十几分钟,期间所有超时单都没被发现,积压了几百单异常,用户投诉量突增。修法是加延迟消息通道(状态变更时就投一个到期消息,到点直接触发),并且给扫描任务本身加监控——扫描任务的执行时长和上次成功时间要有告警。兜底机制自己出故障是最危险的情况,因为它是静默的。
没做的部分:没做异常单的自动决策(根据历史数据自动选择最优干预动作)。目前是运营在干预台上人工选择。自动化需要对各种干预动作的效果有量化评估,数据积累不够,当时不敢做——干预动作里包含「取消退款」这类不可逆操作,自动决策错了代价大。
「状态乱序未再出现」怎么验证:构造场景——模拟骑手端离线状态下连续上报「已到店」「已取货」,然后乱序补传(先传已到店),验证订单状态仍是已取货、时间轴里两条记录都在且时间正确。跑多种乱序组合。这类正确性用确定性场景验证。
异常单从产生到进入干预队列的时长:从状态超时的时刻到异常池记录创建的时间差,分钟级。要说明这个时长由什么决定(延迟消息的精度 + 扫描周期),并且分别报延迟消息通道和扫描通道的表现——正常情况下延迟消息更快,扫描是兜底。
「客诉可回放」怎么验证:随机抽订单,检查时间轴是否完整(每个状态变更都有记录)、关键事件是否带上下文(到店时有位置坐标、派单时有骑手距离)。可以把这个做成自动化校验:已送达的订单必须有完整的状态序列,缺失就告警——缺失说明有状态变更没被记录。
干预动作的使用情况:报各类干预动作的使用次数分布。这个数据有双重价值:证明干预台在真实使用;分布本身能反映系统短板(如果「重新派单」占大头,说明派单质量有问题)。
不要报「异常单处理率 100%」或「配送准时率提升」。前者是绝对化断言;后者受商家、天气、路况影响,不是状态机模块的成果。可以报的是「异常单的发现时长」和「进入干预队列的完整性」——这两个是我们能控制的。
where status = 期望的前置状态)。我们踩过这个坑:服务端按接收顺序处理,最后写入的是「已到店」,订单状态从已取货退回到已到店,用户和客服都懵了。通用教训是:任何「客户端可能延迟上报」的状态流转,都必须按事件时间判定,并且状态要有明确的偏序关系。另外状态上报要幂等(幂等键用订单号加目标状态),补传重复不会产生两条记录。
没有匹配的内容,换个关键词试试。
项目拆解 · 即时配送派单调度(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据