← 返回目录

物流供应链实战 — 从出库到签收的全链路追踪

状态追踪 路径优化 库存同步 运力调度 异常处理

快递中转站 — 仓库=物流中心、中转站=分拨中心、快递员=末端配送、物流轨迹=快递查询、异常=包裹丢失/破损

📦 业务全景 — 物流供应链做什么

通俗类比:快递中转站
你在淘宝下单后,商品从商家仓库出发,坐卡车到分拨中心,再根据目的地分流,最后由快递员送到你手上。每到一个节点,物流信息就更新一次——"已出库""运输中""派送中""已签收"。如果包裹丢了、坏了,就需要走异常处理和理赔。这就是物流供应链的全貌。
🏭 仓库 = 物流中心 🏗️ 分拨中心 = 中转 🚚 干线运输 = 长途
🏍️ 快递员 = 末端配送 📍 物流轨迹 = 快递查询 ⚠️ 异常 = 丢失/破损
全链路:从发货到签收的完整流程
商家发货 仓库出库 干线运输 分拨中转 末端配送 签收
商家发货:商家在后台点击"发货",系统生成运单号,分配物流公司。

仓库出库:仓库根据运单信息拣货、打包、贴面单,交给物流公司。系统扣减库存,更新运单状态为"已出库"。

干线运输:物流公司用大卡车/飞机把包裹从始发仓运到目的地的分拨中心。这是物流中最"长途"的一段。

分拨中转:包裹到达目的地分拨中心后,根据收件地址分拣到不同的网点。就像火车站旅客根据目的地换乘不同列车。

末端配送:快递员从网点取件,骑车送到收件人手上。这是物流中最"精细"的一段——要联系收件人、处理代收、放到快递柜等。

签收:收件人确认收到包裹。系统更新运单状态为"已签收",触发结算和评价流程。

🎯 核心挑战 — 物流系统的5座大山

