← 返回业务系统实战目录

订单系统实战 — 从下单到完成的完整链路

状态机 库存扣减 超时关单 幂等 分布式事务

订单系统管理一笔交易从创建到完成/取消的完整生命周期

业务全景 — 订单系统做什么

订单完整流程

阶段操作说明
选品浏览商品加入购物车用户在APP上挑选商品
下单提交订单生成一笔订单,锁定库存
支付付款通过支付宝/微信完成支付
发货仓库发货商家安排配送
签收确认收货用户确认收到商品
售后退款/退货不满意可申请退款
取消超时关单30分钟不付款自动取消

核心挑战 — 订单系统的6道难关

挑战1:状态机 — 订单状态流转必须合法

订单状态流转必须合法——"已取消"的不能再变"已发货","待支付"的不能直接跳到"已完成"。状态机就是保证每一步变化都合法的规则。
待支付 已支付 已发货 已完成
待支付 已取消
已支付 退款中 已退款

实现思路:用 Map<当前状态, Set<允许的目标状态>> 定义合法转换规则,每次变更前校验。核心是 永远不要直接 set 状态,而是通过状态机校验后再修改。

查看代码:OrderStatus + OrderStateMachine + OrderService
order-service / src / main / java / com / order / domain
▼ domain
OrderStatus.java
OrderStateMachine.java
▼ service
OrderService.java
// ========== 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);
}

挑战2:库存扣减 — 什么时候扣?怎么防超卖?

库存只有100件,5个用户同时下单各买30件。如果都接单了,实际只能满足3个用户——这就是"超卖"。核心是保证"检查+扣减"的原子性。
方案做法优点缺点适用场景
下单扣库存提交订单时立即扣减不会超卖恶意下单不支付会占库存库存少、抢购场景
支付扣库存支付成功后才扣不占库存可能超卖库存充足、非抢购
预扣+确认(推荐)下单时预扣,支付时确认,超时释放兼顾防超卖和防占库存实现稍复杂大多数电商场景

推荐方案:预扣+确认,用 Redis + Lua 实现原子操作。"检查库存→扣减→记录预扣"全在一个Lua脚本里完成,中间不会被其他请求打断。

查看代码:Lua原子扣减脚本 + InventoryService
order-service / src / main / resources + java / com / order / service
▼ resources
deduct_stock.lua
▼ service
InventoryService.java
-- ========== 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) { ... }

挑战3:超时关单 — 30分钟不付就自动取消

用户下单后不付款,库存一直被占着。需要设置超时时间(如30分钟),到期自动取消订单、释放库存。
方案实时性可靠性复杂度DB压力推荐度
定时任务轮询差(分钟级)★★
Redis过期通知好(秒级)低(可能丢)★★★
RabbitMQ死信好(秒级)★★★★
RocketMQ延迟好(秒级)★★★★★
最佳实践:RocketMQ延迟消息为主 + 定时任务兜底。双保险。
查看代码:RocketMQ延迟消息实现
order-service / src / main / java / com / order / mq
▼ mq
OrderTimeoutListener.java
▼ service
OrderService.java
// 发送延迟消息(下单时调用)
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);
        }
    }
}

挑战4:幂等性 — 重复提交不会出问题

用户双击"提交订单"、支付回调重复推送、退款接口被多次调用——同一个操作执行1次和N次,结果必须一致。

三层防线

层次方案防什么原理
第1层Token机制前端重复提交进入页面时获取token,提交时带上,服务端SETNX原子消费
第2层Redis去重并发重复(如支付回调重推)以业务唯一标识为key,SETNX防重复处理
第3层数据库唯一索引最终兜底订单号UNIQUE索引,DuplicateKeyException捕获
查看代码:Token机制 + Redis去重 + 唯一索引
order-service / src / main / java / com / order / controller + service
▼ controller
OrderController.java
▼ service
PaymentCallbackService.java
▼ sql
t_order.sql
// ========== 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 → 返回已有订单

挑战5:分布式事务 — 三个操作要么全成功,要么全失败

下单涉及3个操作——扣库存、创建订单、扣优惠券。如果库存扣了但订单创建失败,数据就不一致了。需要保证要么全成功,要么全回滚。
方案原理优点缺点适用场景
Seata AT自动两阶段提交代码侵入小(加个注解)全局锁性能差一致性要求高
TCCTry-Confirm-Cancel手动三步性能好代码量大(每个操作写3个方法)高并发
MQ事务消息本地事务+消息+消费者重试性能最好最终一致(有延迟)高并发
查看代码:Seata AT模式(最简单的方案)
order-service / src / main / java / com / order / service
▼ service
OrderService.java
▼ feign
InventoryClient.java
CouponClient.java
// 加一个注解就能搞定全局事务
@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自动回滚所有操作
}

挑战6:并发安全 — 同一订单不能同时被两个操作修改

