
如果你以为“生鲜同城配送骑手系统”就是把订单列表加一个骑手接单按钮那后面一定会被几个业务问题追着打。作为一个用 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 个点批量上报一次服务端把上报的坐标先扔进内存队列由消费者定时批量插入比如ListTrackPoint凑够 200 条再 flush。表结构按月分表表名rider_track_202606索引建(rider_id, create_time)。查询轨迹回放只按骑手和时间范围查效率很高。如果你只有几万单量这套方案完全不需要引入 MongoDB。5.3 用 Redis GEO 解决“附近骑手”和电子围栏附近骑手搜索不用专门上 GIS 数据库Redis GEO 足够。把每个骑手的最新坐标写入一个按城市或商圈划分的 key比如geo:fresh:beijingGEOADD 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.activedev第一次运行有个小技巧如果源码里包含大量 MQ 消费者建议先把消费者临时关掉也就是设置spring.rabbitmq.listener.simple.auto-startupfalse。这样能避免“数据库还没建好消费者一直消费失败刷日志”的问题等你确认基础接口通了再打开消费端。6.2 源码阅读路线按一次完整履约链路去读不要从 controller 按包路径一个个读那样很容易陷进去。正确路线是先看common/enums里的状态枚举和数据字典再打开t_order表看字段设计然后跟着一单的完整生命周期走一遍。具体路径用户下单接口创建订单 - 骑手抢单接口乐观锁更新 - 骑手到店位置上报触发 - 骑士取货状态机流转 - 送达处理异常分支。每走一步把日志打出来对照数据库变更你就知道每个接口到底做了什么。整套源码读下来的核心不在于背代码而在于理解状态机、并发控制和异步消息这三条主线。6.3 二次开发时最容易踩的坑我基于自己的经验把新手最容易踩的坑列一下第三方平台的 key 一定要换掉。源码里可能有高德地图、阿里云 OSS 甚至推送服务的 key不换成自己的轻则功能失效重则被刷接口费用这笔账算在项目头上就亏大了。数据库连接串必须指定时区。比如serverTimezoneAsia/Shanghai否则时间字段会莫名差 8 到 13 个小时超时预警全乱。抢单失败不要无脑重试。骑手端点一次抢单后端要做好幂等同一个订单同一秒内的重复请求直接返回“已被抢”而不是再次执行更新。发 MQ 消息不要放在事务内。事务回滚后消息已经发出去了消费端拿到的状态是脏的。正确做法是先提交事务再发消息如果担心丢消息可以配合本地消息表。注意源码的开源协议和商用边界。“全源码”项目并不等于免费商用有的附带 GPL 协议有的要求保留版权信息。动手二次开发前先看清 license避免后续法律风险。我在实际改这类项目时感受最深的是生鲜配送系统的技术难度并不算大难就难在骑手不可控、商家不可控、用户情绪也不可控。代码层面把状态流转写清楚把异常流程想完整把并发边界卡死比堆一百个微服务更管用。这套 Java 构建的思路对想上手生鲜同城配送骑手系统源码的朋友应该够用了剩下的就是耐心把每一个细节跑通、压一遍、再修一遍。