Skip to content

订单、库存与数据一致性:状态机、幂等与对账 ​

订单系统里最容易出错的不是下单逻辑,而是同一笔订单被两条路径同时推进:支付回调刚到,超时关闭任务也跑到了这一笔。实测 1000 笔订单并发执行「支付」和「关闭」,用「先查后改」的写法有 990 笔同时被打上了支付时间和关闭时间。

本文围绕订单链路的四个具体问题展开:订单号怎么设计、状态流转怎么保证唯一、重复支付怎么处理、库存怎么扣。实验在 MySQL 8.4.11 与 JDK 21 上完成。

一、先说结论 ​

  • 订单号和主键分开:主键用自增 BIGINT,订单号用业务可读的字符串并加唯一索引。实测 CHAR(36) 随机 UUID 做主键,插入比自增 BIGINT 慢约 47%,总空间是它的 2.2 倍。
  • 状态流转一律用条件更新:UPDATE ... SET status = 'PAID' WHERE id = ? AND status = 'CREATED',用影响行数判断是否成功。实测被两条路径同时改写的订单从 990 笔降到 0。
  • 幂等靠业务唯一键加唯一索引,不是靠锁。重复请求命中唯一约束后,直接返回已有结果。
  • 库存扣减用一条带条件的 UPDATE(qty >= n),不要「查余量 → 判断 → 扣减」。热点商品需要分桶或前置到缓存。
  • 对账是最后一道防线:状态、金额、库存三类数据都要有定时对账,并把不一致数量做成指标。

二、订单号设计 ​

2.1 主键与订单号分开 ​

一个常见的做法是把业务订单号直接当主键,这会让聚簇索引变得又大又乱。实测 MySQL 8.4.11 插入 20 万行(每批 1,000 行,表上另有订单号唯一索引和 user_id 索引,3 次取中位数):

主键类型插入耗时数据二级索引总计
BIGINT 自增1,403ms11.5MB15.0MB26.5MB
BINARY(16) 有序 UUID(UUID_TO_BIN(uuid, 1))1,725ms13.5MB18.1MB31.6MB
CHAR(36) 随机 UUID2,060ms28.6MB30.2MB58.8MB

两个原因:

  • 随机主键导致页分裂。InnoDB 按主键顺序组织数据,顺序插入总是追加在最后一页;随机插入要插进已满的中间页,触发分裂,空间利用率下降。
  • 主键越长,二级索引越大。每个二级索引的叶子节点都存着主键值,实测 CHAR(36) 主键的两个二级索引合计是 BIGINT 的两倍。

所以推荐:主键用自增 BIGINT,订单号作为普通列加唯一索引。确实需要分布式 ID 时,用雪花算法这类趋势递增的 ID,或者 UUID_TO_BIN(uuid, 1)(交换时间低位,使其按时间有序),都能避免随机插入的代价。

2.2 订单号本身的要求 ​

要求做法
全局唯一加唯一索引兜底,不依赖生成器「应该不会重复」
趋势递增便于按时间范围查询和归档
不可枚举不能让人从 20260918000001 推出下一笔订单;可以加随机位或做混淆
可读可定位通常包含日期、业务线、分片位,便于排查和路由
长度可控通常 16—24 位,太长影响索引和传输

一个可用的组成:日期(8) + 业务线(2) + 分片位(2) + 序列(6) + 随机(2)。分片位的作用是分库分表时可以直接从订单号定位到库表,不用回查路由表。

生成方式上,雪花算法(时间戳 + 机器 ID + 序列)是最常见的选择。它的两个坑要注意:时钟回拨(要检测并拒绝生成,或等待追平)和机器 ID 分配(容器环境下随机分配会冲突,要从注册中心或数据库获取)。

三、状态机:让每次流转只成功一次 ​

3.1 实测:先查后改 vs 条件更新 ​

