
做同城生鲜配送系统很多人第一反应是把电商订单系统拿过来改一改加上骑手端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 MapInteger, SetInteger 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) { SetInteger allowed ALLOWED_TRANSITIONS.get(from); return allowed ! null allowed.contains(to); } }所有配送单的状态变更在数据库层面必须通过条件更新保证一致性。最典型的场景就是抢单骑手点击接单后端执行更新的SQL必须同时带两个条件——id 配送单ID和status 当前期望状态。如果更新返回的影响行数是1说明抢单成功如果影响行数是0说明状态已经被别人改了抢单失败。这个操作本质就是数据库层面的乐观锁int rows deliveryOrderMapper.update(null, new LambdaUpdateWrapperDeliveryOrder() .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 LambdaUpdateWrapperDeliveryOrder() .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同城生鲜配送物流和独立骑手端源码已经能支撑一个城市范围内的生鲜配送业务。如果业务量再往上走有几个方向可以扩展。第一个是多门店多区域的维度。现在调度算法是单门店维度如果城市里有多个门店要引入区域网格的概念按网格分配运力跨网格的单子要设计转单机制。第二个是引入更精细的骑手评分体系通过对配送时效、客诉率、接单率的综合打分让派单算法不仅能算距离和负载还能算谁更靠谱。第三个是精细化成本控制比如按订单重量、配送距离、温层要求动态计算配送费这需要在结算模块里加入更多的规则引擎。做这套系统的过程中我最大的感受是写业务代码不难难的是把真实世界的规则、异常、人情世故翻译成代码逻辑还得保证系统在高并发下不乱。希望这篇拆解能帮到正在做同城配送、即时零售这类项目的朋友少走几步我走过的弯路。尤其是状态机、并发控制、定位数据这几块值得多花时间打磨。