掘金-订单系统业务设计.md 13 KB

订单系统怎么做才不乱?从状态机、库存到对账的完整设计

很多团队做订单系统,最后做成了"带金额的 CRUD"。这篇不讲框架,讲清楚订单系统的业务设计:状态怎么流转、库存什么时候扣、价格怎么算、退款退货怎么走、月底怎么对账。

做过订单系统的开发者,大概率被这些问题折磨过:

  • 用户取消了订单,库存怎么还没回来?
  • 订单状态改了,但没人知道是谁改的?
  • 退款退了一半,订单还显示"已完成"?
  • 月底对账,订单系统和支付系统金额对不上?
  • 并发下单,同一个库存被扣了两次?

这些问题不是 bug,是业务设计没想清楚

订单系统的难度不在代码量,在于状态、库存、金额这三件事必须严丝合缝。这篇就把这三件事讲透。


一、订单的核心数据模型

先建模型,别一上来就写接口。

订单(Order)
  ├── 订单明细(OrderItem)     一个订单多个商品
  ├── 支付记录(Payment)       一个订单多笔支付(定金+尾款)
  ├── 退款记录(Refund)        一个订单多笔退款
  ├── 订单操作日志(OrderLog)  状态变更全记录
  └── 发货记录(Shipment)      一个订单多次发货
实体 核心字段 说明
订单 订单号、客户ID、总金额、实付金额、状态、支付方式、下单时间 订单主体
订单明细 订单ID、商品ID、数量、单价、优惠金额 一个订单多行商品
支付记录 订单ID、支付金额、支付方式、支付时间、第三方流水号 对账用
退款记录 订单ID、退款金额、退款原因、退款状态、退款时间 退款追溯
操作日志 订单ID、操作人、操作类型、操作前状态、操作后状态、时间 谁改了什么

最常见的坑:把所有信息塞进一张订单表。 商品信息、支付信息、物流信息全塞订单字段里。一个订单买 5 个商品时存不下,改状态时没有日志可查。

原则:订单表只存订单级别的信息,明细、支付、退款、日志各自独立成表。


二、订单状态机:最容易出乱子的地方

订单状态是订单系统的命脉。状态设计不好,后面全是坑。

标准状态流转

待付款 → 待发货 → 待收货 → 已完成
   ↓        ↓        ↓
 已取消    已取消    已取消
状态 含义 触发条件
待付款 下单成功,等用户付款 创建订单
待发货 已付款,等商家发货 支付成功回调
待收货 已发货,等用户签收 商家发货
已完成 用户签收 / 自动确认 确认收货
已取消 用户取消 / 超时取消 用户操作 / 定时任务

状态流转的 3 条铁律

1. 状态只能往前走,不能跳

不能从"待付款"直接跳到"已完成"。必须经过"待发货→待收货→已完成"。每一步都有业务含义,跳了就丢失了业务节点。

2. 取消只能从特定状态走

  • 待付款 → 可取消(没付钱,库存要还)
  • 待发货 → 可取消(付了钱,要退款)
  • 待收货 → 一般不可取消(货已经发了,只能走退货)
  • 已完成 → 不可取消(只能走售后退货)

3. 每次状态变更必须记日志

INSERT INTO order_log (order_id, operator_id, from_status, to_status, remark)
VALUES (1001, 456, 'PENDING_PAYMENT', 'PENDING_SHIPMENT', '用户支付成功');

三个月后老板问"这个订单怎么回事",翻日志就知道每一步谁干的、什么时候干的。

用枚举 + 状态机管理,不要用 if/else

public enum OrderStatus {
    PENDING_PAYMENT,  // 待付款
    PENDING_SHIPMENT, // 待发货
    PENDING_RECEIPT,  // 待收货
    COMPLETED,        // 已完成
    CANCELED;         // 已取消

    // 定义合法的状态流转
    public boolean canTransitionTo(OrderStatus target) {
        return switch (this) {
            case PENDING_PAYMENT  -> target == PENDING_SHIPMENT || target == CANCELED;
            case PENDING_SHIPMENT -> target == PENDING_RECEIPT  || target == CANCELED;
            case PENDING_RECEIPT  -> target == COMPLETED;
            default -> false;
        };
    }
}

