订单、库存与数据一致性:状态机、幂等与对账
订单系统里最容易出错的不是下单逻辑,而是同一笔订单被两条路径同时推进:支付回调刚到,超时关闭任务也跑到了这一笔。实测 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,403ms | 11.5MB | 15.0MB | 26.5MB |
BINARY(16) 有序 UUID(UUID_TO_BIN(uuid, 1)) | 1,725ms | 13.5MB | 18.1MB | 31.6MB |
CHAR(36) 随机 UUID | 2,060ms | 28.6MB | 30.2MB | 58.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 条件更新
支付成功回调和超时关闭任务同时处理同一笔订单:
// 危险写法:查询和更新之间,状态可能已经被另一条路径改了
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 笔订单,支付和关闭两个线程逐笔处理,每一笔同时开始(模拟两条路径撞上同一笔订单的最坏情况):
| 写法 | 支付成功 | 关闭成功 | 两个动作都执行过的订单 |
|---|---|---|---|
| 先查后改 | 992 | 998 | 990 |
| 条件更新 | 523 | 477 | 0 |
条件更新下,两个线程的成功数加起来正好是 1000——每笔订单只有一条路径成功。而先查后改的写法里,990 笔订单既有支付时间又有关闭时间,数据已经不可信了。
3.2 状态机要先画出来
条件更新的前提是知道每个流转的合法前置状态。把状态机画清楚,代码里就只有一种写法:
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,直接跳过。延时任务的几种实现见 延时任务与消息队列。
四、重复支付与幂等
4.1 下单接口的幂等
客户端在超时重试时携带同一个 request_id:
ALTER TABLE `order` ADD UNIQUE KEY uk_request_id (request_id);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 支付回调的幂等
支付渠道的回调会重试,同一笔支付可能收到多次通知。处理方式是三层:
- 回调记录表:
(channel, channel_trade_no)建唯一索引,插入成功才继续处理; - 条件更新:订单状态流转本身是幂等的;
- 金额校验:回调金额与订单金额不符时直接告警,不要自动接受。
4.3 重复支付怎么办
用户可能真的付了两次(比如两个渠道各付一次)。这不是技术问题能完全避免的,处理流程是:
- 支付单与订单分开,一个订单可以有多个支付单;
- 第二笔支付到账后,订单已是
PAID,则把这笔支付标记为「多付」,走自动退款; - 资金相关的记录只增不改,用流水表记录每一笔变动,便于对账和追溯。
五、库存扣减
5.1 一条 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;最后用对账把残余问题变成可观测的数字。这些规则单独看都很简单,但少任何一条,系统在高并发下都会出现「说不清是怎么发生的」的数据。
配套实验
- codesphere-labs/distributed/lock-and-transaction:先查后改与条件更新的状态流转、三种主键的插入耗时与空间(验证记录)
参考资料