SQL UNIQUE约束详解:原理、数据库差异与防重复设计实践
1. UNIQUE约束到底是什么为什么你需要它做数据库设计这些年我见过太多因为“忘记加唯一约束”而崩溃的业务系统。最典型的场景用户注册接口被脚本刷了一遍username重复了订单表里同一笔订单被回调程序insert了两次系统里凭空多出一笔重复交易商品编码被业务人员手工录入时敲错了两个字符后来对账的时候怎么都对不上。这些问题的根源其实都不是代码逻辑写得有多烂而是数据表在最底层缺少一道闸门。SQL里这道闸门就叫UNIQUE约束。一句话解释UNIQUE约束它保证一张表里某一列或几列的组合的值不会重复。你只要在创建表的时候把约束加上数据库引擎就会替你盯着每一次INSERT和UPDATE一旦有人想写入重复值直接拒绝并抛错。这是数据库自身提供的完整性保障不依赖任何应用代码。有人可能会说“我在业务代码里先查一遍再插入不就行了”理论上可以但实际中总有缝隙并发请求同时查到“没重复”然后同时插入就撞车了代码升级、接口重复回调、数据迁移工具没写判断逻辑……任何一个环节漏掉脏数据就进来了。UNIQUE约束的价值在于它把校验下沉到了数据层让重复数据从根本上没有机会落库。这篇内容适合谁看正在做表结构设计的数据工程师、后端开发的同学、写SQL脚本做数据清洗和分析的人以及所有被业务数据重复问题坑过、想从源头解决问题的从业者。我会从原理讲到实操从语法讲到坑点把UNIQUE约束的方方面面都拆开说清楚。2. UNIQUE约束的工作原理与设计逻辑2.1 它背后的机制唯一索引UNIQUE约束之所以能“强制不重复”本质原因是它会在数据库里创建一个唯一索引。你可以把唯一索引想象成一本按笔画排好序的字典每次你要往里面加一个新词数据库先在字典里查一遍如果发现已有相同的词条就直接报错。这个“查询”过程因为有索引的排序结构效率很高不会全表扫描。这里有个很关键的认知UNIQUE约束和唯一索引在很多数据库里其实是同一个东西。MySQL里你写UNIQUE KEY本质就是建索引PostgreSQL里创建UNIQUE约束系统会自动附带创建同名的唯一索引SQL Server同理。所以在讨论UNIQUE约束的时候顺带把唯一索引的性能特性一起考虑了这样才能真正理解它为什么既能保证完整性又能加速查询。2.2 它与PRIMARY KEY的区别这是面试和实际设计中最常被问到的问题。PRIMARY KEY主键本身就蕴含了唯一性但它和UNIQUE约束有本质差异对比维度PRIMARY KEYUNIQUE约束数量限制一张表只能有一个一张表可以有多个NULL值绝对不允许允许但通常每列只能有一个NULL不同数据库有差异下面细讲底层索引唯一索引聚集或非聚集视数据库而定唯一索引通常非聚集语义含义唯一标识一行记录保证某个字段组合不重复外键引用可以被外键引用也可以被外键引用部分数据库支持我的建议是主键留给业务上没有任何实际含义的代理键比如自增ID或者UUID而那些需要保证不重复的业务字段比如身份证号、手机号、订单号、SKU编码全部用UNIQUE约束去兜底。主键负责“表的基本盘”UNIQUE负责“业务规则的硬底线”。2.3 NULL值到底怎么算NULL是UNIQUE约束最容易让人迷糊的地方。在绝大多数数据库里NULL和NULL是被视为“不相同”的也就是说一列如果允许NULL你可以插入多行NULL值数据库不会认为它们重复。这符合SQL标准里“NULL表示未知未知不等于未知”的逻辑。但不同数据库对“多个NULL”的处理有细节差异后文会专门开一节梳理。这里先说结论如果你想让某个字段“要么不填要么唯一”UNIQUE约束天然满足这个需求如果你想让“不填”也不行那就把它设为NOT NULL双约束一起加。2.4 组合约束多列联合判重UNIQUE约束最常见的高级用法是“组合唯一约束”。它的逻辑是不单独检查某一列是否重复而是检查“这几列的组合值”是否重复。举个实际例子一张订单明细表里有order_id和product_id同一笔订单可以包含多个商品同一个商品也可以出现在不同订单里但一笔订单内不允许出现同一个商品两次。这种情况下光给order_id加UNIQUE不对因为同一订单有多行光给product_id加UNIQUE更不对因为同一个商品可以出现在不同订单里。正确的设计是加联合唯一约束order_id, product_id数据库会检查每一行的这两个字段的组合是否在整张表中唯一。组合约束特别适合做“防重”场景比如用户关注关系表user_id, target_id、好友关系表、订票系统的航班号, 座位号、库存台账里仓库ID, 物料编码这类“复合业务维度”的判重。可以说凡是业务上存在“多因素联合决定唯一性”的地方都用得上组合UNIQUE。3. 不同数据库的实现差异与踩坑点3.1 MySQL中的UNIQUEMySQL里创建UNIQUE最常见的方式是在CREATE TABLE语句里直接定义CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL, username VARCHAR(100) NOT NULL, UNIQUE KEY uk_email (email), UNIQUE KEY uk_username (username) );表已经存在的话用ALTER TABLE追加ALTER TABLE users ADD UNIQUE KEY uk_email (email);MySQL的默认存储引擎InnoDB下UNIQUE约束会建立唯一二级索引。还有个细节MySQL中UNIQUE KEY是可以给索引命名的上面的uk_email就是我起的索引名。不加名字的话系统会自动生成一个后续删除约束时你得先去查这个名字。MySQL对NULL的处理允许多个NULL值也就是说email列可以有多行是NULL不受唯一性约束限制。这一点要特别注意有些同学以为加了唯一约束后NULL也不允许重复这是错的。还有InnoDB的一个冷知识当一张表没有显式定义主键时系统会优先选择一个UNIQUE非空约束的列作为“隐式主键”。这个行为在InnoDB里很微妙可能导致你原本以为的二级索引变成了聚集索引。实际工作中还是建议显式定义主键别让数据库帮你做选择。3.2 PostgreSQL中的UNIQUEPostgreSQL的语法也很直观并且它把约束和索引分离得更干净CREATE TABLE products ( sku VARCHAR(50) UNIQUE, name VARCHAR(200) NOT NULL, category_id INT );你同样可以用约束名的方式显式声明CREATE TABLE products ( sku VARCHAR(50), name VARCHAR(200) NOT NULL, CONSTRAINT uq_sku UNIQUE (sku) );PostgreSQL对NULL的处理和MySQL一样允许多个NULL。不过PostgreSQL给了你更多的控制权你可以通过部分索引来创建“只针对非NULL值的唯一约束”这个概念很强大后面会单独讲。PostgreSQL里删除约束的语法是ALTER TABLE ... DROP CONSTRAINT因为约束名和索引名在某些场景下是混用的记得先通过\ d 表名或者查询information_schema确认约束名。3.3 SQL Server与SQLite的差异SQL Server处理UNIQUE约束时会自动建立一个唯一索引默认是非聚集索引。它和MySQL的SQL语法几乎一致但有一个历史遗留坑SQL Server在唯一索引的NULL处理上有个特殊情况早期版本里每列只能存一个NULL最后一个版本虽然已做了改进但如果你在SQL Server上写代码还是要小心。稳妥的做法是如果有“必须唯一且不允许为空”的字段直接把NOT NULL和UNIQUE一起用省得给自己找麻烦。SQLite的UNIQUE约束语法最简单直接在列类型后面加UNIQUE就行CREATE TABLE users ( id INTEGER PRIMARY KEY, email TEXT UNIQUE );SQLite也支持表级约束和组合约束。它在NULL处理上同样允许多个NULL但SQLite在ON CONFLICT冲突处理上提供了非常灵活的方案后面讲数据冲突时一并说。3.4 用一张表快速对比各数据库差异数据库创建语法NULL重复策略额外能力MySQLUNIQUE KEY 索引名(列)允许多个NULL与ON DUPLICATE KEY配合好PostgreSQLUNIQUE / CONSTRAINT ... UNIQUE允许多个NULL支持部分唯一索引、表达式唯一索引SQL ServerCONSTRAINT ... UNIQUE一般允许多个NULL早期版本受限可指定索引属性SQLite列级UNIQUE或表级UNIQUE允许多个NULL内置ON CONFLICT处理了解这些差异的意义不在于炫技而在于你负责的系统一旦从一种数据库迁移到另一种或者一个团队同时维护两种数据库不会因为“同样的SQL语句却跑出不同行为”而莫名其妙踩坑。4. 创建与修改UNIQUE约束的实操指南4.1 建表时定义唯一约束的三种写法第一种列级约束适合单列判重CREATE TABLE customer ( id INT PRIMARY KEY, id_card VARCHAR(18) UNIQUE );第二种表级约束适合组合判重也方便给约束命名CREATE TABLE order_items ( order_id INT NOT NULL, product_id INT NOT NULL, quantity INT, CONSTRAINT uk_order_product UNIQUE (order_id, product_id) );第三种既加约束又加非空双保险CREATE TABLE accounts ( id INT PRIMARY KEY, phone VARCHAR(20) NOT NULL UNIQUE );这里有一个容易被忽略的设计细节列级UNIQUE写的列通常是允许NULL的如果业务上这个字段必须填写记得同时加上NOT NULL。很多新手看到“UNIQUE”就以为已经限制了非空实际完全不是一回事。4.2 表已经存在怎么补加约束生产环境往往不是从零建表而是表已经跑了一年半载数据都已经积累很多了这时候想加约束SQL很简单ALTER TABLE customer ADD CONSTRAINT uk_id_card UNIQUE (id_card);但真正的难点不在语法而在前置条件。加约束之前表里已有的数据必须满足唯一性否则这条ALTER语句直接报错。我的标准操作流程是这样的先查有没有重复数据SELECT id_card, COUNT() FROM customer GROUP BY id_card HAVING COUNT() 1;如果查出重复先把重复数据处理好需要保留哪条、删除哪条、合并还是标记都必须先确认业务逻辑确认清零后再执行ALTER TABLE执行完毕后用SHOW INDEXMySQL或查询information_schema确认约束已生效最后抽查几条数据验证重复插入是否会被拦截。这个过程里最容易犯的错是“查出来有重复但直接硬加约束导致生产库报错系统写入彻底失败”。记住加约束是一个DDL操作在部分数据库上执行期间会锁表必须安排在低峰期操作。4.3 删除与修改约束删除约束的语法取决于数据库MySQLALTER TABLE customer DROP INDEX uk_id_card;PostgreSQLALTER TABLE customer DROP CONSTRAINT uk_id_card;SQL ServerALTER TABLE customer DROP CONSTRAINT uk_id_card;修改约束的前提是删除后重建因为标准和主流实现里都没有“ALTER约束”这种操作。需要改组合约束里的某一列就先把整个约束删了再按新列组合重新创建。这个过程中业务会短暂失去唯一的保护所以建议在同一个事务里完成或者安排在业务低峰期快速执行。有一个实际中多次遇到的坑你明明记得自己给约束取了名但删除时提示找不到。原因可能是创建表的时候建的是列级UNIQUE约束数据库自动生成了一个随机名字。解决办法是查询元数据表里的约束名例如MySQL里查SHOW INDEX FROM 表名左边第一列就是索引名PostgreSQL里查SELECT conname FROM pg_constraint WHERE conrelid 表名::regclass;4.4 导入数据时的顺序问题一旦加了UNIQUE约束数据导入的顺序就变得敏感了。最典型的坑是用mysqldump或者其它工具做数据迁移目标表已经建好UNIQUE约束源数据又有历史重复导入必然中断。我的实操经验是先导入数据后加约束。批量导入阶段不建约束导入完成后先做重复检测与清洗再统一加上UNIQUE约束。这样既避免导入过程频繁中断又能保证最终数据的干净。对于超大表后加约束还会触发一次全表索引构建耗时较久需要提前评估窗口期。5. 深入边界场景NULL、部分约束与表达式约束5.1 对NULL的完整理解什么时候会有问题先明确一件事大多数数据库允许多个NULL在带UNIQUE约束的列里共存。但有些业务场景下你希望“一条记录只能有一个空值”比如某个表的token列正常情况下每个用户只能有一个“未绑定手机号”的记录数据库却允许插入了三行token都是NULL。虽然符合标准行为却不符合业务预期。解决方案有两个方向。第一个方向是应用层做前置检查但前面已经说过有并发漏洞。第二个方向是在数据库层做更精细的设计PostgreSQL里可以用部分唯一索引来把NULL排除在检查范围之外或者反过来说把NULL对应的业务状态用单独的非空占位值表示。举个例子你在一个任务表里想保证“每个用户最多只能有一个待审批的任务”但已审批和已驳回的任务可以有多个。这个约束用普通UNIQUE做不到因为它会连审批结束的历史记录也限制住。很多人在这种场景下写复杂的应用逻辑实际上PostgreSQL用一个“部分唯一索引”就搞定了CREATE UNIQUE INDEX one_pending_task_per_user ON tasks (user_id) WHERE status pending;这个索引只对status为pending的行生效数据库中其他状态的行完全不受影响。这其实是UNIQUE约束的进阶形态也是我强烈推荐PostgreSQL支持者熟练掌握的利器。部分唯一索引本质上不再是传统的约束语法但解决的却是约束解决不了的问题。5.2 表达式唯一约束对加工后的值判重还有一种进阶场景业务上要求“邮箱的字母大小写不敏感地唯一”比如某系统已经录入testexample.com就不允许再录入TestExample.com。单纯对原字段加UNIQUE不能拦截大小写变体因为数据库排序规则不同行为也不同。最稳妥的方案是对表达式或生成的列加唯一约束。PostgreSQL直接支持表达式唯一的索引CREATE UNIQUE INDEX uq_email_lower ON users (LOWER(email));MySQL这边可以通过生成列来实现ALTER TABLE users ADD COLUMN email_normalized VARCHAR(255) GENERATED ALWAYS AS (LOWER(email)) STORED, ADD UNIQUE KEY uk_email_norm (email_normalized);这类做法在数据进入表的那一刻就把所有“等价变体”规整成同一个值再判重。它比应用层先做转换再写入更加可靠因为不管从哪个接口、哪个程序写入数据都逃不过数据库这层检查。5.3 组合约束与NULL的摩擦组合UNIQUE约束遇到某个列为NULL的情况需要格外留意。前面说了NULL和NULL不相同所以(order_id, product_id)的组合约束里如果order_id有值、product_id为NULL查询时可能允许存在多行。这不一定是bug但如果业务上认为“NULL等价于默认值”问题就来了。一个可操作性很强的对策凡是参与组合唯一约束的列最好都声明为NOT NULL。如果业务确实存在“某些维度未填”的情况用一个默认值占位比如0或空字符串不要用NULL。这样做的代价是语义上稍有折扣但换来的是判重逻辑简单、可预期。5.4 UPSERT操作与UNIQUE约束的配合加了UNIQUE约束后最常遇到的问题就是插入时撞重复值。这时候有两个方向一是直接让插入失败然后由应用层处理二是利用数据库的“插入或更新”能力让重复值触发更新逻辑。MySQLINSERT ... ON DUPLICATE KEY UPDATE它会根据唯一索引冲突自动走UPDATE分支。PostgreSQLINSERT ... ON CONFLICT (email) DO UPDATE SET ...SQLiteINSERT OR REPLACE或者更精细的ON CONFLICT子句。我用一个实际场景说明用户表以email做UNIQUE约束注册接口收到一个已存在邮箱的请求业务上希望“邮箱相同就更新昵称和登录时间”用PostgreSQL写INSERT INTO users (email, nickname, updated_at) VALUES (aexample.com, 新昵称, NOW()) ON CONFLICT (email) DO UPDATE SET nickname EXCLUDED.nickname, updated_at EXCLUDED.updated_at;这套写法极大地简化了应用代码里“先查后改”的重复逻辑也是UNIQUE约束带来的红利。不过要记住ON CONFLICT只能针对唯一的冲突键如果你要判断的字段没有UNIQUE约束或唯一索引是不会触发冲突的。6. 性能影响与索引利用6.1 写入性能的小幅代价UNIQUE约束带来的性能代价主要在写入侧。每次INSERT或UPDATE数据库都需要对唯一索引做一次查找来确认新值是否与现有行冲突。这个查找走的是索引复杂度一般是O(log n)比全表扫描快得多。但对于极高写入TPS的系统每次多出来的索引查找和可能的锁竞争仍然有一些影响。实践中数据库的最佳策略是为UNIQUE约束选择合适的列顺序尤其是在组合唯一索引里。把“区分度高、查询最频繁”的列放在前面可以让索引更高效。比如(order_id, product_id)和(product_id, order_id)虽然逻辑上判重结果一致但索引扫描效率和覆盖查询的能力不同。如果你的业务经常单独按product_id过滤那把product_id放前面做联合唯一索引会更合适。6.2 读取性能的加分项UNIQUE约束的另一面是查询加速。因为约束自带唯一索引凡是用到该字段做等值查询的SQL都能直接命中索引省去回表的开销。特别是对登录场景按username或email查询用户如果这两个字段恰好有UNIQUE约束一次索引定位就能拿到整行数据速度非常可观。所以设计表的时候把“经常用于查询且必须唯一”的字段做成UNIQUE约束是一箭双雕的操作既保证数据完整性又顺手建好了常用查询的索引。这比单独建一个普通索引省了存储空间也减少了维护开销。6.3 超大表添加UNIQUE的时间窗口如果你在千万行以上的表上加UNIQUE约束本质上是在线建索引。这个操作耗时可能从几十秒到几十分钟不等取决于表大小、服务器性能和当前负载。大多数情况下在线DDL并不会长时间阻塞读写但过程中会有明显的系统资源占用。我的建议先评估表行数和预估耗时再选择低峰期执行。执行前检查磁盘可用空间和缓冲池状态避免建索引一半把磁盘占满。执行后检查索引是否被查询计划器正确使用必要时执行ANALYZE更新统计信息。7. 常见问题与排查技巧实录7.1 加约束时报“Duplicate entry”错误这是最经典的问题ALTER TABLE ADD UNIQUE时数据库发现已有重复数据直接拒绝。处理步骤前面已经说过这里强调一个细节重复判断的粒度是什么如果是组合约束要把参与联合的列全部放进GROUP BY。还有一个隐藏坑字符集的排序规则可能导致看似不同的值被认为相同例如MySQL里varchar列使用大小写不敏感的排序规则时abc和ABC会被视为重复很多人查了半天的确没看到完全相同的值但就是因为大小写问题被拦截了。7.2 生产中插入数据突然报错一种容易混淆的情况是多个唯一索引同时存在插入行同时违反了两条约束数据库报错信息却只提示其中一个。排查时别只盯着报错里提到的那个字段应该把整行的每个有唯一约束的字段都检查一遍。另一种情况是由外键关联触发的。比如父表有一个字段带UNIQUE约束子表通过外键引用它删除父表某行时数据库会因为外键关联而拒绝删除或做级联问题看起来出在DELETE语句上但根子还是唯一约束牵动的引用链路。排查时看清约束方向的依赖关系很重要。7.3 NULL导致的“假重复”问题我把这个单独拎出来是因为太容易被误解了。你的SQL查出来的确有两行它们的业务值“看起来都算是同一件事”但严格的数据库逻辑里并没有违反UNIQUE约束因为其中一个是NULL。这不是数据库Bug是你对NULL语义的理解偏差。处理方式参照5.1节明确业务要求要么征用非NULL占位值要么调整数据的表示模型。7.4 在线系统加约束导致锁等待部分老版数据库在做ALTER TABLE ADD UNIQUE时可能会阻塞DML导致业务写操作长时间等待。如果你所在环境对在线DDL支持不够好公司又不允许停服就有必要先用影子表或在线迁移工具处理。我的常规做法是把原表结构和数据克隆到新表在新表上建好UNIQUE约束再通过改名或增量同步切换流量。这套流程准备充分的话可以做到对业务几乎无感。7.5 兼容性问题从MySQL迁移到PostgreSQL时的写法适配如果你做过异构数据库迁移会发现同一份建表脚本在不同数据库里跑不过去。MySQL的UNIQUE KEY写法在PostgreSQL里无效SQL Server的CONSTRAINT命名方式也略有差异。为了减少迁移成本统一用ANSI标准风格的“UNIQUE(列1, 列2)”表级约束写法在主流的数据库里都能识别。约束命名尽量用CONSTRAINT显式给名字这样可以跨数据库地通过元数据查询和维护。8. 设计实践中的独门经验8.1 什么时候不应使用UNIQUE约束数据库里的规则不是越多越好。有些表本质上是日志表、流水表比如用户行为日志、埋点数据、操作审计这些数据天然允许重复强行加约束反而会让业务无法写入。判定是否加约束的唯一标准是业务语义上这一行所代表的实体或事件是否具备“同一维度下只能存在一次”的自然要求。还有一些情况数据源本身来自外部系统你无法保证外部数据满足唯一性但业务又允许“最后写入的覆盖先写入的”那就不适合用UNIQUE约束硬拦。更合适的方案是把数据落到临时层做清洗或者用UPSERT语义去覆盖旧记录。8.2 唯一性与软删除的冲突这是真实项目里特别常见的矛盾。很多系统做删除操作并不真正物理删除而是标记deleted_at时间戳或is_deleted标志位。问题来了表里某字段有UNIQUE约束比如username。用户A注册了zhang后来A注销了账号但对记录做了软删除。此时新用户B想注册zhang理论上用户名已经没人在用了但因为A的记录还在表里只是被标记删除了UNIQUE约束会拦截B的注册。针对这个问题常见的方案有以下几种。第一种是物理删除彻底挪走旧记录最简单但不利于审计。第二种是让软删除字段参与唯一约束例如唯一索引从username改成(username, deleted_at)被删除的记录deleted_at有具体时间新注册的NULL或默认值借助不同数据库的NULL行为就能实现“同一用户名只能有一个活跃记录”。第三种在不支持表达式索引的数据库上可以用额外状态字段强制填充非空默认值来区分。我在设计维护过用户体系的表时最推荐的是“唯一索引 软删除时间戳参与联合判重”的方案前提是你的软删除状态标识不是简单的0/1标志位而是精确到时间戳或单调递增的序号。如果你的软删除只有is_deleted一个布尔值那情况会比较棘手需要配合应用层做大量额外处理。8.3 主键用自增还是业务唯一值这里想从UNIQUE的角度反推主键设计。如果你的业务字段天然唯一比如身份证号、手机号有些人会直接拿它当主键。直觉上没毛病但我一般不建议。因为身份证号或手机号一旦成为主键业务语义变更的成本会非常高而且字符型主键往往比整型主键占用更大索引空间。更好的方案是自增或UUID做主键业务唯一字段单独加UNIQUE约束。这样主键稳定、索引轻量业务字段的约束也随时可以调整而不动主键结构。8.4 用UNIQUE约束反向发现数据治理问题这个角度很少人提但非常实用当你尝试给一个老表加UNIQUE约束时重复数据被查出来不要只把它们机械地去掉而应该把重复数据当成数据质量问题的线索。比如发现同一个用户ID关联了多个邮箱说明业务早期的注册入口没有统一发现同一订单出现在多行且金额不同说明回调逻辑存在重复处理场景。从约束出发反查一遍业务流程往往能揪出隐藏的系统缺陷。8.5 约束命名规范最后说一个容易被忽略的好习惯给UNIQUE约束取名字。不命名的话数据库自动生成的名字往往是一串随机字符可读性极差。一旦后续要查元数据、定位错误、做迁移你会被这些随机名字折腾够呛。我个人的命名规范是uniq_表名_字段名比如uniq_users_email组合约束写成uniq_order_items_order_product。清晰的命名让你一年后看到报错信息就能立刻定位到是哪张表的哪个字段出了问题。9. 一个完整的实战设计案例为了把上面的知识点串起来我模拟一个刚接手某电商系统的订单模块来演示完整的UNIQUE约束设计过程。系统涉及三张核心表用户表、订单表、订单明细表。用户表里手机号和邮箱要求唯一且必须非空CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL, email VARCHAR(255) NOT NULL, nickname VARCHAR(100), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uniq_users_phone UNIQUE (phone), CONSTRAINT uniq_users_email UNIQUE (email) );订单表里订单号是业务单号要求全局唯一同时每个用户能给同一个商家下的未支付订单不能重复用(user_id, merchant_id, status)联合唯一约束来控制其中status用数字0表示未支付CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, merchant_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT uniq_orders_order_no UNIQUE (order_no), CONSTRAINT uniq_orders_pending UNIQUE (user_id, merchant_id, status) );订单明细表里同一订单下同一商品只能出现一次CREATE TABLE order_items ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL, CONSTRAINT uniq_order_items_order_product UNIQUE (order_id, product_id) );这套设计里主键全部是无业务含义的自增ID而所有业务上“不允许重复”的规则全部落到UNIQUE约束上。后续任何程序想破坏这些规则数据库都会报错拦截。在实际接管旧订单系统时我们第一次尝试给旧表加这些约束就抓出了上百组重复的订单号数据反过来证明了此前这些规则并没有被执行到位。通过先清洗、后加约束这个系统的数据质量才真正稳定下来。撰写这篇内容时我最深刻的体会是UNIQUE约束看起来只是SQL里一个简单的关键词但它牵涉到数据建模的底层哲学——约束即规则规则应该在离数据最近的地方执行。把一个表设计得健不健壮很大程度上就看你会不会正确地使用这套约束体系。希望这篇文章能帮你在下一次建表时多问自己一句这个业务字段真的不该重复吗该不该让数据库替我把这道关这是迈出高质量数据建模的第一步。