🎯 核心挑战 — 物流系统的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;
};
}
}
🔄 关键流程 — 代码级详解
流程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);
}
}