做同城生鲜配送系统,很多人第一反应是把电商订单系统拿过来改一改,加上骑手端App就完事。如果你的业务范围只在同城3到5公里、配送时效要求两小时内,那确实可以这么凑合。但生鲜这个品类自带两个狠角色:一个是时间窗口极短,从用户下单到食材上门,前后也就几十分钟;另一个是履约链条长,平台、门店、骑手、用户四端同时在线,任何一个环节断了,用户感知都非常直接。我这次做的Java同城生鲜项目,就围绕配送物流和独立骑手端源码这条主链路展开,前后经历了三轮重构,踩了不少坑。这篇文章把配送履约、骑手端、调度策略、并发控制这些核心模块的实践经验完整写出来,适合正在做同城配送、跑腿、即时零售这类业务的Java工程师参考。
整个项目最难的地方不是写接口,而是把线下配送的规则翻译成线上可执行的状态机,再把状态机的异常情况都处理干净。以下内容,全部来自实际编码和上线后的反馈,尽量把每一步的思路和代码都交代清楚。
1. 同城生鲜配送到底难在哪:先认清问题再写代码
1.1 一次配送包了全链路:从下单到骑手上门的真实流程
同城生鲜的配送不是简单的A点到B点。用户下单后,订单先落到距离最近的门店,门店拣货打包,然后调度系统把配送任务派给骑手,骑手到店取货,再配送到用户手里。这一整条链路里有一个核心指标:从订单支付完成到骑手送达,通常要求在30到60分钟以内。生鲜不同于标品,用户对晚到几分钟的容忍度非常低,菜放久了会蔫、冰品会化,这事没有任何商量余地。
所以系统里的配送物流模块,本质上是围绕时间窗在调度资源。代码层面要处理的事情包括:订单生成时计算应该在哪个门店履约、该门店当前有多少骑手在线、预计需要多少分钟能送到。这些计算不能等到骑手抢单后才开始,必须在订单创建的同时就把"配送可行性"算出来。如果高峰期门店运力不足,就得提前提示用户延长配送时间,而不是等骑手接单后才发现送不了。
我在这套系统里,订单和配送单是分开存储的。订单表只管商品和金额,配送单表记录履约相关人员、状态、时间节点、坐标信息。拆开的原因很直接:生鲜订单后续还有售后、退款、复购这些业务,而配送单的生命周期非常短,几十分钟就走完了,高频变更的状态都压在配送单上。两者拆开之后,订单表不会被频繁UPDATE,数据库压力小很多,排查问题时也能分清楚是交易链路还是配送链路的异常。
1.2 派单还是抢单:两套机制背后的思路差异
很多人一上来就问"抢单功能怎么做",其实抢单只是配送调度的一种形态。我先说结论:单量少、骑手少的时候用抢单,单量上来之后一定要做派单兜底。
抢单的逻辑很好理解——订单进入公共池子,骑手根据自己的实时位置和订单顺路程度决定接不接。这个模式对骑手友好,但对平台不友好,因为骑手只会挑顺路的、赚钱多的单子,偏远地区的订单会一直没人接。生鲜配送业务里,单子分布往往跟着小区走,有的小区单子密集,有的小区配送距离远但单值不低,如果全靠抢,配送体验没法保证。
我做的是混合模式:默认系统自动派单,按"距离最近、在线时长、负载均衡"三个维度给骑手打分,取分最高的骑手推送配送任务;如果推送后3分钟内骑手没有响应,订单转回公共池子开放抢单;再等5分钟没人抢,系统强制指派给附近负载最低的在线骑手。这套逻辑对应到代码里,就是几种状态:待派单、推送中、待抢单、已指派。状态越多,状态机设计就越重要,这一块后面专门讲。
派单算法初期不用上太复杂的机器学习,用一个加权评分就能跑起来。距离权重占0.5、骑手当前待配送订单数占0.3、接单率占0.2,三项加权后取最高分。代码结构上我把计算逻辑单独抽了个类,后面想调参或者加入更复杂的约束条件,不用动主流程。
1.3 技术选型里的关键判断:为什么用这些而不是那些
技术栈我用的是Spring Boot 2.7 + MyBatis-Plus + MySQL + Redis + RabbitMQ。这几个选型不是拍脑袋定的,对应了配送系统里不同类型的需求。
Spring Boot负责把订单、配送、骑手管理这些接口快速搭起来,MyBatis-Plus主要图它代码生成和条件构造器方便,像骑手端分页查询配送记录这类需求,几乎不用写XML。Redis在这里面承担的角色最多:抢单的分布式锁、骑手实时位置缓存、在线状态管理、订单池队列,全都在Redis上。RabbitMQ用来解耦下单和配送创建这两个环节,用户支付成功后订单消息发到队列,配送模块异步消费创建配送单,避免用户支付接口被配送逻辑拖慢。
MySQL存的是订单、配送单、骑手、结算这些核心业务数据。所有状态类和坐标类的高频更新数据都不直接写库,而是先写Redis再异步落库。刚开始我也偷懒直接把位置信息写进MySQL,结果高峰期每秒并发几十条位置更新,数据库CPU直接报警。改成Redis批量落库后,才算真正稳下来。
提示:如果项目预算有限,不建议一上来就上微服务。我这个项目初期就是单应用多模块,配送物流、骑手端接口、商户端接口用Maven module分开。等业务复杂到确实需要独立部署了,再把配送模块拆出去,比一开始就拆好维护得多。
2. 配送模块的工程结构设计与数据建模
2.1 代码模块的划分方式
整个项目按照业务边界拆成了几个Maven模块:delivery-api、delivery-service、rider-api、order-api、common。每个模块职责单一,模块之间通过内部接口调用,不直接相互依赖实现类。
骑手端独立成了一个模块,这一点是产品层面的要求:骑手端页面交互节奏很快,后来还要单独接推送、语音播报,如果和服务端包在一起,每次发版都要互相等。实际开发中也确实受益于此,骑手端接口的QPS和后台接口QPS完全不在一个量级,独立成模块后限流降级配置都可以单独做。
服务端对外暴露的是REST接口,骑手端App调用/rider/order/accept这类接口去抢单。内部服务统一走Spring的接口注入。分布式锁和Redis操作封装在common模块里,避免每层到处写同样的代码。
2.2 核心数据表的结构与设计意图
配送相关的表我重点设计了四张:配送单表、骑手表、骑手位置轨迹表、结算流水表。
配送单表是最核心的,字段包括:配送单号、关联订单号、门店ID、骑手ID、状态、预计送达时间、实际取货时间、实际送达时间、配送距离、配送费、取消原因。这里特别要说的是状态字段,我在数据库里用整数存,0到10分别对应"待派单、推送中、待抢单、已指派、待取货、取货中、配送中、已送达、已取消、超时异常、配送失败"。用整数而不是字符串,传参方便,索引用起来效率也更高。代码里会做一个枚举类,把数字和状态含义一一对应。
骑手表相对简单,核心字段是骑手ID、姓名、手机号、当前在线状态、当前经纬度、接单状态(是否在配送中)、评分、累计接单量。手机号这字段要注意脱敏,骑手端列表展示时只显示前三位和后四位。
骑手位置轨迹表负责存储骑手移动轨迹,按天分表。字段包含骑手ID、经纬度、速度、方向角、上报时间。这个表初期不参与核心业务判断,主要是给后台做轨迹回放和出现客诉时定责用的。生鲜配送经常有用户投诉"骑手没送到就点了送达",如果没有轨迹数据做证据,平台基本百口莫辩。
结算流水表则服务于骑手佣金结算:骑手ID、配送单号、配送费、平台抽成比例、骑手实际收入、结算状态。初期用最简单的方式——每单结算,配送完成后计算金额入账,月底再汇总打款。
2.3 MyBatis-Plus环境下建表SQL的维护方式
这个项目里使用了一个非常顺手的流程:用MyBatis-Plus根据实体类生成建表SQL。项目里所有数据库表结构都从实体类维护,实体类加字段,构建时自动生成ALTER语句,配合Flyway做版本管理,开发环境验证过再提交到生产。相比手写纯SQL,这个方式能大大减少字段名打错、类型不一致的低级问题。
具体做法是在实体类上用注解标注表名和字段类型,例如:
@Data @TableName("delivery_order") public class DeliveryOrder { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long orderId; private Long storeId; private Long riderId; private Integer status; private LocalDateTime estimatedDeliveryTime; private LocalDateTime actualPickupTime; private LocalDateTime actualDeliveryTime; private BigDecimal deliveryFee; }建表SQL生成时,只要扫描这些实体类,就能拼出完整的CREATE TABLE语句。需要考虑的额外事情是给高频查询字段加索引:配送单表的状态、骑手ID、订单ID都要建索引。配送单表按rider_id和status做联合索引,骑手位置轨迹表按rider_id加report_time做联合索引。索引不是越多越好,但这两个联合索引属于配送模块的命脉,加了之后查询速度立竿见影。
3. 配送状态机的建模与边界处理:物流模块真正的心脏
3.1 主状态链路如何流转:每个节点在代码里如何控制
配送状态机是整个物流模块里最容易写乱的部分。我先来说正常情况下的流转顺序:待派单 -> 已指派 -> 待取货 -> 配送中 -> 已送达。如果是抢单模式,顺序会变成:待派单 -> 待抢单 -> 已指派 -> 待取货 -> 配送中 -> 已送达。
每个节点在代码里对应一个方法,方法内做两件事:校验当前状态是否允许跳转到目标状态,以及执行跳转。我在项目里封装了一个状态流转工具类,核心代码是:
public class DeliveryStateMachine { private static final Map<Integer, Set<Integer>> ALLOWED_TRANSITIONS = new HashMap<>(); static { ALLOWED_TRANSITIONS.put(DeliveryStatus.WAIT_DISPATCH, Set.of(DeliveryStatus.PUSHING, DeliveryStatus.WAIT_ROB)); ALLOWED_TRANSITIONS.put(DeliveryStatus.PUSHING, Set.of(DeliveryStatus.ASSIGNED, DeliveryStatus.WAIT_ROB)); ALLOWED_TRANSITIONS.put(DeliveryStatus.WAIT_ROB, Set.of(DeliveryStatus.ASSIGNED)); ALLOWED_TRANSITIONS.put(DeliveryStatus.ASSIGNED, Set.of(DeliveryStatus.PENDING_PICKUP, DeliveryStatus.CANCELED)); ALLOWED_TRANSITIONS.put(DeliveryStatus.PENDING_PICKUP, Set.of(DeliveryStatus.DELIVERING, DeliveryStatus.CANCELED)); ALLOWED_TRANSITIONS.put(DeliveryStatus.DELIVERING, Set.of(DeliveryStatus.COMPLETED, DeliveryStatus.TIMEOUT_ABNORMAL, DeliveryStatus.FAILED)); } public static boolean canTransition(int from, int to) { Set<Integer> allowed = ALLOWED_TRANSITIONS.get(from); return allowed != null && allowed.contains(to); } }所有配送单的状态变更,在数据库层面必须通过"条件更新"保证一致性。最典型的场景就是抢单:骑手点击接单,后端执行更新的SQL必须同时带两个条件——id = 配送单ID和status = 当前期望状态。如果更新返回的影响行数是1,说明抢单成功;如果影响行数是0,说明状态已经被别人改了,抢单失败。这个操作本质就是数据库层面的乐观锁:
int rows = deliveryOrderMapper.update(null, new LambdaUpdateWrapper<DeliveryOrder>() .eq(DeliveryOrder::getId, deliveryOrderId) .eq(DeliveryOrder::getStatus, DeliveryStatus.WAIT_ROB) .set(DeliveryOrder::getStatus, DeliveryStatus.ASSIGNED) .set(DeliveryOrder::getRiderId, riderId)); if (rows == 1) { // 抢单成功,继续后续逻辑 }3.2 超时、取消、异常单:三种分支情况的处理策略
状态机只处理正常流转还不够,生鲜配送里真正刁钻的全是异常分支。
超时是我在设计时最容易忽略、却最致命的场景。骑手接单后一直不取货,或者取货后一直不送达,系统如果不自动干预,用户体验会很差。我设计了两个定时任务:一个扫描"已接单但超过10分钟未取货"的配送单,把状态改成超时异常,同时推送提醒骑手,并把单子重新挂起准备改派;另一个扫描"配送中但超过预计送达时间15分钟"的配送单,给用户端推送延迟通知,并把客诉风险前置标记出来。定时任务的实现没有用quartz这种重量级框架,直接用Spring自带的@Scheduled,配合分片处理,每分钟跑一批,扫100条历史数据加上状态变更,对数据库压力不大。
取消单的处理要分两个阶段。骑手接单前的取消,用户随时可以发起,配送单直接置为取消状态就行。骑手接单后用户要取消,不能直接置取消,要先调骑手端确认——因为这时候可能货已经取到手了。我在接口里做了两层校验:配送单状态如果是"待取货"或"配送中",取消请求会落到一个待确认的中间态,等骑手端确认后才能取消,取消后的货损计算又是另一套逻辑。
异常单包含骑手报备的"商家出餐慢""用户联系不上""地址错误"这几类。配送单进入异常状态后,如果是"商家出餐慢",我先给用户端推送一个等待提示,同时把该骑手的负载权重降下来,避免系统再派新的单子;如果是"用户联系不上",定时任务直接开始倒计时,15分钟内联系不上就允许骑手把商品送回门店。
3.3 状态变更的并发安全:为什么必须坚持条件UPDATE
这套配送系统上线后遇到最多的数据问题,就是状态覆盖。比如配送单在"配送中",骑手同时点了"已送达",后台运营又在执行"配送单改派"。三方同时操作一个单子,如果不控制,最后的状态就是后写入的那个,完全可能把正确的状态覆盖掉。
我的解决方案很简单也很有效:所有改状态的操作一律走条件UPDATE,绝不先SELECT再UPDATE。上面那段SQL就是标准写法,把期望状态作为UPDATE的WHERE条件,然后用影响行数判断是否成功。只有影响行数为1,才允许继续执行后续的业务逻辑,比如发推送、写轨迹记录、生成结算流水。别小看这一个小习惯,它避免了95%以上的并发数据问题,也让我在排查问题时少了很多冤枉时间。
4. 骑手端实时定位与轨迹上报:经纬度数据怎么做到又快又省
4.1 定位上报的时机与频率设计
骑手端是独立安装的App,定位数据是整个配送调度体系的眼睛。没有精准的骑手位置,派单算法、距离计算、超时判断全是瞎猜。
上报频率我一开始设计的是每3秒上报一次,结果单量一上来,服务端CPU就被打满了。后来做了一次调整:骑手静止不动时不重复上报;骑手移动中每10秒上报一次;骑手进入取货或送达关键节点时立刻补报一次。这样的策略让整体上报量减少了60%以上,而且业务需要的关键坐标全都能覆盖到。
上报接口本身要做限流,我直接用了Sentinel的QPS限流,每个骑手每秒最多5次请求。超出以后请求直接丢弃,骑手端SDK本地缓存坐标,等网络空闲了再批量补报。骑手端的定位SDK用的是高德地图的定位能力,它输出的是一个包含经纬度、精度、速度和方向的对象,服务端接的是这个对象JSON化后的数据。
4.2 轨迹聚类与抽稀:让轨迹数据可读可用
轨迹数据如果全量存在数据库里,既占空间又难查询。我做了抽稀处理:连续两点距离小于50米的轨迹点直接丢弃,只有关键点才保留。抽稀算法用最朴素的逻辑就够了——计算两点之间的球面距离,大于阈值就记录。球面距离的计算公式用的是Haversine公式,误差在百米级以内,足够支撑同城配送场景。
public static double distance(double lat1, double lon1, double lat2, double lon2) { double radLat1 = Math.toRadians(lat1); double radLat2 = Math.toRadians(lat2); double a = radLat1 - radLat2; double b = Math.toRadians(lon1) - Math.toRadians(lon2); double s = 2 * Math.asin(Math.sqrt( Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2))); return s * 6371000; }这段代码被用在两个地方:一个是骑手距离门店或者用户的距离计算,另一个是轨迹回放时的距离校验。骑手到底走没走冤枉路、是否超范围配送,后台搜轨迹记录出来一算便知。
4.3 用Redis GEO实现骑手分布检索
同城配送里有一个高频需求:查某个门店周围3公里内有哪些在线骑手。如果每次都去MySQL里扫表,一旦骑手量到几百人,查询就会很慢。我改成用Redis的GEO数据结构来解决。
骑手上线时,把骑手ID和坐标写入Redis的GEO key;骑手移动更新坐标时,同样操作GEO。查骑手时,使用Redis的GEORADIUS命令,传入门店经纬度和半径,直接返回范围内的骑手ID列表。整个过程是纯内存操作,延迟在毫秒级。
这里要提醒一句:Redis GEO的数据是实时内存数据,掉电后会丢失。所以骑手真正上线前的坐标校验,必须以MySQL里的最新位置为准;Redis GEO只承担"快速筛选候选骑手"的角色,筛选出来的骑手再回MySQL核对一次状态,防止把已下线的骑手也纳入候选。两级校验会多一次查询,但可靠性高了不少。
5. 抢单与调度里的并发控制:两个骑手同时点了接单怎么办
5.1 抢单的三道防线:从Redis锁到数据库条件UPDATE
抢单场景最典型的并发问题是:同一个订单被多个骑手同时抢,但只有一个人能成功。我的实现里总共设了三道防线,每一道的职责不一样。
第一道防线是Redis分布式锁。抢单接口进来后,先尝试获取订单维度的锁,用SETNX命令。拿到锁的骑手进入后续流程,没拿到锁的请求直接返回"订单已被抢"。锁的过期时间设置的是5秒,防止持锁线程崩溃导致死锁。
第二道防线是前文提到的数据库条件UPDATE。Redis锁只能保证同一时刻只有一个线程进入业务逻辑,但如果业务逻辑处理得慢,第二个请求在锁过期后进入,照样可能重复更新数据。因此数据库层面的条件UPDATE是真正的最终裁决者,只有影响行数为1的更新才算数。
第三道防线是状态机校验。每次操作前,业务代码里再一次确认配送单当前状态和操作期望的状态匹配。三道防线看起来有重复,实际效果是完全消除了抢单并发导致的分脏数据。
Redis分布式锁的核心实现是:
String lockKey = "delivery:order:lock:" + deliveryOrderId; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, riderId.toString(), Duration.ofSeconds(5)); if (!Boolean.TRUE.equals(locked)) { throw new BizException("手慢了,订单已被其他骑手接取"); } try { // 抢单核心逻辑 } finally { stringRedisTemplate.delete(lockKey); }注意setIfAbsent同时设置过期时间这个写法,必须放在同一个调用里,才能保证原子性。我一开始分开写成两步,Redis宕机时出现过锁永久不释放的情况,后来改成一步调用才算没有隐患。
5.2 抢单失败后的补偿:订单怎么重新回到池子
骑手抢到订单后不是万事大吉了,后面还有可能出现取货超时、主动拒单、异常报备等情况,这时候订单必须能重新回到可分配的池子里。
我做了一个"回池"机制:配送单处于"已指派"状态超过5分钟但骑手未点击"到店取货",定时任务会自动把配送单回收到待抢单池子,并清空原始骑手ID。回池的同时会把一条消息推到RabbitMQ队列,由单独的消费者负责清理由骑手锁占的资源,比如恢复骑手的负载权重、发送扣分通知等。这样即使骑手端网络断了、App被杀掉,服务端的回池机制也一定能兜得住。
回池操作里最需要注意的是幂等性。定时任务和骑手主动拒单可能同时触发回池,所以回池任务的回池条件更新SQL同样要带期望状态:
int rows = deliveryOrderMapper.update(null, new LambdaUpdateWrapper<DeliveryOrder>() .eq(DeliveryOrder::getId, deliveryOrderId) .eq(DeliveryOrder::getStatus, DeliveryStatus.ASSIGNED) .set(DeliveryOrder::getStatus, DeliveryStatus.WAIT_ROB) .set(DeliveryOrder::getRiderId, null));影响行数为0,说明单子已经被其他流程处理过了,直接跳过即可。
5.3 派单失败后怎么样一步步降级
派单和抢单不一样,派单是系统主动选人推送给骑手。推送后骑手可能正在休息模式,也可能正在配送其他订单来不及看手机。我设了三档降级:推送3分钟内没有人接受,进入抢单池;抢单池5分钟内无人接,强制指派给附近负载最低的在线骑手;强制指派后如果骑手点了拒单,配送单转入"配送失败"状态,门店自配送兜底。这三档降级用简单的定时扫描实现,每一档的入参只有配送单ID,代码逻辑非常清晰。
降级过程里最容易遗忘的是用户端的感知。派单失败再强制指派,用户端看到的配送时间必然延后。所以每次状态降级,我都会同时给用户端推送一条"配送时间可能延迟X分钟"的消息。这个功能虽然不直接影响系统运行,但在真实业务中避免了大量客诉。
6. 真实项目里最折磨人的几个细节:踩坑记录与优化方案
6.1 骑手弱网环境下接单接口的重复请求怎么处理
骑手端跑在外面,网络状况没法保证。地铁里、电梯里、地下车库,网络抖动家常便饭。骑手点一下接单按钮,客户端请求超时自动重试,如果不做幂等,同一个骑手可能同一秒提交两次接单请求,第一次成功了,第二次进来可能把单子状态覆盖掉。
解决思路是在骑手端请求头里带上一个全局唯一的requestId,服务端收到请求先去Redis查这个requestId是否已经处理过。处理过就直接返回上一次的结果,不重复执行业务逻辑。Redis的setnx同样是原子的,天然适合做这个幂等控制键。
另外,骑手端App双击按钮的问题,我在客户端做了防抖:按钮点击后置灰2秒。服务端兜底,双保险,实测下来重复请求导致的脏数据几乎归零。
6.2 GPS漂移问题:定位数据不能无脑信
GPS漂移在高层小区、隧道附近特别严重,骑手定位可能瞬间跳到一两公里外。如果调度系统拿漂移后的坐标去算"骑手离门店最近",就会把单子派给几公里外的人,配送体验立刻崩掉。
解决手段是给定位数据加一个信任级别。高德定位SDK返回结果里带精度的字段,值越小定位越准。我设置了规则:精度大于100米的定位点不参与调度计算,只存档进轨迹表;精度在50到100米之间的点,需要结合上一个有效点做速度校验,如果两点间计算出的速度超过80公里每小时,判定为漂移点也丢弃。只有精度小于50米且速度在合理范围内的点才进入Redis GEO缓存,用于派单计算。
这套过滤规则上线后,骑行速度过快的误判和骑手位置跳变的客诉都减少了。
6.3 超时判定数据不一致:超过预计时间与实际送达时间
这个坑是我上线后半个月才发现的。用户端数据显示"已经超时10分钟",但配送单状态还是"配送中",后台运营也没看到任何告警。排查后定位到问题出在超时判定的依据不一致:用户端页面的超时时间是下单时预估的时间戳,而服务端定时任务扫描的是"预计送达时间字段",两个时间来源不同口径,产生了偏差。
修复方式是统一超时判定的唯一数据源:服务端定时任务扫描时,读取配送单上的预计送达时间字段,和当前时间比较,超过15分钟才判定为超时。用户端页面上展示的超时状态全部以后端推送的消息为准,不再本地计算。所有时间相关的展示统一走服务端时间戳,禁止客户端本地时间参与业务判断,这个原则后来救了我很多次。
6.4 配送距离与费用结算:坐标计算和实际行驶路的差距
配送费里的距离费用,如果直接按经纬度直线距离算,跟实际骑行距离差很多,骑手满意度会下降。线上下单时预估费用可以用直线距离先顶着,但实际结算必须考虑真实导航距离。我在骑手端"已送达"动作触发时,集成高德路径规划接口,按照实际骑行路线计算距离,再去更新配送单的距离字段和结算金额。
骑手实际行驶距离和直线距离相差超过1.5倍的订单,系统会打上"路线异常"标记,后台抽查回放轨迹。这样做既防骑手故意绕路,也防用户地址填写不准确导致的多次配送。
关于配送费的抽成,我这边的规则是阶梯式的:配送费若是10元以内,平台抽成20%;超过10元的部分抽成15%。这样小额订单平台能覆盖基础调度成本,大额订单骑手能拿更多,两方都能接受。结算逻辑用一个独立的服务类处理,每次配送完成时异步计算并写入结算流水表,月底对账直接跑SQL汇总,比起事后再人工核对省了太多事。
7. 针对这套系统的压测与容量评估心得
同城生鲜业务的流量有明显的潮汐特征:早高峰集中在7点到9点,午高峰集中在11点到13点,晚高峰在17点到20点。骑手端的接口和订单提交接口在高峰期都会承受突发流量。我对全项目做了一次JMeter压测,重点验证了三个接口:抢单接口、位置上报接口、订单创建接口。
抢单接口加了Redis锁和数据库乐观锁后,单机TPS稳定在800以上,响应时间P99在180毫秒以内,没有出现锁超时导致的丢弃。位置上报接口因为做了限流,单机TPS到1500时开始拒绝请求,客户端有本地缓存兜底,实测没有数据丢失。订单创建接口因为走MQ异步拆分了配送单创建,支付接口本身的响应时间反而快了,高峰期数据库连接池还留了余量。
压测也暴露了一个问题:MySQL的delivery_order表在状态频繁更新后,索引碎片率明显上升,定期执行OPTIMIZE TABLE能让查询性能回升。我后来在代码里加了一个定时任务,每周日凌晨执行一次碎片整理,避免高峰期出现查询抖动。
8. 这套源码后续还能怎么扩展
目前这套Java同城生鲜配送物流和独立骑手端源码,已经能支撑一个城市范围内的生鲜配送业务。如果业务量再往上走,有几个方向可以扩展。
第一个是多门店多区域的维度。现在调度算法是单门店维度,如果城市里有多个门店,要引入区域网格的概念,按网格分配运力,跨网格的单子要设计转单机制。第二个是引入更精细的骑手评分体系,通过对配送时效、客诉率、接单率的综合打分,让派单算法不仅能算距离和负载,还能算"谁更靠谱"。第三个是精细化成本控制,比如按订单重量、配送距离、温层要求动态计算配送费,这需要在结算模块里加入更多的规则引擎。
做这套系统的过程中我最大的感受是:写业务代码不难,难的是把真实世界的规则、异常、人情世故翻译成代码逻辑,还得保证系统在高并发下不乱。希望这篇拆解能帮到正在做同城配送、即时零售这类项目的朋友,少走几步我走过的弯路。尤其是状态机、并发控制、定位数据这几块,值得多花时间打磨。