订单系统管理一笔交易从创建到完成/取消的完整生命周期
订单完整流程
| 阶段 | 操作 | 说明 |
|---|---|---|
| 选品 | 浏览商品加入购物车 | 用户在APP上挑选商品 |
| 下单 | 提交订单 | 生成一笔订单,锁定库存 |
| 支付 | 付款 | 通过支付宝/微信完成支付 |
| 发货 | 仓库发货 | 商家安排配送 |
| 签收 | 确认收货 | 用户确认收到商品 |
| 售后 | 退款/退货 | 不满意可申请退款 |
| 取消 | 超时关单 | 30分钟不付款自动取消 |
实现思路:用 Map<当前状态, Set<允许的目标状态>> 定义合法转换规则,每次变更前校验。核心是 永远不要直接 set 状态,而是通过状态机校验后再修改。
// ========== OrderStatus.java ========== // 定义订单状态枚举 public enum OrderStatus { PENDING_PAYMENT(1, "待支付"), PAID(2, "已支付"), SHIPPED(3, "已发货"), COMPLETED(4, "已完成"), CANCELLED(5, "已取消"), REFUNDING(6, "退款中"), REFUNDED(7, "已退款"); } // ========== OrderStateMachine.java ========== // 用Map记录合法转换规则 private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = new HashMap<>(); static { TRANSITIONS.put(PENDING_PAYMENT, Set.of(PAID, CANCELLED)); TRANSITIONS.put(PAID, Set.of(SHIPPED, REFUNDING)); TRANSITIONS.put(SHIPPED, Set.of(COMPLETED, REFUNDING)); TRANSITIONS.put(REFUNDING, Set.of(REFUNDED)); // 终态:已完成/已取消/已退款 → 空Set,不能再变 TRANSITIONS.put(COMPLETED, Set.of()); TRANSITIONS.put(CANCELLED, Set.of()); TRANSITIONS.put(REFUNDED, Set.of()); } // 校验状态转换是否合法 public static void validate(OrderStatus from, OrderStatus to) { Set<OrderStatus> allowed = TRANSITIONS.get(from); if (allowed == null || !allowed.contains(to)) { throw new IllegalStateException("不允许从[" + from + "]转换到[" + to + "]"); } } // ========== OrderService.java ========== public void payOrder(String orderId) { Order order = orderRepository.findById(orderId); OrderStateMachine.validate(order.getStatus(), PAID); // 先校验 order.setStatus(PAID); // 再修改 orderRepository.save(order); }
| 方案 | 做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 下单扣库存 | 提交订单时立即扣减 | 不会超卖 | 恶意下单不支付会占库存 | 库存少、抢购场景 |
| 支付扣库存 | 支付成功后才扣 | 不占库存 | 可能超卖 | 库存充足、非抢购 |
| 预扣+确认(推荐) | 下单时预扣,支付时确认,超时释放 | 兼顾防超卖和防占库存 | 实现稍复杂 | 大多数电商场景 |
推荐方案:预扣+确认,用 Redis + Lua 实现原子操作。"检查库存→扣减→记录预扣"全在一个Lua脚本里完成,中间不会被其他请求打断。
-- ========== deduct_stock.lua ========== -- 原子预扣库存:检查→扣减→记录,全在Redis单线程内完成 local current = tonumber(redis.call('GET', KEYS[1])) if current == nil then return -1 end -- key不存在 if current < tonumber(ARGV[1]) then return 0 end -- 库存不足 redis.call('DECRBY', KEYS[1], ARGV[1]) -- 扣减 redis.call('HSET', 'stock:hold:'..ARGV[2], -- 记录预扣 'sku_id', string.match(KEYS[1], 'sku_(.+)'), 'quantity', ARGV[1]) redis.call('EXPIRE', 'stock:hold:'..ARGV[2], 1800) -- 30分钟过期 return 1 -- 成功 // ========== InventoryService.java ========== public boolean preDeductStock(String skuId, int qty, String orderId) { Long result = redisTemplate.execute(deductStockScript, Collections.singletonList("stock:sku_" + skuId), String.valueOf(qty), orderId); return result != null && result == 1; } // 支付成功 → 确认扣减(删预扣记录,DB正式扣减) public void confirmDeduct(String orderId) { ... } // 超时/取消 → 释放预扣(Redis库存加回去) public void releasePreDeduct(String orderId) { ... }
| 方案 | 实时性 | 可靠性 | 复杂度 | DB压力 | 推荐度 |
|---|---|---|---|---|---|
| 定时任务轮询 | 差(分钟级) | 高 | 低 | 高 | ★★ |
| Redis过期通知 | 好(秒级) | 低(可能丢) | 中 | 低 | ★★★ |
| RabbitMQ死信 | 好(秒级) | 高 | 高 | 低 | ★★★★ |
| RocketMQ延迟 | 好(秒级) | 高 | 低 | 低 | ★★★★★ |
// 发送延迟消息(下单时调用) public void sendDelayMessage(String orderId) { // delayLevel=16 → 30分钟后投递 rocketMQTemplate.syncSend("order-timeout-topic", MessageBuilder.withPayload(orderId).build(), 3000, 16); } // 消费延迟消息(30分钟后收到) @RocketMQMessageListener(topic = "order-timeout-topic", consumerGroup = "order-timeout-group") public class OrderTimeoutListener implements RocketMQListener<String> { @Override public void onMessage(String orderId) { Order order = orderService.getById(orderId); // 再次检查:如果已支付就不取消了 if (order != null && order.getStatus() == PENDING_PAYMENT) { orderService.cancelOrder(orderId); } } }
三层防线:
| 层次 | 方案 | 防什么 | 原理 |
|---|---|---|---|
| 第1层 | Token机制 | 前端重复提交 | 进入页面时获取token,提交时带上,服务端SETNX原子消费 |
| 第2层 | Redis去重 | 并发重复(如支付回调重推) | 以业务唯一标识为key,SETNX防重复处理 |
| 第3层 | 数据库唯一索引 | 最终兜底 | 订单号UNIQUE索引,DuplicateKeyException捕获 |
// ========== Token机制:防前端重复提交 ========== @GetMapping("/order/token") public Result<String> getOrderToken() { String token = UUID.randomUUID().toString(); redisTemplate.opsForValue().set("order:token:" + token, "1", 10, TimeUnit.MINUTES); return Result.success(token); } @PostMapping("/order/create") public Result createOrder(@RequestHeader("X-Order-Token") String token) { // 原子删除:返回0说明token已被消费过 = 重复提交 Long deleted = redisTemplate.delete("order:token:" + token); if (deleted == null || deleted == 0) throw new BusinessException("请勿重复提交"); // ...创建订单 } // ========== Redis去重:防支付回调重复 ========== public void handlePaymentCallback(PaymentCallbackDTO callback) { String key = "pay:callback:" + callback.getOutTradeNo(); Boolean isFirst = redisTemplate.opsForValue().setIfAbsent(key, "1", 24, TimeUnit.HOURS); if (!isFirst) return; // 已处理过,忽略 orderService.payOrder(callback.getOutTradeNo(), ...); } -- ========== 数据库唯一索引:最终兜底 ========== CREATE TABLE t_order ( order_no VARCHAR(64) UNIQUE NOT NULL, -- 唯一索引 ... ); // catch DuplicateKeyException → 返回已有订单
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Seata AT | 自动两阶段提交 | 代码侵入小(加个注解) | 全局锁性能差 | 一致性要求高 |
| TCC | Try-Confirm-Cancel手动三步 | 性能好 | 代码量大(每个操作写3个方法) | 高并发 |
| MQ事务消息 | 本地事务+消息+消费者重试 | 性能最好 | 最终一致(有延迟) | 高并发 |
// 加一个注解就能搞定全局事务 @GlobalTransactional(timeoutMills = 60000, name = "create-order") public String createOrder(CreateOrderDTO dto) { // 操作1:创建订单 orderMapper.insert(order); // 操作2:扣减库存(Feign远程调用库存服务) Result r1 = inventoryClient.deductStock(dto.getSkuId(), dto.getQuantity()); if (!r1.isSuccess()) throw new BusinessException("库存不足"); // 操作3:扣减优惠券(Feign远程调用优惠券服务) Result r2 = couponClient.useCoupon(dto.getCouponId(), dto.getUserId()); if (!r2.isSuccess()) throw new BusinessException("优惠券不可用"); // 任何一步抛异常 → Seata自动回滚所有操作 }
| 方案 | 原理 | 适用场景 | 性能 |
|---|---|---|---|
| 乐观锁 | 更新时检查version字段是否变化 | 冲突少的场景 | 好(不加锁) |
| 悲观锁 | SELECT FOR UPDATE先锁再改 | 冲突多的场景 | 差(串行等待) |
// ========== 乐观锁:更新时检查version ========== @Update("UPDATE t_order SET status=#{status}, version=version+1 " + "WHERE id=#{id} AND version=#{version}") int updateWithVersion(@Param("id") Long id, @Param("status") int s, @Param("version") int v); public void shipOrder(Long orderId) { Order order = orderMapper.selectById(orderId); int rows = orderMapper.updateWithVersion(orderId, SHIPPED.getCode(), order.getVersion()); if (rows == 0) throw new BusinessException("订单已被修改,请刷新重试"); } // ========== 悲观锁:SELECT FOR UPDATE ========== @Select("SELECT * FROM t_order WHERE id = #{id} FOR UPDATE") Order selectForUpdate(@Param("id") Long id); @Transactional public void payOrder(String orderId) { Order order = orderMapper.selectForUpdate(orderId); // 加排他锁 // 其他事务必须等到这个事务提交后才能读取这行 order.setStatus(PAID); orderMapper.updateById(order); }
UPDATE inventory SET stock = stock - #{qty} WHERE sku_id = #{skuId} AND stock >= #{qty},让数据库行锁保证原子性。或者用挑战2的Redis+Lua方案。