多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

MySQL约束详解:主键、唯一、外键、非空、默认与CHECK实战

MySQL约束详解:主键、唯一、外键、非空、默认与CHECK实战 搞了几年 MySQL我越来越觉得约束才是表结构设计里最容易被低估的一块。很多项目上线初期风平浪静等数据量上来之后重复数据、脏数据、孤儿关联一个个往外冒排查起来一天都未必能搞定。回头去看根源往往不是 SQL 写得差而是建表的时候没用足 MySQL 的约束机制。约束这东西名字听着像限制实际是数据库引擎帮你守住数据底线的最后一道闸门。今天就以“MySQL 约束”为主线结合这些年实际建表、调优、排障的经验把约束的分类、用法、坑和实操一次聊透。1. 先想清楚约束的本质为什么要花心思在“限制”上1.1 约束到底能解决什么实际问题先抛一个我印象很深的例子。早年接过一个后台管理系统用户表的手机号字段是varchar(11)但允许NULL也没有唯一约束。结果运营手动导数据的时候同一个手机号被插入了三次后面做用户画像、发券、对账全部乱了套。负责的同事花了整整两天去清洗数据最后还要挨个确认到底哪条记录才是“真身”。这个问题的本质不是运营手误而是表结构没有把“手机号不能重复、不能为空”这条业务规则落到数据库层。约束解决的就是这种事把校验规则内置到数据库引擎里任何一条INSERT、UPDATE必须满足条件才能执行不满足直接报错从根上拦截脏数据。我在实际项目里的体会是约束是数据完整性的最后一层防线。应用层可以写校验逻辑ORM 可以加注解但代码总有人漏写、写错、临时绕过。约束在引擎层强制卡住无论谁用什么客户端连上来规则都躲不掉。1.2 数据库完整性理论里的四个维度教科书区分数据完整性通常分成四类MySQL 的约束体系正好对应这四类实体完整性每张表得有能唯一标识每行的字段对应PRIMARY KEY主键约束。域完整性字段的值必须落在合法范围内对应NOT NULL、CHECK、DEFAULT等。参照完整性表与表之间的引用关系必须成立不能出现“订单指向一个不存在的用户”对应FOREIGN KEY。用户自定义完整性特定业务规则比如金额不能为负数、状态字段只能取特定枚举值MySQL 里主要用CHECK约束和触发器实现。这四类完整性不是割裂的。举例说一张订单表同时需要主键每笔订单可标识、非空下单用户不能为空、检查订单金额大于0、外键关联用户表它们协同才是完整的建模方案。我见过不少开发者只加了主键其他约束全靠应用层自觉这种表就是一颗定时炸弹。1.3 约束和索引的关系必须掰清楚刚开始用 MySQL 的人经常会问加了主键之后怎么自动多了一个索引因为 InnoDB 里主键本身就是聚簇索引的入口所以PRIMARY KEY会自动创建一个唯一索引。UNIQUE约束同样会自动创建唯一索引这也是为什么唯一约束不仅能防重复还能加速查询。FOREIGN KEY要求子表的外键列必须有索引目的就是让关联查询和级联操作能走索引不至于全表扫。明白了这一点后面排查性能和约束冲突都会轻松很多。记住一句话约束定义数据规则索引加速规则执行二者经常成对出现但概念上别混。1.4 应用层校验和数据库约束的边界在哪里我不主张把所有校验都堆到数据库过度使用约束也会让 DDL 变得笨重。合理的做法是分层交互层做用户友好的提示“请输入手机号”应用层做复杂业务校验“该手机号已注册”数据库层做底线强制唯一约束、非空、外键。数据库层不需要把话术写得很友好但必须保证任何路径进来都违反不了核心规则。这样即使某个调用方忘了前置判断数据库也会一声不吭地拒绝写入而不是默默收下脏数据。2. 六大核心约束逐个拆解语法、原理与适用场景2.1 主键约束 PRIMARY KEY主键约束是每张 InnoDB 表的“定海神针”。它的定义很直接一个表只能有一个主键主键列的值不能为NULL而且必须唯一。主键可以是单个字段也可以是多个字段组成的复合主键。-- 单列主键 CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;选主键有几个经验。自增整数主键在绝大多数业务场景下最省心写入顺序紧凑索引页分裂少性能稳定。用业务字段做主键要格外谨慎比如“身份证号”看起来唯一但涉及隐私脱敏、变更、格式调整时极其痛苦。UUID 做主键我一般不推荐随机字符串插入会让聚簇索引频繁页分裂写入性能下降明显如果非要用 UUID至少考虑优化成有序存储或者干脆用雪花 ID。复合主键我建议只在关联表、中间表上使用比如“用户角色表”用user_id role_id联合作为主键天然防重复语义也干净。普通业务表尽量用单字段主键简单才可靠。2.2 非空约束 NOT NULL很多人觉得NULL和空字符串差不多其实在 MySQL 里差别巨大。NOT NULL约束直接拒绝NULL值写入而空字符串是正常值会真的存进去。一个高频业务坑是统计用户填没填手机号COUNT(phone)遇到NULL直接忽略遇到空字符串却会计数。如果字段允许NULL查询时还得额外写IS NULL、IS NOT NULL分支WHERE phone NULL永远查不到数据因为NULL不能用等号比较必须写IS NULL。我的建议很明确业务上必须存在的字段一律NOT NULL加DEFAULT兜底。比如订单金额、用户ID、创建时间不允许出现NULL。让字段“没有值”的状态尽量少出现数据模型和维护成本都会降低。CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB;2.3 唯一约束 UNIQUE唯一约束保证一列或一组列的值在表内不重复。业务场景非常典型用户名唯一、订单号唯一、身份证唯一、手机号唯一。语法是给列加UNIQUE或UNIQUE KEY。CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB;这里有一个必须记住的细节MySQL 的唯一约束允许多个NULL值。也就是说如果某列设置为UNIQUE但允许NULL你可以插入多行都是NULL的数据。这在很多业务场景里是隐患因为“不确定值”重复会被放过。解决办法是把这个列设为NOT NULL UNIQUE或者用NULLIF配合业务默认值去归一化。复合唯一约束我也常用。比如“用户每日签到表”用user_id sign_date做复合唯一天然保证一个人一天只能签到一次。写业务代码之前先在表结构层面把这种重复条件卡死比在应用层加锁、加判断要稳得多。2.4 默认值约束 DEFAULT默认值约束解决的是“插入时没写这个字段怎么办”的问题。配合NOT NULL使用效果最好字段不允许为空但用户没有显式赋值时数据库自动填默认值。is_vip TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,MySQL 8.0 还支持表达式默认值比如DEFAULT (UUID())可以用在分布式场景下生成全局唯一字符串。这里提醒一下老版本的默认值基本只支持常量和时间函数遇到表达式默认值要先确认版本。时间字段的默认值是一个重灾区。我见过很多表把created_at设为允许NULL然后靠代码层去填当前时间。一旦哪条路径没填整条记录就没有创建时间对账的时候死活找不到。正确姿势就是NOT NULL DEFAULT CURRENT_TIMESTAMP让数据库自己盖章。2.5 检查约束 CHECKCHECK约束用来限定字段值的范围。最典型的场景是金额必须大于 0年龄必须在 0 到 150 之间状态字段只能取(pending, paid, cancelled)中的某一项。很多人不知道MySQL 5.7 以及更早版本对CHECK只是解析后忽略不会真的强制执行。直到 8.0.16 开始CHECK才被 InnoDB 真正实现。也就是说如果你还在维护 5.7 的老库写了CHECK也拦不住非法数据必须在应用层补逻辑。CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL, PRIMARY KEY (id), CONSTRAINT chk_amount_positive CHECK (amount 0), CONSTRAINT chk_status_valid CHECK (status IN (pending,paid,cancelled)) ) ENGINEInnoDB;CHECK和ENUM的区别也是一个高频讨论点。ENUM限制字段必须从枚举里取值但扩展时需要改表结构而且 MySQL 对非法值在非严格模式下可能静默转成空字符串CHECK表达力更强可以写范围、逻辑组合扩展也更灵活。能上CHECK的业务约束我建议优先用CHECK。2.6 外键约束 FOREIGN KEY外键是参照完整性的核心实现保证子表引用的父表记录真实存在。比如订单表的user_id外键指向用户表的id如果不加外键完全可以通过INSERT插入一个不存在的user_id形成孤儿订单。CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, amount DECIMAL(10,2) NOT NULL, PRIMARY KEY (id), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE RESTRICT ) ENGINEInnoDB;外键的联动规则主要有RESTRICT有引用则拒绝删除、CASCADE级联删除、SET NULL置空子表外键。选哪个要看业务。用户和订单关系我通常用RESTRICT避免误删用户时连带删掉所有订单评论和文章这种“父删子也删”的场景CASCADE才合理。但注意外键不是越多越好。高并发、分库分表环境里外键会带来锁竞争和额外的检查开销很多大厂的实际做法是在数据库层不建物理外键只建普通索引用应用层保证引用关系。这个决策要看团队的技术栈和运维能力。如果是中小项目老老实实加外键数据安全远比省那一点开销重要。3. 实操从建表到运维的完整约束操作指南3.1 一个规范的建表范例把前面的约束组合起来一个完整的用户订单模型长这样CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, email VARCHAR(100) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT UNSIGNED NOT NULL, amount DECIMAL(10,2) NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE RESTRICT, CONSTRAINT chk_amount_positive CHECK (amount 0), CONSTRAINT chk_status_valid CHECK (status IN (pending,paid,cancelled)) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有三个实操要点。第一外键列同时手动建了一个普通索引idx_user_id这是为了让外键检查更快也方便按用户查订单。InnoDB 在创建外键时如果发现目标列没有索引会自动帮你建一个但我习惯显式声明索引名字可控后续维护一眼能看懂。第二DEFAULT CHARSETutf8mb4必须显式指定虽然 MySQL 8.0 默认就是 utf8mb4但老版本或特定配置不一定建表语句写清楚避免后续字符集不一致的坑。第三主键用BIGINT UNSIGNED自增订单号这种业务标识用独立唯一约束不要把两者混在一起。3.2 表已经建好怎么优雅地补约束开发中最常见的场景是表上线跑了一段时间才发现该加约束没加。这时候可以分几步走。先加唯一约束顺便清掉历史脏数据。如果表里已经存在重复值直接ADD UNIQUE会报 1062 错误必须先找出重复并处理。-- 查找重复手机号 SELECT phone, COUNT(*) FROM user GROUP BY phone HAVING COUNT(*) 1; -- 清理完成后添加唯一约束 ALTER TABLE user ADD UNIQUE KEY uk_phone (phone);再补非空约束。历史数据里如果有NULL先更新成业务兜底值否则MODIFY会失败。UPDATE user SET phone WHERE phone IS NULL; ALTER TABLE user MODIFY COLUMN phone VARCHAR(20) NOT NULL DEFAULT ;补外键和检查约束相对顺序自由但如果目标表数据量大操作要谨慎。ALTER TABLE的 DDL 在执行时可能拿到元数据锁MDL如果线上有长事务在跑新的 DDL 会被阻塞在 waiting for table metadata lock看起来像卡死了其实是在排队。我处理线上大表结构变更通常会选在低峰期或者用pt-online-schema-change这类在线 DDL 工具把对业务的影响降到最低。3.3 约束的删除有哪些看不见的连锁反应删除约束的语法比添加简单但连锁反应容易被忽略。-- 删除主键约束 ALTER TABLE orders DROP PRIMARY KEY; -- 删除唯一约束按索引名删除 ALTER TABLE user DROP INDEX uk_phone; -- 删除外键约束 ALTER TABLE orders DROP FOREIGN KEY fk_orders_user;删除主键要特别小心如果主键列是AUTO_INCREMENTInnoDB 不允许直接删除主键必须先去掉自增属性。而且删除主键后InnoDB 聚簇索引也会跟着变化MySQL 会重新组织表的存储结构大表上这个操作耗时非常恐怖。删除外键时不要只删索引得先用DROP FOREIGN KEY把约束删掉再手动DROP INDEX否则约束还在索引删了也会自动重建。我还遇到过新手直接DROP TABLE的那是另外一个故事了。总之删约束前先用SHOW CREATE TABLE看清楚当前的表结构和约束定义再动手。3.4 约束命名与维护规范约束的命名直接影响排障效率。默认情况下如果你不写约束名称MySQL 会自动生成但这个名字可读性很差。我的习惯是统一加前缀主键pk_表名唯一uk_字段名普通索引idx_字段名外键fk_表名_关联表名检查chk_字段名好处是看报错信息时一眼就能定位到是哪个表的哪条约束被违反了。比如ERROR 3819 (HY000): Check constraint chk_amount_positive is violated.直接知道是订单表金额字段出了问题不需要再去翻建表语句。约束多了以后命名规范的价值会被无限放大谁写谁知道。4. 常见问题与排查技巧实录4.1 约束冲突报错速查表实际运行中约束违反的报错信息有固定套路这里整理一张我在工作中反复用到的对照表。报错信息对应约束典型场景ERROR 1062 (23000): Duplicate entry xxx for key uk_xxx唯一约束/主键插入重复用户名、重复订单号ERROR 1048 (23000): Column xxx cannot be null非空约束插入记录时业务字段未赋值ERROR 1452 (23000): Cannot add or update a child row外键约束子表插入的关联ID在父表不存在ERROR 1215 (HY000): Cannot add foreign key constraint外键约束创建外键时字段类型/字符集不一致ERROR 3819 (HY000): Check constraint chk_xxx is violated检查约束金额负数、非法状态枚举ERROR 1364 (HY000): Field xxx doesnt have a default value非空无默认值严格模式下插入时缺少必须字段遇到报错不要一上来就怀疑代码先看报错的错误码和约束名。大部分情况查询一下正在插入的数据对照约束定义问题就清楚了。4.2 我在真实项目里踩过的坑第一个坑是复合唯一索引与NULL的组合。之前设计一个“用户收藏商品表”用user_id product_id做复合唯一。但如果product_id允许NULLMySQL 会认为所有(user_id, NULL)都不算重复用户把同一个商品收藏一百次都不会触发唯一约束。后来把product_id改成NOT NULL问题立刻消失。第二个坑是外键导致线上删除被长时间卡住。一张几千万行的订单表删历史数据DELETE语句走了外键检查子表关联查询没走索引一条删除操作把数据库拖到接近假死。排查之后才发现子表的外键列索引因为之前的误操作被删了。所以外键列的索引一定不要轻易动动了以后外键检查性能会急剧恶化。第三个坑更隐蔽字符集不一致导致外键建不上。user.id是BIGINTorders.user_id是VARCHAR或者两张表一张utf8mb4、一张gbk创建外键时 MySQL 直接报 1215。这类问题在拆库合并、迁移数据后特别容易出现因为表是从不同环境拷贝过来的建表语句里的字符集早就被忽略了。4.3 一条靠谱的排障路径如果约束相关的报错出现了我建议按以下思路排查。先执行SHOW CREATE TABLE 表名\G把当前表的所有约束定义拿出来逐条核对报错里提到的字段。再执行SHOW INDEX FROM 表名检查相关字段的索引是否还在、索引名是否正确。然后手工模拟一条 SQL插入/更新报错场景里的数据看看能否复现。如果复现了就是数据本身和约束冲突如果没复现大概率是程序里传进来的值和应用层展示的值不一致比如时区、格式化、精度转换等等。最后提醒一句排查的时候注意sql_mode的设置。非严格模式下某些非法值会被 MySQL 自动截断或转成默认值然后警告而不是报错很多脏数据就是这么“合法”混进去的。对需要严格数据校验的系统坚持使用严格模式宁可让非法插入直接报错也不能让步接收脏数据。4.4 绕过约束的几种常见做法为什么我不建议用有些开发者为了图省事会在插入前先DELETE再INSERT用“先删后插”的方式规避唯一约束或者干脆频繁TRUNCATE重建表。这些做法在生产环境风险极大。TRUNCATE会清空整表而且 DDL 会触发表结构上的隐式提交后续无法通过事务回滚。还有人在应用层 catch 住约束冲突的异常然后当作“数据已存在”继续往下走这种处理本身没问题但如果大量并发冲突背后往往是设计缺陷光靠吞异常只会掩盖问题。正确做法是把约束当作设计的一部分而不是报错后的处理对象。建表时把主键、唯一、外键、非空都过一遍表结构评审时多问几个为什么比事后救火要舒服太多。写在最后的实践经验聊到这里MySQL 约束的核心内容基本覆盖完了。我个人在实际操作中的体会是约束一定要在表结构设计的最早期就规划到位后期补约束的成本和风险都是前期的好几倍。主键定聚簇、唯一定去重、外键定关联、非空定基线每个约束背后都对应一类业务风险漏掉一个将来就可能要用无数个加班小时去填坑。最后一招强烈建议每次建表之前把“可能出现在 WHERE 条件里的字段、可能出现在关联关系里的字段、业务上必然唯一的字段”这三类列出来逐一确认它们是否需要约束、需要什么类型的约束。这套动作花不了十分钟但对长期数据质量的影响完全值得。希望这篇关于 MySQL 约束的实战拆解能帮你少走几步弯路也欢迎在实践中多总结自己的建表模板形成自己团队的数据规范。
返回列表