同一订单可能同时被多个操作修改——比如用户点"确认收货"的同时客服在处理"申请退款"。不加控制会导致数据混乱。
方案原理适用场景性能
乐观锁更新时检查version字段是否变化冲突少的场景好(不加锁)
悲观锁SELECT FOR UPDATE先锁再改冲突多的场景差(串行等待)
查看代码:乐观锁(version字段)+ 悲观锁(FOR UPDATE)
order-service / src / main / java / com / order / mapper + service
▼ mapper
OrderMapper.java
▼ service
OrderService.java
// ========== 乐观锁:更新时检查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);
}

踩坑实录

坑1:双重支付 — 网络抖动导致用户被扣两次钱

场景:支付宝回调因为网络超时重试了2次,第一次已经把订单改成"已支付",第二次又处理了一遍,用户收到了两条扣款短信!
修复:先判断订单状态,已支付的直接return。(就是挑战4的Redis去重方案)

坑2:库存负数 — 并发扣减没有加锁

场景:库存剩1件,2个用户同时下单,都检查到库存>0,都扣减成功,库存变成-1!
修复:用 UPDATE inventory SET stock = stock - #{qty} WHERE sku_id = #{skuId} AND stock >= #{qty},让数据库行锁保证原子性。或者用挑战2的Redis+Lua方案。

坑3:状态回退 — 运维误操作把订单状态改回去了

场景:运维直接在数据库里把status从4(已完成)改成了3(已发货),系统自动触发了发货通知,用户收到奇怪的短信。
修复:① 状态变更必须经过状态机校验 ② 增加 status_change_log 表记录所有变更 ③ 运维后台也走API,不允许直连DB。

坑4:超时关单失败 — MQ消息堆积

场景:大促期间MQ消息堆积,30分钟延迟消息延迟了2小时才被消费,用户2小时后才发现订单被取消了。
修复:① 关单前再次检查订单状态(是否已支付)② 支付成功时主动取消延迟消息 ③ 定时任务兜底扫描 ④ 监控MQ消费延迟告警。

坑5:大促订单量激增 — 数据库扛不住

场景:双十一0点下单量是平时的100倍,MySQL连接池瞬间耗尽,所有服务不可用。
修复:① 分库分表(按用户ID分库、按订单ID分表)② MQ削峰(下单先进队列)③ 读写分离 ④ Redis缓存热点商品。

排查思路

问题1:订单卡在"待支付" — 超时关单没生效

1
查RocketMQ消费者是否正常:消费者组在线吗?消费延迟多少?
2
查延迟消息是否发出:在RocketMQ控制台搜 order-timeout-topic
3
查定时任务是否在跑:看XXL-JOB调度日志
4
查消费日志:搜订单号,看是否尝试过关单但失败了

问题2:库存数据不对 — Redis和DB不一致

1
先查Redis库存:GET stock:sku_xxx
2
再查DB库存:SELECT stock FROM inventory WHERE sku_id='xxx'
3
查预扣记录:HGETALL stock:hold:order_xxx
4
查最近的扣减日志:看是否有异常扣减
5
对账修复:跑对账脚本,找出差异并修正

问题3:重复扣款 — 支付回调重复处理

1
查支付回调日志:搜订单号,看收到了几次回调
2
查幂等记录:GET pay:callback:order_xxx,看是否SETNX失败
3
查订单状态变更日志:看status是否被重复修改
4
联系支付渠道:确认对方是否重复推送

排查命令速查

# 查看订单状态 $ mysql -e "SELECT id, order_no, status, version, create_time FROM t_order WHERE order_no='202406130001'" # 查Redis库存 $ redis-cli GET stock:sku_10001 # 查预扣记录 $ redis-cli HGETALL stock:hold:ORD202406130001 # 查支付回调幂等记录 $ redis-cli GET pay:callback:ORD202406130001 # 查RocketMQ消费延迟 $ ./mqadmin consumerProgress -g order-timeout-group # 查看最近的订单日志 $ grep "ORD202406130001" /var/log/order-service.log | tail -20

常见问题 FAQ

Q 为什么不用数据库的 SELECT ... FOR UPDATE 做库存扣减?

FOR UPDATE 是悲观锁,高并发下会导致锁等待超时死锁。推荐用 UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty > 0(乐观锁/CAS),只有库存 > 0 才扣减成功,无需加锁。

Q 订单超时关闭为什么用延迟消息而不是定时任务扫描?

定时任务扫描有延迟不可控(扫描间隔越大延迟越大)和全表扫描性能差的问题。延迟消息(RocketMQ TTL + DLX / Redis 过期通知)可以精确到秒级触发,且只处理需要的订单。

Q 分布式事务 Seata AT 和 TCC 怎么选?

AT 模式无侵入(自动生成补偿SQL),适合简单 CRUD;TCC 模式性能更好但需要手写 Try/Confirm/Cancel 三个方法,适合高并发场景(如秒杀扣库存)。订单系统一般用 AT 就够了。

返回目录