很多人做订单表时,第一反应是:先来一个 UNIQUE(order_no)。
这不算错,但通常不够。订单系统里真正难的不是“建一个唯一索引”,而是把“哪一种重复绝对不能发生”定义清楚。
先把概念讲明白:唯一约束不是装饰品
术语:唯一约束(Unique Constraint)
数据库层面的规则:同一组列的值不能重复,重复写入会被数据库拒绝。
生活类比:电影院座位号
一场电影里,7排8座只能卖一次;售票员再忙,也不能卖出第二张相同座位票。数据库里的唯一约束就是这个“硬闸门”。
小案例:电商下单重试
用户点了“提交订单”后网络卡顿,客户端重试了 3 次。应用层可能来不及拦截全部重复请求,但数据库唯一约束会把重复订单挡在库外。
一句话记忆:应用代码负责“尽量不重复”,唯一约束负责“绝对不重复”。
订单表里,常见的 4 类“唯一性”
- 平台订单号唯一:
order_no全局唯一。 - 请求幂等唯一:同一用户同一次请求只能创建一单,常见键是
user_id + client_request_id。 - 外部流水唯一:三方回调避免重复入账,常见键是
channel + external_trade_no。 - 业务规则唯一:例如“同一用户同一商品同一活动只能下一单”,键是业务维度组合。
如果只建 order_no,你只是解决了“库里主键好看”,但没解决“业务不重单”。
术语:联合唯一键(Composite Unique Key)
多列一起组成“唯一身份证”,只有整组列都相同才算重复。
生活类比:快递取件码 + 手机尾号
只看取件码可能撞号,只看手机尾号也不稳;两者合在一起,基本就能唯一定位到你这票快递。
小案例:支付回调去重
某支付渠道在超时后会重复推送回调。系统用 channel + external_trade_no 做联合唯一键,第二次回调直接被数据库拦住,账务不再重复记账。
建约束前的决策流程(流程图)
业务动作 -> 会不会出现重试/回调重复?
-> 会:先定义幂等键(谁+哪次请求)
-> 不会:看是否有外部系统流水
-> 有:加通道+外部流水唯一
-> 无:至少保留订单号唯一
最后一步:把“不能重复”的规则下沉成数据库唯一约束
这张流程图的意思很直接:先找重复来源,再选唯一键,不要反过来拍脑袋建索引。
三种落地方案怎么选(对比表)
| 方案 | 唯一列设计 | 适用场景 | 优点 | 风险点 |
|---|---|---|---|---|
| 只做订单号唯一 | UNIQUE(order_no) | 小系统、单入口创建订单 | 简单、成本低 | 无法防止同请求重复下单 |
| 订单号 + 幂等键 | UNIQUE(order_no) + UNIQUE(user_id, client_request_id) | App/H5 有重试、弱网高频 | 防重复能力强 | 需要客户端稳定传 client_request_id |
| 再加外部流水唯一 | 上述两项 + UNIQUE(channel, external_trade_no) | 支付回调、三方对账 | 可防重复回调/重复入账 | 需要明确空值和通道边界 |
如果你现在在做交易系统,默认从第二种起步;有支付回调就直接上第三种。
你到底该先建哪一个键(决策矩阵)
| 当前现象 | 推荐先加的唯一约束 | 解释 |
|---|---|---|
| 用户经常重复点击“提交订单” | UNIQUE(user_id, client_request_id) | 先挡住入口重试造成的重单 |
| 支付平台回调偶发重复 | UNIQUE(channel, external_trade_no) | 先挡住外部重复通知造成的重复入账 |
| 系统刚起步、链路简单 | UNIQUE(order_no) | 先保底,后续再补业务维度唯一 |
你可以把线上错误日志按这三类归档,命中哪类多,就优先上哪条唯一约束。
可复现演练:MySQL 订单表怎么建
下面这版 DDL 可以直接跑,覆盖最常见的三个唯一需求。
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
client_request_id VARCHAR(64) NOT NULL,
channel VARCHAR(20) NOT NULL,
external_trade_no VARCHAR(64) DEFAULT NULL,
amount_cents BIGINT NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_order_no (order_no),
UNIQUE KEY uk_user_req (user_id, client_request_id),
UNIQUE KEY uk_channel_ext (channel, external_trade_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
再跑一组写入,感受数据库“硬闸门”:
-- 第一次下单:成功
INSERT INTO orders(order_no, user_id, client_request_id, channel, external_trade_no, amount_cents, status)
VALUES ('O202603050001', 1001, 'REQ-abc-001', 'ALIPAY', 'ALI-TXN-7788', 9900, 1);
-- 同一请求重试:命中 uk_user_req,失败(防重复下单)
INSERT INTO orders(order_no, user_id, client_request_id, channel, external_trade_no, amount_cents, status)
VALUES ('O202603050002', 1001, 'REQ-abc-001', 'ALIPAY', 'ALI-TXN-7789', 9900, 1);
-- 同一三方流水重复回调:命中 uk_channel_ext,失败(防重复入账)
INSERT INTO orders(order_no, user_id, client_request_id, channel, external_trade_no, amount_cents, status)
VALUES ('O202603050003', 1002, 'REQ-xyz-001', 'ALIPAY', 'ALI-TXN-7788', 9900, 1);
你下一步要做的是:把你线上真实“重单来源”映射到这三类唯一键,再决定删减或补充。
上线时最容易踩的 3 个坑
- 先加约束、后清脏数据:历史重复数据会导致
ALTER TABLE直接失败。先做去重脚本再加约束。 - 把可空列直接当唯一键核心:可空列的唯一行为和数据库方言有关,设计前要先定空值策略。
- 只靠应用层
if not exists:并发下会穿透,必须让数据库做最终裁判。
最后给你一份落地清单
梳理:列出订单创建、回调入账、补偿任务的所有重复入口。选择:给每个入口定义唯一键(订单号、幂等键、外部流水)。清理:先跑重复数据检测与治理,再执行加约束 DDL。验证:用并发压测和重试回放验证“重复请求只成功一次”。监控:监控唯一键冲突次数,反向发现上游异常重试。
把这 5 步走完,你的订单唯一约束就不是“写在表结构里”,而是变成了可验证、可运维的防重体系。