CREATED待支付PAID已支付CLOSED超时关闭REFUNDED已退款支付成功超时关闭退款关闭后又收到支付UPDATE pay_order SET status='PAID' WHERE id=? AND status='CREATED' → 影响行数 1 才算流转成功
图 1 · 每次状态流转都写成带前置状态的条件更新,用影响行数判断是否抢到;实测 1000 笔订单并发支付与关闭,被两条路径同时改写的订单从 990 笔降到 0

支付成功回调和超时关闭任务同时处理同一笔订单:

java
// 危险写法:查询和更新之间,状态可能已经被另一条路径改了
String status = orderMapper.selectStatus(orderId);
if ("CREATED".equals(status)) {
    orderMapper.updateStatus(orderId, "PAID");
}

// 正确写法:把前置状态写进 WHERE,用影响行数判断
int changed = orderMapper.markPaid(orderId);   // WHERE id = ? AND status = 'CREATED'
if (changed == 0) {
    handleConflict(orderId);                   // 别人已经改过了
}

1000 笔订单,支付和关闭两个线程逐笔处理,每一笔同时开始(模拟两条路径撞上同一笔订单的最坏情况):

写法支付成功关闭成功两个动作都执行过的订单
先查后改992998990
条件更新5234770

条件更新下,两个线程的成功数加起来正好是 1000——每笔订单只有一条路径成功。而先查后改的写法里,990 笔订单既有支付时间又有关闭时间,数据已经不可信了。

3.2 状态机要先画出来 ​

条件更新的前提是知道每个流转的合法前置状态。把状态机画清楚,代码里就只有一种写法:

java
public enum OrderStatus {
    CREATED, PAID, CLOSED, REFUNDED, FINISHED;

    private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED = Map.of(
            CREATED, EnumSet.of(PAID, CLOSED),
            PAID,    EnumSet.of(REFUNDED, FINISHED),
            CLOSED,  EnumSet.of(REFUNDED),      // 关闭后又收到支付,退款
            REFUNDED, EnumSet.noneOf(OrderStatus.class),
            FINISHED, EnumSet.noneOf(OrderStatus.class));

    public boolean canMoveTo(OrderStatus target) {
        return ALLOWED.get(this).contains(target);
    }
}

两个要点:

  • 终态不可再变:REFUNDED、FINISHED 之后不接受任何流转,避免「已退款的订单又被关闭」。
  • 关闭之后收到支付,是正常情况,不是异常。用户在最后一秒付款,回调晚到几秒都可能发生。处理方式是转入退款流程,而不是丢弃回调或强行改成已支付。

3.3 超时关闭本身也要幂等 ​

超时关闭通常由定时任务或延时消息驱动,两者都可能重复触发。条件更新天然解决了这个问题:第二次执行时影响行数为 0,直接跳过。延时任务的几种实现见 延时任务与消息队列。

四、重复支付与幂等 ​

客户端重试携带同一 request_id下单接口插入成功返回新订单唯一键冲突DuplicateKeyException查询并返回已有订单UNIQUE KEY uk_request_id (request_id)幂等键由客户端生成并在重试时保持不变,服务端只负责拒绝重复
图 2 · 客户端在重试时携带同一个 request_id,唯一索引保证只会产生一笔订单,重复请求直接返回已有结果

4.1 下单接口的幂等 ​

客户端在超时重试时携带同一个 request_id:

sql
ALTER TABLE `order` ADD UNIQUE KEY uk_request_id (request_id);
java
public Order create(CreateOrderCommand cmd) {
    try {
        Order order = buildOrder(cmd);
        orderMapper.insert(order);
        return order;
    } catch (DuplicateKeyException e) {
        return orderMapper.selectByRequestId(cmd.requestId());   // 幂等返回,不报错
    }
}

幂等键必须由客户端生成,服务端生成的话,重试时会得到一个新的键,也就失去了意义。客户端什么时候需要带着同一个键重试,见 一次 HTTP 调用超时了,对方到底执行了没有。

