跑腿业务听起来不像电商大促那么“硬核”,但真到了高峰时段,用户下单、骑手抢单、位置上报、状态变更,所有压力几乎在同一时间涌进来,系统能不能扛住,拼的就是订单派发和配送管理这两条主干道的设计水平。我做过的几个同城跑腿项目里,被问得最多的就是“为什么一到中午就卡单”“为什么两个人抢同一个单会同时成功”“为什么配送状态迟迟不更新”。这些问题背后,基本都指向Java高并发优化。这篇内容围绕订单派发与配送管理方案,把我在实际项目里用到的架构思路、代码片段、核心参数以及踩过的坑完整梳理一遍,适合正在做同城配送、外卖、跑腿类系统的后端同学参考,也适合准备Java面试时把高并发场景讲得更落地的人。
1. 先拆清楚跑腿业务的高并发热点与整体架构
1.1 跑腿系统的三个高并发核心场景
跑腿业务的并发模型和普通秒杀不一样。秒杀是短时峰值、商品固定,跑腿业务则是持续的高频请求流,而且每个请求都带有地理位置维度。我把它拆成三个核心场景来设计。
第一个是用户端的下单和支付。高峰时段比如午高峰、下雨天、节假日,用户下单请求会在几分钟内快速爬升。订单接口需要完成计价、风控、商家确认、优惠校验等逻辑,任何一个环节出现慢SQL或者外部调用超时,都会拖垮下单主链路。这个场景的瓶颈通常不在单纯的下单写库,而在下单前的一系列校验和关联查询。
第二个是骑手端的抢单与接单。这是整个系统并发压力最高的点,一个订单发布后,可能同时被几百上千个骑手看到,每个骑手点“抢单”都是一次写入操作。这里最怕的是“超抢”,也就是多个骑手同时抢同一个订单,最终数据不一致。另一个极端是“抢不到”,订单被某个骑手锁定但又不推进,导致好事者一直等待。
第三个是配送过程中的状态流转和位置上报。骑手位置每隔几秒甚至几百毫秒上报一次,一个城市几千个骑手在线,每秒就是上万次位置写入。订单状态变更,包括取货、送达、异常上报,也要在秒级内反馈给用户。这个场景的瓶颈在大流量写入和状态的一致性与时效性。
这三个场景各有各的底层矛盾:下单集中在数据库写入和关联查询,抢单集中在并发写和锁竞争,位置上报集中在大流量顺序写。架构设计时如果只做“通用高并发优化”,很容易顾此失彼,所以必须先按场景拆开,再分别选择方案。
1.2 整体架构与时延预算,先把问题量化
很多团队一上来就微服务、上K8s,结果连基本的时延目标都没定。我自己做这套系统时,第一件事不是画架构图,而是定可量化的时延预算:用户点“下单”到看到订单生成结果,P95响应时间低于500ms;骑手点“抢单”到看到结果,P95低于200ms;订单状态变更从发生到用户可见,也就是App推送或页面刷新,低于1s。
根据这个目标,整体架构按链路拆成了接入层、订单服务、派单中心、配送服务、数据存储五块。接入层用Nginx和网关做限流、鉴权和灰度;订单服务负责计价、风控、下单和订单查询;派单中心负责抢单和智能派单;配送服务负责位置上报、状态流转和超时处理;数据层用Redis扛热数据,MySQL作为最终存储,针对地理位置查询引入空间检索能力。
这里有一个容易被忽略的点:跑腿业务的所有核心逻辑都和“距离”强相关,从候选骑手筛选到配送范围校验到计价规则,全都依赖位置数据。所以存储和计算层面都需要考虑空间维度,而不是靠MySQL普通索引硬扛。我在早期版本就吃过亏,用MySQL的经纬度字段加普通索引做范围查询,一旦订单量上来,SQL直接全表扫描,响应时间飙升到秒级。
下面用一个表格把各层职责和选型整理出来,方便对照。
| 层级 | 核心职责 | 常用组件 | 关键指标 |
|---|---|---|---|
| 接入层 | 限流、鉴权、路由 | Nginx、Spring Cloud Gateway | 单机QPS、限流准确率 |
| 订单服务 | 计价、下单、订单查询 | Spring Boot、MySQL、Redis | 下单P95耗时 |
| 派单中心 | 抢单、智能派单、重试 | Redis、Redisson、MQ | 抢单P95耗时、派单成功率 |
| 配送服务 | 位置上报、状态流转 | Redis GEO、MQ、线程池 | 位置写入吞吐、状态延迟 |
| 数据层 | 最终存储、查询分析 | MySQL、ES、分库分表 | 慢SQL数量、主从延迟 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 订单派发模块:从“抢”到“派”,锁与算法的取舍
2.1 抢单模式:Redis预减、分布式锁与幂等控制
抢单模式是跑腿系统最直接的派发方式,实现起来比智能派单简单,但并发控制必须做扎实。我把核心链路拆成四步。
订单创建后,状态置为“待抢单”,同时把订单ID和状态写入Redis,key是订单状态,value用字符串或Hash保存,主要是为了让后续判断走Redis,不把压力打到MySQL。骑手端调用抢单接口,第一步并不是直接更新数据库,而是先通过Redis检查订单当前状态,如果已经不是“待抢单”,直接返回失败。
第二步是加分布式锁。锁的粒度一定要细,我踩过用全局锁的坑,后来改成按订单ID加锁,key为dispatch:rob:{orderId},避免所有抢单请求竞争同一把锁。锁的实现我用Redisson的可重入锁,设置等待时间和自动释放时间,代码如下:
java复制String lockKey = "dispatch:rob:" + orderId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 等待200ms,超过则放弃,避免大量线程阻塞
if (lock.tryLock(200, 5000, TimeUnit.MILLISECONDS)) {
// 持锁后二次校验订单状态,防止等待期间订单已被其他骑手抢走
String status = redisTemplate.opsForValue().get("order:" + orderId + ":status");
if (!"WAIT_ROB".equals(status)) {
return Result.fail("订单已被抢");
}
// 在这里执行数据库状态更新和骑手绑定
boolean updated = orderService.assignRider(orderId, riderId);
if (updated) {
redisTemplate.opsForValue().set("order:" + orderId + ":status", "ASSIGNED");
}
return updated ? Result.success() : Result.fail("抢单失败");
} else {
return Result.fail("系统繁忙,请重试");
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
这段代码有个关键点:数据库状态更新必须带前置条件,SQL大概长这样:
sql复制UPDATE `order` SET rider_id = #{riderId}, status = 'ASSIGNED', assign_time = NOW()
WHERE id = #{orderId} AND status = 'WAIT_ROB'
这样即使Redis中的数据因为某些原因出现滞后,数据库层面也能通过条件更新拦住“超抢”。
关于Redis预减,我要多说一句。抢单场景如果只在数据库层做限制,高并发下数据库扛不住大量“查询状态+更新状态”的操作。我采用的方式是对每个骑手维护一个当日的接单额度,比如接口里先调用decr操作预占一个名额,如果名额不足则直接返回。这个操作是原子的,不会出现超发。预占后如果后续派单失败,需要把名额再加回来,否则会白白消耗骑手的接单额度。
幂等控制也是必须做的。骑手连续点击“抢单”按钮,前端做了防抖,但后端依然要防重复请求。我用的方案是同一订单同一骑手在同一时间窗口内只允许一次抢单请求进入核心逻辑,可以用Redis的SETNX实现一个简单的幂等键,key设计为rob:order:{orderId}:rider:{riderId},过期时间设置为3秒,超过则提示“请勿重复操作”。
2.2 智能派单模式:候选集计算、评分排序与派发确认
抢单模式虽然简单,但在单量密集的区域容易出现“马太效应”,老骑手抢得过多、新骑手抢不到单。所以系统还要支持智能派单,由后台根据规则自动把订单分配给最合适的骑手。
智能派单的核心流程是四步:产生候选骑手集、对候选骑手打分、选出最优骑手、发送派单确认。
候选骑手集的筛选不能做全量计算,那会慢到无法接受。我用的是两层过滤:第一层通过Redis GEO或数据库空间索引,找出订单周边3公里范围内的在线骑手,这一步把范围缩小到几百人;第二层再根据骑手当前接单数、是否顺路、服务评分、历史取消率等条件做预筛,把候选集缩小到10人以内。筛选过程中要特别注意“方向一致性”,因为骑手正在送别的订单,方向和路径是否匹配,直接影响取件时长。
打分模型我一开始做得特别复杂,引入了机器学习排序,结果线上效果还不如几个固定的权重算分。后来收敛成加权评分,主要的评分维度包括:骑手到商家的距离,权重最高,通常占四成;骑手当前任务方向与订单方向的匹配度,占两成;骑手当前负载,也就是手上还有多少未完成订单,占两成;骑手历史评分和取消率,占两成。评分结果是百分制,按分数从高到低排序。
派单确认的机制是整个智能派单最容易出问题的地方。系统把订单推送给得分最高的骑手,骑手App弹窗提示“您有一个新订单”,骑手点击“接受”后订单才算真正锁定。这里有两个细节要处理好。
第一个细节是派单消息的实时性,推送通常走WebSocket或厂商推送通道,但推送可能延迟甚至丢失,所以不能只靠推送,还需要主动查询或定时拉取。第二个细节是超时处理,我给骑手设置的接受时限是20秒,超时未响应则自动流转到下一个候选骑手。为了避免同一个订单同时在两个骑手端弹出,我引入了一个“派单中”的临时状态,候选骑手在接收到派单请求时会被短暂锁定,锁定的key是dispatch:candidate:{orderId},锁内记录当前候选骑手ID,其他骑手收到消息时检查key,发现不是自己的ID就自动忽略。
2.3 派发失败的兜底与重试链路
派发失败是常态,不是异常。骑手可能拒单、可能离线、可能正在忙,如果系统设计时只考虑了“正常指派成功”的路径,上线后一定会被各种边缘情况打乱。
我把派发失败兜底做成了三层。
第一层是候选队列的自动流转。智能派单模式下,得分最高的骑手超时未确认或明确拒单后,系统自动把订单推到候选列表中的下一个骑手。重试间隔我设置为10秒,这样既不会给骑手造成轰炸感,也不会让用户等待太久。
第二层是兜底扩散,当某个订单在候选池中流转了3轮仍然没有骑手接受,说明预设的半径3公里内合适的骑手不足,系统会自动扩大搜索半径,比如扩大到5公里,同时降低部分评分权重,优先保证订单能派出去。
第三层是人工介入与补偿。如果5分钟内仍然没有骑手接单,订单状态会被标记为“派单异常”,此时需要推送给运营人员进行调度。这种订单通常不是通过技术优化可以解决的,背后可能是高峰期运力严重不足,或者某些偏僻区域根本没有骑手。这时候与其反复重试浪费系统资源,不如及时标记异常走人工通道。
重试链路最需要关注的是幂等。每一轮派发、每一次推送,都应该有唯一的消息ID或业务ID,下游处理方通过这个ID做去重。我遇到过一次线上问题,一个订单被MQ重试推送了三次,结果三个骑手都收到同一个订单的接单通知,用户端显示“订单已被接”,但骑手端各自都认为自己是接单人。后来我在派发消息的消费逻辑里加了orderId + riderId + dispatchRound的唯一约束,才算彻底解决。
3. 配送管理:状态机、位置流与超时兜底
3.1 订单状态机的正向流转与异常补偿
订单派出去之后,真正的配送过程才开始。配送管理最核心的其实是订单状态机,因为所有业务流程,包括骑手操作、用户退款、超时处理,都要围绕状态机展开。
我把订单状态定义为:CREATED(已创建)、WAIT_ROB(待抢单)、ASSIGNED(已指派)、PICKED_UP(已取货)、DELIVERING(配送中)、COMPLETED(已完成)、CANCELED(已取消),另外还有TIMEOUT(超时未处理)这种中间态。
使用Java枚举实现状态机时,我建议把状态流转关系定义在枚举里,而不是散落在各个Service类中。否则随着业务迭代,很容易出现“这个状态谁都可以改”的混乱局面。代码大致长这样:
java复制public enum OrderStatus {
CREATED,
WAIT_ROB,
ASSIGNED,
PICKED_UP,
DELIVERING,
COMPLETED,
CANCELED;
private static final Map<OrderStatus, List<OrderStatus>> TRANSITIONS = new HashMap<>();
static {
TRANSITIONS.put(CREATED, Arrays.asList(WAIT_ROB, CANCELED));
TRANSITIONS.put(WAIT_ROB, Arrays.asList(ASSIGNED, CANCELED, PICKED_UP));
TRANSITIONS.put(ASSIGNED, Arrays.asList(PICKED_UP, CANCELED, ASSIGNED));
TRANSITIONS.put(PICKED_UP, Arrays.asList(DELIVERING, CANCELED));
TRANSITIONS.put(DELIVERING, Arrays.asList(COMPLETED, CANCELED));
TRANSITIONS.put(COMPLETED, Collections.emptyList());
TRANSITIONS.put(CANCELED, Collections.emptyList());
}
public boolean canTransitTo(OrderStatus target) {
return TRANSITIONS.getOrDefault(this, Collections.emptyList()).contains(target);
}
}
状态更新的SQL必须带前置状态条件,这一点我在前面抢单部分已经强调过,在配送环节更是如此。比如骑手点“已取货”,SQL应该是UPDATE order SET status='PICKED_UP' WHERE id=? AND status='ASSIGNED'。如果更新影响行数为0,说明状态已经被其他操作变更过,比如用户已经取消订单,这时候需要给骑手一个明确提示,而不是静默失败。
一个很容易被忽视的细节是状态变更的前置校验不能只依赖数据库,还需要在服务层先做一次业务校验。例如骑手点击“已送达”时,系统应该主动检查用户是否发起了售后,或者订单是否处于异常状态,避免产生“已送达但从没取货”这种矛盾数据。
3.2 骑手位置上报的高频写入,怎么抗住
位置上报是跑腿系统里写入量最大的接口。假设一个城市2000个骑手在线,每3秒上报一次,每秒就是约6700次写入。如果每次都直接写MySQL,数据库很快就会被拖垮。
我的方案是分层写入。第一层是内存或Redis的实时位置层,骑手上报的位置先写入Redis,使用GEO数据结构,key按城市维度拆分,比如rider:loc:{cityId},field是骑手ID,value是经纬度。查询附近骑手时直接调用Redis的GEORADIUS命令,毫秒级返回结果。
第二层是批量落库。位置数据最终需要持久化,用于轨迹回放和纠纷处理,但不需要实时落库。我使用一个独立的线程池或者消息队列做异步批量写入,攒一批位置数据后按骑手ID和时间维度做聚合,再批量插入MySQL。批量插入的SQL也可以用INSERT INTO ... VALUES (...), (...), (...)的方式,减少网络往返和事务开销。
这里要说一个我踩过的坑:位置上报本身是无状态的,但如果消费者处理能力跟不上,消息积压会导致轨迹数据严重过期。解决方式是给位置消息设置合理的TTL,比如超过5分钟的位置消息直接丢弃,因为配送轨迹的实时性比完整性重要,一旦骑手已经走远了,追溯5分钟前的位置意义不大。
空间索引方面,如果数据量到了大几十亿级别,Redis GEO的容量可能不够,此时要考虑引入Elasticsearch,用geo_point类型存储位置,配合geo_distance查询做附近骑手检索。但ES的写入延迟比Redis高,通常用于离线分析和较低频次的查询场景,不适合作为高频位置更新的主存储。
3.3 配送超时与取消场景的处理
配送过程中有三种典型的超时场景:骑手超时未取货、骑手超时未送达、用户申请取消时系统需要判断是否允许。
这三种场景都需要延时触发,我选择用延迟消息来实现,而不是简单的定时任务扫描。具体来说,订单状态变为“已指派”后,发送一条延迟消息,延时30分钟,内容为“检查订单是否已取货”;骑手点击“已取货”后,发送一条延时45分钟的消息,内容为“检查订单是否已送达”。消息消费者收到后,先查询订单当前状态,如果仍然处于超时状态,则进行对应的兜底处理。
技术选型上,RabbitMQ的延迟消息插件、RocketMQ的定时消息都可以实现。如果项目里用的是Kafka,则需要配合一个轻量级的定时任务框架来做延迟触发,因为Kafka本身不支持精确的延迟消息。
用户取消订单这个场景尤其复杂。我的处理原则是:订单在ASSIGNED之前,用户可以无责取消;订单在ASSIGNED之后、PICKED_UP之前,用户取消需要判断骑手是否已经到达商家,如果到达则不允许直接取消,需要用户承担可能的取消费用;订单一旦PICKED_UP,用户取消实际上变成了“申请退款”,系统需要走售后流程。
这里最容易出Bug的是“用户取消”和“骑手接单”这两个操作同时发生。我在设计时从两个方向做了防护:在用户取消接口中,先检查订单状态,只允许CANCELED和CREATED状态的订单直接取消;在骑手接单的数据库更新语句中,强制加上状态条件。两个方向同时拦截,才能保证不会出现“用户取消成功但骑手还在送”的数据。
4. 高并发优化的组合拳:缓存、异步、消息队列与JVM调优
4.1 缓存与异步化:把热点请求从数据库剥离
派单系统的热数据特征非常明显:订单创建后的一段时间内,用户和骑手反复查询的是同一个订单、同一个骑手、同一个区域的状态,这些数据很适合放缓存。我的原则是“能进缓存的一定进缓存,缓存里没有的才允许查库”。
以订单详情为例,用户下单成功后,订单基本信息、配送状态、骑手位置三个数据分别用三个Redis key存储。每次订单状态变更时,先更新数据库,再更新缓存;如果缓存更新失败,则通过MQ发一条补偿消息,由消费者重新刷新缓存。这里有个常见误区是“先删缓存再更新数据库”,在跑腿这种读多写少的场景下,反而会造成缓存穿透,我建议直接更新缓存,辅以过期时间兜底。
列表页的高频查询也是重点。用户端“进行中订单列表”、骑手端“可抢订单列表”都属于热点数据。骑手端可抢订单列表不能直接从数据库查,因为每次查询都要做距离计算和状态过滤,数据库压力非常大。我的做法是把可抢订单的ID列表实时写入Redis,按城市+区域维度维护,key为order:wait_rob:{cityId}:{districtId},骑手进入大厅时直接查这个列表,再根据订单ID批量从缓存拉取订单详情。
异步化方面,短信通知、App推送、计费、日志统计这些操作不应该阻塞主流程。Java侧我经常用CompletableFuture做异步编排,配合一个独立的线程池。比如用户下单成功后,主流程只需要完成写库、写缓存、发消息三步,后续的优惠券核销、积分赠送、商家通知全部通过CompletableFuture.runAsync()执行。这类异步任务有一个共同点:允许丢失或允许延迟,比如短信发送失败可以重试,积分赠送晚几秒到账用户根本感知不到。
4.2 消息队列削峰与数据最终一致
高峰期下单接口如果直接同步写库,数据库的写入并发很容易打满。我常用的削峰方式是:下单接口收到请求后,先完成基本参数校验和风控判断,然后直接把订单消息写入MQ,返回“下单中”给前端,后续由消费者异步完成真正的订单创建。
但这种方式有一个前提:下单请求本身必须是可恢复的,不能因为MQ消费失败导致订单凭空消失。所以消费者在处理时要做幂等,比如用订单号或请求ID建唯一索引,重复消费时直接忽略。
MQ在派单系统里还承担着“最终一致”的职责。订单创建成功后,订单服务发布一个“订单已创建”事件,派单中心订阅这个事件后启动派单流程,配送服务订阅后初始化配送单。三个服务之间不直接同步调用,保证单个服务的故障不会影响主流程。事件消息中只传递订单ID和必要字段,消费方需要完整数据时再通过接口或缓存获取。
选型方面,我帮不同团队做过技术选型,结论是:如果公司已有RocketMQ,优先用RocketMQ,它的延迟消息和事务消息在订单场景特别合适;如果团队对Java技术栈更熟且追求简单,RabbitMQ足够;如果订单量极大且可以容忍少量消息丢失,Kafka也可以,但需要额外引入延迟消息组件。
4.3 JVM层容易忽视的问题
很多高并发问题最后都暴露在JVM层,而不是业务代码层。跑腿系统最常见的有三类:内存溢出、Full GC频繁、线程池耗尽。
先说“java: outofmemoryerror: insufficient memory”这个报错。很多同学在本地开发时遇到过,这通常是启动参数堆内存设置过小。我在IDEA里启动项目时会在VM options里加上-Xms512m -Xmx1024m,线上服务器则根据机器内存和业务量设置,一般8G内存的机器,给JVM的堆大小建议控制在4G以内,留出系统和其他进程的余量。线上出现OutOfMemoryError时,第一件事是抓堆转储文件,用MAT或VisualVM分析是哪个对象占用了大量内存,而不是盲目调大堆内存。
GC停顿对派单系统的影响很大,因为骑手抢单要求低延迟。我给线上JVM用的是G1垃圾回收器,设置目标停顿时间-XX:MaxGCPauseMillis=100,并开启GC日志,方便排查。如果发现Full GC频繁,优先检查是否有大对象被频繁创建、是否在循环中创建了大量临时对象,以及线程池是否使用了无界队列导致堆积。
线程池这块有个教训:位置上报消费者线程池的队列不能是无界的。如果消费者处理速度跟不上,无界队列会堆积大量任务,最终可能导致内存溢出。我用ThreadPoolExecutor时,会明确设置有界队列和拒绝策略,比如队列容量10000,拒绝策略为CallerRunsPolicy,让超过容量的任务在调用线程中执行,起到天然背压的作用。
4.4 数据库与连接池的容量规划
数据库的容量规划,很多人会走入“连接数越大越好”的误区。我遇到过一个项目,数据库连接池设置为1000,结果高峰期数据库线程数被打满,数据库所在机器CPU直接飙到100%。那是因为连接数越多,上下文切换和系统线程开销越大,数据库本身也需要更多的内存保存会话状态。
正确的做法是先估算应用需要的并发连接数。假设应用实例有4台,每台配置最大连接数50,总连接数200;数据库CPU核数16左右,这个连接数已经足够了。如果真的是因为业务量太大导致连接不够,优先考虑加实例、拆分库表,而不是无限调大单库连接数。
分库分表规则在派单系统中要按业务维度来设计。订单表按照订单ID做哈希分片,这样同一个订单的读和写会落在同一分片,避免跨库事务;配送记录表按照骑手ID做哈希分片,因为骑手查看自己历史配送记录时,都是按骑手维度查询。这里要特别提醒:不要把订单和配送记录放在同一张表里强行分片,否则订单服务和配送服务的请求会产生大量的跨分片查询。
还有一个容易被忽略的点是慢SQL。高并发下,一个慢SQL就可能拖垮整个数据库连接池。我在上线前会把所有核心SQL都过一遍执行计划,重点看是否走了索引。跑腿业务里“按状态查询待派订单”“按区域查询在线骑手”这两类SQL最容易出现全表扫描,前者是因为状态字段区分度不够,后者是因为经纬度范围查询无法有效利用普通索引。
5. 压测与线上排查:真实遇到过的坑
5.1 压测方法:从单机到全链路,先测哪个
很多团队的压测方式是“上线前用JMeter随便打一打,看看平均响应时间”,这种做法基本发现不了问题。我建议跑腿系统优先压三个核心接口:下单、抢单、位置上报,这三个接口是系统最关键的链路。
压测时要分层进行。第一层是单机单接口压测,把服务部署在一台机器上,用固定数量的线程持续打压,测出单机吞吐量和P95响应时间的拐点。第二层是集群链路压测,模拟真实的用户行为比例,比如下单和抢单的比例约为1比10,同时压测订单服务、派单中心、配送服务,观察哪一环先出现瓶颈。第三层是容量测试,按照预期的峰值QPS乘以1.5倍余量压测,验证系统的扩展性。
压测结果不能只看平均值,平均值会掩盖长尾问题。我习惯看P95、P99和错误率。比如下单接口的平均耗时可能只有120ms,但P99达到了800ms,这说明系统存在少量极慢请求,可能来源于GC停顿、锁等待或者慢SQL,这些才是优化的重点。
这里给一个容量估算公式做参考:假设目标峰值QPS是5000,单请求平均耗时100ms,那么需要并发线程数约500。如果单机Tomcat线程池配置200,就需要至少3台实例。但线程数增加会带来上下文切换成本,所以还需要结合异步化来降低线程阻塞时间,而不是一味加机器。
5.2 常见故障速查表
我在跑腿项目上线后遇到过的故障,整理成一个速查表,方便大家遇到类似问题时快速定位。
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 抢单偶发失败 | Redis锁超时时间过短 | 查看Redis慢日志和锁等待日志 | 调大锁自动释放时间,缩短锁内业务耗时 |
| 订单状态不更新 | 更新SQL缺少状态条件 | 看错误日志和binlog | SQL加状态前置条件,增加状态机校验 |
| 位置上报积压 | 消费者消费能力不足 | 查看MQ积压量和线程池活跃度 | 增加分区、批量消费、调整线程池参数 |
| 用户取消但骑手仍在送 | 取消和接单并发 | 查看订单状态变更日志 | 两个方向的状态校验 + 数据库CAS更新 |
| 数据库CPU飙高 | 慢SQL或连接数过多 | 查慢查询日志和连接数监控 | 优化SQL、降低连接池上限、分库分表 |
| 订单被重复派给两个骑手 | 派发消息缺少唯一性约束 | 查看派发日志和骑手接单记录 | 增加唯一键、加分布式锁、做幂等控制 |
这个表格里的每条我都实际遇到过,其中“订单被重复派给两个骑手”是最严重的一次线上事故,直接导致用户投诉暴增。那次问题的根因是派单中心在发送派单消息时,没有保证消息的唯一性,MQ重试机制下同一个派单指令被重复投递。修复方式就是在消息消费端增加orderId + riderId + dispatchRound的唯一索引,并做消费幂等。
5.3 Java版本与编译问题,别让环境问题拖累性能排查
聊完业务问题,再补一个Java开发中非常常见但又特别影响效率的问题,就是编译和运行环境不一致。
“java: 警告: 源发行版 17 需要目标发行版 17”这个报错,我几乎每个月都会在团队里看到一次。原因很简单:pom.xml里配置的maven.compiler.source和目标版本是17,但本地JDK环境是11或8,导致编译时无法找到对应的运行时。这类问题排查思路就是统一三处配置:IDEA的Project Structure里Project SDK、pom.xml里的maven.compiler.source/target、Maven运行时使用的JDK版本。
还有lombok相关的报错,比如“you aren't using a compiler supported by lombok”,通常是lombok版本和JDK版本不兼容。比如JDK17以上,旧版lombok就无法正常实现注解处理,需要升级到1.18.30及以上版本。这种环境问题虽然不影响业务性能,但如果线上出现了性能瓶颈,而本地因为编译问题无法复现和调试,会浪费大量排查时间。
在面试中聊Java高并发优化时,如果你能把这类实际问题讲清楚,往往比背八股文更有说服力。因为面试官想听的是你真正遇到过什么问题、怎么定位、怎么解决,而不是单纯复述HashMap原理或线程池参数。
回到优化本身,跑腿系统的高并发优化没有银弹。把状态流和派发链路的幂等做对,是保证数据正确性的基础;把Redis、MQ、线程池、JVM这些基础组件用对,是保证系统性能的关键。每次大促前,我都会把“下单、抢单、位置上报”三条链路完整拉一遍压测,用真实数据说话。踩过几次坑之后,最深的一点体会是:高并发优化不是堆机器,而是先找到最窄的那根瓶颈,把它拆掉,再找下一根。系统调优就是这样一轮一轮迭代出来的。
