简介:一份面向安卓开发学习者的同城物流派送系统完整项目,覆盖从订单发起、司机抢单到货物送达的全流程管理,适合作为移动端课程设计或毕业设计的参考蓝本。压缩包内共275个文件,大小约53.42MB,以Java源码、XML布局、PNG图片资源及Class编译文件为主,同时包含可直接安装的APK与SO库,便于在模拟器或真机上运行调试、对照源码拆解学习。目前已有323人学习下载。项目围绕SQLite数据库展开,涵盖账号注册与登录、订单增删改查、自定义适配器绑定列表、司机抢单与派送状态更新等核心模块,并引入GPS定位与地图路线规划来提升配送效率;通过这些功能可以系统理解安卓应用从数据层到界面层的协作方式,也能借鉴其文件目录划分和适配器封装思路,对提升项目实战能力有明显的参考价值。
1. 同城物流派送:为什么你的调度链路总在“爆单”边缘挣扎
做同城派送系统最怕的不是单量小,而是单量刚上来系统就乱成一锅粥:订单接入和调度引擎脱节,运力池和分区规则不匹配,人工改派操作频繁到像在打地鼠。很多人以为是算法不够智能,实际上问题出在订单状态流转、运力建模和路由成本计算这些基本功上。同城物流派送的核心不是“把货送完”,而是“用对的人在对的窗口期走对的路”。这篇文章围绕调度系统的完整链路展开,从订单接入到签收复盘,讲清楚每一步的参数怎么设、数据怎么流、异常怎么兜底,适合正在做即时配送调度、同城货运派单或者自营物流系统的开发者,以及需要评估自建调度体系还是采购成品方案的决策者。方向很明确:先让链路稳定,再谈优化模型。
2. 订单接入与调度引擎:先把入口的“吞吐”和“并发”问题按住
2.1 订单状态机设计:没有明确流转,就没有调度依据
同城派送订单从创建到签收,至少要经历“待派单、已派单、待取件、运输中、待签收、已签收、异常取消”这几个稳定状态。很多团队在这上面吃过亏:状态只有“进行中”和“已完成”两个字段,调度引擎拿不到订单阶段的准确信息,催单和超时判断全靠人工盯。
我一般会在订单表里加一个status_history的 JSON 字段,每次状态变更都追加一条记录,包含操作人、操作时间、状态来源(系统派单/人工改派/骑手操作)。这样做有两个好处:一是调度引擎可以直接从当前状态判断该走哪条派单规则,二是后续做时效分析时,可以从历史记录里还原订单在哪一环被卡住。
-- 订单状态记录表设计 CREATE TABLE order_status_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, from_status TINYINT NOT NULL, -- 原状态枚举值 to_status TINYINT NOT NULL, -- 新状态枚举值 operator_type TINYINT NOT NULL, -- 操作方:1-系统 2-调度员 3-骑手 operator_id BIGINT NOT NULL, -- 操作人/操作规则ID created_at DATETIME NOT NULL, INDEX idx_order_time (order_id, created_at) );状态机里最容易翻车的是“待派单”和“已派单”之间的流转条件。如果订单在调度引擎分配骑手后没有同步确认回执,另一个并发任务就可能把同一笔订单再派一次。所以“已派单”必须依赖骑手端的接单回执,而不是调度引擎的单向推送。接单超时未确认的,订单自动回到“待派单”池,并做一次改派计数,超过三次就转人工处理,避免系统死循环。
参数上,接单确认超时一般设置在 30 到 60 秒。太短容易误伤骑手正在忙的场景,太长会导致订单滞留。同城生鲜类订单要求时效高,我一般压到 20 秒;普通文件类可以放到 45 秒。这个值建议做成后台可配置项,而不是写死在代码里。
2.2 库存锁单与并发控制:超卖和重复派单是同一个问题
同城派送的“库存”不是商品,而是运力。当一笔订单进入调度池,它需要锁住某个区域内的可用骑手额度,防止多个订单同时争抢同一个骑手。这个场景和商品超卖的并发问题完全同构,解决方案也通用:用 Redis 的原子扣减代替“先查再扣”。
# 基于 Redis Lua 脚本实现运力锁单 # 返回 1 表示锁单成功,返回 0 表示运力不足 lock_script = """ local key = KEYS[1] -- 运力池key,如 capacity:zone:{zone_id} local order_id = ARGV[1] -- 订单号 local weight = tonumber(ARGV[2]) -- 该订单消耗的运力权重(大件=2,小件=1) local current = redis.call('GET', key) if not current then return 0 end local remaining = tonumber(current) if remaining >= weight then redis.call('SET', key, remaining - weight) -- 记录订单和扣减信息的映射,用于回滚 redis.call('HMSET', 'order_capacity', order_id, weight) return 1 end return 0 """锁单成功后,订单进入调度队列;如果后续派单失败或者用户取消,需要释放运力回池。释放操作也建议走 Lua 脚本,保证扣减和释放是原子操作。我见过某系统先GET再SET释放运力,高峰期出现线上运力数据越变越少,就是因为两个释放请求同时执行,后一个覆盖了前一个的写入。
运力权重的设置要结合实际装载能力:文件类订单算 1,标准纸箱算 1.5,大件家电算 3,还有体积和重量的二重判定。如果只看重量不看体积,面包车会被几个轻泡货占满,跑一趟就不划算了。
2.3 高峰期削峰策略:让订单“排队”而不是“丢弃”
同城物流的典型高峰在午间和傍晚,订单量可能达到平时的 4 到 6 倍。调度引擎如果直接用同步接口接收全部订单,数据库和计算资源都会被瞬时打满。常见的做法是入口分两级缓冲:API 网关直接落消息队列,调度引擎从队列里按批次拉取。
# RocketMQ 生产端配置节选 rocketmq: producer: group: dispatch-order-producer default-topic: dispatch-inbound send-msg-timeout: 3000 consumer: group: dispatch-order-consumer consume-thread-min: 20 consume-thread-max: 40 pull-batch-size: 100消费端按订单创建时间排序后分批处理,这样既保证了时序,又避免一次拉取过多导致调度计算超时。这里有个细节:消费线程数和分区数要匹配,否则会出现部分分区消费积压、部分分区空闲。通常分区数设为消费线程数的整数倍,20 个线程对应 40 或 60 个分区,重平衡成本才够低。
削峰时的“降级预案”也要提前想好:队列积压超过阈值时,系统自动关闭“预约单提前调度”功能,把调度资源让给即时单。判定阈值一般参考队列积压数量和消费落后时间,落后超过 3 分钟就触发降级。注意降级不能死板,要能在积压缓解后自动恢复,否则持续降级会导致预约单永远调度不出去。
3. 运力模型与分区:为什么网格分区比画圆好用
3.1 手动分区与网格分区的取舍
同城物流运力建模的第一步是分区。分区的目的不是给订单贴个标签,而是让调度引擎能在有限范围内快速圈定候选骑手。
手动分区适合业务量小、商圈边界清晰的场景,比如一个城市只有几个核心商圈,每个商圈内骑手密度足够。但麻烦在于:业务扩展后,新开的商圈需要人工维护边界;存在大量跨区订单时,手动边界会导致相邻区域的运力无法互相应急。
网格分区是把地图切成固定大小的矩形网格,每个网格有独立的运力池和订单池。它的好处是边界固定,自动扩容方便,跨区协同通过“网格邻接表”实现。调度时先定位订单所在网格,如果该网格运力不足,向邻接网格扩散搜索。
# 根据经纬度计算网格编码 import math GRID_SIZE = 0.01 # 约1km x 1km(纬度方向约110m,经度方向取决于纬度) def get_grid_code(lat, lng): lat_grid = int(math.floor(lat / GRID_SIZE)) lng_grid = int(math.floor(lng / GRID_SIZE)) return f"{lat_grid:05d}_{lng_grid:05d}" # 调用示例:上海某坐标点落在哪个网格 # print(get_grid_code(31.2304, 121.4737))网格大小直接影响运力利用率和调度效果。0.01 度约等于 1 公里,适合步行和电动车密集的城区核心。郊区或乡镇区域网格要放大到 0.02 度甚至 0.05 度,否则网格内长期只有一两个骑手,调度引擎可用运力太少,容易陷入死区。
3.2 网格粒度参数:一小时运力覆盖率是硬指标
网格设定后不能只凭感觉调大小,要有一个明确的量化指标驱动。我会用一个“运力覆盖率”来验证网格粒度:统计每个网格在高峰时段的有效运力(接单中+空闲中+即将完成)是否覆盖该网格的历史订单量峰值。
# 计算网格运力覆盖率 def check_grid_coverage(orders, riders, grid_map): uncovered = [] for grid_key, order_count in orders.items(): rider_capacity = grid_map.get(grid_key, 0) # 有效运力 = 空闲骑手 + 配送中但剩余时间小于15分钟的骑手 # 这里按每骑手每小时可完成单量折算 effective_capacity = rider_capacity * 2.5 if order_count > effective_capacity: uncovered.append(grid_key) return uncovered覆盖率低于 80% 的网格说明划分太粗或者运力配置不足,要么调小网格,要么在该时段增加骑手排班。我自己常用“高峰小时波动系数”做判断:如果网格内订单量是平峰的 4 倍,但运力只有 2 倍,那么这个网格不是网格粒度问题,而是排班问题,收窄网格反而会加剧局部运力碎片化。
网格参数还需要配合骑手“跨网格接单”的开关。默认情况下,骑手可以接邻接网格的订单,但跨 3 个以上网格的订单不计入调度推荐,除非没有候选骑手。这样既保证骑手路线合理,又给了异常场景一个兜底出口。
3.3 班次与运力峰值配置:不是全天一个模型
同城物流的运力曲线是典型的“M”型:早高峰、午高峰、晚高峰,午夜只有零星订单。单一模型全天跑,要么峰时运力不足,要么谷时大量空驶。正确的做法是按时段设置多套运力参数。
我一般把 24 小时划分成 8 个时段,每个时段独立维护运力池。时段切换时,正在履约的骑手不强制切换,但新订单的派单策略和配送范围参数用新时段的配置。
配置示例:
# 时段配置示例片段 time-segments: - segment: 07:00-09:00 dispatch-policy: default max-delivery-radius: 5km rider-shift: morning - segment: 09:00-11:00 dispatch-policy: default max-delivery-radius: 8km rider-shift: day - segment: 11:30-13:30 dispatch-policy: peak max-delivery-radius: 4km rider-shift: noon注意峰值时段的配送半径不能和谷值时段的半径用同一个值。午高峰交通拥堵,骑行速度下降,路由引擎如果把配送半径往大放,会导致承诺时效全部超时。我自己的经验是高峰半径比平峰缩 30% 到 40%,同时把超载保护阈值调低——宁愿让订单多等几分钟,也不要让骑手带着一堆无法按时送达的订单在路上跑。
4. 路径规划与路由引擎:同城不是算最短距离,而是算最小成本
4.1 同城路由引擎的构成:不是一张路网跑到底
同城物流的路径规划和城际干线有本质区别。同城场景里,配送距离短、路口密集、红绿灯影响大、骑手和司机是两种完全不同的移动模型。骑手走非机动车道,汽车要走机动车道,这两类交通工具的“路网”不能共用一个拓扑图。
一个可落地的同城路由引擎至少包含三张表:机动车路网表、非机动车路网表、禁行/限行规则表。机动车路网从公开地图数据抽取;非机动车路网需要结合城市骑行管理规则,比如哪些路段自行车不能上高架、哪些区域禁行电动车。
路由引擎在计算时优先使用对应交通工具的路网。如果非机动车路网在某片区域数据缺失,则回退到机动车路网做简化计算,并在结果上加一个 15% 的成本惩罚,表示“该路径可能绕行”。
4.2 路径成本与多约束参数:ETA 不合理根因在成本矩阵
路径规划的本质是求解“最小成本路径”,但成本不只有距离。时间成本、骑手熟悉度、天气系数、取货等待时间都要折算进成本函数。
我的做法是这样的:
# 成本函数示意代码 def path_cost(segment, rider_weight, traffic_factor, weather_factor): # segment: 路径段对象, 包含距离和通行时间 # traffic_factor: 实时交通系数(拥堵时1.5,通畅时0.9) # weather_factor: 天气系数(晴天1.0,雨天1.3) time_cost = segment.base_time * traffic_factor * weather_factor distance_cost = segment.distance * 0.02 # 每公里折算0.02单位成本 familiarity_bonus = rider_weight.get(segment.area, 1.0) # 骑手熟悉区域权重 return time_cost * familiarity_bonus + distance_cost这里最有争议的是“骑手熟悉度”权重。有人觉得这个指标主观,但在实际运营里,一个熟悉片区路况的骑手比只看距离的导航路线快 10% 到 15%,尤其在老城区和巷子密集的区域。这个参数生效的范围要限制:只有加减 10% 以内影响,不能因为骑手熟就让一个 5 公里外的订单绕给区域内骑手。
ETA 预估最容易翻车的地方在于“取货等待时间”没进模型。很多同城系统把订单创建到骑手到店的时间直接当行驶时间算,结果骑手到店后商家还没出餐,一单就白白超时 15 分钟。在调度引擎里加一个“取货缓冲时间”的配置,越小越准:
# 取货缓冲时间配置 pickup-buffer: default: 8min merchant-type: restaurant: 12min convenience: 5min warehouse: 15min如果商家有明确出餐状态回传,则优先使用实时状态替代默认缓冲时间。没有状态回传的,按商家历史出餐平均时间走,但要设置上限,避免数据异常导致缓冲时间无限大。
4.3 路由计算的兜底与降级策略:最后一次重复计算
同城调度系统最怕路由引擎在高峰期计算超时。路径规划是计算密集型操作,订单量暴涨时,CPU 可能被路由计算占满,导致派单消息积压。
常规兜底策略是“梯度降级”:路由引擎超时 500 毫秒时,返回直线距离估算的 ETA,标记为“低精度”,同时把该订单从“路径优化”模式降到“就近派单”模式;超时 2 秒时,直接走“分区轮询”模式,按照区内骑手上一次完成订单时间排序,优先派给最早空闲的人。
这样写的好处是:系统不会因为路径计算的瓶颈而完全停摆,只是在高峰时段用“次优解”先顶上。次优解造成的绕路成本可以在低谷时段通过重新规划路径来修正,例如骑手取货后如果发现路线明显不合理,可以触发一次轻量重算。
开放思路:重算的触发点建议放在“取货完成”而不是“派单成功”。取货完成时,商城已经把货交到骑手手上,订单的起点、终点和取货时间都确定了,再做一次路径规划,准确率远高于派单阶段的预估。而且取货完成后订单量已经不在高峰期,计算压力也小得多。
5. 调度全链路监控与常见问题排查:不看数据调优,就是盲调
5.1 调度链路看板:订单、运力、路由三块核心指标
做调度优化之前,先把监控数据接好。不看数据的调优属于“盲调”——现象都靠猜,结论经不起推敲。我习惯把看板分成三块:订单流转看板、运力看板、履约质量看板。
订单流转看板关注每个状态节点上的订单数量,尤其是“待派单”和“派单中”两个节点的积压量。积压超过 50 单并且持续时间超过 5 分钟的时候,定时任务会自动往告警群推送异常消息。
运力看板需要看到每个网格的实时运力、空闲运力、运力使用率。使用率超过 85% 说明该网格可能即将出现运力不足,低于 30% 说明运力冗余,需要调度员人工引导骑手向高需求区域移动。
-- 运力看板核心查询示意 SELECT grid_code, COUNT(DISTINCT rider_id) AS total_riders, SUM(CASE WHEN status = 'idle' THEN 1 ELSE 0 END) AS idle_riders, ROUND(SUM(CASE WHEN status = 'busy' THEN 1 ELSE 0 END) / COUNT(DISTINCT rider_id), 2) AS utilization FROM rider_status WHERE updated_at >= NOW() - INTERVAL 1 MINUTE GROUP BY grid_code;注意这个查询每分钟跑一次就够了,不要每秒钟扫全表,否则你的监控本身就会变成事故。骑手位置上报频率通常在 5 到 10 秒一报,监控做 1 分钟聚合完全够用。
履约质量看板统计超时率、取消率、好评里的“时效不满意”关键词。超时率指标要区分“承诺时效超时”和“系统预估超时”。前者是给用户的承诺,后者是系统内部参考值,两者不能混在一起看,否则你会分不清是骑手跑得慢,还是预估模型本身给得太短。
5.2 同城调度最常见的 4 个翻车点
第一个翻车点:重复派单。
- 现象:用户收到两个骑手先后打来电话,同一个订单被派给了两个人。
- 原因:调度引擎推送订单后,骑手端还没上报接单回执,调度服务就认为超时,进入了下一轮搜索;或者订单状态更新到“已派单”的数据库事务提交,晚于下一次派单查询。
- 解决:订单与骑手之间建立一一绑定关系,用唯一订单号做分布式锁;骑手接单回调(不是推送成功)才允许状态流转到“已派单”,回执超时重试次数设为 2 次,不要无限重试。
第二个翻车点:订单起飞(系统中没有骑手的订单突然消失)。
- 现象:某个网格内有大量订单被调度走了,但骑手列表里找不到对应履约的骑手。
- 原因:订单在“待派单”队列里被某个线程取出后调度,但在骑手侧写入失败时,异常处理逻辑把订单标记成了“已取消”,而骑手端实际已经接了单。
- 解决:调度写入骑手端失败时,要回滚订单状态而不是置为取消;另外在订单状态机里增加“调度中”这一中间状态,该状态不参与取消逻辑,只有调度引擎主动触发取消才会释放。
第三个翻车点:ETA 偏离用户感知。
- 现象:系统预测送达时间还有 8 分钟,但用户看到骑手一直停在原地不动,实际 15 分钟都没送到。
- 原因:骑手轨迹上报存在断点,长时间没有位置更新,ETA 模型按骑手“原地等待”处理;或者地图路线上跨江/跨铁路桥没有桥墩连接,路径计算绕了一个大圈,但还是按直线距离估了时间。
- 解决:在 ETA 模型里加入“位置数据新鲜度”的惩罚项,如果骑手超过 2 分钟没有上报位置,ETA 自动向上取整 20%;对跨河、跨铁路、跨高架的路段,单独维护“绕行系数”,不让路线估算和实际偏离太远。
第四个翻车点:调度参数“一刀切”导致区域失衡。
- 现象:城市中心和郊区的派单响应时间差距成倍,郊区订单长时间无人接单。
- 原因:全城用同一套“最大配送半径”和“最小空闲时间”参数,郊区骑手覆盖面积大,但订单密度低,如果配送半径限制太严,大量边缘订单会被筛掉。
- 解决:分区设置参数,郊区网格适当放大最大配送半径,同时降低骑手时不时的“在线时长”权重,把“愿意接远单”作为郊区派单评分的一个加分项。
5.3 复盘环节:异常不能只用一次补丁
排查完问题,建议形成两类产出:一类是告警规则,这类问题如果再发生要在 1 分钟内被感知到;另一类是回归用例,把这次踩到的场景固化到测试用例里,下个版本上线前自动跑一遍。
我见过很多团队把“修一个 bug”当成“解决了一个问题”,结果问题是移动端和后台各自开发各自的版本,联调时同样的 bug 反复出现。调度系统牵一发动全身,最好保证每一次状态变更、每一次参数调整都有版本记录,并且能一键回滚到上一个稳定版本。固定下来以后,整个系统的稳定性会大幅向上抬升。
6. 调度参数怎么验证和灰度:不能拍脑袋改完就上线
调度系统里最玄学的环节就是“新参数上线”。同样一套规则,在 A 城市跑得好,到 B 城市可能变成灾难。原因很简单:城市路网、订单密度、骑手习惯都不一样,所以参数验证必须分步走。
第一步是离线回放。
拿历史数据做回放是最便宜的验证方式。把前一周的订单数据、骑手轨迹数据、天气数据灌进新的调度参数里,对比新旧方案的超时率和平均送达时长。这里注意不要只用一个“平均送达时长”指标,太笼统了,要拆开看:高峰 vs 平峰、核心网格 vs 边缘网格、2 公里以内 vs 5 公里以上,每个维度单独对比。我遇到过新方案整体平均送达时长下降 8%,但核心商圈超时率上升 15% 的情况——如果只看平均值,这就上线了,上线之后必然投诉爆发。
第二步是小流量灰度。
选一个骑手数量和订单量都适中的网格,灰度 10% 到 20% 的订单流量。灰度期间要盯的指标不是“有没有报错”,而是“超时率变化”和“骑手异常状态数量”。超时率上升超过 2 个百分点,立即切换回原参数,不需要等灰度跑满周期。这个阈值看起来小,但同城的超时率基准通常在 5% 到 10% 之间,2 个百分点的波动已经能明显反映在客诉上了。
第三步是逐步放量。
灰度没问题后,按 50%、100% 两步放量。放量过程中如果出现了需求驱动的新问题(比如某个网格的订单突然多了 30%),不要急着回滚,先看是参数问题还是业务波动。业务波动导致的运力不足,靠调参数没有意义,应该调整排班或者临时增加骑手。
我养成的习惯是:每次参数调整都把“变更前版本”记录下来保存一个镜像,并且写好回滚说明。看似多了一步,但真出问题的时候,能让你少熬一晚。调度系统不像写代码可以随时改完立即编译,它受真实世界约束,改一次要跑一天订单才能看到全貌。
还有一个小技巧:把“有效送达时长”和“骑手空驶率”放在同一张图里观察。如果新参数让送达时长下降但空驶率明显上升,说明系统在牺牲骑手效率换取用户时效,长期下去骑手会流失。这不是一个可持续的方案。
同城物流派送这个方向,做深了之后你会发现自己不是在写派单逻辑,而是在做一项实时资源调配的工程:每一分钟都有订单进来,每一个骑手都在动态变化,系统的容错能力和可观测性比模型复杂度更重要。希望这篇文章能帮你把地基打得稳一点,少踩几个我已经替你踩过的坑。祝你的调度链路跑得顺,也希望这套思路能帮到你。
本文还有配套的精品资源,点击获取