怎么用 这是格式与深度的参考,不是让你照抄的模板。数字全部是示例,直接抄进简历一问就穿——必须换成你自己的真实数据,而且要能说清是怎么测出来的。下面每个模块都有「数字是怎么测的」和「面试追问」两段,写不出那两段的模块就不要往简历上写

面试题库(电商 / 短视频 / 社区 / 本地生活 / 旅游 / 企业服务 多业务场景)

项目背景设定 本地生活的即时配送调度服务端,Java + Spring Cloud + Redis + 消息队列。场景是把订单派给骑手并跟踪履约全程,覆盖下单、派单、接单、到店、取货、送达。高峰期集中在饭点,骑手规模上万。
为什么选这三个模块 派单调度和之前所有项目的技术模型都不一样:它是一个「资源分配」问题而不是「请求响应」问题——不是来一个请求处理一个,而是要在一批订单和一批骑手之间做整体分配;位置上报是极高频的写(上万骑手每几秒一次),和读多写少的场景完全相反;履约链路极长且每一步都会异常,状态机和人工干预是必需的。这三点都能讲出独立的设计。

模块一:实时派单调度

  1. 实时派单调度(批量匹配 + 分配锁 + 抢单降级)★★★
    简历这样写 实时派单调度引擎(Spring Boot + Redis GEO + 批次化批量匹配 + 分配锁 + 状态机条件更新):把「一单进来立刻找最近骑手」改为按秒级时间窗口批量匹配,同一批次内综合距离、在手单量、顺路性、骑手评级做整体分配而非逐单局部最优;分配结果用骑手维度的锁与状态机条件更新保证一名骑手不会被同批或跨批重复派单;调度服务异常时降级为抢单模式保证订单不积压。压测 3000 单/分钟下单批次决策 P95 约 120ms,重复派单在压测中未再出现,批量匹配相比逐单就近分配平均取送距离有可观察的下降
    展开完整拆解
    为什么要这么设计

    第一版是最直觉的做法:订单一进来,查附近可接单的骑手,取最近的那个派给他。这个逻辑在单量少的时候没问题,饭点一到全面暴露。

    一是逐单分配只能得到局部最优。三点钟方向来了一单,派给了最近的骑手 A;下一秒同一栋楼又来一单,本来 A 顺路一起带走最划算,但 A 已经被占了,只能派给两公里外的 B。逐单决策看不到「下一单」,必然做出短视的分配。

    二是一名骑手被多单同时派中。并发的两个派单请求同时查到骑手 A 空闲,同时派给他,骑手端弹出两个新订单,他接了一个另一个就卡在「已派单未接单」状态。

    三是高峰期算不完就积压。逐单处理时每单都要查 GEO、算距离、排序,单量上来后处理速度跟不上进单速度,待派池越来越大,最后订单等十几分钟没人接

    四是调度服务一挂,整个配送就停了。没有任何降级路径。

    所以四个设计:批次化批量匹配(看到一批订单和一批骑手,做整体分配)、骑手维度加锁防重复派单批量处理提升吞吐降级为抢单模式保底

    核心认知是:派单不是「请求响应」问题,是「资源分配」问题。想清这一点,批量化就是必然选择——分配问题必须看到全局才能做好。

    整体链路
    订单进入待派池 │ ├─ 下单成功 → 写入待派池(Redis,按配送区域分片) ├─ 记录进池时间(用于优先级:等太久的要优先派出去) └─ 骑手拒单或接单超时的订单 → 回池重派(带重派次数) 批次调度(每个区域独立跑,秒级窗口) │ ├─ 定时触发(比如每 3 秒一批) │ ├─ 取本批订单:待派池里的订单,按等待时长排序,取前 N 单 │ 等待超阈值的订单强制进本批(防饿死) │ ├─ 取候选骑手:对每单查 Redis GEO 半径内的骑手 │ 再按状态过滤:在线、可接单、在手单未达上限 │ 并集后去重,得到本批的骑手候选集 │ ├─ 打分:构造「订单 × 骑手」的分数矩阵 │ 距离分 骑手到取货点的距离 │ 负载分 当前在手单量(越多越不适合再加) │ 顺路分 新单的取送路径与在手单路径的重合度 │ 评级分 骑手的历史完成率与超时率 │ 硬约束 超出配送范围、车型不匹配 → 直接排除 │ ├─ 分配:贪心为主 + 局部交换优化 │ 1 按分数降序遍历「订单-骑手」对 │ 2 双方都未被占用则确定分配 │ 3 再做几轮两两交换,若交换后总分更高则交换 │ → 不追求全局最优,追求「在 100ms 内给出足够好的解」 │ ├─ 落定:逐个分配加锁确认 │ SET rider:lock:{riderId} = 批次号 NX EX 30 │ 抢到锁 → 状态机条件更新订单 │ update order set status='已派单', rider_id=? │ where id=? and status='待派单' │ 抢不到锁(骑手已被别的批次占) → 该单回池,下批再派 │ └─ 推送给骑手 → 等接单 接单 → 释放锁、进入履约 拒单或超时(比如 20 秒)→ 回池 + 记录该骑手本单不再派 降级:抢单模式 │ ├─ 触发条件:调度服务不可用 / 批次积压超阈值 / 匹配耗时超标 ├─ 行为:把待派订单广播给区域内所有可接单骑手,先抢先得 │ 抢单用 Redis 原子操作保证只有一人抢到 └─ 代价:分配质量下降(不再考虑顺路与负载均衡) 但订单不积压,这是保底目标
    分步拆解
    1. 先想清这是资源分配问题,不是请求响应问题。这个认知决定了架构:不能来一单处理一单,必须攒一批一起决策,否则永远只能局部最优。这也是这个模块和其他所有项目最大的差异点。
    2. 批次窗口是核心参数,它是「分配质量」和「用户等待」的直接权衡。窗口越大,一批里的订单和骑手越多,分配越优;但订单要多等。秒级(比如 3 秒)是合理的量级——用户感知不到 3 秒,而 3 秒内能攒到足够的订单形成有效批次。要能说出这个参数怎么定的(压测 + 观察不同窗口下的平均取送距离)。
    3. 等待过久的订单要强制进本批,防止饿死。如果纯按分数排序,偏远地区的订单可能永远排不上。按进池时间设阈值,超时的订单优先级拉满强制分配,哪怕分配得不够优。
    4. 候选骑手筛选要有硬约束和软打分两层。硬约束(超出配送范围、车型不匹配、在手单已满)直接排除,不进入打分;软条件(距离、负载、顺路、评级)进打分矩阵。混在一起打分会导致「硬约束被高分抵消」,派出无法完成的单。
    5. 顺路分是批量匹配最大的价值来源。它评估「新单的取送路径与骑手在手单路径的重合度」。逐单分配压根算不了这个(不知道下一单是什么),而顺路合单能显著降低平均配送距离。这是批量化的收益所在。
    6. 分配算法用贪心加局部交换,不追求全局最优。严格的最优分配(匈牙利算法之类)在订单和骑手都上百时计算量大,而调度必须在百毫秒内出结果。贪心先给一个解,再做几轮两两交换优化,实测已经足够好。「不追求最优,追求在时限内给出足够好的解」这个判断要说出来。
    7. 分配落定必须加骑手维度的锁。同一批次内的分配是互斥的(算法保证),但跨批次、跨区域、以及抢单模式可能同时选中同一个骑手。用 Redis 的 SET NX 加锁,抢到才继续。
    8. 锁之外还要有状态机条件更新作为第二道。where status = '待派单',影响行数为 0 说明这单已被别的流程处理了。锁防骑手被重复占用,条件更新防订单被重复派出,两个方向都要防。
    9. 接单要有超时,超时回池并记录。骑手 20 秒不接就回池重派,并且记录「该骑手本单不再派」(否则下一批又派给他,反复超时,订单一直派不出去)。
    10. 降级抢单模式是必需的保底路径。调度不可用时把订单广播给区域内骑手先抢先得。分配质量下降但订单不积压——对配送业务来说「派得不够优」远好于「派不出去」。
    关键决策与取舍

    批次窗口的取舍是这个模块最本质的权衡。窗口大分配优但用户等得久,窗口小响应快但退化成逐单。我们的处理是按时段动态调整——平峰期单量少,窗口开大一点(攒够订单才有批量的意义);高峰期单量密集,窗口可以缩小(3 秒内就能攒到足够多的订单)。参数不是拍的,是压测加观察不同窗口下的平均取送距离定出来的。

    为什么不用严格最优的分配算法。匈牙利算法能给出最优解,但复杂度对订单数和骑手数敏感,而我们必须在百毫秒内出结果——超时了订单就要等下一批。而且现实里的「最优」本身是模糊的(各维度权重怎么定就是主观的),追求数学最优的意义有限。贪心加局部交换的解和最优解的差距,远小于「权重设置不同」带来的差距。这个判断能体现工程感。

    指派 vs 抢单不是二选一,是主备关系。指派(系统决定派给谁)分配质量高,但依赖调度服务;抢单(广播让骑手抢)质量差但极其可靠。我们用指派为主、抢单为降级。有些平台是反过来的(抢单为主),那取决于骑手是自由职业还是专职——自由骑手更接受抢单,专职骑手需要系统保证单量公平。这个业务差异要能说出来。

    踩过的坑:一名骑手被两个区域的批次同时派中。骑手在两个配送区域的交界处,两个区域的批次调度并行跑,各自的 GEO 查询都把他算进了候选集,然后各派了一单给他。骑手端弹出两个订单,接了一个另一个卡死。修法是骑手维度的分布式锁(跨区域全局唯一),抢不到锁的那单回池。教训是:并行的分配流程之间必须有全局互斥点,光靠单个流程内部的算法保证是不够的。

    踩过的坑二:接单超时回池后又派给同一个骑手,订单一直派不出去。骑手手机没网或者放兜里没看到,20 秒超时回池;下一批调度他还是分数最高,又派给他,又超时。这单在池子里循环了十几分钟,用户投诉。修法是回池时记录「该骑手对该订单的拒绝/超时」,后续批次把这个组合排除,并且重派次数超过阈值就进异常池转人工或触发加价。任何「失败后重试」的机制都要防止重试给同一个失败源。

    没做的部分:没做基于路径规划的精确顺路计算。目前的顺路分是用直线距离的重合度近似的,真实的顺路要看实际道路(隔着一条河的两个点直线很近但路上要绕很远)。接路径规划服务的成本是每次计算都要调外部接口,在批量匹配里要算几百次,成本和延迟都不可接受。

    数字是怎么测的

    批次决策耗时:在批次开始和分配落定处打时间戳。必须说明批次规模——「50 单 × 200 骑手的矩阵,P95 约 120ms」才是有意义的表述,只说 120ms 没有参考价值。压测时要覆盖高峰的批次规模。

    重复派单验证:构造场景——让两个区域的批次同时选中同一个骑手(把骑手位置设在交界处),并发触发调度,检查该骑手最终只被派了一单,另一单正确回池。跑多轮。这类并发正确性用确定性场景验证,不要用比率。

    批量匹配的收益:对比「逐单就近分配」和「批量匹配」两种策略下的平均取送距离,在同一批历史订单数据上离线回放对比。要诚实用「有可观察的下降」而不是给一个精确百分比——因为收益高度依赖订单密度(订单越密集顺路机会越多),不同时段不同区域差别很大,报一个固定百分比会被追问穿。

    降级验证:故障演练——停掉调度服务,验证自动切到抢单模式、订单能被骑手抢到、调度恢复后自动切回。切回这一步最容易漏。

    不要报「配送时长缩短 N 分钟」。配送时长受商家出餐速度、天气、路况、骑手个人影响,调度只是其中一环。把不属于自己的指标往身上揽是最容易被问穿的。可以报的是「平均取送距离」和「订单从进池到派出的时长」,这两个是调度直接决定的。

    面试追问
    Q:为什么要批量匹配?来一单派一单不是更快吗? A:快,但分配质量差,而且是结构性的差。逐单分配只能看到当前这一单,做的是局部最优:第一单派给最近的骑手 A,下一秒同一栋楼又来一单,本来 A 顺路一起带走最划算,但 A 已经被占了,只能派给两公里外的 B「顺路合单」这个最大的优化空间,逐单分配压根算不出来(它不知道下一单是什么)。所以要攒一个秒级窗口的批次,在「一批订单 × 一批骑手」的矩阵上做整体分配。核心认知是:派单是资源分配问题,不是请求响应问题,分配问题必须看到全局才能做好。代价是订单要多等几秒——但 3 秒用户感知不到,而分配质量的差距是持续影响每一单的。窗口大小是「分配质量」和「用户等待」的直接权衡,我们按时段动态调整。
    Q:一个骑手同时被两单派中,怎么防? A:两层,锁和条件更新,防的是两个不同方向。同一批次内的分配由算法保证互斥(一个骑手只出现在一个分配对里),但跨批次、跨区域、以及降级抢单模式可能同时选中同一个骑手——我们真踩过:骑手在两个配送区域交界处,两个区域的批次并行跑,各自的 GEO 查询都把他算进候选集,各派了一单,骑手端弹出两个订单。修法是骑手维度的分布式锁SET rider:lock:{id} NX EX 30,跨区域全局唯一),抢不到锁的那单回池下批再派。另外还要有订单侧的状态机条件更新where status = '待派单'),影响行数为 0 说明这单已被别的流程处理。锁防骑手被重复占用,条件更新防订单被重复派出,两个方向都要防。通用教训是:并行的分配流程之间必须有全局互斥点,单个流程内部的算法保证不够。
    Q:高峰期订单暴增,匹配算不完怎么办? A:分几层处理。第一层是按区域分片并行——每个配送区域独立跑自己的批次,天然可以横向扩展,这是最主要的手段。第二层是控制批次规模:单批次的订单数和候选骑手数都设上限,超出的排到下一批,保证单批次的计算量可控(而不是让矩阵无限增大)。第三层是简化打分:高峰期可以关掉最耗时的顺路分计算,只用距离和负载,牺牲一点质量换速度。第四层是降级抢单:批次积压超阈值或匹配耗时超标时,直接把订单广播给区域内骑手先抢先得——分配质量下降但订单不积压,对配送业务来说「派得不够优」远好于「派不出去」。另外要防订单饿死:等待时间超阈值的订单强制进本批,优先级拉满,哪怕分配得不够优,否则偏远地区的单可能永远排不上。
    Q:骑手一直不接单怎么办? A:设接单超时(比如 20 秒),超时回池重派,但关键是要记录并排除。我们踩过坑:超时回池后,下一批调度这个骑手还是分数最高,又派给他,又超时,这单在池子里循环了十几分钟,用户投诉。修法是回池时记录「该骑手对该订单的超时/拒绝」,后续批次把这个组合排除更通用的教训是:任何「失败后重试」的机制都要防止重试给同一个失败源,这在派单、消息投递、供应商调用上都适用。另外还要有升级路径:重派次数超过阈值就进异常池,触发干预动作——扩大搜索半径、触发配送费加价吸引骑手、或者转人工调度。骑手侧也要有约束:频繁拒单或超时会影响他的评级分,进而影响后续派单优先级,这是业务上的反向激励。