4.2 支付回调的幂等 ​

支付渠道的回调会重试,同一笔支付可能收到多次通知。处理方式是三层:

  1. 回调记录表:(channel, channel_trade_no) 建唯一索引,插入成功才继续处理;
  2. 条件更新:订单状态流转本身是幂等的;
  3. 金额校验:回调金额与订单金额不符时直接告警,不要自动接受。

4.3 重复支付怎么办 ​

用户可能真的付了两次(比如两个渠道各付一次)。这不是技术问题能完全避免的,处理流程是:

  • 支付单与订单分开,一个订单可以有多个支付单;
  • 第二笔支付到账后,订单已是 PAID,则把这笔支付标记为「多付」,走自动退款;
  • 资金相关的记录只增不改,用流水表记录每一笔变动,便于对账和追溯。

五、库存扣减 ​

5.1 一条 SQL 完成判断和扣减 ​

sql
-- 危险:查询和扣减之间可能被别人扣走
SELECT qty FROM sku_stock WHERE sku_id = ?;
UPDATE sku_stock SET qty = qty - 1 WHERE sku_id = ?;

-- 正确:把库存充足写进条件,影响行数为 0 表示卖完了
UPDATE sku_stock SET qty = qty - ? WHERE sku_id = ? AND qty >= ?;

加上 qty >= ? 之后,即使并发再高也不会超卖——行锁保证了每次扣减是串行的,条件保证了不会扣成负数。

5.2 热点商品 ​

爆款商品的库存行会成为热点,实测 64 个线程并发更新同一行只有约 2,200 TPS,而分散到 1000 行是 16,420 TPS。解决方式(分桶、请求合并、前置到缓存)见 表设计里的三个细节。

5.3 预扣与回补 ​

下单预扣、支付后确认扣减、超时未支付回补,是常见的三段式。要注意:

  • 回补也要幂等:用「订单号 + 动作」建唯一索引,防止重复回补导致库存虚增;
  • 回补要有兜底任务:消息丢失、进程崩溃都会让回补漏掉,需要定时扫描超时未支付的订单补偿;
  • 缓存中的库存只用于展示和挡量,最终扣减以数据库为准。

六、对账:把问题变成可观测的数字 ​

前面所有机制都可能因为某个分支遗漏而失效,所以要有独立的对账:

对账类型比对内容频率
状态对账支付渠道的成功订单 vs 本地已支付订单每小时 / 每日
资金对账订单金额 vs 支付流水金额 vs 退款金额每日
库存对账库存表余量 + 已售数量 vs 初始库存每日
缓存对账缓存中的库存、状态 vs 数据库抽样,持续

关键是把不一致的数量做成指标并告警,而不是生成一份没人看的报表。数字长期为 0 说明机制有效;突然上升说明某条路径出了问题,这时再去查日志才有方向。

七、常见误区 ​

  • 「用订单号做主键更直观」:随机字符串主键会导致页分裂和索引膨胀,实测总空间是自增主键的 2.2 倍。
  • 「状态流转前先查一下就行」:实测 990 笔订单被两条路径同时改写。
  • 「加了分布式锁就不会重复下单」:锁会因超时和网络问题失效,唯一索引才是兜底。
  • 「关闭之后不应该再收到支付回调」:这是正常情况,要设计成退款流程。
  • 「对账是财务的事」:对账是验证系统正确性的手段,应该作为技术指标持续观察。

小结 ​

订单链路的一致性,靠的不是某一个精巧的方案,而是几个朴素规则的叠加:主键用自增、订单号加唯一索引;每次状态流转都用条件更新并检查影响行数;幂等键由客户端生成、由数据库约束保证;库存扣减把条件写进 SQL;最后用对账把残余问题变成可观测的数字。这些规则单独看都很简单,但少任何一条,系统在高并发下都会出现「说不清是怎么发生的」的数据。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权