订单系统是电商、支付等业务的核心,也是数据库压力和复杂度最高的地方之一。一张订单的生命周期涉及状态流转、库存扣减、支付回调、超时取消等多个环节,任何一个环节处理不当,都可能引发超卖、重复扣款、状态不一致等严重问题。
本节从数据库设计的角度,拆解三个最关键的设计点:订单状态模型、库存扣减方案以及接口幂等性保障。
26.2.1 订单状态流转设计
订单状态并不是简单的几个枚举值,它既关系到业务流程的正确性,也直接决定了系统的可扩展性。好的设计应该是状态有限、流转清晰、变更可追溯。
一个典型的订单状态模型可以这样定义:
| 状态值 | 状态含义 | 来源/触发 |
|--------|---------|-----------|
| 0 | 待支付 | 用户提交订单 |
| 1 | 已支付 | 支付回调成功 |
| 2 | 已发货 | 仓库发货回传 |
| 3 | 已完成 | 用户确认收货或系统自动确认 |
| 4 | 已取消 | 超时未支付自动取消 / 用户手动取消 |
| 5 | 退款中 | 用户申请退款 |
| 6 | 已退款 | 退款处理完成 |
在设计表结构时,有几条实用原则:
用数字状态码而非字符串。 字符串虽然可读性好,但占用空间大、比较慢,而且容易因为大小写、空格等引发潜在 bug。TINYINT 已完全够用,配合注释说明含义即可。
不在状态列上直接建单一索引。 状态列的基数很低(通常只有几种到十几种),单独建索引的区分度很差,MySQL 优化器可能直接放弃使用,扫全表反而更慢。更好的做法是联合索引:(status, create_time) 或 (status, user_id),既过滤了状态又利用了高基数列加速定位。
加一个乐观锁字段,防止状态并发覆盖。 假设订单当前是“待支付”,支付回调和超时取消可能几乎同时到达。如果不加控制,可能状态先变为“已支付”,紧接着又被“已取消”覆盖掉,钱付了订单却取消了。
典型的防护手段是在更新时带上当前状态作为条件:
UPDATE orders
SET status = 1, pay_time = NOW()
WHERE order_id = 1001 AND status = 0;
这条 SQL 会返回受影响的行数。如果一个请求先把状态更新为 1,另一个取消请求再带着 status = 0 的条件来更新,受影响行数就是 0,应用程序据此判断业务逻辑冲突,返回“订单状态异常”即可。
记录状态变更日志。 对于核心订单表,建议额外建一张 order_status_log 表,记录每次状态变更的时间、操作人、原因等。这不仅方便问题排查,也为日后的数据分析留下清晰轨迹。
26.2.2 库存扣减方案与超卖防护
库存扣减是订单系统中最容易出并发问题的地方。错误的做法是:先查库存,判断充足,再执行扣减。在并发请求下,两个线程可能同时“查到库存为 5”,各自都以为可以扣 3,最终扣了 6,库存变负数。
要安全地扣减库存,必须把“判断”和“扣减”合并成一个原子操作,让数据库兜底。
方案一:数据库行锁 + 原子更新(推荐)
最可靠的实现是利用 InnoDB 的行锁和条件更新:
UPDATE inventory
SET stock = stock - ?
WHERE product_id = ? AND stock >= ?;
这里的关键是 stock >= ? 条件。这个 UPDATE 会锁定目标行,在行锁保护下,只有一个事务能拿到锁并进行扣减。当库存不足时,条件不满足,受影响行数为 0,应用程序可以直接返回“库存不足”。
这个方案简单、有效,几乎无副作用,对于大多数中小型电商已经足够。但它的瓶颈也很明显:所有扣减请求最终都会串行化在同一个 product_id 的行锁上,如果某个爆款商品瞬时涌入大量下单请求,数据库的锁等待可能会瞬间拉高,拖慢整体性能。
方案二:Redis 预减 + 异步落库(适用于超高并发)
如果预计会面临秒杀级别的并发,单纯的 MySQL 行锁扣减可能成为瓶颈。更激进的方案是用 Redis 做一层预减缓存:
- 活动开始前,将商品的库存同步到 Redis(
SET product:123:stock 1000)。 - 用户下单时,用 Redis 的
DECR或 Lua 脚本原子扣减,判断返回值是否 >= 0。 - 扣减成功后,把订单信息快速写入一个消息队列,由后台消费者异步地在 MySQL 中做最终扣减并创建订单。
- 若 Redis 扣减返回负数,直接拒绝请求。
这个方案可以扛住极高的瞬时并发,因为 Redis 单线程处理命令,整个扣减过程没有磁盘 I/O 和行锁竞争。但它引入了缓存和消息队列,数据最终一致性成了需要重点考虑的代价:
- 需要处理消息队列消费失败、重复消费的问题。
- 如果活动结束或系统故障,要进行 Redis 和 MySQL 的库存对齐,防止少卖或多卖。
- 消费者在最终扣减 MySQL 时,同样要用
UPDATE ... WHERE stock >= ?再校验一次,作为最后防线。
方案选型建议:日常业务用方案一,只有确认单商品有秒杀级别流量时,才把方案二引入进来,并且做好回滚预案。
补充:库存记录的字段设计
库存表通常长这样:
CREATE TABLE inventory (
product_id INT PRIMARY KEY,
stock INT NOT NULL DEFAULT 0,
sold INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0, -- 乐观锁版本号,可选
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
把 stock(当前库存)和 sold(已售数量)分开维护,可以避免实时计算历史销量带来的性能损耗,也方便对账。
26.2.3 幂等性设计:防止重复下单与重复扣款
在网络不稳定的真实世界里,用户点击一次“提交订单”按钮,客户端可能发出两次请求;支付回调可能因为下游超时而被上游重试三次。如果数据库层面不做幂等处理,轻则生成两条一模一样订单,重则一笔钱扣两次。
幂等性的本质是:同一个业务操作,即使执行多次,产生的效果与执行一次相同。
在订单系统中,主要靠唯一约束 + 业务流水号来实现。
方案:唯一索引 + 业务唯一键
最典型的做法是在订单表中增加一个业务生成的唯一键,并创建唯一索引:
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL, -- 展示给用户的订单号
request_id VARCHAR(64) NOT NULL, -- 业务幂等键(重要!)
user_id BIGINT NOT NULL,
product_id INT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_request_id (request_id)
) ENGINE=InnoDB;
request_id 的生成规则可以是:用户ID + 时间戳 + 随机数 或使用 snowflake 算法生成的全局唯一 ID。关键在于同一个业务操作,其 request_id 是固定不变的。
应用层处理逻辑如下:
- 用户点击“提交订单”,后端生成
request_id,放入订单数据中。 - 执行
INSERT INTO orders (...) VALUES (...)。 - 如果插入成功,返回“下单成功”。
- 如果因为
uk_request_id冲突而插入失败,说明这个请求已经被处理过。此时再查询一下该request_id对应的订单,检查其状态,如果状态正常,直接返回已有的订单信息(或“订单已存在”提示),完全覆盖了重复提交的情况。
这种设计的优势在于极度可靠,事务内的唯一约束是数据库的强保证,不会有漏网之鱼;而且实现简单,不需要额外引入分布式锁。
对于支付回调的幂等性,逻辑完全一致:
在支付流水表里建唯一索引(通常用支付网关返回的流水号作为幂等键)。收到回调时,先尝试插入一条状态为“处理中”的流水记录,插入成功则继续更新订单状态并修改流水状态为“已完成”;插入冲突说明已处理过,直接返回成功即可。
缓存辅助方案:防重 Token
对于下单场景,前端发起的第一个请求就把 request_id 固定下来了,一般只会有后端直接重试或用户重复点击。但对于支付表单这类场景,用户可能刷新页面重新提交,导致同样的 request_id 再次被发送。除了数据库唯一键兜底外,还可以加一层 Redis 防重令牌 来提前拦截:
- 在进入下单页面时,后端生成一个 token,存入 Redis(
SET token:xxx 1 EX 300),并返回给前端。 - 用户提交订单时,必须携带 token。后端用
DEL token:xxx或 Lua 脚本先删除 Redis 中的 token,删除成功才执行后续逻辑。 - 如果 token 已被删,说明该请求已处理,直接返回。
这种方式能在业务入口处就筛掉大部分重复请求,减轻数据库压力。
小结
订单系统的数据库设计核心在于三个保证:
- 状态一致性:通过带条件的 UPDATE 和状态日志让流转可控。
- 库存不超卖:用数据库行锁 + 条件更新作为保底方案,极端并发场景可用 Redis 预减缓存,但要做好最终一致性兜底。
- 幂等不重复:用业务唯一键 + 唯一索引让数据库成为最强守门员。
掌握这三个原则,你就能够设计出一个稳得住、扛得住、查得清的订单系统基础数据层。