模块二:骑手位置流与 ETA

  1. 骑手位置流与 ETA(自适应上报 + 漂移过滤 + 分段预估)★★★
    简历这样写 骑手位置流与配送 ETA(自适应上报频率 + 漂移过滤 + Redis 实时位置 + 轨迹冷热分层 + 分段 ETA):骑手端按状态自适应上报频率(空闲低频、送单中高频、静止降频),服务端做去重与 GPS 漂移过滤后写实时位置缓存、轨迹批量落冷存储;用户侧骑手位置走服务端推送加客户端插值替代轮询;ETA 按「到店取货段 + 配送送达段」分段预估并在关键节点重算。压测 2 万点/秒位置上报下写入延迟可控,用户侧位置更新可感知延迟约 5 秒,GPS 漂移导致的位置乱跳与轨迹锯齿过滤后未再出现
    展开完整拆解
    为什么要这么设计

    位置上报是这个系统里写入频率最高的东西,而且它的特点和电商、社区的场景完全相反:写远多于读,数据时效性极短(几秒后就没用了),但历史轨迹又必须留存(用于对账、纠纷、路径优化)。

    第一版是最直白的:骑手端每 5 秒上报一次经纬度,服务端每条都写数据库,用户端每 3 秒轮询一次骑手位置。四个问题。

    一是写入量压垮数据库。上万骑手每 5 秒一次,每条都 insert,数据库的写入很快到上限,而且表以每天千万行的速度膨胀

    二是 GPS 漂移导致位置乱跳。骑手进了隧道或者高楼间,定位精度骤降,上报的坐标会跳到几百米外甚至另一条街,用户看到骑手在地图上瞬移,轨迹画出来是一堆锯齿。用户的反馈是「骑手怎么跑到别的地方去了」。

    三是用户轮询把读也压垮了。用户在等外卖时会反复看骑手到哪了,一个订单在配送期间可能轮询几百次,而这段时间的订单量是上万级

    四是 ETA 一个数字算一次根本不准。下单时算出「预计 30 分钟送达」,但骑手还没接单、商家还没出餐,这个数字完全是猜的。用户看到 30 分钟等了 50 分钟,投诉。

    所以四个设计:上报频率自适应(从源头减量)、服务端做漂移过滤实时位置与历史轨迹分开存ETA 分段并在关键节点重算

    整体链路
    骑手端上报(从源头减量) │ ├─ 频率按状态自适应 │ 空闲待接单 30 秒一次(只需要知道大概在哪) │ 已接单去取货 10 秒一次 │ 取货后配送中 5 秒一次(用户在看,要准) │ 静止超过 N 秒 降到 30 秒(在等餐或休息,位置不变) │ ├─ 本地也做一次过滤:位移小于阈值则不上报 │ → 骑手在店里等餐时几乎不产生上报 │ └─ 弱网时本地缓存待上报点,恢复后合并补传(带各自时间戳) 服务端接收与过滤 │ ├─ 接入层:位置上报走独立的接口与线程池 │ 不能和业务接口共用资源,否则高频上报会挤占业务 │ ├─ 过滤链(顺序很重要) │ 1 精度过滤:上报带的定位精度差于阈值 → 丢弃 │ 2 时间过滤:时间戳比已有的最新点更旧 → 丢弃(补传乱序) │ 3 速度过滤:与上一个点算出的瞬时速度超过合理上限 → 丢弃 │ 这一条能挡掉大部分漂移(瞬移意味着超高速) │ 4 平滑:滑动窗口取均值,去掉小幅抖动 │ ├─ 写实时位置:Redis 只存最新一个点(覆盖写,不累积) │ 同时更新 GEO 索引(供派单查附近骑手用) │ └─ 写轨迹:先进队列,消费端批量写入 热数据(近几小时)留在可快速查询的存储 冷数据(历史轨迹)压缩后归档,按订单号可查 用户侧看骑手位置(不要轮询) │ ├─ 用户打开订单详情 → 建立推送通道(长连接或订阅) │ ├─ 服务端按节流推送骑手位置(比如 5 秒一次) │ 只推有变化的,位置没动不推 │ ├─ 客户端做插值动画:收到新点后让图标平滑移动过去 │ → 视觉上是连续移动,实际只收到稀疏的点 │ 这一步让「5 秒一次推送」看起来像实时 │ └─ 用户离开页面 → 断开订阅,停止推送 ETA 分段预估(一个数字算不准,要分段) │ ├─ 阶段一 下单到商家出餐 用商家的历史出餐时长(分品类、分时段) ├─ 阶段二 骑手到店取货 用骑手当前位置到店的路径耗时 ├─ 阶段三 取货到送达 用店到用户的路径耗时 + 在手单顺序 │ ├─ 总 ETA = 三段之和 + 缓冲(按时段与天气调整) │ └─ 关键节点重算并推送更新 骑手接单时 / 骑手到店时 / 取货完成时 → 每次重算都比上次更准(不确定性在减少) 展示上给区间而不是精确值(「预计 20 到 30 分钟」)
    分步拆解
    1. 先从源头减量,这比任何服务端优化都有效。上报频率按骑手状态自适应:空闲时 30 秒、配送中 5 秒、静止时降频。骑手在店里等餐的那十几分钟几乎不产生上报,而这段时间在整个履约里占比不小。这一步能把总上报量降一大截。
    2. 位置上报要走独立的接口和线程池。它是极高频的写,和业务接口共用资源的话会把业务挤死。这是舱壁模式的典型场景。
    3. 漂移过滤的顺序有讲究:精度 → 时间 → 速度 → 平滑。精度过滤最便宜先做;时间过滤挡掉补传乱序;速度过滤是挡漂移的主力——和上一个点算出瞬时速度,超过合理上限(比如电动车不可能 200 公里每小时)就丢弃,因为瞬移必然意味着超高速;最后做滑动平均去掉小抖动。
    4. 实时位置和历史轨迹要分开存,它们的访问模式完全不同。实时位置只要最新一个点,用 Redis 覆盖写不累积;历史轨迹是追加写、极少读、但要长期保留,走队列批量落冷存储。混在一起存必然一头顾不上。
    5. GEO 索引要和实时位置一起更新。派单要查「附近的骑手」,靠的是 GEO 索引。位置更新时同步更新 GEO,否则派单会基于过时位置做决策。
    6. 用户侧绝不能轮询,要用推送加插值。轮询在配送期间会产生几百次请求。改成服务端按 5 秒节流推送,客户端收到新点后做插值动画让图标平滑移动插值是关键——它让稀疏的推送在视觉上变成连续移动,用户感觉是实时的。
    7. 只推有变化的位置。骑手在等餐位置不动,不需要推送。这个判断在服务端做,客户端保持上次的位置就行。
    8. ETA 必须分段,因为不确定性是逐步消解的。下单时三段都是估的,最不准;骑手接单后第二段变准;到店后第二段归零、第三段变准;取货后只剩第三段。每到一个节点重算并推送更新,用户会感觉「越来越准」而不是「一开始就骗我」。
    9. ETA 展示给区间而不是精确值。「预计 25 分钟」会被当成承诺,「预计 20 到 30 分钟」是预期管理。这是产品表达问题但影响技术方案——如果承诺精确值,就要为超时负责。
    10. 补传的位置点要带各自的时间戳并按时间过滤。骑手弱网时本地缓存了一批点,恢复后一起传上来,这些点是过去的,不能覆盖当前的最新位置。靠时间过滤挡住(比最新点更旧的丢弃或只入轨迹不更新实时位置)。
    关键决策与取舍

    降低上报频率的代价是位置精度下降。5 秒一次意味着最坏情况位置滞后 5 秒,骑手骑行速度下这可能是几十米的误差。但用户对「骑手在哪条街」的精度需求就是这个量级,没人在意骑手是在门口还是门口前 30 米。取舍依据是「用户实际需要的精度」而不是「技术能做到的精度」。

    速度过滤会误杀真实的快速移动。骑手上了高架或者坐地铁(部分平台允许),真实速度可能超过阈值而被误判为漂移。处理办法是阈值设得宽松一点,并且连续多个点都超阈值时接受它们(真实移动是持续的,漂移是孤立的跳变)。方向上宁可漏过一些漂移,不可误杀真实轨迹——因为轨迹是配送距离结算的依据,误杀会导致骑手收入算少。

    踩过的坑:补传的旧位置点覆盖了最新位置,用户看到骑手退回去了。骑手过隧道时断网,缓存了一批点,出隧道后一起上报。服务端按接收顺序处理,最后写入的是缓存里最早的那个点,实时位置被改成了几分钟前的位置,用户看到骑手在地图上突然退回去。修法是严格按上报点自带的时间戳过滤:比当前最新点更旧的,只入轨迹不更新实时位置。通用教训是:任何「可能延迟到达」的数据,都要用数据自带的时间戳而不是接收顺序来判定新旧。

    踩过的坑二:ETA 在骑手接单时突然从 30 分钟变成 45 分钟,用户炸了。因为下单时的 ETA 用的是乐观估计(假设立刻有骑手接单),实际接单晚了 8 分钟,重算后自然变长。用户的感受是「你们改了承诺」。修法两个:下单时的 ETA 要包含「预计等待接单时长」(用历史数据估,不假设立刻接单);展示用区间而不是精确值,区间上限留足缓冲。ETA 的技术难点不在算得多准,而在预期管理——宁可一开始报得保守,也不要中途往后调。

    没做的部分:没做基于实时路况的路径耗时预估。目前用的是路径规划服务返回的常规耗时,没有结合实时拥堵。接实时路况数据的成本高(每次估算都要调外部接口,而 ETA 要频繁重算),当时用「按时段的经验系数」近似。

    数字是怎么测的

    位置上报的承载量:压测模拟 2 万骑手按配送中频率上报,观察接入层的处理延迟、队列积压、Redis 与冷存储的写入情况。要说明这个数字对应的骑手规模和上报频率——「2 万点/秒」如果不说是多少骑手多高频率,无法判断真实性。

    「漂移未再出现」怎么验证:找一批已知有漂移的真实轨迹数据(从线上历史轨迹里捞出有明显瞬移的),跑过滤链前后对比,检查瞬移点是否被正确剔除、正常点是否被误杀。误杀率比漂移剔除率更该关注——因为轨迹是配送距离结算的依据,误杀会让骑手收入算少。

    用户侧位置更新延迟:量从「骑手上报」到「用户端图标位置更新」的端到端时间,约 5 秒(主要是推送节流的间隔)。要说明这个延迟是有意设计的节流结果,不是性能问题,并且配合客户端插值后用户感知是连续的。

    ETA 准确性怎么衡量:诚实的做法是报「实际送达时间落在预估区间内的比例」,而不是「ETA 误差 X 分钟」。并且要分阶段报——下单时的预估、接单后的预估、取货后的预估,准确性依次提高。只报最后一段的准确性是选择性呈现。

    不要报「配送时长缩短」或「ETA 准确率 95%」。前者不是位置模块的成果;后者需要明确定义「准确」(误差在几分钟内算准),随口报一个会被追问口径。

    面试追问
    Q:上万骑手每几秒上报一次位置,怎么扛? A:顺序是先减量,再分流,最后才是存储优化。减量是最有效的:上报频率按骑手状态自适应(空闲 30 秒、去取货 10 秒、配送中 5 秒、静止时降到 30 秒),骑手端本地还做位移过滤(没动就不报)——骑手在店里等餐的十几分钟几乎不产生上报,这段时间占比不小。分流:位置上报走独立接口和独立线程池,绝不能和业务接口共用资源,否则高频写会把业务挤死。存储分层:实时位置只要最新一个点,用 Redis 覆盖写不累积,同时更新 GEO 索引供派单查附近骑手;历史轨迹是追加写、极少读、要长期留存,走队列批量落冷存储。两者访问模式完全不同,混在一起存必然一头顾不上。另外读侧也要治——用户端绝不能轮询,改成服务端节流推送。
    Q:GPS 漂移导致骑手位置乱跳,怎么处理? A:服务端做过滤链,顺序是精度 → 时间 → 速度 → 平滑。精度过滤最便宜先做(上报带的定位精度差于阈值直接丢);时间过滤挡补传乱序;速度过滤是挡漂移的主力——和上一个点算出瞬时速度,超过合理上限就丢弃,因为瞬移必然意味着超高速;最后滑动窗口平滑去掉小幅抖动。但方向上要注意:宁可漏过一些漂移,不可误杀真实轨迹——因为轨迹是配送距离结算的依据,误杀会让骑手收入算少。所以速度阈值设宽松些,并且连续多个点都超阈值时接受它们(真实的快速移动是持续的,漂移是孤立的跳变,比如骑手上了高架)。还有个必踩的坑:骑手过隧道断网后缓存的旧点一起补传,服务端按接收顺序处理会用旧点覆盖最新位置,用户看到骑手退回去了——必须按数据自带的时间戳判定新旧,比最新点旧的只入轨迹不更新实时位置。
    Q:用户在等外卖时反复看骑手位置,怎么不把服务器压垮? A:不让客户端轮询,改成服务端推送加客户端插值。轮询的问题是一个订单在配送期间可能产生几百次请求,而同时在配送的订单是上万级。做法是:用户打开订单详情时建立推送通道(长连接或订阅),服务端按 5 秒节流推送骑手位置,且只推有变化的(骑手在等餐位置不动就不推),用户离开页面就断开订阅。关键的一步是客户端插值——收到新位置后让骑手图标平滑移动过去而不是瞬间跳过去,视觉上就是连续移动,用户完全感觉不到实际只收到了稀疏的点。「用插值把稀疏推送伪装成实时」是这类地图跟踪功能的标准做法,它让节流的代价对用户不可见。这也是为什么 5 秒的延迟是可接受的设计选择而不是性能缺陷。
    Q:ETA 算不准,用户投诉怎么办? A:先承认 ETA 本质上算不准,然后从「分段重算」和「预期管理」两头解决。不准的根因是下单时不确定性最大——骑手还没接单、商家还没出餐,这两段完全是估的。所以要分段预估:下单到出餐(用商家历史出餐时长)、骑手到店(用骑手位置到店的路径耗时)、店到用户(路径耗时加在手单顺序),并在骑手接单、到店、取货这三个关键节点重算并推送更新——每次重算不确定性都在减少,用户会感觉「越来越准」。预期管理更重要:展示给区间而不是精确值(「预计 20 到 30 分钟」而不是「25 分钟」),因为精确值会被当成承诺。我们踩过的坑是下单时用了乐观估计(假设立刻有骑手接单),实际接单晚了 8 分钟,重算后 ETA 从 30 变 45,用户认为我们改了承诺。修法是下单时的估算要包含预计等待接单时长,宁可一开始报得保守,也不要中途往后调。

