如果你以为“生鲜同城配送骑手系统”就是把订单列表加一个骑手接单按钮,那后面一定会被几个业务问题追着打。作为一个用 Java 构建过配送中台的人,我可以很直白地告诉你:这个系统的难点不是“接单”,而是怎么把时效、温层、位置、异常责任这些东西在代码里拧成一股绳。
这篇文章就围绕“Java构建生鲜同城配送骑手系统全源码”这条主线,把这套系统的业务差异、源码结构、核心链路、生鲜履约、实时定位和部署二次开发讲透。适合正准备做同城配送/生鲜电商后端、或者在看相关岗位面试题的同学参考。你只要有 Spring Boot、MySQL、Redis 的基础,剩下的事情就是顺着业务场景把代码一步一步捋清楚。
1. 先别急着写接口:生鲜骑手系统与外卖系统的三处关键差异
1.1 时效语义不同:餐品可以“晚一点”,生鲜超时基本等于损失
普通外卖晚送到 10 分钟,用户多半只是给个差评;生鲜晚送到 10 分钟,冷冻化冻、冰鲜出水、活鲜死亡,直接就是商品损耗,用户拒收的概率非常高。所以生鲜配送系统的核心不是“把订单派出去”,而是围绕“时效”和“温层”设计整套状态流转。
我当初接到这类需求时,第一版也天真地照搬了外卖订单流程:下单、派单、取货、送达。结果运营方提了一个要求:所有生鲜订单必须能提前预警超时,而且这个预警不是给用户看的“您的订单即将送达”,是给骑手端推送“您还有 10 分钟超时,请优先配送”。这意味着订单状态机里要单独存在“配送中-即将超时”这种由系统计算出来的中间态。代码上要有一个专门的超时扫描任务,不断比对订单预计送达时间和当前时间,而不是傻傻等用户投诉。
这里最反直觉的地方在于:生鲜配送的“预计送达时间”不是一个装饰字段,它是整个履约链路的锚点。订单排产、骑手取货顺序、超时赔付,全都挂在它身上。你要是把它当成普通外卖的“预计时间”来做,后面所有环节都会失真。
1.2 商品状态、温层与损耗责任:普通订单管理系统不会管的事
生鲜订单的明细项自带温层属性:冷冻、冷藏、常温。一张订单里可能同时出现冻虾、鲜奶和水果,骑手在同一个商家取货时,冷热商品必须分开装。表面看这是个线下操作问题,但它会倒逼你改表结构:订单明细表不能只存商品名和数量,还要存storage_type、temperature_requirement,甚至包装方式。
更麻烦的是损耗责任判定。普通外卖点击“已送达”就结束了,生鲜系统里“已送达”只是一个中间态。用户可能当面拒收、部分拒收,骑手可能因为联系不上用户被迫带回商品。这种情况下系统需要记录完整的证据链:取货照片、送达照片、异常上报图片、现场备注。这些证据最终要关联到赔付单或结算单,否则财务和客服后面一定会来求你做一堆补丁功能。
我建议在设计表结构时,把“履约异常单”和“订单主表”分开,而不是在订单表里堆一堆可空字段。原因很简单:一条订单可能会产生多次异常申诉,每次申诉都有自己的类型、责任方、证据和处置结果。订单表只保留当前履约状态,异常明细放到子表,这样查询和统计都干净。
1.3 骑手运力模型:众包/自营决定你的派单引擎简单还是复杂
生鲜配送的骑手来源通常分两种:一种是平台自营骑手,类似上班制,系统指派任务;另一种是众包骑手,更像滴滴司机,自己抢单。这两种模式对后端派单模块的要求完全不同。
自营模式下,派单算法要组合考虑骑手当前位置、当前负载、路线顺路度、历史履约率;众包模式下,系统更需要的是“抢单池”和“广播机制”,让附近骑手通过 App 在几秒内抢单。很多开源源码只实现了“手动抢单”或者“固定指派”,但真实项目里往往是混合模式:系统先指派给金牌骑手,如果 2 分钟内没人接,自动转入抢单池。
所以你在看源码时,要先搞清楚它支持哪几种模式。如果代码里DispatchStrategy这类接口预留了扩展点,那说明结构是好的;如果只有硬编码的 if/else,那后面加需求会非常痛苦。我用策略模式把这几种模式做成了配置项,一个dispatch.mode可以切换,运营侧不用重新发版就能调整。
2. 源码骨架怎么搭:模块边界、技术选型和包结构的一次性规划
2.1 技术栈选型:不是越新越好,而是匹配团队维护能力
一套能落地的 Java 骑手系统源码,技术栈通常不用太花哨。我自己常用的组合是:Spring Boot 3 + MyBatis-Plus + MySQL + Redis + RabbitMQ + XXL-Job + WebSocket。地图服务直接对接高德或百度开放平台,不建议自己造路线规划轮子。
为什么不用 Spring Cloud 那一套微服务?很多人一看“同城配送”就觉得要上分布式架构,实际上骑手系统的瓶颈根本不在服务拆分,而在订单状态一致性、定位数据吞吐、消息推送这三件事。两三人的小团队维护一个微服务全家桶,光是服务发现和配置中心就够你喝一壶。单体应用 + 清晰模块边界,是更务实的起点。等订单量真到了每天几十万单,再把调度、结算、位置服务单独拆出去也不迟。
数据库选 MySQL,定位轨迹量大的时候可以后面对接 MongoDB 或时序数据库,但第一版直接用 MySQL 加按月分表完全够用。Redis 用来做骑手在线状态、附近骑手搜索、分布式锁。RabbitMQ 处理订单状态变更通知和位置批量落库。XXL-Job 扫超时订单、统计昨日履约数据。这套组合的优点是好招人、好排查、资料多。
2.2 包结构设计:一眼看懂从接口到落库的路径
源码拿到手,第一步不是运行,而是看包结构。一个清晰的包结构能帮你少走很多弯路。我习惯这样分层:
com.example.fresh.delivery ├── DeliveryApplication.java ├── controller │ ├── rider/RiderOrderController.java │ ├── rider/RiderLocationController.java │ └── admin/AdminDispatchController.java ├── service │ ├── order/OrderService.java │ ├── order/OrderStateMachine.java │ ├── dispatch/DispatchStrategy.java │ ├── dispatch/GrabStrategy.java │ ├── dispatch/AssignStrategy.java │ ├── rider/RiderWorkbenchService.java │ ├── location/RiderLocationService.java │ ├── track/TrackPointService.java │ └── settle/SettlementService.java ├── mapper ├── entity ├── mq │ ├── OrderStatusMqConsumer.java │ └── TrackPointBatchConsumer.java ├── job │ ├── OverdueScanJob.java │ └── TrackArchiveJob.java ├── common │ ├── enums/OrderStatusEnum.java │ ├── enums/StorageTypeEnum.java │ ├── exception/BizException.java │ └── util/DistanceUtil.java └── config ├── RedisConfig.java ├── RabbitMqConfig.java └── WebSocketConfig.java这个结构有几个好处:controller 层只做参数接收和登录态校验,不写业务;订单状态流转集中在OrderStateMachine,其他地方不允许随意 update 状态;mq 和 job 不直接操作 mapper,而是调用 service,保证业务复用。你后面加一个“订单改派”功能,只需要在 service 加方法,然后让 controller、mq、job 各自调它就行。
2.3 为什么用 MyBatis-Plus,而不是纯 MyBatis 或 JPA
骑手系统里表很多,字段变更频繁,纯 MyBatis 写 XML 工作量巨大,JPA 的隐式查询又让人心里没底。MyBatis-Plus 对单表 CRUD 非常友好,还能根据 Java 实体类生成建表 SQL 和基础 CRUD 代码,这是它在这个场景下最实用的地方。
具体做法是:定义好实体类,加@TableName和字段注解,然后用代码生成器连表结构一起生成。后面需求改字段,直接改实体和对应 SQL 脚本,再用 git diff 对比,基本不会出现“数据库字段和实体对不上”的经典事故。
但要注意,MyBatis-Plus 不是万能药。多表关联、复杂报表统计、动态排序这种查询,依然要老老实实写自定义 XML。骑手系统的“今日履约报表”“骑手效率排名”这类统计,我基本都是手写 SQL,用 MP 硬拼接反而容易出性能问题。
3. 骑手接单链路拆解:订单状态机、抢单锁与派单策略
3.1 订单状态机:用一张表约束所有操作
骑手系统里最容易出现 Bug 的地方就是订单状态。一个订单从创建到完成,至少要经历待接单、已接单、已取货、配送中、已送达这几个状态;中间还穿插着用户取消、超时未接自动取消、拒收等异常分支。如果每个接口都靠if (status == 1) update...这种魔法数字去控制,后面一定会改到怀疑人生。
我一般会先定义枚举OrderStatusEnum,再在OrderStateMachine里维护一张合法流转表。每次状态变更都走同一个方法,方法内判断当前状态是否允许跳到目标状态。比如“已接单”可以跳“已取货”,但不能直接跳“已送达”;“配送中”可以跳“已送达”,也可以跳“异常拒收”,但不能回退到“待接单”。
这样做的目的不只是规范,更是为了审计。生鲜配送一旦出现纠纷,客服要查“这个订单为什么变成了拒收”,状态机链路加上t_order_status_log表就能完整还原整个过程。某一步是谁在什么时间触发的,一目了然。
3.2 抢单的并发控制:为什么不能只靠 if 判断
两个骑手同时点抢单,如果代码写成先查订单状态,等于“待接单”再更新,那在并发高的时候一定会超发。问题不在查询,而在“检查和更新”不是一个原子操作。
最稳的做法是数据库乐观锁。订单表加一个version字段,抢单 SQL 直接带上旧版本号和时间条件:
UPDATE t_order SET status = #{newStatus}, version = version + 1, rider_id = #{riderId}, grab_time = NOW() WHERE id = #{orderId} AND status = #{oldStatus} AND version = #{version}对应 Java 方法:
@Transactional public boolean grabOrder(Long orderId, Long riderId) { Order order = orderMapper.selectById(orderId); if (order == null || !OrderStatusEnum.WAIT_GRAB.equals(order.getStatus())) { return false; } int rows = orderMapper.updateStatusWithVersion( orderId, OrderStatusEnum.WAIT_GRAB, OrderStatusEnum.GRABBED, order.getVersion()); return rows == 1; }这里返回的rows == 1才是成功,rows == 0说明别人已经抢走或者状态已经变了。除了乐观锁,抢单入口还可以加 Redis 分布式锁,防止同一个骑手重复点击造成重复请求;但核心一定要靠那条带条件的 update,分布式锁只是锦上添花。
3.3 派单策略:抢单、指派、混合模式如何共存
前面说了,自营和众包对派单要求不同。源码里最好抽象一个接口:
public interface DispatchStrategy { DispatchResult dispatch(OrderDetail order); }然后分别实现GrabStrategy和AssignStrategy。抢单策略把订单投递到抢单池,通过 WebSocket 广播给附近骑手;指派策略根据骑手距离、当前负载、历史履约率打分,选出最优骑手后推送。
混合模式也不复杂:先走AssignStrategy,如果 2 分钟后没人接,就把订单丢给GrabStrategy。这里还要考虑一个问题:指派出去的订单不能被抢单池里的骑手抢到。我习惯在订单上打一个dispatch_type字段,抢单 SQL 额外加条件dispatch_type = 'GRAB',从根源避免两边同时操作。
打分逻辑不要做得太重。第一版用最简单的线性加权就行:分数 = 距离分 × 0.5 + 负载分 × 0.3 + 履约分 × 0.2。后面订单量大了再引入更复杂的算法,反正策略模式预留了替换空间。
4. 生鲜履约的硬骨头:时效、温控和异常补偿如何落到代码里
4.1 ETA 预估算准了,后面的超时预警才有意义
生鲜配送系统的超时预警全部依赖“预计送达时间”ETA 算得准不准。ETA 不是一个拍脑袋字段,它至少要拆成三段:商家打包时长 + 骑手到店时长 + 预计配送时长。
商家打包时长不能写死 10 分钟,要用近 7 天的平均出餐时间,并且按商家分开统计。有些商家外卖爆单的时候,平均出餐时间是 30 分钟,你给他估 10 分钟,骑手到店就是干等,后面超时全算在骑手头上,这不合理。骑手到店时长和预计配送时长可以接地图 API 的路线规划接口,但最后 1 公里要考虑小区门禁、电梯等待,这些地图接口算不出来,那就用历史配送数据回归一个“末端耗时修正值”。
我建议把这三段时间封装成一个DeliveryEstimate值对象,提供totalRemainMinutes()方法。这样超时预警、骑手端倒计时、用户端状态展示,全都调用同一个对象,不会出现“预警说还有 10 分钟,用户端显示还有 15 分钟”的尴尬。
4.2 超时预警和定时扫描的落地
超时预警不能等服务超时才触发,要在剩余时间进入阈值时就开始干预。我用 XXL-Job 写了一个扫描任务,比如每 30 秒跑一次,找出“配送中且剩余时间小于 5 分钟”的订单,给骑手端推送提醒,同时给运营后台生成一条预警记录。
扫描 SQL 大概这样:
SELECT id, rider_id, expect_arrive_time FROM t_order WHERE status = 'DELIVERING' AND expect_arrive_time <= DATE_ADD(NOW(), INTERVAL #{warnMinutes} MINUTE) AND warn_time IS NULL注意这里不是查expect_arrive_time < NOW(),而是查“预计到达时间是否已经进入 5 分钟窗口”。预警阈值按温层调整:冷冻订单剩余 10 分钟就要提醒,因为冷冻商品解冻速度很快;常温订单可以压到 5 分钟。阈值放到配置表,不要写死在代码里,否则每次调整都要发版。
任务扫描要防止实例重复执行。如果部署了多台机器,记得给 XXL-Job 配分片或者用 Redis 分布式锁卡住同一时刻只有一台机器在扫。
4.3 拒收、洒漏、取消:生鲜特有异常流程
生鲜订单的异常处理比外卖复杂得多。我按三类场景拆开讲:
- 用户取消:如果骑手还没取货,取消后订单直接回到待接单池或关闭,同时要回补库存。如果骑手已经取货,用户取消就要进入“退货/拒收”流程,不能再简单关单。
- 骑手上报异常:比如商品洒漏、包装破损、联系不上用户。骑手端要拍照上传,系统生成异常单,并冻结这笔订单的结算佣金,等人工处理。
- 用户拒收:骑手点“已送达”后,用户当面拒收,订单不能直接变“已送达”,要变成“已拒收”,并关联赔付单。
这些异常流程会破坏两条链路:库存链路和结算链路。如果你把库存回补、佣金冻结全部放在一个事务里,主流程会被拖慢,而且异常单经常需要人工介入,不适合强事务。我的做法是订单状态变更走本地事务,库存回补和佣金冻结通过 RabbitMQ 异步处理,保证最终一致。这样既不会丢数据,也不会因为一个赔付单把订单纯粹卡死。
5. 实时位置与轨迹上报:不用地图厂商 SDK 也能实现的轻量方案
5.1 通道选型:WebSocket 不是全部答案,但多数场景够用
骑手端 App 需要实时接收订单广播、状态变更提醒,最佳通道是 WebSocket。服务端可以用 Netty 自建,也可以用 Spring WebSocket 快速实现。要注意的是,WebSocket 在集群环境下必须处理“连接状态路由”问题:骑手通过负载均衡连到了 A 机器,如果 B 机器要给他推送消息,B 不知道他的 session 在哪,推了个寂寞。
所以源码里一定要有一个“骑手连接管理”组件,把riderId -> session的映射放到 Redis,配合消息广播机制。这样任意一台机器收到推送需求,都能先查 Redis 找到 session 所在节点,再把消息转发过去。这个细节很多项目不做,单机演示没事,一上生产多实例就崩。
位置上报通道用 WebSocket 还是 HTTP 都可以,我更倾向于 HTTP 批量上报。因为定位点密集且不需要实时响应,HTTP 更容易做批量处理和负载均衡,也不容易把长连接资源打满。
5.2 轨迹批量写入:让每个定位点都直接操作 MySQL,很快会撑不住
骑手端 3 到 5 秒上报一次坐标,假设同时在线 2000 个骑手,每秒就有 400 到 600 次写入。如果每次都 insert 一条轨迹,MySQL 的压力非常大。我一般做两层攒批:
客户端先把点存在本地,每 15 秒或每 50 个点批量上报一次;服务端把上报的坐标先扔进内存队列,由消费者定时批量插入,比如List<TrackPoint>凑够 200 条再 flush。
表结构按月分表,表名rider_track_202606,索引建(rider_id, create_time)。查询轨迹回放只按骑手和时间范围查,效率很高。如果你只有几万单量,这套方案完全不需要引入 MongoDB。
5.3 用 Redis GEO 解决“附近骑手”和电子围栏
附近骑手搜索不用专门上 GIS 数据库,Redis GEO 足够。把每个骑手的最新坐标写入一个按城市或商圈划分的 key,比如geo:fresh:beijing:
GEOADD geo:fresh:beijing 116.397 39.908 rider_1001抢单广播前,用GEORADIUS查订单门店周边 3 公里内的骑手,然后只给这些人推送。电子围栏也一样:把门店坐标和半径存好,骑手每次上报坐标后用 GEO 距离计算判断是否进入了门店 200 米范围,从而自动触发“到店打卡”事件。
这套方案精度到 10 米以内是没问题的,配送业务半径就是几公里,没必要上太重的组件。如果后面要做复杂的配送区域多边形围栏,再考虑引入 JTS 或者地图服务的围栏 API。
6. 构建部署和二次开发:源码阅读路线与踩坑手册
6.1 环境准备和一条命令跑起来
先确认环境:JDK 17、Maven 3.8+、MySQL 8.0、Redis 6+、RabbitMQ 3.9+。拿到全源码后不要急着mvn install,先看resources/sql/目录有没有初始化脚本,把库和表建好。然后修改application.yml里的数据库、Redis、MQ 连接配置。
启动命令很简单:
mvn clean package -DskipTests java -jar target/fresh-delivery.jar --spring.profiles.active=dev第一次运行有个小技巧:如果源码里包含大量 MQ 消费者,建议先把消费者临时关掉,也就是设置spring.rabbitmq.listener.simple.auto-startup=false。这样能避免“数据库还没建好,消费者一直消费失败刷日志”的问题,等你确认基础接口通了再打开消费端。
6.2 源码阅读路线:按一次完整履约链路去读
不要从 controller 按包路径一个个读,那样很容易陷进去。正确路线是:先看common/enums里的状态枚举和数据字典,再打开t_order表看字段设计,然后跟着一单的完整生命周期走一遍。
具体路径:用户下单接口(创建订单) -> 骑手抢单接口(乐观锁更新) -> 骑手到店(位置上报触发) -> 骑士取货(状态机流转) -> 送达(处理异常分支)。每走一步,把日志打出来,对照数据库变更,你就知道每个接口到底做了什么。整套源码读下来的核心,不在于背代码,而在于理解状态机、并发控制和异步消息这三条主线。
6.3 二次开发时最容易踩的坑
我基于自己的经验,把新手最容易踩的坑列一下:
- 第三方平台的 key 一定要换掉。源码里可能有高德地图、阿里云 OSS 甚至推送服务的 key,不换成自己的,轻则功能失效,重则被刷接口费用,这笔账算在项目头上就亏大了。
- 数据库连接串必须指定时区。比如
serverTimezone=Asia/Shanghai,否则时间字段会莫名差 8 到 13 个小时,超时预警全乱。 - 抢单失败不要无脑重试。骑手端点一次抢单,后端要做好幂等,同一个订单同一秒内的重复请求直接返回“已被抢”,而不是再次执行更新。
- 发 MQ 消息不要放在事务内。事务回滚后消息已经发出去了,消费端拿到的状态是脏的。正确做法是先提交事务,再发消息;如果担心丢消息,可以配合本地消息表。
- 注意源码的开源协议和商用边界。“全源码”项目并不等于免费商用,有的附带 GPL 协议,有的要求保留版权信息。动手二次开发前先看清 license,避免后续法律风险。
我在实际改这类项目时感受最深的是:生鲜配送系统的技术难度并不算大,难就难在骑手不可控、商家不可控、用户情绪也不可控。代码层面把状态流转写清楚,把异常流程想完整,把并发边界卡死,比堆一百个微服务更管用。这套 Java 构建的思路,对想上手生鲜同城配送骑手系统源码的朋友应该够用了,剩下的,就是耐心把每一个细节跑通、压一遍、再修一遍。