挑战1:状态追踪 — 包裹到了哪?
类比:快递查询页面——每到一个节点就更新状态,"已出库"→"运输中"→"派送中"→"已签收"。用户最关心的就是"我的快递到哪了"。
物流状态机
CREATED 已创建 PICKED_UP 已揽收 IN_TRANSIT 运输中
IN_TRANSIT 运输中 DELIVERING 派送中 SIGNED 已签收
EXCEPTION 异常 任意状态均可转入异常
// ====== 物流运单状态机 ====== // 核心原则:状态只能单向流转,不能倒退(已签收不能变回运输中) // 就像快递只能往前走,不能退回去 public enum WaybillStatus { CREATED("已创建"), // 运单刚生成 PICKED_UP("已揽收"), // 快递员取到件 IN_TRANSIT("运输中"), // 在干线运输中 AT_STATION("已到站"), // 到达分拨中心 DELIVERING("派送中"), // 快递员派送中 SIGNED("已签收"), // 收件人签收 EXCEPTION("异常"); // 丢失/破损/拒收等异常 private final String label; WaybillStatus(String label) { this.label = label; } } // 状态流转规则定义 public class WaybillStateMachine { // 允许的状态转换映射 private static final Map<WaybillStatus, Set<WaybillStatus>> TRANSITIONS = Map.of( WaybillStatus.CREATED, Set.of(WaybillStatus.PICKED_UP, WaybillStatus.EXCEPTION), WaybillStatus.PICKED_UP, Set.of(WaybillStatus.IN_TRANSIT, WaybillStatus.EXCEPTION), WaybillStatus.IN_TRANSIT, Set.of(WaybillStatus.AT_STATION, WaybillStatus.EXCEPTION), WaybillStatus.AT_STATION, Set.of(WaybillStatus.DELIVERING, WaybillStatus.EXCEPTION), WaybillStatus.DELIVERING, Set.of(WaybillStatus.SIGNED, WaybillStatus.EXCEPTION) ); /** * 执行状态流转 * 就像检票口 — 只有合法的路径才能通过 */ public static void transition(Waybill waybill, WaybillStatus target) { WaybillStatus current = waybill.getStatus(); Set<WaybillStatus> allowed = TRANSITIONS.get(current); if (allowed == null || !allowed.contains(target)) { throw new BizException( "非法状态流转:" + current + " → " + target ); } waybill.setStatus(target); waybill.setStatusTime(LocalDateTime.now()); } } // ====== 物流轨迹数据设计(时序数据) ====== // 每条轨迹记录:谁在什么时间在什么地点做了什么操作 @Data @TableName("waybill_track") public class WaybillTrack { private Long id; private String waybillNo; // 运单号 private WaybillStatus status; // 当前状态 private String location; // 操作地点:北京分拨中心 private String operator; // 操作人/设备 private String description; // 描述:已到达北京分拨中心 private LocalDateTime trackTime; // 轨迹时间 private BigDecimal latitude; // 经度(可选,用于地图展示) private BigDecimal longitude; // 纬度 }
挑战2:路径优化 — 怎么走最快最省钱?
类比:用导航选路——有的路近但堵车,有的路远但通畅,有的路要收费。物流也一样,要综合考虑距离、时间、成本、可靠性多个因素。不是简单的"最短路径",而是"最优路径"。
方案策略优点缺点适用场景
最短路径 Dijkstra/BFS选最短距离 实现简单 不考虑时效和成本 短途同城配送
最短时间 考虑路况的A*算法 时效最优 可能绕远增加成本 生鲜冷链/急件
最低成本 动态规划+成本矩阵 省钱 时效可能慢 大宗货物/不急的件
综合最优 多目标优化(加权评分) 平衡各因素 权重难调 大部分场景
// ====== 路径规划服务 — 多因素综合决策 ====== @Service public class RoutePlanService { @Autowired private RouteRepository routeRepo; /** * 综合最优路径规划 * 就像导航给你推荐路线 — 考虑时间、距离、成本、可靠性 * 最终得分 = 时间权重*时间分 + 成本权重*成本分 + 可靠性权重*可靠性分 */ public RoutePlan planBestRoute(String origin, String destination, RoutePreference preference) { // 1. 查询所有可选路线 List<Route> candidates = routeRepo.findRoutes(origin, destination); if (candidates.isEmpty()) { throw new BizException("没有可用的路线"); } // 2. 根据偏好设置权重 double timeWeight, costWeight, reliabilityWeight; switch (preference) { case FAST: timeWeight = 0.6; costWeight = 0.2; reliabilityWeight = 0.2; break; case CHEAP: timeWeight = 0.2; costWeight = 0.6; reliabilityWeight = 0.2; break; case BALANCED: default: timeWeight = 0.35; costWeight = 0.35; reliabilityWeight = 0.3; } // 3. 对每条路线打分 Route bestRoute = null; double bestScore = Double.MIN_VALUE; for (Route route : candidates) { // 归一化到0-1分(越低越好 → 反转为越高越好) double timeScore = normalizeTime(route.getEstimatedHours(), candidates); double costScore = normalizeCost(route.getEstimatedCost(), candidates); double relScore = route.getReliabilityScore(); // 0-1,越高越可靠 // 加权综合得分 double totalScore = timeWeight * timeScore + costWeight * costScore + reliabilityWeight * relScore; if (totalScore > bestScore) { bestScore = totalScore; bestRoute = route; } } return RoutePlan.builder() .route(bestRoute) .score(bestScore) .build(); } // 归一化:值越小得分越高 private double normalizeTime(double hours, List<Route> routes) { double maxHours = routes.stream().mapToDouble(Route::getEstimatedHours).max().orElse(1); return 1.0 - (hours / maxHours); } private double normalizeCost(double cost, List<Route> routes) { double maxCost = routes.stream().mapToDouble(Route::getEstimatedCost).max().orElse(1); return 1.0 - (cost / maxCost); } } enum RoutePreference { FAST, CHEAP, BALANCED } @Data @Builder class RoutePlan { private Route route; private double score; }
挑战3:库存同步 — 多仓库库存实时同步,避免超卖
类比:连锁超市——总店和分店的库存要同步。你在线上下单了最后一瓶牛奶,分店库存要马上减1,否则另一个人也下单就会超卖。这在多仓库场景下更复杂:北京仓有10件,上海仓有5件,广州仓有3件,用户下单后要决定从哪个仓发货,同时扣减对应库存。
多仓库库存扣减方案
// ====== 库存扣减 — Redis预扣 + DB最终一致 ====== // 核心思路:先在Redis中扣减(快),再异步同步到DB(稳) // 就像超市收银台先扫码(快),晚上再盘账(稳) @Service public class InventoryService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private InventoryMapper inventoryMapper; @Autowired private RocketMQTemplate mqTemplate; private static final String STOCK_KEY = "stock:"; // stock:{warehouseId}:{skuId} /** * 库存预扣 — 在Redis中原子性扣减 * 就像抢票 — 先锁住座位,再慢慢付款 */ public StockResult preDeductStock(Long warehouseId, String skuId, int quantity) { String key = STOCK_KEY + warehouseId + ":" + skuId; // Redis Lua脚本原子性扣减 — 防止并发超卖 String luaScript = """ local stock = tonumber(redis.call('GET', KEYS[1])) if stock == nil then return -1 end if stock >= tonumber(ARGV[1]) then redis.call('DECRBY', KEYS[1], ARGV[1]) return tonumber(ARGV[1]) else return -2 end """; DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class); Long result = redisTemplate.execute(script, List.of(key), String.valueOf(quantity)); if (result == -1) { // Redis中没有库存数据,从DB加载 loadStockFromDB(warehouseId, skuId); return preDeductStock(warehouseId, skuId, quantity); } else if (result == -2) { return StockResult.outOfStock(); } // 发MQ消息,异步同步到数据库 StockDeductEvent event = new StockDeductEvent(); event.setWarehouseId(warehouseId); event.setSkuId(skuId); event.setQuantity(quantity); event.setOrderNo(OrderContextHolder.getOrderNo()); mqTemplate.syncSend("stock-deduct", event); return StockResult.success(quantity); } /** * 从DB加载库存到Redis * 就像超市开门前先清点货架 */ private void loadStockFromDB(Long warehouseId, String skuId) { Inventory inv = inventoryMapper.selectByWarehouseAndSku(warehouseId, skuId); if (inv != null) { String key = STOCK_KEY + warehouseId + ":" + skuId; redisTemplate.opsForValue().set(key, String.valueOf(inv.getAvailableQty())); } } } // ====== 库存同步消费者 — MQ消费后更新数据库 ====== @Component @RocketMQMessageListener(topic = "stock-deduct", consumerGroup = "stock-group") public class StockDeductConsumer implements RocketMQListener<StockDeductEvent> { @Autowired private InventoryMapper inventoryMapper; @Override public void onMessage(StockDeductEvent event) { // 数据库中扣减库存(乐观锁防超卖) int rows = inventoryMapper.deductStock( event.getWarehouseId(), event.getSkuId(), event.getQuantity() ); if (rows == 0) { log.error("库存扣减失败!仓库:{} SKU:{} 数量:{}", event.getWarehouseId(), event.getSkuId(), event.getQuantity()); // 需要回补Redis中的库存 StockCompensator.compensate(event); } } } // ====== MyBatis Mapper — 乐观锁扣减 ====== @Mapper public interface InventoryMapper { @Update(""" UPDATE inventory SET available_qty = available_qty - #{quantity}, locked_qty = locked_qty + #{quantity} WHERE warehouse_id = #{warehouseId} AND sku_id = #{skuId} AND available_qty >= #{quantity} """) int deductStock(@Param("warehouseId") Long warehouseId, @Param("skuId") String skuId, @Param("quantity") int quantity); }
库存同步方案:Canal监听MySQL binlog + MQ广播。当仓库A扣减库存后,Canal捕获binlog变更,通过MQ广播给所有相关服务,实现最终一致性。就像超市总部知道了分店的库存变化,自动同步给所有门店。
挑战4:运力调度 — 快递员不够怎么办?
类比:双11快递爆仓,平时50个快递员能搞定,双11可能需要200个。这时候要临时加人——从第三方物流公司借人、启用兼职快递员、增加配送车辆。这就是"弹性运力"。
自有运力(稳定) + 第三方运力(弹性) = 混合运力(最优)
// ====== 运力调度服务 ====== @Service public class CapacityDispatchService { @Autowired private CourierMapper courierMapper; @Autowired private OrderPoolMapper orderPoolMapper; /** * 智能调度 — 把运单分配给最合适的快递员 * 就像打车平台派单 — 考虑距离、当前负载、时效要求 */ public DispatchResult dispatch(String stationId, List<Waybill> pendingOrders) { // 1. 获取当前可用快递员 List<Courier> couriers = courierMapper.findAvailable(stationId); if (couriers.isEmpty()) { // 没有自有快递员,触发第三方运力 return dispatchToThirdParty(pendingOrders); } // 2. 计算每个快递员当前的负载 Map<String, int> courierLoad = new HashMap<>(); for (Courier c : couriers) { int currentLoad = orderPoolMapper.countPendingByCourier(c.getId()); courierLoad.put(c.getId(), currentLoad); } // 3. 贪心分配:每次把运单分配给负载最低的快递员 List<Assignment> assignments = new ArrayList<>(); for (Waybill order : pendingOrders) { // 找负载最低的快递员 String bestCourierId = courierLoad.entrySet().stream() .min(Map.Entry.comparingByValue()) .map(Map.Entry::getKey) .orElse(null); if (bestCourierId != null) { assignments.add(new Assignment(order.getWaybillNo(), bestCourierId)); courierLoad.merge(bestCourierId, 1, Integer::sum); } } // 4. 如果还有剩余运单没分配,触发第三方运力 int assigned = assignments.size(); if (assigned < pendingOrders.size()) { List<Waybill> remaining = pendingOrders.subList( assigned, pendingOrders.size() ); dispatchToThirdParty(remaining); } return DispatchResult.builder() .assignments(assignments) .totalAssigned(assigned) .totalPending(pendingOrders.size() - assigned) .build(); } /** * 触发第三方运力 — 调用外部物流公司API */ private DispatchResult dispatchToThirdParty(List<Waybill> orders) { // 调用第三方物流公司接口 ThirdPartyDispatchRequest request = new ThirdPartyDispatchRequest(); request.setOrders(orders); ThirdPartyDispatchResponse response = thirdPartyClient.dispatch(request); return DispatchResult.thirdParty(response); } }
挑战5:异常处理 — 包裹丢失、破损、地址错误、拒收
类比:快递出了问题怎么赔、怎么找?
丢失 = 找不到了,按保价赔偿
破损 = 东西坏了,按损失程度赔偿
地址错误 = 送错地方了,重新配送
拒收 = 收件人不要了,退回商家
// ====== 异常工单系统 ====== @Data @TableName("exception_ticket") public class ExceptionTicket { private Long id; private String waybillNo; // 关联运单号 private ExceptionType type; // 异常类型 private ExceptionLevel level; // 严重级别 private String description; // 异常描述 private String evidence; // 凭证(照片URL等) private TicketStatus status; // 工单状态 private String handler; // 处理人 private BigDecimal compensateAmount; // 赔偿金额 private LocalDateTime deadline; // 处理截止时间 } public enum ExceptionType { LOST("丢失"), DAMAGED("破损"), WRONG_ADDRESS("地址错误"), REJECTED("拒收"), DELAYED("超时"), OTHER("其他"); } public enum TicketStatus { CREATED, INVESTIGATING, COMPENSATING, RESOLVED, CLOSED } // ====== 异常处理服务 ====== @Service public class ExceptionService { @Autowired private ExceptionTicketMapper ticketMapper; @Autowired private WaybillService waybillService; /** * 创建异常工单 * 就像报案 — 记录时间、地点、经过、证据 */ @Transactional public ExceptionTicket createTicket(String waybillNo, ExceptionType type, String description, String evidence) { // 1. 运单状态改为异常 waybillService.markException(waybillNo); // 2. 创建工单 ExceptionTicket ticket = new ExceptionTicket(); ticket.setWaybillNo(waybillNo); ticket.setType(type); ticket.setLevel(determineLevel(type)); ticket.setDescription(description); ticket.setEvidence(evidence); ticket.setStatus(TicketStatus.CREATED); ticket.setDeadline(LocalDateTime.now().plusHours(24)); // 24小时内处理 ticketMapper.insert(ticket); // 3. 发送通知给客服 notifyService.notifyCustomerService(ticket); return ticket; } /** * 理赔 — 根据异常类型计算赔偿金额 * 就像保险理赔 — 根据保额和损失程度计算 */ public BigDecimal calculateCompensation(ExceptionTicket ticket) { Waybill waybill = waybillService.getByNo(ticket.getWaybillNo()); switch (ticket.getType()) { case LOST: // 丢失:按保价金额全额赔偿,未保价按运费3倍 return waybill.getDeclaredValue() != null ? waybill.getDeclaredValue() : waybill.getFreight().multiply(new BigDecimal("3")); case DAMAGED: // 破损:按损失程度比例赔偿 return waybill.getDeclaredValue() .multiply(getDamageRate(ticket.getEvidence())); case DELAYED: // 超时:退运费 return waybill.getFreight(); default: return BigDecimal.ZERO; } } private ExceptionLevel determineLevel(ExceptionType type) { return switch (type) { case LOST -> ExceptionLevel.CRITICAL; case DAMAGED -> ExceptionLevel.HIGH; case REJECTED, WRONG_ADDRESS -> ExceptionLevel.MEDIUM; case DELAYED -> ExceptionLevel.LOW; default -> ExceptionLevel.LOW; }; } }

