同城物流派送调度算法实战:从订单分桶到动态插单的工程避坑指南
发布时间:2026/10/15 15:50:32 锦皓数字建站

简介这份资源是面向Android初学者与移动应用开发练习者的同城物流派送系统完整项目源码围绕城市内即时配送场景解决订单发起、司机抢单、状态跟踪到货物送达的全流程管理问题。压缩包共275个文件约53.42MB以94个png界面素材、86个class编译文件、39个xml布局、31个java源码为主另含9个jar依赖、5个so库及apk安装包覆盖从资源、逻辑到可运行产物的完整结构。项目以SQLite数据库为核心串联用户注册登录、订单增删改查、自定义适配器数据绑定、抢单派送状态流转以及GPS定位与路线规划等模块适合对照源码理解数据存储与界面交互的配合方式。目前已有323人学习下载可作为课程设计或毕业设计的参考蓝本帮助读者梳理Android端数据库操作、列表展示与业务流程控制的实现思路。1. 同城物流派送为什么你的调度算法总在午高峰翻车同城物流派送这件事表面看是骑手跑腿底层其实是一道带时间窗、带容量约束、带动态扰动的车辆路径问题。很多团队一开始都觉得自己能搞定——不就是把订单按距离分给最近的骑手吗结果一到午高峰系统里堆着三百个待派订单骑手端却显示暂无新任务客服电话被打爆。这不是玄学是典型的调度模型没考虑时空耦合订单的取货点和送货点在不同方向骑手接了一单往东下一单却在西边来回穿插导致超时率飙升。我做过一个模拟项目专门复现同城即时配送的派单逻辑。核心结论很反直觉派单准确率的瓶颈往往不在算法本身而在订单特征工程和骑手状态建模。你把 VRP 求解器换成最先进的元启发式如果骑手的位置更新延迟超过 30 秒或者订单的预计取货时长用的是商家历史平均值而不是实时出餐速度再好的算法也是白搭。这篇文章面向三类人正在做同城配送调度系统的后端工程师、想从零搭建派单模块的技术负责人、以及被最后一公里问题折磨过的产品经理。我会把同城物流派送拆成可落地的工程步骤——从订单分桶、骑手画像、路径规划到实时重调度每一步给参数、给代码、给踩坑记录。不堆公式只讲能跑起来的方案。2. 同城物流派送的订单分桶与骑手画像先别急着上算法2.1 为什么订单分桶比路径规划更影响派单结果同城派送的第一刀不是切路径是切订单。把全量待派订单直接丢给求解器计算量会随订单数指数级膨胀。常见做法是按取货网格做空间分桶每个桶内再按时间窗排序。我一般用 500 米 × 500 米的网格这个粒度在多数城市能保证桶内订单的取货点步行距离不超过 6 分钟。分桶之后要做可合并性判断两个订单能不能由同一个骑手顺路完成判断条件不是简单的距离和而是取送方向夹角。如果订单 A 的取送向量和订单 B 的取送向量夹角超过 120 度强行合并会导致骑手绕路。这个阈值我调过很多次120 度是超时率和单均成本的平衡点。import math def can_merge(order_a, order_b, angle_threshold120): 判断两个订单是否适合合并给同一骑手 order 结构: {pickup: (lat, lng), dropoff: (lat, lng)} # 计算取送方向向量 vec_a (order_a[dropoff][0] - order_a[pickup][0], order_a[dropoff][1] - order_a[pickup][1]) vec_b (order_b[dropoff][0] - order_b[pickup][0], order_b[dropoff][1] - order_b[pickup][1]) # 计算夹角余弦 dot vec_a[0] * vec_b[0] vec_a[1] * vec_b[1] norm_a math.sqrt(vec_a[0]**2 vec_a[1]**2) norm_b math.sqrt(vec_b[0]**2 vec_b[1]**2) if norm_a 0 or norm_b 0: return False cos_angle dot / (norm_a * norm_b) cos_angle max(-1, min(1, cos_angle)) # 防止浮点误差 angle math.degrees(math.acos(cos_angle)) return angle angle_threshold这段代码的关键在angle_threshold参数。设成 90 度太严很多顺路单被拆散骑手空驶率上升设成 150 度太松骑手会出现送完 A 再折返取 B的倒挂路线。我实测 120 度左右午高峰超时率能控制在 8% 以内。注意norm_a或norm_b为 0 的情况——取货点和送货点重合的异常订单要单独过滤否则acos会报 domain error。2.2 骑手画像的四个必录字段和更新频率骑手不是移动的点是有状态、有偏好、有容量限制的实体。画像字段至少要有当前载货量、当前任务队列、历史准时率、常驻区域。前两个是硬约束后两个影响派单优先级。载货量要区分件数和体积。同城派送里一束花和一台笔记本的件数都是 1但体积差 10 倍。我一般给每个订单打一个volume_score骑手电动车的标准箱容积设为 100超过 80 就不再派体积大的单。历史准时率用滑动窗口算窗口大小取最近 50 单。这个值低于 0.85 的骑手派单时降权处理——不是不派是优先派时间窗宽松的单。常驻区域用骑手最近 7 天取货点的网格众数用来做分桶匹配。更新频率是个容易翻车的点。骑手位置如果每 10 秒才上报一次在午高峰的十字路口10 秒能差出 200 米。我的做法是骑手端在行进中状态每 3 秒上报静止状态每 30 秒上报。后端收到位置后不直接写库先过一道卡尔曼滤波平滑轨迹避免 GPS 漂移导致派单抖动。from collections import deque class RiderProfile: def __init__(self, rider_id, capacity100): self.rider_id rider_id self.capacity capacity self.current_load 0 self.task_queue deque() self.on_time_window deque(maxlen50) # 最近50单准时记录 self.home_grid None def on_time_rate(self): if len(self.on_time_window) 10: return 0.9 # 新骑手给默认值避免冷启动歧视 return sum(self.on_time_window) / len(self.on_time_window) def can_accept(self, order): 判断骑手能否接单 if self.current_load order[volume_score] self.capacity: return False if len(self.task_queue) 4: # 同时最多4单超过体验急剧下降 return False return Trueon_time_window用deque而不是 list是因为要频繁做滑动窗口淘汰。maxlen50意味着自动丢弃最老记录不用手动 pop。新骑手不足 10 单时给 0.9 的默认准时率这是为了避免冷启动阶段新骑手接不到单——很多系统在这里翻车新骑手因为没数据被降权越接不到单越没数据形成死循环。3. 路径规划与实时重调度从静态求解到动态插单3.1 用 OR-Tools 求解带时间窗的取送问题单骑手多订单的路径规划本质是 PDPTWPickup and Delivery Problem with Time Windows。我一般用 Google OR-Tools 的 routing 模块它支持取送对约束和时间窗。下面是一个最小可运行示例处理 1 个骑手、3 个订单的取送顺序。from ortools.constraint_solver import routing_enums_pb2 from ortools.constraint_solver import pywrapcp def solve_pdp(distance_matrix, time_windows, pickups_deliveries): distance_matrix: 节点间距离矩阵节点0是骑手起点 time_windows: [(start, end), ...] 每个节点的时间窗 pickups_deliveries: [(pickup_idx, delivery_idx), ...] manager pywrapcp.RoutingIndexManager( len(distance_matrix), 1, 0) # 1辆车起点0 routing pywrapcp.RoutingModel(manager) # 距离回调 def distance_callback(from_index, to_index): from_node manager.IndexToNode(from_index) to_node manager.IndexToNode(to_index) return distance_matrix[from_node][to_node] transit_callback_index routing.RegisterTransitCallback(distance_callback) routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) # 时间窗约束 def time_callback(from_index, to_index): from_node manager.IndexToNode(from_index) to_node manager.IndexToNode(to_index) return distance_matrix[from_node][to_node] # 简化距离即时间 time_callback_index routing.RegisterTransitCallback(time_callback) routing.AddDimension( time_callback_index, 30, # 允许等待30分钟 120, # 单车最大行驶时间120分钟 False, Time) time_dimension routing.GetDimensionOrDie(Time) for idx, (start, end) in enumerate(time_windows): index manager.NodeToIndex(idx) time_dimension.CumulVar(index).SetRange(start, end) # 取送对约束同一订单的取货和送货必须由同一车完成且取在送前 for pickup_idx, delivery_idx in pickups_deliveries: pickup_index manager.NodeToIndex(pickup_idx) delivery_index manager.NodeToIndex(delivery_idx) routing.AddPickupAndDelivery(pickup_index, delivery_index) routing.solver().Add( routing.VehicleVar(pickup_index) routing.VehicleVar(delivery_index)) routing.solver().Add( time_dimension.CumulVar(pickup_index) time_dimension.CumulVar(delivery_index)) # 搜索参数 search_params routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC solution routing.SolveWithParameters(search_params) if solution: route [] index routing.Start(0) while not routing.IsEnd(index): route.append(manager.IndexToNode(index)) index solution.Value(routing.NextVar(index)) return route return NoneAddDimension里的三个参数要重点说第一个30是等待时间上限骑手早到取货点可以等但超过 30 分钟这单就不划算了第二个120是单车最大行驶时间防止求解器排出 3 小时的离谱路线第三个False表示不累计到起点因为骑手起点时间固定。AddPickupAndDelivery是 OR-Tools 专门为取送问题提供的约束它会自动保证取货和送货在同一个车辆路径上且取货节点在送货节点之前。但要注意它不保证时间窗时间窗还得靠CumulVar单独设。我见过有人只加取送约束不加时间窗结果求解器排出先送后取的路线骑手到了送货点发现货还没取。3.2 动态插单的重调度触发条件静态求解只适合批量派单。同城派送的真实场景是骑手已经在路上新订单不断进来。这时候不能重新跑全量求解只能做增量插入。我的做法是维护一个可插入窗口对每个骑手计算当前任务队列中每两个相邻节点之间的时间余量如果余量大于新订单的绕路成本就允许插入。触发重调度有三个条件满足任一就执行新订单等待超过 90 秒、骑手完成当前任务、骑手位置偏离规划路线超过 500 米。第一个条件保证用户体验第二个是自然触发点第三个是异常纠正。def find_insertion_position(rider, new_order, distance_matrix): 在骑手现有路线中找新订单的最佳插入位置 返回 (pickup_pos, delivery_pos, extra_cost) 或 None route list(rider.task_queue) best None min_cost float(inf) # 尝试所有取货插入点 for i in range(len(route) 1): # 尝试所有送货插入点必须在取货之后 for j in range(i 1, len(route) 2): # 计算插入后的额外距离 extra calc_extra_distance(route, i, j, new_order, distance_matrix) # 检查时间窗是否满足 if not check_time_windows(route, i, j, new_order): continue if extra min_cost: min_cost extra best (i, j, extra) # 绕路成本超过阈值则拒绝 if best and best[2] 3000: # 单位米 return best return Nonecalc_extra_distance计算的是插入前后路径总距离的差值。check_time_windows要逐节点验证因为插入一个订单会推迟后续所有节点的到达时间。这里有个性能坑如果骑手队列有 4 单插入点组合是 O(n²)单次计算很快但如果有 200 个骑手同时做插入判断CPU 会飙。我的优化是先用网格粗筛——只对取货点在新订单 2 公里内的骑手做精确插入计算。绕路成本阈值 3000 米是我根据单均配送成本反推的。超过 3 公里的额外绕路这单的利润覆盖不了骑手的时间成本。这个值因城市而异平原城市可以放到 4000山城要降到 2000。4. 同城物流派送避坑五个让系统半夜报警的坑4.1 坑一骑手位置漂移导致派单抖动现象同一个骑手在 10 秒内被派了 3 单又取消了 2 单骑手端频繁弹窗。原因GPS 在楼宇密集区漂移骑手位置在 200 米范围内跳变每次跳变都触发骑手进入新网格事件调度器误判为可接单区域变化。解决位置上报后先做卡尔曼滤波再用 30 秒的滑动窗口判断骑手是否真的移动。具体做法是维护骑手最近 5 个位置点计算质心只有质心移动超过 100 米才更新骑手网格。滤波参数process_noise设 0.01measurement_noise设 0.1这个组合在城区实测能把抖动降低 70%。4.2 坑二商家出餐时间用历史均值导致取货超时现象系统显示骑手 12:05 到店12:08 取货但实际骑手 12:05 到店后等了 15 分钟12:20 才取到货后续订单全部超时。原因预计取货时长用的是商家过去 30 天的平均出餐时间但午高峰的出餐时间比平峰长 2 到 3 倍。均值把高峰和低谷混在一起严重低估。解决出餐时间按时段 当前待出餐单量分桶统计。午高峰 11:30 到 13:00 单独一个桶桶内再按商家当前积压单量做线性修正。积压 10 单以上时预计出餐时间 基础时间 × 1.8。这个系数我调过1.5 太乐观2.0 太保守导致骑手不愿接。4.3 坑三时间窗硬约束导致无解现象求解器返回 None系统显示暂无可用骑手但实际有骑手空闲。原因时间窗设得太死比如要求 12:00 到 12:15 送达但骑手从当前位置过去最快也要 12:18。硬约束下求解器直接放弃。解决时间窗改成软约束超时加惩罚值而不是禁止。OR-Tools 里可以用SetCumulVarSoftUpperBound惩罚系数按超时分钟数递增。我一般设前 5 分钟每分钟罚 1005 到 15 分钟每分钟罚 300超过 15 分钟罚 1000。这样求解器会尽量准时但实在做不到时会给一个迟到但能送的方案而不是直接无解。4.4 坑四骑手容量只算件数不算体积现象骑手接了 4 单每单都是小件但其中一单是 24 瓶矿泉水电动车踏板放不下只能分两趟送后两单超时。原因容量约束只用了order_count 4没有体积维度。解决每个订单打volume_score骑手容量设 100矿泉水这种大件单独设 60。派单时同时检查件数和体积。体积分怎么来商品库里有包装尺寸的直接算没有的按品类给默认值——餐饮 10商超小件 15饮用水 60粮油 80。这个默认值表要持续维护新品类出现时人工补。4.5 坑五重调度频率过高导致骑手端路线乱跳现象骑手按导航走走到一半路线突然变了掉头回去取新单取完新单路线又变了。原因每次新订单进来都触发全量重调度骑手已规划路线被反复覆盖。解决重调度分两级。一级是微调只允许在骑手当前任务队列末尾追加新单不改变已有节点的顺序这个可以频繁触发。二级是重排允许调整所有节点顺序但每个骑手 10 分钟内最多触发一次。微调解决 80% 的插单需求重排只处理极端情况。5. 派单效果的离线验证与在线灰度5.1 用历史订单回放做离线评估算法上线前必须做离线回放。把过去 7 天的订单按时间顺序喂给调度器记录每个订单的派单结果和模拟送达时间。评估指标看三个准时率、单均绕路距离、骑手空驶率。回放时要注意信息隔离——调度器只能看到当前时刻之前的信息不能偷看未来订单。我见过有人回放时把全天订单一次性加载调度器提前知道后面有大单故意留着骑手不派离线指标好看得离谱上线就崩。def replay_orders(orders, dispatcher, time_step60): orders: 按创建时间排序的订单列表 dispatcher: 调度器实例 time_step: 模拟时间步长秒 current_time orders[0][created_at] pending [] results [] while pending or orders: # 加载当前时刻之前创建的订单 while orders and orders[0][created_at] current_time: pending.append(orders.pop(0)) # 调用调度器派单 assignments dispatcher.dispatch(pending, current_time) # 记录结果并从待派列表移除 for order_id, rider_id in assignments.items(): order next(o for o in pending if o[id] order_id) results.append({ order_id: order_id, rider_id: rider_id, dispatch_time: current_time, simulated_delivery: simulate_delivery(order, rider_id) }) pending [o for o in pending if o[id] ! order_id] current_time time_step return resultstime_step设 60 秒是平衡精度和速度。设 10 秒回放 7 天数据要跑 10 小时设 300 秒会漏掉午高峰的快速变化。60 秒下7 天数据大约 2 小时跑完足够发现大部分逻辑问题。5.2 灰度上线的分流策略离线指标好不代表线上好。灰度上线时按骑手 ID 分流10% 骑手走新调度器90% 走旧的。分流要保证同一骑手的订单始终走同一个调度器否则骑手会收到两套逻辑的派单体验混乱。观察指标除了准时率还要看派单拒绝率——骑手主动拒绝的订单占比。新调度器如果派了很多远单骑手会用脚投票。拒绝率超过 15% 就要回滚。灰度周期至少 3 天覆盖一个完整的午高峰和晚高峰。我一般周一上线观察周一到周三周四决定是否扩量。周四扩到 50%下周一全量。这个节奏不快但稳。5.3 一个容易被忽略的指标骑手端操作耗时派单效果不只看送达时间还要看骑手在 App 上的操作耗时。如果新调度器让骑手每天多花 20 分钟在确认接单、导航切换、上报异常上准时率再好骑手也会流失。我在骑手端埋了一个operation_duration字段记录从收到派单到点击开始配送的时间。这个值超过 30 秒说明派单信息展示有问题——可能是路线不清晰可能是取货点描述模糊。优化这个字段比优化算法本身更能提升骑手满意度。派单这件事算法是骨架数据是血肉骑手体验是灵魂。我踩过最深的坑是盯着准时率调参数调了两个月提升 2%结果骑手流失率涨了 5%。后来把骑手操作耗时纳入核心指标反而倒逼调度器简化了派单展示逻辑准时率跟着涨了 3%。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。