每次改状态前校验:

public void updateStatus(Long orderId, OrderStatus target) {
    Order order = orderMapper.selectById(orderId);
    if (!order.getStatus().canTransitionTo(target)) {
        throw new BizException("订单状态不允许从 " + order.getStatus() + " 变更为 " + target);
    }
    orderMapper.updateStatus(orderId, target);
    orderLogMapper.insert(new OrderLog(orderId, currentUserId, order.getStatus(), target));
}

非法状态变更直接拦截,比一堆 if (status == xxx) 清晰得多。


三、库存扣减:最容易被并发坑的地方

库存是订单系统里最容易出 bug 的环节。核心问题是:什么时候扣库存?

三种扣减时机

方案 时机 优点 风险
下单扣库存 创建订单时扣 不会超卖 大量未付款订单占库存
付款扣库存 支付成功后扣 库存占用时间短 付款时可能没库存了
预扣库存 下单预扣,超时释放 兼顾两者 实现复杂

推荐方案:下单预扣 + 超时释放。

  • 下单时预扣库存(锁定,不是真扣)
  • 支付成功后,预扣转真扣
  • 30 分钟未支付,预扣自动释放

并发问题怎么解决?

100 个人同时下单买最后 1 件商品,不能都下单成功。

不要在应用层用 synchronized——多实例部署就失效了。用数据库乐观锁:

-- 扣库存时加条件:库存必须 >= 扣减量
UPDATE product_stock
SET stock = stock - #{quantity}
WHERE product_id = #{productId}
  AND stock >= #{quantity}

如果返回影响行数 = 0,说明库存不够,下单失败。这一条 SQL 就能防超卖。

如果并发量更高,可以用 Redis 预扣 + 异步落库:

// Redis 原子扣减
Long remaining = redisTemplate.opsForValue()
    .decrement("stock:" + productId, quantity);
if (remaining < 0) {
    redisTemplate.opsForValue().increment("stock:" + productId, quantity);
    throw new BizException("库存不足");
}

取消订单时库存怎么还?

待付款取消 → 释放预扣库存
待发货取消 → 退款 + 增加库存

关键:还库存也要做幂等。 一个取消操作不能触发两次还库存。用订单号 + 操作类型做唯一约束:

INSERT INTO stock_change (order_id, product_id, change_type, quantity)
VALUES (1001, 501, 'RELEASE', 3)
ON DUPLICATE KEY UPDATE id = id;

四、价格计算:不要相信前端传的金额

这是订单系统最大的安全红线。

错误做法

// 直接用前端传的金额下单
Order order = new Order();
order.setTotalAmount(dto.getTotalAmount()); // 危险!
orderMapper.insert(order);

前端传 totalAmount = 0.01,你信了,用户用 1 分钱买了 1000 块的东西。

正确做法

public Order createOrder(OrderDTO dto) {
    BigDecimal totalAmount = BigDecimal.ZERO;

    for (OrderItemDTO item : dto.getItems()) {
        // 从数据库查商品当前价格,不用前端传的价格
        Product product = productMapper.selectById(item.getProductId());
        BigDecimal itemAmount = product.getPrice()
            .multiply(new BigDecimal(item.getQuantity()));
        totalAmount = totalAmount.add(itemAmount);
    }

    // 优惠金额也在后端算
    BigDecimal discount = calculateDiscount(totalAmount, dto.getCouponId());
    BigDecimal actualAmount = totalAmount.subtract(discount);

    Order order = new Order();
    order.setTotalAmount(totalAmount);
    order.setDiscountAmount(discount);
    order.setActualAmount(actualAmount);
    // ...
}

原则:前端只传"买什么、买多少、用哪张券",金额全部后端算。

金额用什么类型?

  • BigDecimal,不要用 double/float——浮点数有精度问题
  • 数据库用 DECIMAL(12,2),不要用 FLOAT
  • 金额单位用"分"存 Long 也行,但要注意前端展示时除以 100

五、退款退货:正向流程的反向操作

退款和退货是两件事:

  • 退款:还钱,不一定退货(虚拟商品、服务)
  • 退货:还货,退款是伴随的

退款流程

用户申请退款
   ↓
商家审核
   ↓
审核通过 → 调用支付渠道退款
   ↓