🏗️ 架构设计 — 物流系统架构

📋 运单服务 🏭 仓储服务 🚚 调度服务 📍 追踪服务 ⚠️ 异常服务
📋 运单服务
运单创建 状态流转 面单生成 运费计算
职责:运单是物流的核心单据,所有操作都围绕运单展开。运单服务负责运单的创建、状态流转、面单生成和运费计算。运单创建时调用仓储服务检查库存,调用路径服务规划路线,创建完成后推送MQ消息给下游服务。
🏭 仓储服务
库存管理 出库/入库 库存同步(Canal/MQ) 多仓库路由
职责:仓储服务管理所有仓库的库存。核心是"库存不能超卖"——通过Redis预扣+DB最终一致性保证。多仓库场景下,根据用户地址自动选择最近的仓库发货。库存变更加上Canal监听binlog,通过MQ广播同步。
🚚 调度服务
运力管理 智能派单 第三方运力 预调度
职责:调度服务是物流的"大脑"——决定哪个快递员送哪个件。自有运力+第三方运力混合调度,高峰期自动弹性扩容。预调度机制:根据历史数据预测未来2小时的件量,提前准备运力。
📍 追踪服务
轨迹上报 轨迹查询 状态推推送 地图展示
职责:追踪服务负责包裹"到哪了"这个最核心的问题。快递员APP每次扫码/点击都上报一条轨迹。轨迹数据写入ES(方便搜索)+ ClickHouse(方便分析)。用户查询走Redis缓存+ES,推送走WebSocket。
⚠️ 异常服务
异常检测 工单管理 理赔处理 SLA监控
职责:异常服务负责所有"出了问题"的情况。自动检测(超时未签收、轨迹长时间未更新)+ 人工上报(快递员报告破损)。工单24小时内必须处理,超时自动升级。SLA监控每分钟检查一次,发现异常自动告警。

