MySQL约束实战:从数据完整性到生产环境避坑指南
我最早对 MySQL 约束有深刻体会不是因为学会了约束而是因为接手了一个没有约束的老系统。那张订单表里什么都能插进去订单状态可以写成已付款也可以写成已付歀金额可以是负数同一个用户居然能同时存在两条主身份记录。那段时间我每天的工作就是从脏数据里猜业务真相后来实在受不了花了两周把所有表结构重新设计了一遍该加的主键、外键、唯一键、CHECK 全部补上从那之后数据质量才真正稳下来。这篇文章想把 MySQL 约束这件事从头到尾讲透。内容不只是五种约束怎么写而是把它当成一套完整的表结构设计方法论约束解决什么问题、每种约束的底层原理、最容易踩的坑、以及生产环境里到底怎么用才不翻车。适合有基本 SQL 基础、正在学表结构设计的开发同学也适合那些被历史脏数据折磨、想系统重建数据规则的运维或后端工程师。看完之后你能动手设计出一套能自己扛住错误数据的表结构而不是把校验全指望业务代码。1. 约束到底是干什么的先对齐底层认知1.1 数据完整性到底指什么先问一个问题为什么 MySQL 需要约束表面答案是限制能插入的数据但底层其实是四个字——数据完整性。数据库领域把完整性分成四层实体完整性每一行都要能被唯一识别对应的就是主键约束。没有主键的表在 InnoDB 里其实也会有个隐式主键但你自己不定义后续做关联、做同步、做数据订正都会非常痛苦。域完整性每一列的值必须落在合法的域里。比如年龄不能是负数、邮箱格式要大致对、状态字段不能乱填对应 NOT NULL、CHECK、DEFAULT 这些机制。参照完整性A 表引用 B 表的数据B 表那条记录必须真实存在而且不能随便删。这就是外键约束做的事。用户定义完整性业务自己的规则比如同一用户不能重复参与同一个活动、结束时间必须晚于开始时间通常用唯一约束和 CHECK 组合实现。约束不是一个孤立概念它是数据库替你守住这四层完整性的工具。把它想成仓库门口的保安货不对板不让进标签重复不让进引用不到上游单据的也不让进。仓库管理得越严后面出账、盘点、追溯就越省心。1.2 五大约束的功能矩阵MySQL 官方语境下约束主要指这五类NOT NULL、UNIQUE、PRIMARY KEY、FOREIGN KEY、CHECK。它们的核心作用和对索引的影响各不相同很多人搞混我直接给一张对照表约束类型保证的完整性核心作用是否自动建索引NOT NULL域完整性列不允许为 NULL否UNIQUE实体完整性 / 用户定义完整性列或列组合的值不重复是唯一索引PRIMARY KEY实体完整性唯一标识一行记录是聚簇索引FOREIGN KEY参照完整性子表引用父表的合法记录是若列上无索引会自动创建CHECK域完整性 / 用户定义完整性值必须满足布尔表达式否这张表建议存一下面试也常考。注意 UNIQUE 和 PRIMARY KEY 都会建索引所以约束在某些场景下还能顺带加速查询但 NOT NULL 和 CHECK 纯粹是数据规则对查询性能没有直接影响别指望用它们优化慢 SQL。1.3 约束和索引为什么总被搞混很多人以为唯一约束等于唯一索引严格说不完全对。约束是规则索引是实现规则的手段之一。MySQL 里 UNIQUE 在创建时确实会附带一个唯一索引删掉索引就等于删掉约束外键则反过来它需要一个索引去加速扫描子表是否有引用如果对应列本来没有索引InnoDB 会自动帮你建一个普通索引。这意味着什么意味着你建外键时即使没主动建索引MySQL 也会默默建一个。在高并发写入场景下这个自动索引会占用额外空间、拖慢写入速度这也是后面说的外键不是不能用但要慎重用的原因之一。掌握约束与索引的关系排查慢查询时思路会清晰很多。2. 五大约束逐个击破语法、行为与易错点2.1 NOT NULLNOT NULL 管不住空字符串NOT NULL 是最简单的约束语法上就是在列定义后面加一排CREATE TABLE t_user ( id INT PRIMARY KEY, nickname VARCHAR(50) NOT NULL );但很多人对它有个误解以为加了 NOT NULL 之后空字符串就插不进去了。其实和NULL在 MySQL 里是两种完全不同的语义NULL表示未知、尚未赋值表示已经赋了一个空值。所以nickname 完全合法只有nickname NULL才会报错。这个区别在业务上非常关键。曾经有个项目要统计用户昵称填写率代码里一直用WHERE nickname IS NULL去查结果发现好多记录写的是统计直接偏了。所以设计的时候要跟业务方对齐这个字段是必填但可能为空内容还是暂不知晓前者用DEFAULT 后者才用允许 NULL。另外强烈建议把 NOT NULL 和 DEFAULT 成对设计。比如status TINYINT NOT NULL DEFAULT 1既保证插入时不会因为漏字段而报错又保证数据是可控的。光有 NOT NULL 没有默认值应用层一旦漏传就会整条 SQL 失败线上很容易变成事故。2.2 UNIQUE唯一性与 NULL 的微妙关系UNIQUE 约束要求列或列组合的值不重复语法CREATE TABLE t_user ( id INT PRIMARY KEY, email VARCHAR(100), UNIQUE KEY uk_user_email (email) );这里有个极其容易踩坑的地方UNIQUE 约束允许多个 NULL 共存。也就是说你可以插入两条email NULL的记录MySQL 不会报错。这是标准 SQL 的行为因为 NULL 不等于任何值包括 NULL 自己。但这就给业务埋了一个雷如果业务想表达邮箱要么不填一旦填了就必须唯一用普通 UNIQUE 是成立的但如果业务想表达所有用户要么不填填了的必须唯一但历史上已经有多条 NULL那就得靠其他手段兜底。复合唯一约束也很常用比如一个用户对同一个商品只能有一条收藏记录CREATE TABLE t_favorite ( id INT PRIMARY KEY, user_id INT NOT NULL, product_id INT NOT NULL, UNIQUE KEY uk_user_product (user_id, product_id) );组合唯一的意义是组合值不能重复不是每列各自不能重复。2.3 PRIMARY KEY为什么一张表只有一个主键约束是实体完整性的核心一张表只能有一个主键。它可以由单列组成也可以由多列组成复合主键。从语法上看主键和UNIQUE NOT NULL几乎等价但有几处本质区别主键自动成为 InnoDB 的聚簇索引数据的物理存储顺序按主键组织。外键引用时默认引用目标是主键或唯一键。主键只有一个唯一键可以有多个。主键优先作为复制、日志、同步的定位依据。关于主键类型我个人的生产经验是坚决推荐自增 BIGINT不推荐业务主键或 UUID 主键。自增主键写入顺序和聚簇索引顺序一致能减少页分裂UUID 主键随机性太强插入时会导致聚簇索引频繁页分裂产生大量碎片写入性能明显下降。8.0 里虽然可以用 UUID 转二进制存储但能不用还是不用。还要注意自增主键并不保证连续事务回滚后会跳号这个不是 Bug 而是设计如此。如果有人纠结主键怎么缺号了把文档甩给他就行。2.4 FOREIGN KEY强大的参照完整性但别急着用外键约束保证子表引用父表的记录必须存在语法涉及的东西比较多CREATE TABLE t_order ( id INT PRIMARY KEY, user_id INT NOT NULL, ... CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user (id) ON DELETE RESTRICT ON UPDATE CASCADE );这里是重点ON DELETE和ON UPDATE的行为有五种容易记混行为父表删除/更新时子表表现适用场景RESTRICT有子表引用则拒绝操作不允许删有引用的父记录NO ACTION和 RESTRICT 等价即时检查同上CASCADE同步删除/更新子表记录级联清理明细数据SET NULL子表外键列置为 NULL保留明细但解除引用SET DEFAULT子表外键列设为默认值InnoDB 实际不执行该动作不推荐MySQL 的 InnoDB 引擎才支持外键MyISAM 建了也不会生效。而且外键并不是免费的午餐每次在子表插入或更新时InnoDB 都要检查父表是否存在对应记录意味着额外读父表删除或更新时需要扫描子表索引可能引发行锁竞争。在高并发系统里外键往往是死锁和慢操作的来源之一。所以我建议内部管理系统、数据一致性要求极高的场景可以放心用外键高并发互联网应用谨慎使用甚至不用物理外键这个后面专门开一节讲。2.5 CHECK从摆设到真校验的进化CHECK 约束是最容易被忽视、也最容易出问题的约束。它的作用是限制列值必须满足某个布尔表达式CREATE TABLE t_user ( id INT PRIMARY KEY, age INT, salary DECIMAL(10,2), CONSTRAINT ck_user_age CHECK (age 0 AND age 120), CONSTRAINT ck_user_salary CHECK (salary 0) );还可以写多列之间的约束比如结束时间必须晚于开始时间CONSTRAINT ck_plan_date CHECK (end_date start_date)但这里必须强调一个历史大坑MySQL 8.0.16 之前CHECK 约束虽然语法合法、SHOW CREATE TABLE 里也显示但根本不会执行。也就是在 5.7 上建了 CHECK你照样能插入 age -1数据不会报错。很多人因此骂 MySQL 是假的 CHECK。真正的强制执行从 8.0.16 开始才落地我建议生产环境用 8.0.16 以上版本并且确认SHOW CREATE TABLE里能看到约束再相信它真的会拦截非法数据。CHECK 比 ENUM 类型更灵活ENUM 只能枚举合法值新增一个枚举值要改表结构CHECK 的条件可以写成status IN (...),也能写范围、比较表达式。从 8.0 开始能上 CHECK 就别再用 ENUM 硬撑了。3. 从零设计三张表的约束一次完整实操3.1 需求与实体关系光讲语法太虚我带你把一套三张表的约束从零设计一遍。场景是一个轻量内容系统用户t_user、文章t_article、评论t_comment。用户id、手机号登录标识、昵称、年龄、创建时间。文章id、作者 user_id、标题、正文、状态草稿/已发布/已下线、发布时间。评论id、文章 article_id、评论者 user_id、内容、创建时间。表关系很简单用户 1 对 N 文章用户 1 对 N 评论文章 1 对 N 评论。但关系简单不代表约束简单设计的时候要把每一步业务规则想清楚。3.2 约束设计顺序先业务规则后写 SQL我建表有个固定顺序先不在键盘上动手而是把下面的问题过一遍哪些列能唯一确定一行哪个做主键是否需要复合主键本案例都用单列自增 id哪些列业务上要求唯一手机号必须唯一昵称是否唯一要跟产品确认。哪些列不允许为空哪些列必须有默认值哪些列会被其他表引用比如 user_id、article_id 是不是要建外键哪些列的值需要满足范围或枚举状态字段、年龄字段用不用 CHECK这套自问做完表结构基本就八九不离十了。约束设计的本质是把业务规则前置到数据库层而不是等应用层漏校验了再补。3.3 完整建表语句附注释下面是我实际会写出来的建表语句CREATE TABLE t_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, phone VARCHAR(20) NOT NULL COMMENT 手机号登录账号, nickname VARCHAR(64) NOT NULL COMMENT 昵称, age TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 年龄, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_user_phone (phone), CONSTRAINT ck_user_age CHECK (age 0 AND age 120) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE t_article ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, user_id BIGINT UNSIGNED NOT NULL COMMENT 作者ID, title VARCHAR(200) NOT NULL COMMENT 标题, content TEXT NOT NULL COMMENT 正文, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0草稿 1已发布 2已下线, published_at DATETIME DEFAULT NULL COMMENT 发布时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), KEY idx_article_user (user_id), CONSTRAINT fk_article_user FOREIGN KEY (user_id) REFERENCES t_user (id) ON DELETE RESTRICT, CONSTRAINT ck_article_status CHECK (status IN (0, 1, 2)) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT文章表; CREATE TABLE t_comment ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 主键, article_id BIGINT UNSIGNED NOT NULL COMMENT 文章ID, user_id BIGINT UNSIGNED NOT NULL COMMENT 评论者ID, content VARCHAR(1000) NOT NULL COMMENT 评论内容, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 评论时间, PRIMARY KEY (id), KEY idx_comment_article (article_id), CONSTRAINT fk_comment_article FOREIGN KEY (article_id) REFERENCES t_article (id) ON DELETE CASCADE, CONSTRAINT fk_comment_user FOREIGN KEY (user_id) REFERENCES t_user (id) ON DELETE RESTRICT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评论表;几个设计决策说明一下t_user.id用BIGINT UNSIGNED生产环境用户量大INT 很容易不够用直接一步到位。t_article.user_id建普通索引既加速查询也让外键检查不拖后腿。t_comment对article_id用ON DELETE CASCADE文章删了评论一起删符合业务直觉对user_id用RESTRICT用户被引用时禁删保护审计链路。状态字段用TINYINT CHECK比 ENUM 灵活后续加状态不用改表。3.4 用 INSERT 测试约束是否真的生效建的约束不是用来好看的写完表我习惯立刻跑几条非法 SQL 验证-- 违反非空约束 INSERT INTO t_user (phone, nickname, age) VALUES (NULL, 张三, 20); -- ERROR 1048: Column phone cannot be null -- 违反唯一约束 INSERT INTO t_user (phone, nickname, age) VALUES (13800138000, 李四, 21); -- ERROR 1062: Duplicate entry 13800138000 for key t_user.uk_user_phone -- 违反 CHECK 约束 INSERT INTO t_user (phone, nickname, age) VALUES (13800138001, 王五, -5); -- ERROR 3819: Check constraint ck_user_age is violated -- 违反外键约束 INSERT INTO t_article (user_id, title, content) VALUES (99999, 测试, 内容); -- ERROR 1452: Cannot add or update a child row: a foreign key constraint fails这四条验证跑完约束算真正生效了。很多同事建完表不验证等应用层测试报错才发现约束建错地方白白浪费一整天。4. 约束的日常运维查看、修改与删除的正确姿势4.1 查看约束的三种途径实际维护中你经常会遇到不知道表上有什么约束的情况尤其接手别人系统时。我常用的查看方式有三个第一种最直观直接看建表语句SHOW CREATE TABLE t_article;它会把所有约束定义原样打印出来适合人工评审。第二种查系统库适合写脚本巡检SELECT TABLE_NAME, CONSTRAINT_NAME, CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA your_db;第三种细看约束对应的列和外键引用关系SELECT TABLE_NAME, COLUMN_NAME, CONSTRAINT_NAME, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA your_db AND TABLE_NAME t_comment;信息碎片都在 information_schema 里把它当成你的约束查询入口比肉眼翻表结构高效得多。4.2 追加与删除约束的 ALTER TABLE 语法表已经上线了再补约束是 DBA 的日常。语法分成几类-- 追加主键 ALTER TABLE t_user ADD PRIMARY KEY (id); -- 追加唯一约束 ALTER TABLE t_user ADD UNIQUE KEY uk_user_phone (phone); -- 追加外键约束 ALTER TABLE t_comment ADD CONSTRAINT fk_comment_article FOREIGN KEY (article_id) REFERENCES t_article(id) ON DELETE CASCADE; -- 追加 CHECK 约束 ALTER TABLE t_user ADD CONSTRAINT ck_user_age CHECK (age 0 AND age 120); -- 修改列为 NOT NULL ALTER TABLE t_user MODIFY COLUMN phone VARCHAR(20) NOT NULL;删除约束的姿势要按类型区分-- 删除主键 ALTER TABLE t_article DROP PRIMARY KEY; -- 删除唯一约束本质是删索引 ALTER TABLE t_user DROP INDEX uk_user_phone; -- 删除外键约束 ALTER TABLE t_comment DROP FOREIGN KEY fk_comment_article; -- 删除 CHECK 约束 ALTER TABLE t_user DROP CHECK ck_user_age;这里有个小坑如果表上有自增主键直接 DROP PRIMARY KEY 会报错得先把列的自增属性去掉ALTER TABLE t_user MODIFY COLUMN id BIGINT UNSIGNED NOT NULL; ALTER TABLE t_user DROP PRIMARY KEY;还需要注意 DROP FOREIGN KEY 时如果外键曾自动创建了索引约束删除后索引不会自动删需要单独 DROP INDEX。很多人删了外键发现索引还在其实就是这个原因。4.3 大表变更要小心锁与复制延迟加约束在大表上不是小事。尤其在 5.7 上很多 ALTER TABLE 操作会重建表期间对表的 DML 会被阻塞线上可能出现连接数打满的连锁反应。8.0 对部分操作做了在线 DDL 优化但也不是所有操作都天然安全。我的建议是三个字先看量。确认要操作的表有多大如果超过百万行先把变更放到维护窗口同时观察主从延迟。比如加 CHECK 约束时MySQL 需要扫描全表校验已有数据这个扫描在从库也会执行大表上的耗时不能忽略。如果确实需要在业务高峰期操作可以考虑用工具软变更比如 gh-ost、pt-osc它们通过复制流把表结构变更拆成小事务执行能显著降低锁的影响。但这类工具本身也很考验运维能力团队不熟就不要贸然上。5. 我在生产环境踩过的约束相关的五个坑5.1 大表外键导致死锁与慢删除有一年我维护过一个订单系统订单主表、订单明细表、操作日志表之间通过外键串成一条链。平时写入量不大看着挺美好。直到做一次大促后的历史数据清理要按时间删除一批老订单结果线上 DELETE 慢得像乌龟爬还频繁报死锁。排查链路是这样的先看慢查询日志发现耗时全在 DELETE 订单主表那条语句上接着SHOW ENGINE INNODB STATUS看死锁日志发现锁等待集中在明细表和日志表最后用KEY_COLUMN_USAGE一查发现这些子表都带ON DELETE CASCADE外键。删除一条主订单InnoDB 要逐个去子表索引里扫描匹配记录索引一旦不完善锁范围就扩大死锁自然就来了。处理办法是把物理外键去掉改成应用层事务里显式删除明细和日志同时补偿一个定时对账任务。这样既能保证最终一致性又能把删除操作拆分到可控粒度问题立刻缓解。你要问我现在怎么选我依然坚持小项目和内部系统随便用外键高并发、大表、频繁批量删除的系统别让外键成为性能瓶颈。5.2 ON DELETE CASCADE 一删删一片另一个外键事故发生在一次数据订正时。运营发现一批测试数据要清理我直接执行了DELETE FROM t_user WHERE id IN (...)想着评论表的外键是 CASCADE文章表的外键也有 CASCADE应该清理得很干净。结果删完发现这个用户的所有历史文章、评论连带全部没影了连审计需要的原始记录都没留下。问题的根源不是 CASCADE 本身而是我低估了级联删除的传染性。删除动作会像涟漪一样扩散到所有引用它的子表、孙表一旦范围判断失误数据就找不回来了没有备份的话。从那次之后我对线上删除立了三条规矩能逻辑删除绝不物理删除物理删除前必须执行 SELECT COUNT 验证影响行数涉及 CASCADE 的删除必须多一层审批确认。级联约束好用但它是高危工具不是给你随便玩的。5.3 5.7 里 CHECK 约束静默失效还有一次接手一个老项目建表时写了 CHECK 约束但线上数据却出现了 age -3 的奇迹。刚开始我以为 INSERT 语句没走这套逻辑后来才发现 MySQL 版本是 5.7CHECK 约束在 5.7 里只是语法上被接受实际执行时直接被忽略。SHOW CREATE TABLE 能看到约束名但它就是个纸老虎。这类静默失效最可怕因为你看不出结构有问题但它根本没在保护你。排查方式很简单确认版本SELECT VERSION();然后故意插入一条非法数据试试如果 INSERT 成功说明约束没生效。这也提醒我升级 MySQL 到 8.0.16 以上之后一定要把历史表里的 CHECK 约束检查一遍因为从忽略到生效这个转变可能导致原本能插入的数据在新版本被拒绝应用层需要配合调整。5.4 UNIQUE NULL 的组合陷阱用户表加过唯一约束要求邮箱字段要么不填填了就不能重复。用 UNIQUE 约束建好之后测试阶段一直正常。结果导入历史数据时ETL 工具把空值统一写成了空字符串而不是 NULL。两个用户都是直接触发唯一约束冲突整个导入流程报错。这个案例的关键在于空字符串是值两个空字符串重复UNIQUE 当然拦NULL 才是未知UNIQUE 放行。解决的方法是统一规则业务上表示没填写的字段全部走 NULL不要在应用层或 ETL 层把空值转成。如果字段本身已经存在大量历史数据且不能清空那唯一约束就很难加。要么把统一 UPDATE 成 NULL 后再加约束要么用生成列做条件唯一索引。后者在 8.0.13 可以用函数索引实现但复杂度更高能不改旧数据就尽量改数据。5.5 大字段加唯一索引直接报错有一个评论系统想给评论内容加唯一约束防重复提交实际这个需求不合理但当时需求方坚持结果建索引时报错ERROR 1071: Specified key was too long; max key length is 3072 bytes。原因不难理解InnoDB 在 DYNAMIC 行格式下索引键最大 3072 字节utf8mb4 一个字符最多 4 字节VARCHAR(1000) 最大可能占用 4000 字节直接超限。就算变成 VARCHAR(768) 也很接近上限不建议赌。最终方案是给唯一索引加前缀长度只取前 100 或 150 个字符做唯一判断。但前缀唯一有个副作用只要前缀相同就判定重复可能误伤内容不同但前缀相同的记录。所以防重复这种需求最稳妥还是应用层哈希列 唯一约束组合建一个 content_hash CHAR(64) 列存 SHA256再对哈希列加唯一索引。这个思路同样适用于那些字段太长不能直接加唯一索引的场景。6. 约束设计的最佳实践清单把规则写进数据库6.1 约束命名规范要早定约束没有统一命名规范的库维护起来非常痛苦。我见过id_2、key_1这种系统自动生成的约束名出问题根本不知道它是干嘛的。从第一天就定一套规则成本几乎为零收益长期显著主键pk_表名唯一约束uk_表名_列名组合唯一用下划线连列名外键fk_表名_引用表名或fk_子表_父表CHECKck_表名_业务含义建约束时都指定名字千万别空着让 MySQL 自动取。之前排障时深夜去查一个外键叫什么最后靠系统表翻半天才定位到那个滋味不想体验第二次。6.2 物理外键用不用看完这组对比再决定物理外键是一个争议话题我的观点可以浓缩成一张表维度用物理外键不用物理外键数据一致性数据库层强制最可靠靠应用层事务与补偿开发效率建表稍繁琐但约束内聚灵活但代码要更严谨高并发写入每次写有额外检查开销开销更小扩展更自由分库分表基本不支持跨库外键不受影响批量删除级联和锁风险高可控但是要自己删子表我的个人倾向是团队规模小、系统逻辑集中、数据一致性要求高放心用外键大流量互联网业务、频繁迭代、表拆分接近分库分表场景就别让外键捆住手脚把参照完整性逻辑放到服务层去保证同时用定期对账兜底。不要盲目吹某一个选择站在团队实际情况上做决定。6.3 软删除场景下唯一约束怎么设计这是个很高频的问题用户表有唯一约束在用户名字段上产品要求逻辑删除保留记录不物理删但删掉的数据还占着 username新用户注册时始终提示用户名已存在。常见方案是加一个deleted_at字段参与唯一索引ALTER TABLE t_user ADD COLUMN deleted_at DATETIME NOT NULL DEFAULT 1970-01-01 00:00:00 COMMENT 删除时间未删除则为初始值; ALTER TABLE t_user DROP INDEX uk_user_name, ADD UNIQUE KEY uk_user_name_deleted (username, deleted_at);未删除的用户deleted_at统一等于初始值用户名唯一性生效删除时把deleted_at更新为当前时间戳这样同名的两个不同删除记录时间戳不同不会冲突新用户也能用原来的用户名。有一点千万注意deleted_at不能允许 NULL否则多个 NULL 在唯一索引里还是会互不冲突。8.0.13 之后也可以考虑函数索引比如只对未删除记录建唯一索引但对运维要求高一些。能看懂原理的团队用第一种方案最稳。6.4 上线前用这几条 SQL 巡检约束最后分享一个我每次发版前都会跑的巡检思路用系统表把数据库里保护力度一目了然-- 查看所有表是否有主键 SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA your_db AND TABLE_NAME NOT IN ( SELECT TABLE_NAME FROM information_schema.TABLE_CONSTRAINTS WHERE CONSTRAINT_TYPE PRIMARY KEY ); -- 查看所有外键及级联策略 SELECT TABLE_NAME, CONSTRAINT_NAME, DELETE_RULE, UPDATE_RULE FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA your_db;这套 SQL 不需要额外工具复制到客户端就能用。上线前花十分钟扫一遍比出事故后加索引、补约束省太多精力。我后来把这段逻辑写进定时巡检报告每周自动跑一次谁往库里加了没有约束的表第一时间就能发现。约束这件基础功平时看不见摸不着但真的到了数据出问题那天你才会知道它替你挡了多少灾难。别嫌麻烦把规则写进数据库是给未来的自己省时间。