订单表的唯一约束,到底该怎么建?

5 阅读6分钟

很多人做订单表时,第一反应是:先来一个 UNIQUE(order_no)
这不算错,但通常不够。订单系统里真正难的不是“建一个唯一索引”,而是把“哪一种重复绝对不能发生”定义清楚。

先把概念讲明白:唯一约束不是装饰品

术语:唯一约束(Unique Constraint)
数据库层面的规则:同一组列的值不能重复,重复写入会被数据库拒绝。

生活类比:电影院座位号
一场电影里,7排8座只能卖一次;售票员再忙,也不能卖出第二张相同座位票。数据库里的唯一约束就是这个“硬闸门”。

小案例:电商下单重试
用户点了“提交订单”后网络卡顿,客户端重试了 3 次。应用层可能来不及拦截全部重复请求,但数据库唯一约束会把重复订单挡在库外。

一句话记忆:应用代码负责“尽量不重复”,唯一约束负责“绝对不重复”。

订单表里,常见的 4 类“唯一性”

  1. 平台订单号唯一order_no 全局唯一。
  2. 请求幂等唯一:同一用户同一次请求只能创建一单,常见键是 user_id + client_request_id
  3. 外部流水唯一:三方回调避免重复入账,常见键是 channel + external_trade_no
  4. 业务规则唯一:例如“同一用户同一商品同一活动只能下一单”,键是业务维度组合。

如果只建 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 个坑

  1. 先加约束、后清脏数据:历史重复数据会导致 ALTER TABLE 直接失败。先做去重脚本再加约束。
  2. 把可空列直接当唯一键核心:可空列的唯一行为和数据库方言有关,设计前要先定空值策略。
  3. 只靠应用层 if not exists:并发下会穿透,必须让数据库做最终裁判。

最后给你一份落地清单

  • 梳理:列出订单创建、回调入账、补偿任务的所有重复入口。
  • 选择:给每个入口定义唯一键(订单号、幂等键、外部流水)。
  • 清理:先跑重复数据检测与治理,再执行加约束 DDL。
  • 验证:用并发压测和重试回放验证“重复请求只成功一次”。
  • 监控:监控唯一键冲突次数,反向发现上游异常重试。

把这 5 步走完,你的订单唯一约束就不是“写在表结构里”,而是变成了可验证、可运维的防重体系。