资讯动态

UNIQUE约束从原理到实践:唯一索引、NULL行为与并发防重

发布时间:2026/10/14 4:39:20 来源:尧图企业网站定制
最近帮同事排查过一个线上数据对不上账的问题抽丝剥茧后发现根源竟然跟 SQL 里的 UNIQUE 约束有关。那张表在业务设计阶段明明要求“同一个用户、同一个渠道、同一天只能有一条订单”当时图省事只在应用层做了判重没在数据库层加约束。结果接口被并发请求一压同一条请求被处理了两次表里凭空多了两条一样的记录下游统计全部偏掉。后来给表补上 UNIQUE 约束再重新压测同样的并发场景再也没出现重复数据。很多刚入行的同学一提 UNIQUE 约束第一反应是“这不就是字段不能重复嘛SQL 语法背下来就完事了”。但真正到了生产环境你会发现它牵扯出主键语义、NULL 值规则、复合唯一、不同数据库之间的行为差异甚至还有大表补约束的运维风险。这篇文章我就把 UNIQUE 约束从头到尾拆开讲一遍包括底层机制、定义方式、NULL 行为、与主键的区别、复合唯一的实战用法以及跨数据库的差异对照最后聊聊生产环境中补约束时容易被忽略的实操问题。如果你是做后端开发、数据建模或者日常要维护数据库这篇应该能帮你少踩几个坑。1. UNIQUE约束的底层机制——它本质上是一个索引1.1 每个约束背后都藏着一棵B树我最早接触 UNIQUE 约束时把它当成一个简单的建表语法选项只要列后面加个 UNIQUE 关键字就能保证字段不重复。后来被问到“数据库是怎么做到不重复的”才意识到自己对这个约束的理解一直停留在表面。UNIQUE 约束在绝大多数数据库里的实现形式其实就是创建了一个唯一索引。约束是逻辑层面的规则索引是物理层面的载体。以 MySQL 为例执行下面这段建表语句CREATE TABLE 用户订单 ( 订单号 VARCHAR(32) NOT NULL, 用户ID INT NOT NULL, 渠道 VARCHAR(20) NOT NULL, 下单时间 DATETIME, CONSTRAINT uk_用户订单_订单号 UNIQUE (订单号) );建表之后去查看索引列表你会发现系统自动为订单号创建了一个名为uk_用户订单_订单号的唯一索引。也就是说你建了一个 UNIQUE 约束相当于同时得到了一个完整性规则和一个加速查询的索引。PostgreSQL、SQL Server、Oracle 虽然语法细节略有差别但底层思路完全一致——唯一约束必须依赖唯一索引来执行。理解这一点对于表结构设计非常重要。有些字段既要求业务上不允许重复又经常出现在查询条件里那一个 UNIQUE 约束就能同时满足唯一性保障和查询性能两个需求。反过来索引也不是免费的。每条 INSERT、UPDATE 语句执行时数据库都要额外维护这棵索引树写入性能会因此下降。我在实际项目里见过一张日写入量很大的流水表上面挂了五六个唯一索引批量导入数据时明显感觉到速度比没有索引的表慢不少。约束数量真的不能无脑堆叠对写入性能敏感的表尤其要克制。1.2 唯一性检查在 INSERT 和 UPDATE 时的完整链路要真正理解 UNIQUE 约束得站在一条 INSERT 语句的视角看它经过了什么。数据库执行插入时不是简单地把一行数据写进表里大致要经历这些步骤解析 SQL 语句做权限校验。检查目标表上所有的约束定义。对唯一约束通过对应的唯一索引做一次键值查找。如果发现索引中已有相同键值直接抛出一连串约束冲突错误。如果没冲突写入数据行同时更新索引节点。判断“有没有重复”的关键是在唯一索引的 B 树中查找目标值。因为 B 树查找的时间复杂度很低这个过程的性能通常不是瓶颈。但当表数据量上了亿级唯一性检查的耗时就会明显体现出来尤其是在索引字段设计不合理的时候。还有一点很容易被忽略唯一性检查在 UPDATE 时同样会执行。不少人只关注 INSERT 会不会撞约束但实际业务里更常见的冲突是 UPDATE 把一个字段改成了与其他行相同的值。例如UPDATE 用户订单 SET 订单号 ORD-2025-001 WHERE 订单号 ORD-2024-999;如果ORD-2025-001已经存在于表中这条 UPDATE 一样会触发唯一约束冲突。所以写数据订正脚本、数据迁移脚本时不能只盯着新增数据所有涉及唯一字段的 UPDATE 语句也得过一遍。1.3 为什么数据库约束比应用层判重更可靠懂了底层机制就能回答一个经常被人问的问题应用层代码明明可以做判重为什么还要在数据库里加 UNIQUE 约束核心原因在于高并发下存在时间差。应用层判重最常见的写法是先 SELECT 查一遍再根据结果决定是否 INSERT。这个“先查后插”的过程不是原子操作两个请求可能同时通过了 SELECT随后又一起执行 INSERT最终产生重复数据。数据库里的 UNIQUE 约束把判断和写入放在同一条事务链路上由存储引擎和锁机制保证整个过程是原子的不存在“两个请求都通过检查”的漏洞。我之前参与的模拟项目 X 里曾经就靠应用层加缓存过滤再加上数据库查重来防并发重复但压测里仍然出现了漏网之鱼。后来给表加了复合 UNIQUE 约束问题才从根本上解决。所以现在做表结构评审时只要字段具有“业务上不应重复”的语义我都会建议考虑用数据库约束兜底应用层的判重代码可以保留用来提前拦截、提升用户体验但不应作为最终防线。2. 定义UNIQUE约束的几种方式以及NULL行为的分水岭2.1 列级约束与表级约束的区别定义 UNIQUE 约束有两种基础写法一种写在列定义后面称为列级约束一种写在所有列定义之后称为表级约束。CREATE TABLE 用户 ( 用户ID INT PRIMARY KEY, 邮箱 VARCHAR(255) UNIQUE, 手机号 VARCHAR(20), CONSTRAINT uk_用户_手机号 UNIQUE (手机号) );列级约束的优点是写法简洁一眼就能看出哪列需要唯一。缺点也明显约束名由数据库自动生成后期想删掉或禁用得先费劲查清楚自动生成的约束名。表级约束可以自己指定有业务意义的名字这个习惯值得强制推广。团队协作和迁移脚本里如果约束名都用uk_前缀加上表名和字段命名比如uk_用户_手机号后面维护的人一看就知道这个约束是干什么的。复合唯一必须使用表级约束因为要约束的多列作为一个整体CREATE TABLE 用户角色 ( 用户ID INT NOT NULL, 角色ID INT NOT NULL, CONSTRAINT uk_用户角色_用户_角色 UNIQUE (用户ID, 角色ID) );2.2 通过 ALTER TABLE 补充约束更多时候约束是在表已经上线、数据积累到一定规模后才发现需要补的。这时要用 ALTER TABLEALTER TABLE 用户 ADD CONSTRAINT uk_用户_邮箱 UNIQUE (邮箱);这条语句执行时数据库会先扫描目标字段的所有现有数据。只要发现哪怕一条重复值整条语句就会报错。这段经历我有发言权曾经在一张上百万数据的表上补约束SELECT 查重时报错信息直接展示了重复的数据行逼得我只能先停下去梳理业务上应该保留哪一条。正确流程一定是先查重复再清理最后补约束。决不能在没确认存量数据干净的情况下直接上手 ALTER TABLE否则大概率得到一个报错。2.3 NULL 值行为——UNIQUE 约束最容易被误解的地方UNIQUE 约束和 NULL 的关系是我见过误解率最高的一块。根据 SQL 标准UNIQUE 约束允许多个 NULL 值存在。原因很简单NULL 表示“未知”两个未知值并不被视为相等所以不会判定为重复。MySQL、PostgreSQL、Oracle 都遵守这个标准一个允许为 NULL 的字段上建 UNIQUE 约束可以插入多条 NULL。但 SQL Server 是一个例外。它认为 NULL 在唯一索引里只能出现一次同一个可空字段建了 UNIQUE 约束第一条 NULL 可以插入第二条 NULL 直接报错。这个问题在跨数据库迁移时非常容易引爆。比如你把一张 MySQL 表迁到 SQL Server原本在 MySQL 里正常存在的多条 NULL 记录到了 SQL Server 就变成了库存垃圾约束加不上去数据又无法自动补值。具体差异我用表格列一下方便对照数据库单列UNIQUE允许多条NULL复合UNIQUE中含NULL列时MySQL允许只要某一列是NULL该行不参与重复判断PostgreSQL允许与MySQL一致Oracle允许与MySQL一致SQL Server不允许仅一条索引键中只要含NULL整条组合视为唯一这个表大家在写跨库数据同步脚本的时候最好贴在旁边别踩了 NULL 规则的坑。3. 主键与UNIQUE约束的纠缠功能重叠但互相不可替代3.1 共同点两者都是唯一性约束主键和 UNIQUE 约束真的太容易混淆了。从功能和底层实现看两者有不少共同点都要求字段值唯一都会自动创建唯一索引都能加速查询都可以作为业务上标识一行记录的载体。很多人因此觉得“主键就是特殊一点的 UNIQUE”这话只对了一半。确实主键在关系模型中本质上是一种特殊的候选键。数据库设计理论里一张表可能有多个候选键选一个作为主键其余的自然就归为主键的“备选”。UNIQUE 约束对应的正是这些非主键候选键。比如一张用户表用户ID是主键邮箱自然就是候选键用 UNIQUE 约束把它保护起来。3.2 最大分水岭NULL 值的语义不同主键和 UNIQUE 约束最大的差异在于对 NULL 的处理。主键列不允许出现任何 NULL这是语法层面就写死的规则而 UNIQUE 约束除了 SQL Server 那种特殊情况外通常允许 NULL 存在。这个差异带来的业务效果非常明显。一个字段如果加的是主键那么每一行都必须有一个明确的标识值这适合真正意义上的“业务实体主键”。比如订单号、流水号、用户编号。一个字段如果加的是 UNIQUE 约束那么这条规则在语义上允许“这条数据尚未被赋值”的状态。比如用户表里的“关联员工工号”某些新注册用户还没分配员工这部分记录可以留空但在已分配的范围内不允许重复。3.3 外键引用场景里的差别外键引用也是一个容易踩坑的点。在关系型数据库里一张子表要引用父表的某列作为外键时被引用的父表列必须是一个唯一索引最常见的就是主键或 UNIQUE 约束。主键作为外键引用目标自然没问题这也是绝大多数设计的选择。但实际建模中可能出现子表在业务上需要引用的是“父表的业务编号”而不是“主键ID”。数据仓库或者历史表设计里这种场景不少见。此时如果父表没有对应的 UNIQUE 约束外键根本创建不成功。反过来UNIQUE 约束的灵活性表现在它可以在多列上建立而主键虽然也支持复合但在逻辑定位上通常只承担“核心实体标识”的角色。3.4 数量限制与实际选型每张表只能有一个主键但可以有多个 UNIQUE 约束。主键在语义上强调的是“这一行的核心身份”而 UNIQUE 约束强调的是“这些数据取值的方式必须不同”。做选型时我一般遵循一个思路能当主键的字段优先当主键其他业务上唯一但允许空的字段用 UNIQUE 约束。比如一张订单表订单ID做主键这没问题第三方支付流水号业务上应该唯一但可能因为支付未完成而暂时为空那就用 UNIQUE 约束。这样数据建模既符合业务逻辑也不会把主键的语义无限扩大。4. 复合唯一约束当唯一性不再是一个列的事4.1 业务场景什么情况必须用复合唯一单列唯一解决的是“这个字段不能有重复值”的问题但真实业务里大量唯一性要求是“这几列的组合不能重复”。举一个最典型的例子会员签到表。一个会员一天可以签好几次但业务规则要求“同一会员同一天只能签到一次”那么约束就应该是(会员ID, 签到日期)组合唯一而不是单独对哪个字段加约束CREATE TABLE 会员签到 ( 会员ID INT NOT NULL, 签到日期 DATE NOT NULL, 签到渠道 VARCHAR(20), CONSTRAINT uk_会员签到_会员_日期 UNIQUE (会员ID, 签到日期) );再比如推广活动里的“同一用户同一活动只能领一次优惠券”交易系统里的“同一订单内分期序号不能重复”都是复合唯一约束的典型场景。这类需求如果不用复合约束实现就只能写触发器或者在应用层做非常复杂的逻辑判断成本远高于直接建约束。4.2 复合约束里的 NULL 行为复合唯一约束里NULL 行为的细节更需要注意。在 MySQL 和 PostgreSQL 中只要参与约束的某一列是 NULL这一行就自动被排除出唯一性判定。也就是说你可以插入多条(NULL, 日期)相同的数据因为 NULL 的存在使得数据库认为这几行的键值不完整。这个特性有时会带来困扰。比如一个“用户绑定终端”场景约束是(用户ID, 终端设备号)唯一但某些设备的设备号还没有采集到被存成了 NULL。此时复合约束等于失效了同一个用户可以挂多条终端号为 NULL 的记录。解决办法在业务侧通常是给这些列设计一个兜底值比如用空字符串替代 NULL或者强制业务补齐让参与约束的列全部非空。4.3 列顺序影响索引使用复合唯一约束在底层也是一个复合唯一索引而复合索引的列顺序是会影响查询是否能命中索引的。比如UNIQUE (会员ID, 签到日期)创建的索引对根据会员ID单独查询的场景也能生效因为会员ID是索引的最左前缀。但如果你的查询条件里只出现签到日期那就无法充分利用这个索引。所以设计复合唯一约束时不能只考虑“唯一性怎么表达”还要想想这个约束的索引能顺带服务哪些查询。一般情况下把选择性高、经常单独出现在查询条件里的列放在前面收益会更高。我见过不少表把复合唯一约束里的列顺序完全按“业务理解的先后顺序”排结果某些高频查询完全用不上这棵索引树还要额外再建一个普通索引白白浪费时间。5. 四个主流数据库的UNIQUE行为对照迁移前必须了解5.1 定义语法的基本形态不同数据库对 UNIQUE 约束的 DDL 语法大同小异但各有各的小特性。基础定义方式都可以用列级或表级数据库列级写法示例约束命名习惯MySQLemail VARCHAR(255) UNIQUE约束名默认与索引名一致PostgreSQLemail VARCHAR(255) UNIQUE自动生成名加引号可自定义SQL Serveremail NVARCHAR(255) UNIQUE自动生成名建表时可指定 CONSTRAINT 名字Oracleemail VARCHAR2(255) UNIQUE类似于 SQL Server支持命名约束其中 MySQL 的常规存储引擎中约束名和索引名是同一个对象的两面放弃约束时用的语句是DROP INDEX或者ALTER TABLE DROP INDEX这一点和其他数据库习惯上有明显差异。如果你在团队里从 MySQL 迁移到 PostgreSQL就会发现 PostgreSQL 里更习惯用ALTER TABLE ... DROP CONSTRAINT因为它的约束和索引虽然底层关联但语义上是两个独立对象。5.2 NULL 语义差异与 SQL Server 的特殊规则前面已经提到过 NULL 语义的分水岭。这里再划一下重点MySQL、PostgreSQL、Oracle 允许一条 NULL也允许一百条 NULLSQL Server 只能容忍一条 NULL。实际做迁移时如果源库是 MySQL、目标库是 SQL Server唯一约束的 NULL 规则会直接卡住数据导入。我处理过的方案是临时把列默认值改为空字符串导入完成后再按业务需求改回来。还有一个新趋势值得关注Oracle 和 SQL Server 的最新版本都提供了类似NULLS NOT DISTINCT的选项允许把 NULL 也纳入“重复”的判断范围。简单说就是你可以选择让多条 NULL 被当作重复而去重。这个功能在“业务上 NULL 也代表一个确定值”的场景里非常有用但使用前一定要确认团队使用的数据库版本是否支持别把文档里的新特性直接套到旧版本生产环境。5.3 部分唯一索引PostgreSQL 独有的玩法PostgreSQL 有一个其他数据库没有的独特能力部分唯一索引 (Partial Unique Index)。它的特点是可以在 UNIQUE 索引后面加 WHERE 条件只对满足条件的部分数据进行唯一性约束。这个特性太实用了。最经典的应用场景是软删除。业务上规定“用户名不能重复”但用户删号后并不把物理记录删掉而是通过deleted_at字段标记。如果直接对用户名建 UNIQUE 约束软删除后想再注册一个同名用户就会撞约束。此时部分唯一索引派上用场CREATE UNIQUE INDEX uk_用户_用户名_有效 ON 用户 (用户名) WHERE deleted_at IS NULL;这样只有未被删除用户的用户名才参与唯一性约束历史废弃数据可以重复。类似的需求在 MySQL 里通常要靠“唯一列状态字段”设计来迂回解决或者引入一张单独的有效用户表实现上都要别扭不少。5.4 字符集与排序规则可能引发的问题UNIQUE 约束判断重复时依赖的是索引字段的排序规则 (collation)。同一个字符串在大小写敏感的排序规则下Abc和abc是不同值但在大小写不敏感的排序规则下会被当成重复。最典型的例子是 MySQL 的utf8mb4_unicode_cici 表示 case-insensitive大小写不敏感。如果你在邮箱字段上建 UNIQUE并且字段排序规则是utf8mb4_unicode_ci那么ZhangSangmail.com和zhangsangmail.com会被判定为重复。这对于邮箱实际使用场景来说也许是好事因为邮箱本来就大小写不敏感。但换到用户名、优惠码这类需要大小写敏感的字段用这种排序规则就会出现“业务上明明不同数据库却不允许插入”的诡异问题。做数据库迁移或者在已有系统里补 UNIQUE 约束时要先把字段的排序规则搞清楚。PostgreSQL 也提供了类似的排序规则设置只是默认行为通常是大小写敏感所以从默认配置相反的数据库迁数据时最容易踩中这种隐藏差异。6. 生产环境给表加UNIQUE约束的实操建议6.1 先清理重复数据再执行约束创建给已经有一堆历史数据的表补 UNIQUE 约束顺序非常重要强烈建议按下面这套流程走先用 SQL 找出有重复值的字段或字段组合SELECT 邮箱, COUNT(*) FROM 用户 GROUP BY 邮箱 HAVING COUNT(*) 1;结合业务规则确认每一组重复数据保留哪一条把多余记录更新掉或删除。对会长期变动的表尽量找一个业务低峰期执行约束添加。执行 ALTER TABLE 添加约束并观察日志。这四步里最花时间的其实是第二步。重复数据清理不能简单地“保留一条”要搞清楚业务上哪条状态正确、哪条是脏数据、有没有下游表引用了被删的那条记录一个处理不好就会产生连锁问题。6.2 大表加约束的时间和策略问题在千万级甚至亿级的大表上直接执行 ALTER TABLE 加 UNIQUE 约束需要做好心理预期。这个操作底层是要扫描全表、给目标列构建唯一索引的数据量越大耗时越长。MySQL 里在线 DDL 的完整支持也有限可能中途会阻塞写入。我在生产环境里的处理思路是先创建一个常规的唯一索引观察它对写入的影响再决定是否要转成约束语义。如果业务允许也可以利用数据库的在线 DDL 能力选择业务低峰期执行实在不行的场景会考虑搭建从库并行操作再把流量切换过去。不同数据库、不同版本的具体能力差别很大动手前一定先查清楚当前版本对在线加唯一索引的支持情况而不是凭记忆直接敲命令。6.3 约束命名、文档化与团队协作UNIQUE 约束虽然不像代码那样需要 code review但它的命名和文档同样重要。我见过的项目里有约束名自动生成的有叫unique_email的有叫UQ_user_123的五花八门。等真正要删除或者迁移约束时根本分不清哪个是哪个。这里分享一下我自己的团队在用的约定所有约束统一用uk_前缀格式是uk_表名_字段1_字段2。比如uk_用户_手机号、uk_会员签到_会员ID_签到日期。这样建约束时就能从名字里读出业务含义执行 DROP CONSTRAINT 时也敢确认自己删的是哪个。同时把唯一约束清单维护在表结构文档里注明“这个约束满足了什么业务规则”让后面接手的人不需要从代码里猜设计意图。6.4 一个经验应用层检查只是优化数据库约束才是保险最后说一个我实际踩过多次的教训。很多项目所谓的“防重复”做的都是先 SELECT 检查再 INSERT功能看起来没问题测试也过了上生产后一旦流量上来就出事。原因前面已经讲过——并发场景下应用层的检查和写入没有原子性。数据库里的 UNIQUE 约束不是让你替代应用层判断而是让应用层的判断只负责拦截常规错误、给用户友好提示真正的兜底必须交给数据库。我自己现在做表结构评审时几乎第一眼就会扫每个字段的约束设计。遇到“业务上不能重复”的字段却没有 UNIQUE 约束心里立刻就会紧张。因为我知道这些字段迟早会在某个深夜的压测或者线上故障里暴露问题到时候再来修成本和风险都会被放大好几倍。数据库约束从设计源头就放对位置是性价比最高的一种投资。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