
拼车打包这件事听起来像收拾行李但在出行调度里它是一个很实在的工程问题一批拼车订单进来之后怎么把出发地、目的地、可等待时间都接近的乘客合并成同一个批次交给一辆车执行。对应到算法和工程语境这个词通常就叫 packing本质上是带约束的装箱问题。如果你刚接手一个代号叫packing(⊙v⊙)的拼车打包项目或者只是想给订单系统补上批次合并能力这篇内容可以当作第一份实测笔记来看。我看项目时最关心的不是代码量而是先回答三个问题这个模块到底处理哪些订单、打包结果长什么样、批量跑的时候能不能稳定复现。这篇就从这三个问题展开把环境和前置条件、核心规则、参数调法、批量工程化、结果判断和排查顺序串一遍。1. 先搞清楚“拼车打包”到底处理什么订单1.1 不是所有订单都适合塞进一个批次先说结论拼车打包不是把订单合并得越多越好而是在“乘客能接受”和“车辆能承载”之间找平衡。落地时订单会被分成两类一类是不参与打包的订单比如乘客明确选择不拼车或者携带大件行李、宠物需要单独派车另一类是进入打包池的订单只有这部分订单才能参与批次合并。很多人第一次做这个功能会把所有订单直接拉进候选集结果发现时间完全对不上路线绕得厉害拼成率很低。这个问题的根源不是算法不行而是业务规则没有前置。我一般会在建表或者写候选集查询时先加一层过滤只保留可拼订单并且乘客数、车型、出发地周边范围都满足条件再进入打包模块。这一步看起来简单但影响很大。过滤条件如果太宽松后面的合并计算会浪费大量资源如果太严格又会让可能顺路的订单被误杀。建议先把“什么订单不参与打包”明确写出来再写“什么订单可以参与打包”。1.2 打包的本质是带约束的装箱问题传统装箱问题是把物品装进固定容量的箱子尽量少用箱子。拼车打包多了一层空间和时间的弱化订单不是固定形状的物品它可以被拼进不同批次也可以在不同时间被接起但所有上下车点都要落在合理范围内。所以你需要一个can_place(batch, order)判断函数在每一轮合并前检查几个维度总人数是否超过车辆可拼座位数新订单的时间窗和批次里已有路线的所有节点是否冲突插入新订单后路线整体绕行时间是否在可接受范围出发地和目的地距离是否在一个合理阈值内乘客有没有特殊要求这五个维度里最容易出问题的是时间判断。同一辆车可能已经接了上一个订单新增一个上下车点后后面所有节点的到达时间都会变化。判断时不能只看订单的期望出发时间要把整个路线每个节点的最早到达、最晚到达都算出来。1.3 先定义清楚输出再写算法我的习惯是先定义输出不急着写算法。打包模块的输出至少是一张批次表批次号、被合并的订单列表、候选司机、计划出发时间、路线节点、状态。没有这张表后面做日志、回放、失败重试、指标统计都很难展开。输入材料里没有给出具体的数据格式这里按通用落地设计来演示实际表结构要以你的业务为准。但有一点很关键批次表里必须有状态字段至少包含WAITING、PUSHED、ACCEPTED、STARTED、FINISHED、CANCELLED这几个可回溯状态。这样一旦出问题你能快速定位是打包阶段的问题还是派单阶段的问题。2. 跑通第一版打包逻辑最少需要哪几份数据2.1 订单表里缺一不可的字段订单表至少要有订单号、乘客数、出发经纬度、目的经纬度、期望出发时间、最晚到达时间、当前状态。为什么这几个字段缺一不可因为打包判断的每一步都在拿它们做交叉计算。没有经纬度就做不了距离计算没有时间窗就判断不了几个订单能不能放同一辆车没有乘客数就不知道容量是否超载。这里有一个很常见的坑坐标精度不够。如果订单表坐标只精确到小数点后四位两条不同街道的出发地可能重合距离计算会失真。虽然不一定导致打包失败但会让路线规划变得不可解释。我在数据清洗阶段会加一个校验坐标为空、坐标明显超出城市范围的订单先剔除。时间字段也要统一格式。真实环境里同一个订单可能有下单时间、支付时间、期望出发时间、最晚到达时间。打包逻辑使用的是后两个不要把下单时间当作出发时间算。2.2 司机和车辆信息不能只用“座位数”座位数只是起步。决定一辆车能接几个拼车订单的通常是当前可用的拼车座位数。比如一辆五座车司机一个人开车理论上还有四个座位但如果平台规定同一批次最多只接两组拼车订单那四个座位就不能直接使用。落地时我建议给车辆侧增加一个available_pickup_slots字段表示这辆车在当前时刻还能接受几个拼车乘客组。这个字段和总座位数分离更新起来更方便。车辆类型也要保留有些订单要求舒适型有些要求六座打包的前提是车辆类型和订单类型一致否则即使路线顺路也不能放进同一个批次。2.3 路线矩阵距离和时间必须分开存路线矩阵是打包计算里最耗资源的部分。不建议实时去调地图接口每判断一次就调一次速度慢成本也高。我会在批量打包前提前算好一小片区域的 OD 距离和时间至少覆盖候选订单涉及的起点和终点缓存起来复用。距离单位建议用米时间单位建议用秒。这样后续计算绕行系数和预计到达时间比较直接。原型阶段可以用直线距离乘以系数来估算跑通流程没问题但不能当作正式结论。正式环境必须接入地图服务并且要区分高峰时段和低峰时段同一段路在不同时段的耗时差别很大。2.4 最小样例和手算验证第一版不要直接上全量数据。我一般会构造一个最小样例三个订单两个出发点在同一个小区附近目的地都在同一栋写字楼附近期望出发时间相差不超过十分钟总人数不超过车辆可拼座位数。然后手算一遍预期能合成一个批次。再跑程序看输出是否和手算一致。如果一致再增加一个反例某个订单的最晚到达时间特别早把它放进批次后会挤压前一个订单的送达时间那么这一轮它就不该被合并。这种“先确认能合并再确认不误合并”的顺序比直接看批量数据更容易发现问题。# 示意逻辑按期望出发时间排序逐个尝试加入已有批次 def build_batches(orders): batches [] for order in sorted(orders, keylambda x: x[pickup_time]): placed False for batch in batches: if can_place(batch, order): batch[order_ids].append(order[order_id]) placed True break if not placed: batches.append({ order_ids: [order[order_id]], plan_start_time: order[pickup_time] }) return batches这段只是示意真实环境里还要加入司机匹配、路线节点更新、状态锁和失败回滚。3. 单批次跑通后核心参数该怎么调3.1 时间窗先给多少从 ±10 分钟开始试时间窗是拼车打包里最影响拼成率的参数。如果订单之间必须精确到同一分钟几乎拼不成。如果给到前后半小时乘客等待时间会明显变长。第一版建议先按“期望出发时间 ±10 分钟”来分时间桶跑一次历史订单回放看拼成率是多少再按 ±15 分钟对比。这里要说明时间窗不是一个单独数字。它包含两层订单被接起的时间范围以及订单最晚到达时间。有些订单出发时间很宽松但要求九点半前必须到达这类订单的时间余量反而更紧。判断时要看整条路线每个节点的时间不能只看起点。3.2 每车最多拼几个从两组订单开始限制我建议把“每车最多拼单数”和“每组订单人数”分开看。一个批次里可以包含多个订单但每增加一个订单路线复杂度会明显上升。第一版跑通时五座车按最多两个拼车组来限制比较稳妥。确认指标正常后再决定要不要放开到三组。为什么这样限制每增加一组乘客路线就多两个上下客点司机中途停靠次数变多哪怕总人数没超座位数乘客体验也会下降。低峰期订单少一组订单单独走可能更好高峰期可以适当放宽但前提是绕行系数和平均等待时间仍然能接受。3.3 绕行容忍度1.2 倍不是固定值绕行容忍度通常用系数表示如果用户从 A 到 B 直达需要 20 分钟拼车后因为先送别人总共需要 25 分钟那么绕行系数就是 1.25。第一版可以先按 1.2 到 1.3 的常见区间去测但不要把系数当成固定真理。系数太低能拼在一起的订单会非常少拼成率上不去太高司机绕路变长乘客投诉增加。建议用历史订单回放来测跑某一天的可拼订单统计不同系数下的平均绕行时间再结合乘客评价和投诉一起判断。3.4 先跑单线程再谈并发打包这个动作在数据量不大时完全不需要并发。先把单线程版本跑通确认输出稳定再考虑并发。直接开并发会遇到两个典型问题同一批订单被多个 worker 同时读取可能被拼进不同批次状态没有及时更新时重复写入非常难排查。如果一定要并发至少做到两件事一是订单进入打包池时先加状态锁只有WAIT_PACKING状态的订单可以被读取二是每次写入批次都使用带状态条件的更新而不是先查询再覆盖。没有这两个保障并发带来的不是效率而是脏数据。参数第一版建议值设置过小的表现设置过大的表现时间窗±10 分钟拼成率下降可合并订单少乘客等待时间变长最大拼车组数2 组车辆空间利用率偏低路线复杂司乘体验下降绕行系数1.2 附近顺路订单也拼不上乘客投诉绕路过多4. 批量打包的工程化从脚本到定时任务4.1 输入输出统一成标准表脚本阶段可以直接读 CSV 或接口返回但进入批量阶段后输入输出最好都落到标准表。订单从上游落库打包服务读取可拼订单输出批次表派单服务再消费批次表。这样每一步都有日志出了问题可以回查。批次表可以用这样的结构CREATE TABLE packing_batch ( batch_id VARCHAR(64) PRIMARY KEY, order_ids JSON, driver_id VARCHAR(32), plan_start_time DATETIME, total_passengers INT, route_waypoints JSON, status VARCHAR(20), created_at DATETIME, updated_at DATETIME );这里order_ids和route_waypoints用 JSON 只是方便原型阶段观察正式环境建议拆成批次明细表和路线节点表避免每次都要解析整串 JSON。4.2 批次命名、落库和参数快照批次号要具备可读性。我建议用日期加时间戳加序号比如B20250101093001。这样排序日志时不用解析 JSON也能直接看出生成时间。批次表里还要记录这一版打包用了哪些参数比如时间窗、最大拼单数、绕行系数。同样的订单在不同参数下打包结果会不同。没有参数快照后面很难复盘为什么这一版结果和上一版不一样。我一般会加一张packing_run表记录每次全量打包的运行时间、参数快照、输入订单数、输出批次数量、失败数量。每次调参后只需要对比两次run_id的指标就能判断改动是变好还是变差。4.3 失败重试要保证幂等批量打包最核心的工程问题是重复执行。定时任务可能因为网络超时重复触发某个批次推送给派单服务时也可能失败。如果重试机制没有做幂等同一批订单可能会生成两个批次号司机侧就会看到重复单。我建议给每条订单增加处理状态WAIT_PACKING、IN_BATCH、PUSHED、FINISHED、FAILED。打包服务只处理WAIT_PACKING的订单。写入批次明细时要同时把这些订单状态改成IN_BATCH。这样即使任务被重复触发第二次也会因为状态变化而跳过。这里最忌讳的是只判断“订单号是否已经在某个批次里”因为查询和写入之间有时间差并发时会漏判。更稳的做法是把“读取候选订单”和“写入批次明细”放进同一个事务用状态条件更新防止并发覆盖。4.4 实时拼车和定时打包不是一回事很多项目先做的是定时打包比如每天按高峰时段提前算好一批批次。但真实业务里乘客是实时下单的司机也是实时出车的。定时打包只能作为预调度真正服务实时订单还需要一个增量打包入口新订单进来后先尝试插入已有批次不能插入再创建新批次。增量入口的耗时要求更严格。全量打包跑一分钟还能接受实时请求必须在几秒内返回。这时候需要缩小候选集比如只取附近两公里、时间窗相近的订单去尝试合并。如果项目定位是离线实验先做定时打包是对的。不要一上来就设计复杂的实时事件流先把离线逻辑跑稳再迁移到增量场景。5. 打包结果好不好不能只看“有没有拼成”5.1 上线前至少盯四个指标拼成率只是最表面的指标。打包质量好不好我会同时看四个维度拼成率成功被打包进批次的订单占比空驶率司机从出发点到第一位乘客接起点的空驶距离平均等待时间从下单到实际上车的时长司机座位利用率车辆可拼座位里被实际使用的比例这四个指标经常互相牵制。比如把距离很远的订单硬拼在一起拼成率上去了但空驶率和平均等待时间都会变差。上线前先定一个可接受区间而不是只看单个值。拼成率比原方案低五个点以内但等待时间明显下降也可以接受。5.2 日志里该记录什么才能回溯排查时最怕日志里只有“打包成功”这种结果。至少要记录每个批次组建时的信息运行 ID、读入多少订单、过滤掉多少、创建多少批次、每个订单最终落在哪个批次里。如果某个订单没被拼进去要能看到被过滤的原因比如“坐标缺失”“超出时间窗”“乘客数超限”“候选集合为空”。日志建议按 JSON 格式输出每条日志带上run_id、order_id、batch_id。这样排查时可以直接按订单号搜索也能按运行 ID 拉出整轮打包链路。这个习惯很重要批量任务出问题时能不能快速定位很大程度取决于日志字段是否完整。5.3 历史订单回放怎么设计改参数或改算法之后怎么判断有没有变好最可靠的方式是历史订单回放。取某一天的真实订单数据按照当时的可拼状态重新跑一遍打包和目标指标对比。回放时要注意两点。第一订单状态必须还原到打包前不能带上已经派单后的结果。第二回放不能实时请求地图路线接口速度和成本都扛不住要用缓存或预计算矩阵。一次完整回放至少输出两组结果旧逻辑跑出来的批次、新逻辑跑出来的批次以及两者的指标差异。5.4 灰度先放一条线路再放全量打包逻辑直接影响司机收入和乘客体验不建议全量上线。更稳的顺序是先选一条订单量中等、投诉率不高的线路做灰度。灰度期间可以同时跑旧逻辑和新逻辑先用新逻辑的结果做参考不实际派单等确认指标不劣化后再切真实流量。如果项目本身是实验性质或者内部工具可以简化这一步。但要接真实订单灰度是必须的。至少要在真实验收环境里把“重复打包”“订单漏单”“司机侧重复推送”这三个问题全部验证过再考虑扩大范围。6. 常见问题排查顺序6.1 订单没进任何批次先看清洗和去重如果某个订单一直没有被拼进任何批次不要先调算法。先把这条订单从原始数据到候选池全链路查一遍坐标有没有被清洗掉时间字段是否合法状态是不是已经被改成IN_BATCH去重逻辑有没有把它当成历史订单剔除。好几次问题都出在数据源而不是打包逻辑本身。我排查时固定顺序是先看这条订单有没有进入候选查询结果再看能不能被时间桶覆盖最后才看打包算法有没有选它。前两步最快也最容易发现问题。6.2 同一个订单被重复打包状态锁没有生效重复打包最典型的原因是状态没有加锁或者状态更新不是原子操作。定时任务反复触发、消息重复消费都会导致同一个订单被多个执行单元读到。遇到重复打包先看批次明细表里有没有两个批次包含同一个订单。如果存在把每次任务触发的运行 ID 和订单状态更新时间拉出来对比。修复重点不是加一个“是否已打包”的判断而是把读取候选订单和写入批次放进同一事务用状态条件更新防止并发覆盖。6.3 路线计算很慢矩阵缓存和候选集没做好打包速度慢通常不是算法本身慢而是路线计算太频繁。每判断一个订单能否加入某个批次都要计算好几段新增路线。实时调地图接口必然慢成本也兜不住。解决办法有两个方向一是把常用区域的 OD 矩阵缓存起来按小时或按天刷新二是缩小候选集合先按“出发地距离小于阈值”“时间窗相近”“车型相同”做粗筛只有粗筛通过的候选订单才进入精细路线判断。6.4 批次派给司机后被拒偏好约束没进模型如果打包结果算法觉得很好司机却拒绝接单最常见原因是模型里没有司机偏好。比如司机只愿意接起点和终点都在某个商圈内的单或者司机对某条路线不熟悉。打包时只考虑了乘客侧的时间、地点和容量没有纳入司机侧偏好自然会被拒。这里要先看司机侧设置里有没有这类偏好字段。如果有打包候选集的过滤条件里必须加上司机偏好过滤。这属于需求边界问题不是代码 bug。7. 哪些边界不能忽略7.1 低峰期硬拼会让体验更差订单稀疏时把两个相隔很远的订单硬拼在一起等单时间可能超过乘客预期。这种情况下更合适的做法是直接单走而不是强行拼车。我一般在打包前会判断候选订单密度如果某个时间桶内只有一个或两个可拼订单且距离明显超过阈值就跳过拼车按单订单派发。这个判断需要一条规则只有当候选订单密度和距离同时满足条件时才允许创建多订单批次。低峰期强制使用同一套打包参数等于把乘客体验当成算法的牺牲品。7.2 特殊订单要提前标记携带宠物、大件行李、儿童安全座椅或者乘客人数较多这类订单都不适合参与常规打包。如果把它们也塞进批次司机到达后可能因为无法装载而取消订单影响整批乘客。实现上建议给订单增加一个special_requirements字段打包候选查询里默认排除这些订单除非业务上明确支持同类特殊订单合并。7.3 实时路况和静态距离是两套数据打包判断里的路线时间不能只用静态距离换算。同一段两公里路早晚高峰可能差出二十分钟凌晨几分钟就能跑完。如果打包发生在服务时段内建议使用地图服务返回的实时 ETA如果只是离线回放则要使用对应时段的平均耗时而不是当前时刻的实时路况否则回放结果没有代表性。7.4 后续迭代顺序第一版能稳定跑通单批次和每日批量任务之后再考虑三件事。第一增加实时增量打包入口处理新订单插入已有批次。第二增加可视化看板把拼成率、等待时间、空驶率按线路和时段展示出来。第三把参数配置外置到配置中心避免每次调时间窗、调绕行系数都要重新发版。这三个方向里实时增量打包的复杂度最高建议放在最后。这类打包功能真正落地时最该盯住的不是算法够不够新颖而是输入数据是否干净、状态流转是否可靠、批次输出能不能回放。先把单批次跑稳再逐步加批量、加参数对比、加灰度整体推进会顺很多。