🔄 关键流程 — 代码级详解

流程1:发货出库
// ====== 发货出库 — 商家发货到仓库出库的完整流程 ====== @Service public class ShipmentService { @Autowired private WaybillService waybillService; @Autowired private InventoryService inventoryService; @Autowired private RoutePlanService routePlanService; @Autowired private LabelService labelService; /** * 发货出库 * 就像快递点收件 — 扫码、称重、贴面单、放传送带 */ @Transactional public ShipmentResult shipOut(ShipRequest request) { // 1. 创建运单 Waybill waybill = waybillService.create(WaybillCreateDTO.builder() .orderId(request.getOrderId()) .senderName(request.getSenderName()) .senderAddress(request.getSenderAddress()) .receiverName(request.getReceiverName()) .receiverAddress(request.getReceiverAddress()) .weight(request.getWeight()) .build()); // 2. 选择发货仓库(离商家最近的仓) Long warehouseId = selectNearestWarehouse(request.getSenderAddress()); // 3. 扣减库存 StockResult stockResult = inventoryService.preDeductStock( warehouseId, request.getSkuId(), request.getQuantity() ); if (!stockResult.isSuccess()) { throw new BizException("库存不足,无法发货"); } // 4. 规划路线 RoutePlan route = routePlanService.planBestRoute( request.getSenderAddress(), request.getReceiverAddress(), RoutePreference.BALANCED ); // 5. 生成面单(条码+二维码) LabelInfo label = labelService.generate(waybill.getWaybillNo()); // 6. 更新运单状态为"已出库" WaybillStateMachine.transition(waybill, WaybillStatus.PICKED_UP); return ShipmentResult.builder() .waybillNo(waybill.getWaybillNo()) .warehouseId(warehouseId) .route(route) .label(label) .build(); } }
流程2:物流轨迹上报
// ====== 物流轨迹上报 — 快递员扫码/操作触发 ====== // 每次状态变更都产生一条轨迹记录 @RestController @RequestMapping("/api/track") public class TrackController { @Autowired private TrackService trackService; /** * 快递员APP上报轨迹 * 就像打卡 — 到一个地方就刷一次 */ @PostMapping("/report") public Result<Void> reportTrack(@RequestBody TrackReportDTO dto) { trackService.reportTrack(dto); return Result.success(); } } @Service public class TrackService { @Autowired private WaybillTrackMapper trackMapper; @Autowired private WaybillService waybillService; @Autowired private SseEmitterService sseService; @Transactional public void reportTrack(TrackReportDTO dto) { // 1. 保存轨迹记录 WaybillTrack track = new WaybillTrack(); track.setWaybillNo(dto.getWaybillNo()); track.setStatus(dto.getStatus()); track.setLocation(dto.getLocation()); track.setOperator(dto.getOperator()); track.setDescription(dto.getDescription()); track.setTrackTime(LocalDateTime.now()); track.setLatitude(dto.getLatitude()); track.setLongitude(dto.getLongitude()); trackMapper.insert(track); // 2. 更新运单状态 Waybill waybill = waybillService.getByNo(dto.getWaybillNo()); WaybillStateMachine.transition(waybill, dto.getStatus()); waybillService.update(waybill); // 3. 实时推送给用户(SSE/WebSocket) sseService.pushToUser(waybill.getUserId(), track); // 4. 异步写入ES(方便搜索)和ClickHouse(方便分析) esSyncService.asyncSync(track); ckSyncService.asyncSync(track); } }
流程3:签收确认
// ====== 签收确认 — 最后一步 ====== @Service public class SignService { @Autowired private WaybillService waybillService; @Autowired private SettlementService settlementService; @Autowired private EvaluationService evalService; /** * 签收 — 可以是本人签收、代签、放快递柜 * 就像快递到了,你确认收到一样 */ @Transactional public void confirmSign(SignRequest request) { Waybill waybill = waybillService.getByNo(request.getWaybillNo()); // 校验:只有"派送中"的运单才能签收 if (waybill.getStatus() != WaybillStatus.DELIVERING) { throw new BizException("当前状态不允许签收:" + waybill.getStatus()); } // 1. 状态流转到"已签收" WaybillStateMachine.transition(waybill, WaybillStatus.SIGNED); waybill.setSignType(request.getSignType()); // 本人/代签/快递柜 waybill.setSignTime(LocalDateTime.now()); waybill.setSignImageUrl(request.getSignImageUrl()); // 签收照片 waybillService.update(waybill); // 2. 触发结算(物流费结算给快递员) settlementService.settleDelivery(waybill); // 3. 发送评价邀请 evalService.sendEvaluationInvite(waybill); // 4. 释放库存锁定(如果有的话) inventoryService.releaseLock(waybill.getWarehouseId(), waybill.getSkuId()); } }
流程4:异常工单处理
// ====== 异常工单处理流程 ====== @Service public class ExceptionProcessService { @Autowired private ExceptionTicketMapper ticketMapper; @Autowired private CompensationService compService; /** * 处理异常工单 * 就像保险理赔 — 调查→定损→赔偿→结案 */ @Transactional public void processTicket(Long ticketId, ProcessAction action) { ExceptionTicket ticket = ticketMapper.selectById(ticketId); switch (action.getType()) { case INVESTIGATE: // 调查阶段 — 核实异常是否属实 ticket.setStatus(TicketStatus.INVESTIGATING); ticket.setHandler(action.getHandler()); break; case COMPENSATE: // 赔偿阶段 — 计算赔偿金额并打款 BigDecimal amount = compService.calculate(ticket); ticket.setStatus(TicketStatus.COMPENSATING); ticket.setCompensateAmount(amount); compService.payCompensation(ticket); break; case REDELIVER: // 重新配送 — 地址错误或拒收后重新派 waybillService.reDispatch(ticket.getWaybillNo(), action.getNewAddress()); ticket.setStatus(TicketStatus.RESOLVED); break; case CLOSE: // 结案 ticket.setStatus(TicketStatus.CLOSED); break; } ticketMapper.updateById(ticket); } }