退款成功 → 更新订单状态 → 还库存(如果退货)

退款的 3 个坑

1. 退款不能改原订单状态

很多系统直接把订单状态改成"已退款"。这是错的。

一个订单可能有部分退款(买了 3 个商品退 1 个)。如果直接改原订单状态,"已完成"变成"已退款",那剩下 2 个商品的成交记录就没了。

正确做法:退款独立成表,原订单状态不变,通过退款记录关联。 查一个订单的完整生命周期,要同时看订单 + 退款记录。

2. 退款要幂等

支付渠道的退款接口可能超时重试。如果重试时又退了一次款,就出大事了。

public void refund(Long orderId, BigDecimal amount) {
    // 检查是否已退款
    Refund exist = refundMapper.findByRequestId(requestId);
    if (exist != null) {
        return; // 已退款,幂等返回
    }
    // 调支付渠道退款
    paymentClient.refund(orderId, amount);
    // 记录退款
    refundMapper.insert(new Refund(orderId, amount, requestId));
}

3. 退款金额不能超过实付金额

BigDecimal totalRefunded = refundMapper.sumByOrderId(orderId);
if (totalRefunded.add(refundAmount).compareTo(order.getActualAmount()) > 0) {
    throw new BizException("退款总额不能超过实付金额");
}

六、对账:月底别让财务加班

订单系统和支付系统是两套系统,金额必须对得上。

对账逻辑

订单系统的实付金额  vs  支付系统的收款金额
订单系统的退款金额  vs  支付系统的退款金额

每天跑一次对账任务,找出差异:

-- 找出订单有金额但支付系统无记录的
SELECT o.* FROM orders o
LEFT JOIN payment p ON o.id = p.order_id
WHERE o.status != 'CANCELED'
  AND p.id IS NULL;

-- 找出金额不一致的
SELECT o.id, o.actual_amount, p.paid_amount
FROM orders o
JOIN payment p ON o.id = p.order_id
WHERE o.actual_amount != p.paid_amount;

对账差异通常有几种原因:

  • 支付回调丢失(订单没更新状态但钱到了)
  • 退款异步延迟(退款提交了但渠道还没处理完)
  • 手动改了订单金额(没有同步到支付系统)

有对账才有兜底。 没有对账的系统,金额出问题可能要等用户投诉才发现。


七、数据权限:谁能看哪些订单

和 CRM 一样,订单系统也要数据权限:

角色 数据范围 效果
销售 本人 只看自己负责客户下的单
主管 本部门 看本部门所有销售的订单
财务 全部金额 看所有订单但不能改
老板 全部 看全部

用数据权限拦截器按 owner_iddept_id 过滤,业务代码不用管。


八、订单看板:老板要看什么

指标 说明
本月订单数 业务量
本月成交金额 营收
待发货订单 该发货了
待付款超时数 该取消了
退款金额 售后成本
转化率 线索→订单

九、踩坑总结

1. 订单号不要用自增 ID

自增 ID 暴露业务量,而且分库分表时冲突。用雪花算法或"日期 + 随机数"。

2. 不要硬编码状态值

if (status == 1) 这种代码,3 个月后没人记得 1 是什么。用枚举或字典。

3. 支付回调要做幂等

支付渠道会重复回调。同一个订单号回调两次,不能改两次状态。

4. 取消订单要有时间窗口

待付款订单 30 分钟不付自动取消。不要让待付款订单永久占着库存。

5. 金额不要做加减运算

amount + discount 可能出现精度问题。用 BigDecimaladd 方法。


十、总结:订单系统的本质是状态 + 库存 + 金额

核心问题 解决方案
状态怎么流转 状态机 + 日志
库存什么时候扣 下单预扣 + 超时释放
价格怎么算 后端重算,不信前端
退款怎么走 独立退款表,不改原订单状态
金额怎么保证 BigDecimal + 对账
并发怎么防 乐观锁 / Redis 原子扣减

订单系统不是代码写不出来,是业务设计想不清楚。状态、库存、金额这三件事想透了,代码就是照着实现而已。


体验完整的订单管理能力

你做订单系统踩过最大的坑是什么?超卖、金额不一致、还是状态乱跳?评论区聊聊。