最近整理了一套 Java 构建的生鲜同城配送骑手系统全源码,整个项目从需求梳理到代码落地都有完整链路。如果你正好在做 Java 后端开发,想找一个真实业务场景练手,或者需要一个能写进简历、面试时能讲清楚的项目,这套源码里有几个值得慢慢啃的硬骨头——抢单并发怎么处理、订单状态机怎么设计、骑手位置高频率上报怎么做、WebSocket 推送怎么接。这篇文章我把这套系统的设计思路、核心实现、启动步骤和踩坑记录都拆开讲一遍,内容比较长,建议先收藏再慢慢看。
1. 为什么要做这套骑手系统
1.1 项目定位与业务价值
生鲜配送和普通外卖最大的不同是时效要求更高,蔬菜水果、肉禽蛋类这些商品在途时间稍长就容易导致品质下降,所以整个系统围绕“快”字展开。骑手调度处于用户下单、商家出餐、网点配送这条链路的中间位置,骑手接单是否迅速、路径是否合理、状态更新是否及时,直接决定用户体验。
我在整理这套源码的时候,核心目标不是做一个看起来能跑的 demo,而是把真实业务里的关键矛盾放进去。比如同一个订单被多个骑手同时抢、订单超时状态怎么兜底、骑手手机上不断上报的位置如何在服务端低延迟处理,这些场景在培训班的练习项目里很少见到。从价值角度来看,这套系统不仅仅是一个技术展示,它对应的是即时配送领域里一套标准的业务模型,骑手端、商家端、调度端、用户端四个角色在同一个平台里协同,单看代码体量不算大,但业务链条是完整的。
1.2 技术选型背后的思考
这套源码我选择的是 Spring Boot 2.x 单体应用 + 模块化分包,而不是一上来就拆微服务。原因很直接:订单、骑手、商家这些模块之间的调用关系是强耦合的,单体架构能让业务闭环跑得更简单,部署也轻。但我在分包时已经按模块边界切清楚了,后面想要拆库拆服务,成本不会太高。
数据层用了 MyBatis-Plus,没选 JPA。这里说下我的实际感受:国内多数团队的 MySQL 使用习惯偏向 SQL 可控,MyBatis-Plus 的 CRUD 能力强,分页插件好用,复杂查询写 XML 也方便溯源。缓存层用 Redis 处理热点订单数据、分布式锁和抢单计数,消息队列选 RabbitMQ,负责订单生命周期里各个节点的事件异步通知,订单状态推送用 WebSocket 实现实时通信,数据库选 MySQL 5.7 以上版本,认证鉴权用 JWT + Spring Security。
技术栈的具体组成如下:
- 核心框架:Spring Boot、Spring MVC、Spring Security
- 数据层:MyBatis-Plus、MySQL、Druid 连接池
- 缓存与锁:Redis、Lua 脚本
- 消息队列:RabbitMQ(订单事件、短信通知异步化)
- 实时通信:WebSocket(骑手位置、订单状态推送)
- 定时任务:Spring 自带的 @Scheduled(超时检测、无人接单兜底)
- 定位服务:高德地图 API(路径规划、距离计算、坐标转换)
这套组合在目前中小规模即时配送项目里是非常主流的搭配,没有特别冷门的东西,招聘市场上后端岗的技术栈重合度也比较高,用它来做学习或者简历项目,面试官看着不会觉得陌生。
2. 系统架构与核心模块拆解
2.1 整体架构与模块边界
项目整体采用分层架构,Controller 层、Service 层、Mapper 层、Entity 层界限分明。从业务模块上划分成五个核心部分:
- 用户模块:用户注册登录、收货地址管理、订单下单入口
- 商家模块:商品维护、接单事件、菜品出餐状态管理
- 骑手模块:骑手注册认证、接单能力池、配送状态、轨迹上报、收入统计
- 调度模块:抢单池管理、订单分配策略、超时重新投放
- 推送模块:基于 WebSocket 的实时事件推送、消息中心
模块之间通过 Service 接口交互,避免跨 Controller 直接调用。比如用户下单后,订单模块不直接操作骑手数据,而是发出“订单已创建”的事件,调度模块监听事件后把订单投放到骑手抢单池,推送模块再把新单提醒发给符合条件的骑手。这样每个模块的职责单一,出问题排查范围也小。
目录结构我按功能分包组织,而不是按技术职责分层包装所有类,大概长这样:
com.example.fresh ├── common // 通用工具、常量、异常、状态枚举 ├── config // 配置类:Redis、RabbitMQ、WebSocket、MyBatis ├── controller // 接口入口,按模块拆分包 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 数据库实体 ├── dto // 请求和响应模型 ├── job // 定时任务 └── push // 推送相关逻辑这样做的直接好处是,将来替换某个第三方组件时改动面可控。比如推送模块如果要从原生 WebSocket 换成 Netty,只需要在 push 包内部调整,对外暴露的接口不变,其它模块无感知。
2.2 权限设计与角色边界
系统共四种角色:用户、商家、骑手、平台管理员。JWT 登录成功会返回一个 token,token 里只放用户 ID 和角色编码,后续请求在拦截器里解析 token,权限校验通过注解完成。
我特别强调一个细节:虽然这套系统叫“骑手系统”,但权限模型不能只做骑手端,因为订单流程里骑手要跟商家、用户产生交互,如果没有完整的角色边界,很多业务逻辑没办法闭环。比如骑手接单时,要校验订单是否处于待接单状态、是否还在配送范围内,而这些信息涉及到商家出餐状态和用户收货地址的数据读取。
权限设计的实现思路是 Spring Security 的过滤器链 + 自定义鉴权注解,后台管理接口强制要求管理员角色,骑手端接口只开放给骑手角色,用户端接口同理。源码里没有过度设计,菜单权限和按钮权限没做数据库动态配置,因为那会让项目复杂度上升一个量级,真实小团队起步阶段也通常先用硬编码权限,够用就行。
2.3 核心业务流程与状态机
生鲜配送订单从创建到完成要经过以下环节:
用户下单 → 商家接单 → 订单进入抢单池 → 骑手抢单成功 → 骑手到店取货 → 骑手送达 → 用户确认收货(或系统自动确认)
整个流程中订单状态字段是关键中的关键。我采用的是状态机管理方式,而不是在 Service 里散落到处 setStatus。先定义一个状态枚举:
- CREATE:已创建,等待商家接单
- SELLER_ACCEPT:商家已接单,等待骑手接单
- RIDER_ACCEPT:骑手已接单
- DELIVERING:骑手配送中
- COMPLETED:已完成
- CANCELED:已取消
- TIMEOUT:超时挂起
状态机的核心约束是:任何状态的跳转必须走一个集中转移规则,非法跳转直接抛异常。举个例子,订单从 CREATE 可以直接跳 CANCELED,但不能直接跳 COMPLETED。负责转移的方法里会通过一个转移表判断当前状态和目标状态是否允许转变,这比在每个 Service 方法里手工判断要可靠得多,尤其多人协作时,后面接手的人不会因为漏判断把流程搞乱。
我贴一段状态机核心逻辑的简化版本:
public enum OrderStatus { CREATE, SELLER_ACCEPT, RIDER_ACCEPT, DELIVERING, COMPLETED, CANCELED, TIMEOUT; private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = new EnumMap<>(OrderStatus.class); static { TRANSITIONS.put(CREATE, EnumSet.of(SELLER_ACCEPT, CANCELED, TIMEOUT)); TRANSITIONS.put(SELLER_ACCEPT, EnumSet.of(RIDER_ACCEPT, CANCELED, TIMEOUT)); TRANSITIONS.put(RIDER_ACCEPT, EnumSet.of(DELIVERING, CANCELED, TIMEOUT)); TRANSITIONS.put(DELIVERING, EnumSet.of(COMPLETED, TIMEOUT)); TRANSITIONS.put(TIMEOUT, EnumSet.of(RIDER_ACCEPT, CANCELED)); TRANSITIONS.put(COMPLETED, EnumSet.noneOf(OrderStatus.class)); TRANSITIONS.put(CANCELED, EnumSet.noneOf(OrderStatus.class)); } public boolean canTransitionTo(OrderStatus target) { return TRANSITIONS.get(this).contains(target); } }实际业务里 TIMEOUT 状态的订单会重新进入抢单池,给其它骑手再次抢单的机会,这就是“超时重新投放”的实现基础。状态机保证了异常分支不会把订单流转到不该去的位置,比如一个已经被取消的订单绝不会突然进入配送中。
3. 关键环节实现:从源码构建到本地跑通
3.1 环境准备与依赖工具
源码构建的第一步不是急着启动 Spring Boot,而是把基础环境准备好。我列一下我在本地验证过的版本组合:
- JDK 8 或 11(建议 8,兼容性最好)
- Maven 3.6 以上
- MySQL 5.7 或 8.0
- Redis 5.0 以上
- RabbitMQ 3.8 以上(自带管理插件更方便)
很多初学者跑不起来项目,不是因为代码有问题,而是版本不匹配。比如 JDK 17 跑 Spring Boot 2.3 会遇到各种反射类访问报错,MySQL 8.0 又默认使用 caching_sha2_password 认证插件,老版本的驱动连不上。源码里如果用 mysql-connector-java 8.0.x,配合 MySQL 8.0 是没问题的,但如果你本地是 MySQL 5.7,就要注意配置里是否指定了 useSSL=false 和 serverTimezone,否则启动时连接池初始化就报错。
Maven 依赖下载慢的问题,在国内基本是标配。我在源码的 pom.xml 里建议配置阿里云镜像仓库,配置方式是在你的全局 Maven settings.xml 里加 mirror:
<mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>阿里云公共仓库</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>3.2 数据库初始化与关键表结构
源码根目录一般会带 sql 目录,里面包含完整建库脚本。执行顺序是先创建数据库 fresh_delivery,再按依赖关系导入表,最后补测试数据。
我直接把最核心的几张表的 DDL 结构拿出来讲讲,这些表是理解整个系统的钥匙:
订单表是核心中的核心,字段包含订单号、用户 ID、商家 ID、骑手 ID、订单状态、商品快照、配送地址、配送距离、预计送达时间、实际送达时间、支付状态、创建时间、更新时间。关键索引有两个:一个是订单号的唯一索引,一个是联合索引(状态 + 创建时间),用来查询超时订单、调取抢单池列表。
骑手表除了基本信息外,还包含当前经度、纬度、接单状态(在线/离线/配送中)、今日完成单量、评分、接单半径配置。骑手位置表独立拆分,因为位置数据时效性很强,不属于骑手的静态资料,单独存可以避免频繁更新主表造成锁竞争。
订单状态日志表也很重要,每次状态变更插入一条流水,记录状态从什么变成什么、操作人是谁、变更原因。排查线上问题时,这张表能帮你在没有直接证据的情况下还原整个事件的先后顺序。
我还做了消息推送记录表,记录 WebSocket 推送的消息内容和推送状态,防止推丢了无法追踪。这一点小设计在面试时提出来很加分,说明你有线上排查意识。
核心 DDL 的简化版本大致如下:
CREATE TABLE `order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `user_id` bigint(20) NOT NULL COMMENT '用户ID', `seller_id` bigint(20) NOT NULL COMMENT '商家ID', `rider_id` bigint(20) DEFAULT NULL COMMENT '骑手ID', `status` varchar(30) NOT NULL COMMENT '订单状态', `amount` decimal(10,2) NOT NULL COMMENT '订单金额', `delivery_address` varchar(255) NOT NULL COMMENT '配送地址', `distance_meter` int(11) NOT NULL COMMENT '配送距离(米)', `expect_time` datetime NOT NULL COMMENT '预计送达时间', `actual_time` datetime DEFAULT NULL COMMENT '实际送达时间', `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status_create_time` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;3.3 启动流程与配置关键点
环境准备好之后,启动步骤我按顺序来理一遍。
第一步,启动 MySQL、Redis、RabbitMQ 三个基础服务。RabbitMQ 默认有个 guest 账号,但 guest 只能在本地使用,如果你通过远程连接,需要新建用户并授权,很多人在项目启动时报 RabbitMQ 连接拒绝,十有八九是账号权限问题。
第二步,导入数据库脚本。注意脚本里如果有存储过程或者触发器,直接用命令行导入比用图形化客户端更稳。
第三步,修改 application.yml。需要确认的核心配置有四块:
- 数据源:URL、账号、密码
- Redis:host、port、password
- RabbitMQ:host、port、vhost、账号密码
- JWT:签名密钥、token 过期时间
特别提醒两句。数据源 URL 里一定要加 serverTimezone=Asia/Shanghai,不加的话 MySQL 8 的系统时区匹不上,控制台直接报 UTC 时间差错误。JWT 的签名密钥绝对不能用源码里默认的那一个,默认密钥只是方便演示,一定部署前要换掉,否则别人拿着生成 token 的算法可以直接伪造身份。
第四步,启动项目。有两种方式,直接跑 Application.java 的 main 方法,或者用 Maven 命令:
mvn clean package -DskipTests java -jar target/fresh-delivery.jar这套代码是 Maven 多模块还是单模块取决于你拉到的版本,如果是多模块,记得在根目录执行 mvn install 把 common 模块先装到本地仓库,否则其它模块编译时找不到依赖。
第五步,验证接口。项目启动成功后,可以访问健康检查接口确认状态。然后按业务流程走一遍:注册用户、登录拿 token、下单、商家接单、投放抢单池、骑手抢单、骑手配送、送达完成。每一步的返回都符合预期,系统基本就是通的。
4. 骑手系统的高频难点与解决思路
4.1 抢单并发控制
抢单是整个系统里最容易出事的地方。一个订单进入抢单池后,可能有几十上百个骑手同时抢同一个订单,如果控制不好,要么出现重复分配,要么出现超卖一样的逻辑错误。
我先说最基础的方案,数据库乐观锁。直接在更新订单状态时带上条件:
UPDATE `order` SET rider_id = #{riderId}, status = 'RIDER_ACCEPT' WHERE id = #{orderId} AND status = 'SELLER_ACCEPT'这条 SQL 能保证同一时间只有一个骑手能把状态从 SELLER_ACCEPT 改成 RIDER_ACCEPT,因为是数据库层面行锁保证的。但这个方案在高并发下大量请求会同时在行锁上等待,MySQL 内部线程调度开销很大,极端情况下订单多了会把数据库拖垮。而且有些团队会把订单状态放到 Redis 里,那 SQL 这套就没法直接用了。
我在这套源码里用的是 Redis 分布式锁 + Lua 脚本组合方案,核心思路是:第一道关口用 Redis 锁防止大量请求直接打到数据库,锁的粒度是按订单 ID 加锁,不是全局锁,这样不同订单的抢单互不干扰。获得锁之后再做状态校验、更新。释放锁时用 Lua 脚本保证原子性,先比较锁的 value 再删除,防止误删别人持有的锁。
锁的过期时间要特别用心。我曾经踩过一个坑:把锁过期时间设成 10 秒,以为抢单很快能完成,没想到一次订单力度校验还调了高德接口计算配送距离,耗时 12 秒,锁先过期了,第二个骑手的请求拿到锁也通过了校验,最终两个人都以为抢到了。后面我把抢单拆成两段:锁内只做 Redis 状态预占和快速的本地校验,数据库落库移到锁外,配合乐观锁兜底,彻底解决这个问题。锁内不要做远程调用,这句话值得刻在显示器上。
抢单核心逻辑的简化代码:
// 对订单维度加锁 String lockKey = "order:lock:" + orderId; String lockValue = UUID.randomUUID().toString(); Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(acquired)) { throw new BizException("手慢了,订单已被抢"); } try { // 在锁内只做订单状态预校验和预占 String orderStatus = redisTemplate.opsForValue().get("order:status:" + orderId); if (!"SELLER_ACCEPT".equals(orderStatus)) { throw new BizException("订单状态已变化"); } // 预占:标记抢单者,防止其它骑手重复抢 redisTemplate.opsForValue().set("order:grab:" + orderId, riderId, 30, TimeUnit.SECONDS); // 异步落库,返回抢单受理结果 dispatchService.asyncConfirmOrder(orderId, riderId); return "抢单成功,请准时到店取货"; } finally { String currentValue = (String) redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentValue)) { redisTemplate.delete(lockKey); } }注意这里的 try-finally 里释放锁时又读了一次 value,这里不是多余动作,就是为了防止锁过期后别人新建了锁,你直接 delete 把别人的锁误删了,那就踩了大坑。
除了分布式锁,我还配了 Redis 预减或者叫抢单令牌桶的思路。订单进入抢单池时先在 Redis 里设置一个抢单计数 key,值设为 1,抢单请求先进来先用 Lua 脚本做原子扣减,扣减成功才走后续逻辑。这样大部分无效请求在进入业务校验之前就被挡掉了,数据库压力其实很小。
异常情况也要考虑:骑手抢单成功但三分钟内未到店取货,系统要自动取消并重新投放订单。这个机制靠定时任务扫 Redis 超时键实现,而不是扫数据库,因为数据库扫描量大且延迟高。
4.2 订单超时与状态补偿
生鲜配送对超时特别敏感。用户订单超过预计送达时间,系统要主动感知并处理,不能干等着。源码里设计了三层保障。
第一层是用户下单时写入预计送达时间,骑手接单后开始倒计时,快到时间时推送提醒给骑手。这里用的是 RabbitMQ 的延迟队列,不是定时轮询数据库。延迟队列的优势是精确到单条消息,不需要全表扫描,压力小很多。
第二层是状态机的 TIMEOUT 状态兜底。如果延迟队列的消息触发了超时处理,订单状态会流转到 TIMEOUT,此时订单从骑手名下释放,重新投放到抢单池。这层设计的巧妙之处在于不是直接把订单取消,而是给它第二次机会,因为在真实场景里可能是骑手路上堵了,重新派单更合理。
第三层是定时任务全量补偿。为什么有延迟队列还要定时扫?因为队列的消息可能丢失,或者服务重启期间消息被吞了。我用 Spring @Scheduled 写了一个每五分钟跑一次的补偿任务,扫描所有处于 DELIVERING 状态并且超时时间超过阈值仍未送达的订单,强制触发超时处理。定时任务和延迟队列是一主一备的关系,不会重复处理,因为处理逻辑做成了幂等,判断当前状态必须合法才能流转。
这套三层策略让我后来线上处理超时问题时特别省心,不会出现“不知道哪个订单超时了”的慌乱局面。
4.3 骑手位置上报与轨迹链路
骑手的 App 端每三到五秒会上报一次经纬度坐标,如果每秒上报一次,一个骑手一天就是几万条记录,上百个骑手的数据量就不小了。全量实时写数据库完全不现实,所以我把位置上报链路做成了两级缓存加批量落库。
骑手调用上报接口后,服务端先把坐标写入 Redis 的 zset 结构,按骑手 ID 作为 key,score 用时间戳,value 是 JSON 字符串包含经纬度和精度。管理后台需要看实时位置时,直接读 Redis 最新一条数据,几百毫秒内就能看到骑手位置刷新。另一个定时任务每十秒从 Redis 里批量取出上报记录,一次性写入位置历史表,再清理 Redis 里的旧数据。
这里有一个坐标系的问题特别容易忽略。高德地图用的是 GCJ-02 坐标系,而部分硬件或者第三方定位 SDK 返回的是 WGS-84 原始坐标。如果直接拿原始坐标在高德地图上展示,位置会偏几十米到几百米。骑手定位经度纬度存库时要统一转成 GCJ-02,或者前端展示时统一做转换,不然后端计算距离、前端标点完全是两套标准,看起来是同一位置,实际相差几条街。
实现里我封装了一个 CoordinateConverter 工具类,统一处理 WGS-84 和 GCJ-02 的互转,写入前先做一次坐标校验,超出中国范围或者经纬度明显异常的直接丢弃。这样不仅保证数据质量,还能防止脏数据污染统计报表。
4.4 WebSocket 实时推送架构
推送模块的设计用在了三处:骑手抢单成功通知、订单状态变化通知、商家接单提醒。原生 WebSocket 在 Spring Boot 里配置简单,性能在几千连接规模下没问题,真要几十万长连接你再上 Netty。
WebSocket 接入的关键是鉴权。WebSocket 握手阶段浏览器不能自动带 Authorization Header,通常会通过 URL 参数传 token,但 token 放在 URL 里会被日志记录,有泄露风险。我采用的是二次校验方案:连接建立时先用连接参数里的 token 做一次快速校验,连接建立后客户端再发一条鉴权消息,服务端在 onMessage 里校验通过才订阅对应用户的主题,没有通过直接关闭连接。这个方案安全性和灵活性都更优。
每个角色订阅的主题不同。用户订阅 /topic/user/{userId},骑手订阅 /topic/rider/{riderId},商家订阅 /topic/seller/{sellerId}。推送模块做了路由分发,订单状态变化时只推送给相关方,不全局广播。
WebSocket 如果和 Spring Security 一起用,老版本经常会遇到拦截器不生效的问题。我这里直接统一在 WebSocket 的拦截器里做 token 解析和用户身份绑定,不走 Spring Security 的过滤器链,少了框架之间的集成坑,排查问题也直观。
5. 常见问题排查与避坑实录
5.1 问题速查表
源码项目比纯文档更能暴露真实环境的问题。我把启动和运行过程中最常见的几个问题整理成了一张速查表,基本覆盖了新手阶段能遇到的大部分坑。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 启动时端口被占用 | 8080 被其它进程占用 | 查看占用进程,换端口或在启动参数指定 --server.port |
| Redis 连接超时 | Redis 未启动、网络隔离、密码错 | 先 redis-cli ping 验证,再看配置的 host/port/password |
| RabbitMQ 无法登录 | guest 账号远程限制 | 新建账号并授予 vhost 权限,连接配置相应修改 |
| MySQL 驱动连接报错 | 版本不匹配或时区未设置 | pom 中驱动版本与 MySQL 版本对应,URL 加 serverTimezone=Asia/Shanghai |
| 订单状态没有更新 | 乐观锁条件不满足 | 检查更新 SQL 的 status 条件,打印受影响行数 |
| 抢单偶尔重复 | 锁过期时间太短或未续期 | 锁内不做远程调用,配合数据库乐观锁兜底 |
| WebSocket 频繁断开 | 未做心跳检测 | 服务端定时发送 ping,超时未响应主动关闭连接 |
| 高德坐标偏移 | WGS-84 与 GCJ-02 混用 | 统一调用坐标转换工具类再存储或展示 |
| 编译时找不到 common 模块 | 多模块未 install | 在根目录执行 mvn install |
| 接口返回 401 | JWT 过期或 secret 不一致 | 确认 token 有效期,检查各服务 secret 配置是否一致 |
这张表我自己在整理时把高频问题都过了一遍,很多同学在群里问的问题其实就是上面某一个。建一个排查清单,比遇到问题再翻源码快得多。
5.2 踩过的坑和真实教训
我印象最深的一个坑是抢单锁过期时间的问题。上文提到过一个场景,我在锁内调用了高德 API 来计算骑手到商家的距离,这个远程调用在网络抖动时耗时特别长,最差一次到了 15 秒,而锁的过期时间只有 10 秒。结果锁过期后,另一个骑手进来抢单,也通过了状态校验拿到了同一单,数据库层虽然靠乐观锁把最终写库挡掉了,但用户端收到了两条“抢单成功”的推送。这个问题的根因不是乐观锁没有兜底成功,而是锁内做了一个不该做的远程调用和锁过期时间设置不合理。
后来我的调整方式就是把锁内操作压缩到最薄,只保留本地内存判断和 Redis 操作,远程调用全部挪到锁外,在异步执行里再做校验,就算校验失败也有补偿机制回滚。还有一次教训是关于状态机的。最初代码里骑手确认送达时直接就把状态改成了 COMPLETED,没有经过 DELIVERING 校验。结果线上有个问题:用户取消的订单,骑手端还能操作确认送达,因为 Service 层没有统一的状态校验,只有一个 if 判断,漏了取消场景。这个 Bug 修复起来不复杂,但排查过程很曲折。后来我意识到,状态机这样的关键逻辑一定要集中管理,不能让各个业务方法自己写状态判断,否则新增业务分支时遗忘就能造成灾难。这也是为什么源码里我把状态转移表作为核心设计单独抽出来的原因。
还有个比较小的坑,但也想提醒大家。MySQL 的时间字段和 Java 的 LocalDateTime 之间如果类型映射不当,查询出来时间对不上。用 MyBatis-Plus 时实体字段类型用 LocalDateTime,配合 MySQL 的 datetime 类型,同时数据库连接串加 useSSL=false 和 characterEncoding=utf8,基本不会出问题。反过来,如果实体用 java.util.Date,配合的时区处理就会啰嗦,我统一推荐 LocalDateTime,尤其是在做订单超时计算时,LocalDateTime 的 API 比 Date 好写太多了。
6. 源码阅读路线与扩展建议
6.1 合理的源码阅读顺序
拿到这套源码,我的建议不是从第一个类按顺序往后读,而是按业务链路去读,效率会高很多。
第一步,先看数据库建表脚本。读表是最快的了解业务的方式,拿到订单表、骑手表、商家表后,心里对业务模型就有基本轮廓了。第二步,看启动类和全局配置,了解系统开了哪些功能、集成了哪些组件。第三步,选一条完整业务链路,从用户下单的 Controller 入口进去,一路追到 Service、Mapper,体验一下整个调用链是怎么流转的。有了这条主线的上下文,再去看骑手抢单、配送、推送这些分支逻辑,就不会觉得特别抽象。
在看代码时我有个小技巧:可以重点看状态机枚举类、抢单 Service 实现类和推送模块的路由类。这三个文件基本涵盖了系统的核心设计思想,读懂了它们,很多散落的业务逻辑都能自动串起来。
6.2 后续可扩展的方向
这套系统虽然是完整的,但放到真实生产环境还有不少可以扩展的地方。比如当前的调度策略比较简单,骑手接单范围是按固定半径判断的,实际调度平台会结合骑手实时位置、路径拥堵情况、订单重量做综合评分。这可以做为第二期的优化点。
另一个方向是缓存和数据库一致性。当前订单状态同步更新 Redis 和 MySQL,严格说存在短暂不一致的可能。你可以引入 Binlog 监听或者本地消息表模式,把这个话题变成面试中很有深度的项目亮点。扩展技术栈方面,可以试着把单体模块拆分出来。订单模块和推送模块拆成两个独立的 Spring Boot 服务,用 OpenFeign 或者 Dubbo 做远程调用,体验一下微服务改造过程中要面对的数据一致性、链路追踪等真实问题。
从学习成长角度看,把这套代码跑通只是第一步,试着改一两个功能、加一张表、写一个接口,才算真正把它消化成自己的东西。
就我个人经验来说,拿到任何全源码项目先不要急着跑起来,先花半小时把数据库表结构和状态流转读一遍,这个时间投入非常值得。后面你调试起来心里有张地图,知道每一步数据应该在哪、状态应该是什么,遇到问题定位快得多。这套骑手系统我建议你重点研究抢单并发和状态机两块,它们是整个项目里最有面试价值的部分。如果中间有任何跑不通的地方,先对照上面那张速查表,大部分问题都能解决。