💥 踩坑实录 — 血泪教训

坑1:物流轨迹延迟 — 用户看到还在仓库实际已发出
场景:用户查物流,显示"已出库"但实际包裹已经在运输中了。原来轨迹上报走的是MQ异步写入,MQ消费有延迟,导致用户看到的信息落后于实际情况。
解决:推+拉结合!
1. 关键状态变更(出库/到达/派送/签收)走实时推送(WebSocket/SSE),不等MQ。
2. 非关键轨迹走MQ异步写入(量大但时效要求不高)。
3. 用户主动查询时,先查Redis缓存(实时写入),再查数据库(MQ消费后写入)。
4. 就像外卖 — 关键节点(骑手接单/到达)实时通知,中间过程后台记录。
坑2:库存超卖 — 多仓库并发扣减
场景:双11零点,北京仓只剩最后1件商品,但同一秒有2个用户下单,都从北京仓扣减——超卖了!原因是Redis扣减和DB扣减之间存在时间差。
解决:分布式锁 + Redis Lua脚本原子操作!
1. Redis扣减用Lua脚本,保证"读+判断+扣减"原子性。
2. DB扣减用乐观锁(WHERE available_qty >= #{quantity})。
3. 两层防御——即使Redis漏了,DB还有最后一道防线。
4. 超卖后的补偿:通知用户库存不足,退款道歉。
// 分布式锁 + Lua原子扣减 public StockResult deductWithLock(Long warehouseId, String skuId, int qty) { String lockKey = "lock:stock:" + warehouseId + ":" + skuId; RLock lock = redissonClient.getLock(lockKey); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { return preDeductStock(warehouseId, skuId, qty); } return StockResult.busy(); } catch (InterruptedException e) { return StockResult.error(e.getMessage()); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }
坑3:双11运力不足 — 快递积压
场景:双11当天件量暴增10倍,快递员忙不过来,大量包裹积压在网点,用户投诉"物流不更新"。
解决:预调度 + 弹性扩容!
1. 预调度:根据历史数据预测未来2-4小时的件量,提前调配运力(类似CDN边缘预热:高峰前把资源推到离用户最近的位置)。
2. 弹性扩容:自有运力不够时,自动接入第三方运力(顺丰/京东/达达等)。
3. 分时段派送:非紧急件延后派送,保证时效件优先。
4. 流量控制:前端限流,如果运力实在不够,暂时不接受新的揽件请求。
坑4:地址解析错误 — 智能分单出错
场景:用户填的地址是"北京市朝阳区建国路88号",智能分单系统解析成了"北京市朝阳区建国门",分配到了错误的分拨中心,导致包裹绕了一大圈。
解决:人工兜底!
1. 智能分单准确率98%,但2%的错误也不能接受。
2. 低置信度地址自动转人工审核——系统不确定时就问人。
3. 地址纠偏服务:调用高德/百度地图API纠正地址。
4. 用户端提示:让用户确认系统解析的地址是否正确。
// 地址解析 — AI + 人工兜底 public ParsedAddress parseAddress(String rawAddress) { // 1. 调用地图API解析 ParsedAddress parsed = mapApiService.geocode(rawAddress); // 2. 检查置信度 if (parsed.getConfidence() < 0.85) { // 置信度低,转人工审核 AddressReviewTask task = new AddressReviewTask(); task.setRawAddress(rawAddress); task.setParsedAddress(parsed); task.setConfidence(parsed.getConfidence()); reviewTaskMapper.insert(task); // 临时用解析结果(可能不准),等人工修正后更新 parsed.setNeedsReview(true); } return parsed; }

🔍 排查思路 — 出了问题怎么找

问题现象可能原因排查步骤定位方法
物流轨迹不更新 1. MQ消费延迟
2. 轨迹上报失败
3. 快递员未扫码
1. 查MQ消费lag
2. 查轨迹上报接口日志
3. 查快递员APP操作日志
Kafka Consumer Lag监控 + 接口日志
库存超卖 1. Redis扣减非原子
2. DB乐观锁失效
3. 并发请求穿透
1. 查Redis Lua脚本执行日志
2. 查DB扣减SQL日志
3. 查并发请求时间点
对比订单创建时间和库存扣减日志
运单状态异常 1. 状态机流转违规
2. 重复状态变更
3. 并发更新冲突
1. 查状态变更日志
2. 检查状态机规则
3. 查数据库乐观锁版本
运单状态变更审计日志
配送延迟 1. 运力不足
2. 路线规划不合理
3. 异常未及时处理
1. 查网点运力配比
2. 查路线规划日志
3. 查异常工单积压
SLA监控看板 + 运力利用率报表
包裹丢失 1. 分拣错误
2. 扫码遗漏
3. 中转站丢失
1. 查最后一条轨迹位置
2. 查中转站视频监控
3. 查关联运单
轨迹时间线分析 + 站点排查
$ 排查物流轨迹延迟 # 查看MQ消费积压 kafka-consumer-groups --describe --group track-consumer-group --bootstrap-server localhost:9092 GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG track-consumer track-log 0 12345 12450 105 ⚠️ LAG=105,消费延迟105条,需要扩容消费者 $ 排查库存超卖 # 对比Redis和DB的库存数据 redis-cli GET "stock:1:SKU001" "-1" ❌ Redis库存为负数!说明Lua脚本没有正确拦截,需要检查脚本逻辑 $ 排查运单状态异常 # 查运单状态变更历史 SELECT * FROM waybill_status_log WHERE waybill_no='SF1234567890' ORDER BY created_at; 2026-06-12 10:00 | CREATED 2026-06-12 11:30 | PICKED_UP 2026-06-12 14:00 | IN_TRANSIT 2026-06-12 14:05 | PICKED_UP ← 异常!状态倒退 ❌ 状态从IN_TRANSIT倒退回PICKED_UP,违反状态机规则

📝 面试高频 — 5道必考题

Q1:物流状态机怎么设计?为什么不能让状态倒退?
:物流状态是有向无环图——CREATED→PICKED_UP→IN_TRANSIT→DELIVERING→SIGNED,单向流转。
为什么不能倒退:已签收的包裹不可能变回"运输中",就像时间不能倒流。如果允许倒退,数据一致性无法保证。
异常处理:任何状态都可以转入EXCEPTION异常态,但异常处理后也不能回到之前的状态,只能向前推进或关闭。
实现方式:维护一个状态转换映射表(Map<当前状态, Set<允许的下一状态>>),每次状态变更先校验合法性。
Q2:多仓库库存扣减怎么防止超卖?
:双层防御——Redis预扣 + DB乐观锁。
1. Redis层:Lua脚本原子扣减,保证"读取-判断-扣减"一气呵成,不会被并发打断。
2. DB层:UPDATE ... WHERE available_qty >= #{quantity},乐观锁兜底。
3. 异步同步:Redis扣减后发MQ,消费者异步更新DB。
4. 分布式锁:极端场景下用Redisson分布式锁,确保同一SKU同一仓库不会并发扣减。
5. 补偿机制:定时对账Redis和DB数据,发现不一致自动补偿。
Q3:物流轨迹数据量大怎么存储和查询?
:多级存储——Redis缓存 + ES搜索 + ClickHouse分析。
1. Redis:存储最近10条轨迹,用户查询"到哪了"直接走Redis,延迟极低。
2. ES:全量轨迹数据,支持按运单号/时间/地点等条件搜索。
3. ClickHouse:轨迹分析(某个站点的时效统计、全链路时效分析等)。
4. 写入链路:快递员上报→MQ→消费者写ES+ClickHouse。关键状态走Redis实时写入。
5. 读取链路:用户查询→先查Redis→再查ES。WebSocket实时推送最新轨迹。
Q4:高峰期运力不足怎么应对?
:预调度 + 弹性运力 + 流量控制。
1. 预调度:根据历史数据预测未来2-4小时的件量,提前准备运力(类似缓存预热:高峰前把资源准备到位)。
2. 弹性运力:自有运力+第三方运力混合,高峰期自动接入第三方(达达/顺丰等)。
3. 流量控制:运力实在不够时,前端限流,暂时不接受新的揽件请求。
4. 分时段派送:时效件优先,普通件延后。
5. 数据驱动:实时监控各网点的运力利用率和件量,动态调整调度策略。
Q5:物流异常(丢失/破损)怎么处理?
:异常工单系统 + 理赔流程 + SLA监控。
1. 异常检测:自动检测(超时未签收、轨迹长时间未更新)+ 人工上报(快递员报告破损)。
2. 工单管理:创建工单→调查→定损→赔偿→结案。24小时内必须处理,超时自动升级。
3. 理赔计算:丢失按保价金额全额赔偿,未保价按运费3倍;破损按损失程度比例赔偿;超时退运费。
4. SLA监控:每分钟检查一次异常工单,发现超时自动告警给主管。
5. 根因分析:定期分析异常原因,是分拣问题、包装问题还是运输问题,针对性改进。