模块三:履约状态机与异常干预

  1. 履约状态机与异常干预(长链路状态机 + 超时检测 + 干预台)★★★
    简历这样写 配送履约状态机与异常干预(状态机条件更新 + 分状态超时检测 + 异常池与干预动作 + 全链路时间轴):把配送全链路建成状态机(待派单、已派单、已接单、已到店、已取货、已送达、已取消),流转一律用条件更新并允许骑手端离线补传的状态按事件时间幂等归并;每个状态配独立超时阈值,超时进异常池并按类型触发干预(重派、加价、改配送、联系用户、取消退款);全链路记时间轴留痕供客诉回放。异常单从产生到进入干预队列在分钟级,骑手端离线补传导致的状态乱序改造后未再出现,客诉可按订单号完整回放履约过程。
    展开完整拆解
    为什么要这么设计

    配送履约的链路比电商发货长得多,而且每一步都在物理世界发生,每一步都会出意外:没人接单、骑手迟到、商家出餐慢、地址找不到、联系不上用户、骑手中途手机没电。

    第一版的问题是把它当成简单的状态字段更新。

    一是状态被乱序覆盖。骑手在地下车库没网,「已到店」和「已取货」两个状态都缓存在本地,出来后一起上报,服务端按接收顺序处理,最后写入的是「已到店」,订单状态退回去了。用户看到骑手已经取货又变成到店,客服也搞不清。

    二是异常单没人管就一直挂着。一单派不出去,它就在待派池里躺着,没有任何机制发现「这单已经等了 20 分钟」。直到用户打电话投诉,客服才去查。

    三是客诉时说不清当时发生了什么。用户说「骑手根本没来过」,骑手说「我到了打电话没人接」,我们只有订单的当前状态,没有过程记录,无法判断谁说的是真的。

    四是干预手段是靠客服提工单让开发改数据库。要重派一单、要给一单加钱吸引骑手,都得找开发。

    所以四个设计:状态流转用条件更新且按事件时间归并每个状态独立超时检测自动进异常池全链路时间轴留痕干预动作产品化让运营自助

    整体链路
    状态机(每个流转都是条件更新,不是直接赋值) │ 待派单 ─▶ 已派单 ─▶ 已接单 ─▶ 已到店 ─▶ 已取货 ─▶ 已送达 │ │ │ │ │ │ └─ 拒单/超时 → 回到待派单 │ │ │ └─ 骑手取消 → 回到待派单(记录责任) │ └────────────────────────────────────▶ 已取消(可发生在多个阶段) 每次流转:update order set status = 新状态 where id = ? and status = 期望的前置状态 影响行数 0 → 说明状态已变,不是异常而是并发的正常结果 骑手端离线补传的状态归并(防乱序) │ ├─ 骑手端每个状态操作都记「事件发生时间」并本地持久化 │ 弱网时排队,恢复后按顺序补传 │ ├─ 服务端按事件时间而非接收时间判定 │ 定义状态的先后次序(已到店 < 已取货 < 已送达) │ 补传的状态若「次序早于当前状态」→ 只记时间轴,不改当前状态 │ → 状态只能向前推进,永不回退 │ └─ 幂等:同一订单同一状态的重复上报只生效一次 幂等键 = 订单号 + 目标状态 分状态超时检测(每个状态有自己的阈值) │ ├─ 待派单超时 → 无人接单 → 扩大搜索半径 / 加价 / 转人工 ├─ 已派单超时 → 骑手不接单 → 回池重派 + 排除该骑手 ├─ 已接单超时 → 骑手迟迟不到店 → 提醒骑手 / 评估改派 ├─ 已到店超时 → 商家出餐慢 → 通知商家 / 通知用户延迟原因 ├─ 已取货超时 → 配送超时 → 提醒骑手 / 通知用户 / 补偿 └─ 检测实现:定时扫描 + 到期任务(延迟消息),两者互为兜底 定时扫描防漏,延迟消息保证及时 异常池与干预台(让运营自助,不找开发) │ ├─ 异常单自动进池,按类型与紧急度排序 │ 紧急度 = 已超时时长 + 订单金额 + 用户等级 │ ├─ 干预动作产品化(每个动作都有权限与留痕) │ 重新派单 / 提高配送费 / 指定骑手 / 改为自提 │ 联系用户 / 联系商家 / 取消并全额退款 / 补偿券 │ └─ 每个动作都记录:谁在什么时候做了什么、理由 全链路时间轴(客诉回放的依据) └─ 一张订单一条时间轴,逐条记录 时间 / 事件 / 触发方(用户·骑手·商家·系统·运营) 关键事件带上下文:派单时的骑手距离、到店时的位置坐标 → 用户说「骑手没来过」时,可以拿出到店时的位置记录
    分步拆解
    1. 所有状态流转用条件更新,带上期望的前置状态。where status = 期望值影响行数为 0 不是异常而是并发的正常结果——说明状态已被别的流程改了,这次操作应该跳过而不是报错。
    2. 状态只能向前推进,永不回退。给状态定义先后次序,补传的旧状态只记进时间轴,不改当前状态。这一条直接解决了离线补传导致的乱序问题。
    3. 骑手端的状态操作必须带「事件发生时间」。服务端按事件时间判定次序,而不是按接收时间。接收顺序在弱网补传场景下完全不可靠。
    4. 状态上报要幂等。幂等键用「订单号 + 目标状态」,骑手端重试或补传重复不会产生两条记录。
    5. 每个状态配独立的超时阈值,因为它们的含义完全不同。「待派单超时」意味着没人接单,「已到店超时」意味着商家出餐慢,两者的干预动作完全不同。用一个统一的「订单超时」阈值等于放弃了区分问题类型的能力。
    6. 超时检测用「定时扫描 + 延迟消息」双通道。延迟消息保证及时(状态变更时投一个延迟到期的消息),定时扫描防漏(消息可能丢)。两者互为兜底,单靠一个都不够可靠。
    7. 异常单要按紧急度排序而不是先到先处理。紧急度综合「已超时时长、订单金额、用户等级」。一单超时 20 分钟的高价订单必须优先于一单刚超时 2 分钟的小额订单。
    8. 干预动作必须产品化,不能靠找开发改数据库。重派、加价、指定骑手、改自提、取消退款,每个动作做成运营可点的按钮,带权限控制和操作留痕。这是把运营从「提工单等排期」解放出来的关键。
    9. 时间轴要记「触发方」和关键上下文。不只记「状态变成已到店」,还要记「骑手端上报,当时位置坐标是 X」。客诉时这些上下文才是判断依据——用户说骑手没来过,我们能拿出到店时的位置记录。
    10. 取消可以从多个状态发生,要记录责任方。用户取消、商家拒单、骑手取消、系统超时取消,责任方不同则退款规则和骑手考核都不同。状态机里不能只有一个笼统的「已取消」。
    关键决策与取舍

    「状态只能前进不能回退」这个约束简化了大量逻辑,但也带来一个真实的限制。骑手误点了「已取货」(实际还没拿到),按规则无法自己改回去。处理办法是提供运营侧的「状态修正」动作(带审批和留痕),而不是允许骑手端随意回退。取舍是:把「回退」从一个常规能力变成一个受控的例外操作——因为允许随意回退会让状态机的所有推理失效。

    延迟消息和定时扫描双通道有重复触发的成本。同一个超时可能被两个通道都检测到。靠幂等消化(异常池里同一订单同一超时类型只入一条)。宁可重复检测也不能漏检测——漏一单超时就是一个客诉。

    踩过的坑:离线补传导致订单状态退回,客服和用户都懵了。骑手在地下车库连续操作了「已到店」和「已取货」,两个请求都失败进本地队列,出车库后一起补传。服务端按接收顺序处理,结果最后写入的是「已到店」,订单状态从已取货退回到已到店。用户看到骑手已经取货又变回到店,客服查也查不明白。修法是状态定义次序 + 只允许前进 + 按事件时间判定通用教训是:任何「客户端可能延迟上报」的状态流转,都必须按事件时间而不是接收顺序判定,并且状态要有明确的偏序关系。

    踩过的坑二:超时检测只有定时扫描,扫描周期内的超时发现太晚。扫描是每分钟一次,看起来够快,但某次扫描任务因为一个慢查询卡了十几分钟,期间所有超时单都没被发现,积压了几百单异常,用户投诉量突增。修法是加延迟消息通道(状态变更时就投一个到期消息,到点直接触发),并且给扫描任务本身加监控——扫描任务的执行时长和上次成功时间要有告警。兜底机制自己出故障是最危险的情况,因为它是静默的。

    没做的部分:没做异常单的自动决策(根据历史数据自动选择最优干预动作)。目前是运营在干预台上人工选择。自动化需要对各种干预动作的效果有量化评估,数据积累不够,当时不敢做——干预动作里包含「取消退款」这类不可逆操作,自动决策错了代价大。

    数字是怎么测的

    「状态乱序未再出现」怎么验证:构造场景——模拟骑手端离线状态下连续上报「已到店」「已取货」,然后乱序补传(先传已到店),验证订单状态仍是已取货、时间轴里两条记录都在且时间正确。跑多种乱序组合。这类正确性用确定性场景验证。

    异常单从产生到进入干预队列的时长:从状态超时的时刻到异常池记录创建的时间差,分钟级。要说明这个时长由什么决定(延迟消息的精度 + 扫描周期),并且分别报延迟消息通道和扫描通道的表现——正常情况下延迟消息更快,扫描是兜底。

    「客诉可回放」怎么验证:随机抽订单,检查时间轴是否完整(每个状态变更都有记录)、关键事件是否带上下文(到店时有位置坐标、派单时有骑手距离)。可以把这个做成自动化校验:已送达的订单必须有完整的状态序列,缺失就告警——缺失说明有状态变更没被记录。

    干预动作的使用情况:报各类干预动作的使用次数分布。这个数据有双重价值:证明干预台在真实使用;分布本身能反映系统短板(如果「重新派单」占大头,说明派单质量有问题)。

    不要报「异常单处理率 100%」或「配送准时率提升」。前者是绝对化断言;后者受商家、天气、路况影响,不是状态机模块的成果。可以报的是「异常单的发现时长」和「进入干预队列的完整性」——这两个是我们能控制的。

    面试追问
    Q:骑手在地下车库没网,出来后一起上报了「已到店」和「已取货」,怎么保证状态不乱? A:三件事配合。一是骑手端每个状态操作都记「事件发生时间」并本地持久化排队,恢复后按顺序补传。二是服务端按事件时间而不是接收时间判定——给状态定义明确的偏序关系(已到店 < 已取货 < 已送达),补传的状态如果次序早于当前状态,只记进时间轴,不改当前状态三是状态只能向前推进永不回退,配合条件更新(where status = 期望的前置状态)。我们踩过这个坑:服务端按接收顺序处理,最后写入的是「已到店」,订单状态从已取货退回到已到店,用户和客服都懵了。通用教训是:任何「客户端可能延迟上报」的状态流转,都必须按事件时间判定,并且状态要有明确的偏序关系。另外状态上报要幂等(幂等键用订单号加目标状态),补传重复不会产生两条记录。
    Q:一单一直没人接,你的系统怎么发现并处理? A:靠分状态超时检测 + 异常池 + 干预动作。「待派单」这个状态有自己的超时阈值,超时后自动进异常池,触发对应的干预:扩大搜索半径、提高配送费吸引骑手、指定骑手、或者转人工。关键设计有三点每个状态要有独立阈值,因为「待派单超时」(没人接)和「已到店超时」(商家出餐慢)的含义和干预动作完全不同,用一个统一的「订单超时」等于放弃了区分问题类型的能力;检测用「延迟消息 + 定时扫描」双通道,延迟消息保证及时、扫描防漏,宁可重复检测也不能漏(漏一单就是一个客诉),重复靠幂等消化;异常池按紧急度排序而不是先到先处理,紧急度综合已超时时长、订单金额、用户等级。另外干预动作必须产品化——重派、加价、改自提、取消退款都做成运营可点的按钮带权限和留痕,而不是让客服提工单找开发改数据库。
    Q:用户说骑手根本没来过,骑手说他到了打电话没人接,你怎么判断? A:靠全链路时间轴,而且时间轴不能只记状态变更,必须记触发方和关键上下文。具体来说:「已到店」这条记录要带骑手端上报时的位置坐标(可以和门店坐标算距离验证他是否真的在店附近);「已取货」同理;如果有拨打电话的功能,通话记录也要进时间轴。这样争议就有了客观依据——他上报到店时的坐标离门店 800 米,那他大概率没到;坐标就在门店,那用户的说法有问题。这个设计的价值在客诉处理,而不在技术先进性,但它是真实业务里最被需要的东西之一。另外要注意坐标本身可以被伪造(模拟定位),所以位置只作为参考证据而不是唯一凭证,更强的凭证是「用户扫码确认收货」这类需要双方在场的动作——这和到店核销的判断一致:物理动作比定位可信。
    Q:骑手误点了「已取货」,实际还没拿到,怎么改回来? A:不允许骑手端自己回退,但提供运营侧的「状态修正」动作。这是「状态只能前进」这个约束带来的必然结果——如果允许随意回退,状态机的所有推理都会失效(比如超时检测就不知道该按哪个状态的阈值算)。所以把「回退」从一个常规能力变成一个受控的例外操作:运营在干预台上执行状态修正,需要权限、需要填写理由、全程留痕,并且修正记录进时间轴(而不是抹掉原记录)。为什么不让骑手自己改:一是容易被滥用(骑手可能为了规避超时考核而回退状态),二是状态变更会触发下游动作(比如通知用户、开始计算配送时长),随意回退会让这些动作的一致性无法保证。取舍上我认为「让例外走受控流程」比「为了少数场景放开约束」更好,因为约束一旦放开,整个状态机的可推理性就没了。

没有匹配的内容,换个关键词试试。

项目拆解 · 即时配送派单调度(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据