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

文章详情

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

MySQL约束体系详解:从非空到外键,构建数据可靠性的第一道防线

MySQL约束体系详解:从非空到外键,构建数据可靠性的第一道防线 做了这么多年 MySQL 业务我越来越觉得“约束”这两个字被低估了。很多同学把约束当成建表时顺手写的几行声明但实际上它是数据库保证数据可靠性最底层、最省心的一层防护。我见过太多事故——订单金额出现负数、用户邮箱重复、删了用户表结果订单表变成一堆孤儿数据——追根溯源都是因为当初建表时删掉了约束或者约束设计得不合理。这篇文章我想把 MySQL 的约束体系整体梳理一遍非空、默认值、唯一、主键、检查、外键它们各自解决什么问题底层为什么这么设计有哪些常见的坑和运维经验。不论你是刚开始学数据库、处于初级开发阶段还是已经带过几个项目、准备完善表结构规范这篇文章应该都能给你一些可以直接落地的参考。1. 先理解约束的本质它是在为哪几种错误买单我最早理解约束的方式特别朴素数据库的约束等于给表数据上了一套“门禁规则”。你提前定好什么数据不允许进来MySQL 就会在写入、修改、删除时自动拦截不符合规则的操作不需要你写一堆应用层的 if 判断。这套门禁背后对应着数据库理论里的四种完整性理解它们比背语法重要得多。第一类叫域完整性Domain Integrity它控制的是“某一列的值范围是否合理”。手机号就不该出现负数性别列就不该出现随机字符串。负责这一块的主要是数据类型、非空约束 NOT NULL、默认值 DEFAULT 和检查约束 CHECK。很多人建表时把 id 设成 varchar或者给年龄列选了一个 TINYINT 却忘了加范围这些都属于域完整性没设计好。第二类是实体完整性Entity Integrity核心是“每一行都应该是可区分的”。这就要靠主键约束 PRIMARY KEY 和唯一约束 UNIQUE 来保证——不允许重复不允许模棱两可。第三类是引用完整性Referential Integrity解决“表与表之间的引用关系不能断裂”。订单表的 user_id 必须对应一个真实存在的用户这就是外键约束 FOREIGN KEY 的职责。没有这套机制应用层删用户时很容易把历史订单变成“无主数据”。第四类是用户自定义完整性其实就是业务规则比如“优惠券不能和特价同时使用”“积分余额不能小于零”。这类规则有些用 CHECK 就能兜住有些必须在应用层或存储过程里实现。我给你列一张对应表方便对照约束类型关键字解决哪类问题典型误用/盲区非空约束NOT NULL列值不允许为 NULL与空字符串混淆默认值DEFAULT缺省时自动填充值显式插入 NULL 不会触发默认值唯一约束UNIQUE列值不能重复允许多个 NULL 常被忽略主键约束PRIMARY KEY非空且唯一业务字段直接做主键导致后续难改检查约束CHECK值必须满足条件8.0.16 之前不强制生效外键约束FOREIGN KEY引用关系完整高并发/分库场景物理外键代价高简单说来约束就是让你在数据入口处就把错误挡掉而不是等数据脏了再审。写代码的人都知道“防御式编程”约束就是数据库层面的防御式编程。2. NOT NULL 和 DEFAULT关于“空”的两种哲学2.1 NULL 和空字符串是两个完全不同的世界入门 MySQL 时最容易踩的坑是把 NULL 当成“空值”“空串”来理解。实际上 NULL 的语义是“未知的、不存在的、还没有赋值”而空字符串 是一个“有效但长度为 0 的字符串”。它们的存储方式、比较方式、索引行为完全不一样。举个例子。假如有一张用户表mobile 字段存手机号。在 MySQL 里WHERE mobile 只能找到空字符串手机号的用户永远找不到 mobile 是 NULL 的用户。反过来WHERE mobile IS NULL也只匹配 NULL。如果你把 NULL 存入了一个被业务代码判断 的字段里过滤逻辑直接失效这种 bug 排查起来特别费劲。判断一个列是否为空不是 NULL而是IS NULL。NULL 和任何值比较结果都是 NULL也就是“不确定”不会返回 true也不会返回 false。这也解释了为什么WHERE 某个值 NULL查不出任何显然“不是 NULL”的行——比较结果永远是未知等值判断逻辑失效。所以设计 NOT NULL 约束之前先要问清楚业务意图这个字段是“允许为空但必须给明确值”还是“允许缺失但语义上代表未知”如果是前者应该用默认值配合非空约束如果是后者你才需要认真考虑要不要允许 NULL 进入表里。2.2 DEFAULT 不是你想的那种兜底默认值约束 DEFAULT 的意思是当这条 INSERT 语句没有提供该列的值时MySQL 才会用默认值填充。注意是“缺省时”不是“显式插入 NULL 时”。如果你执行INSERT INTO user (name, age) VALUES (张三, NULL)而 age 列恰好允许 NULL那么最终存进去的就是 NULL而不是 DEFAULT 里配置的数字。只有当你写INSERT INTO user (name) VALUES (张三)时age 列才会自动落到 DEFAULT 值上。这一点我在很多项目里都见过误解。有人为了“让时间字段自动填充”给 create_time 设置了DEFAULT CURRENT_TIMESTAMP然后前端传了一个null进来应用层直接把这个 null 拼进 SQL。结果数据库老老实实存了个 NULL根本没触发默认值。后来排查到问题根源才发现 DEFAULT 永远不会因为“传入 NULL”而生效。如果业务上希望“传入 NULL 时也当作没传”那需要在应用层做清洗或者干脆把该列设为 NOT NULL DEFAULT CURRENT_TIMESTAMP——传 NULL 直接报错反而能更早暴露问题。2.3 严格模式下和非严格模式下的行为差异这里必须要提到sql_mode这个隐藏的“大佬”。MySQL 在非严格模式下违反约束时经常只给 warning不会报错。比如你给一个 NOT NULL 列插入了 NULL非严格模式下 MySQL 会按照数据类型自动填充隐式默认值——数字填 0、字符串填空字符串然后只弹一个 warning。这种“看似成功了实际数据已经错了”的情况在生产环境里是灾难。严格模式下同样操作就会直接报错整个事务回滚。设置方式是SET sql_mode STRICT_TRANS_TABLES需要的话还可以加上STRICT_ALL_TABLES。我接手过的项目里凡是线上数据变得诡异的八成是某台实例的 sql_mode 被改动过或者建库时用了宽松的全局默认值。所以建议所有实例统一使用 STRICT_TRANS_TABLES 或更严格的组合并把它写入初始化配置别依赖默认。-- 查看当前模式 SELECT sql_mode; -- 会话级别调整为严格模式 SET SESSION sql_mode STRICT_TRANS_TABLES,ALLOW_INVALID_DATES;顺便说“允许为空”和“允许 NULL”是两件事。如果你想表达“该字段不能缺失但内容可以没填写”那么正确做法是NOT NULL DEFAULT 或者NOT NULL DEFAULT 0。如果你既加 NOT NULL 又没给 DEFAULT那么插入时必须显式提供值否则直接报错——这也是一种强制业务尽早明确数据语义的手段。3. 唯一与主键把“同一份数据”关在门外3.1 为什么 UNIQUE 约束允许多个 NULL 存在这是 MySQL 里最容易被质疑的行为。你在 user 表给 email 加上 UNIQUE然后连续插入两条 email 都为 NULL 的记录居然都成功了。很多人觉得“这不就破坏了唯一性吗”MySQL 的设计逻辑是NULL 不等于任何值包括它自己。在唯一索引看来NULL 不是一个具体值而是一个“未知值”未知和未知之间无法比较是否相同所以不会触发冲突。你可以理解为“不同的未知不能说是同一个已知”。这个行为大多数场景下是合理的因为业务语义上“没填邮箱”和“填了同一个邮箱”本来就是两种状态。但如果你真的连“空值也只能出现一次”都要强制那么就需要手工做一个兜底给该列设置 NOT NULL并把所有为空的情况统一映射成某个保留值。比如0或UNKNOWN让空值变成一个具体值这样唯一约束就能生效了。代价是这个保留值会被业务逻辑反复特殊处理所以应用场景比较少见。如果你想封禁业务上的空值参考做法是把不可为空的字段直接设为NOT NULL再把空字符串在应用层拦截掉。3.2 联合唯一索引不只是把两个列都加 UNIQUE联合唯一约束解决的是“组合维度不能重复”。比如一张点赞表(user_id, post_id) 组合必须唯一否则一个用户可以给同一篇文章点无数次赞。建表语句里写CREATE TABLE user_like ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, post_id BIGINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_post (user_id, post_id) );这个约束比想象中复杂联合唯一索引是有列顺序的最左原则在这里依然生效。上面这个索引对于“查某个用户点了哪些文章”的场景非常高效因为 user_id 在最左侧但对于“查某篇文章被谁点了”这个场景它可能就走不了高效索引了需要额外评估。所以我通常建议设计联合唯一约束前先把查询场景列出来看看哪几个维度是筛选最频繁的把高频筛选列放在联合索引左边。约束本身和查询优化在这时是一起决策的不是两条独立的工作线。3.3 主键到底选自增、UUID还是业务字段主键约束 PRIMARY KEY NOT NULL UNIQUE。理论上任何非空唯一的字段都能当主键但在实际项目中我强烈不建议拿有业务意义的字段做主键。业务字段会变比如手机号换号了、邮箱换了服务商一旦主键值发生变更所有引用它的子表都要跟着改牵一发动全身。更稳妥的做法是使用代理主键与业务完全无关。自增主键是最常见的选择简单、有序、对 InnoDB 聚簇索引友好。但高并发写入时自增主键容易成为热瓶颈因为每次插入都要在索引末尾争抢加锁分库分表场景下单表自增没法保证全局唯一还得额外引入分布式 ID 方案。UUID 主键的好处是全局唯一、无序、可以离线生成坏处也很明显占用空间大36 个字符、随机写入会造成页分裂InnoDB 吞吐量往往不如自增主键。折中方案通常是雪花 ID、号段模式或者基于 Redis 的序列表生成。无论选哪种核心思想都一样主键一旦生成就不应该再修改主键不应该承载任何业务含义主键应该在写入前就能确定别依赖数据库临时算。3.4 约束和索引的关系它们是伴生的每个 PRIMARY KEY 和 UNIQUE 约束在 MySQL 里都对应着一个索引。主键会自动创建聚簇索引UNIQUE 会自动创建唯一索引。这意味着你不需要再额外在相同列上建普通索引否则就是重复建设、白占空间。建表时看到不合理的冗余索引大部分都来自这里——已有唯一约束又另建普通索引。一个小习惯删除唯一约束时它对应的索引也会被删除反过来如果你只删了索引约束就失去了支撑行为不可预期所以不建议这么做。4. CHECK 约束活了几个大版本才真正上线的守门员4.1 MySQL 8.0.16 之前CHECK 基本是“摆设”CHECK 约束的含义是“列值必须满足一个表达式”比如年龄必须在 0 到 120 之间、状态字段必须是已定义枚举值之一。听起来很实用但 MySQL 的历史包袱在于8.0.16 之前CHECK 子句在语法上是支持的但实际并不会生效——你写一条不合规的数据进去MySQL 根本不拦仿佛没这回事。这就导致一个非常危险的现状很多老项目里明明写着 CHECK 约束实际上形同虚设。如果你维护的库是 5.7 或者更早的 8.0.15务必去实际验证一下约束到底有没有生效。验证方式很简单往表里插一条违反约束的数据看它是否被拒绝。如果被拒绝了恭喜你的版本真的支持了如果插入成功说明约束只是“纸面约束”。从 8.0.16 开始MySQL 实现了 CHECK 的强制逻辑并且给它加上了约束名可以像其他约束一样被管理和删除。所以如果你还停留在 8.0.15 以下但又不想升级那就别指望用 CHECK 做防护老老实实靠应用层校验。4.2 推荐用它兜底但别把它当唯一防线我个人的做法是凡是字段取值能从语法上抽象成数学或集合规则的一律加上 CHECK。例如年龄范围、库存数量不小于 0、业务状态码在指定集合内。这些规则简单稳定、几乎不会变数据库层面兜底非常划算。CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, stock INT NOT NULL DEFAULT 0, price DECIMAL(10,2) NOT NULL, CONSTRAINT chk_stock_not_negative CHECK (stock 0), CONSTRAINT chk_price_positive CHECK (price 0) );注意 CHECK 只在写入和更新时拦截违规条目不会自动帮你修正数据。所以更完整的防护链应该是应用层负责用户输入的语义校验数据库 CHECK 负责最后一道物理防线线上日志和监控负责发现绕过规则的非预期数据。三层都做才是稳健设计任何一层想偷懒最后都会在某个凌晨让你加班。4.3 小心 ENUM 这个“类约束”的坑很多人偷懒用 ENUM 来模拟检查约束比如status ENUM(pending, success, failed)。这种做法优点是写法简单缺点也很明显改枚举值需要执行 DDL对表会有加锁和重建的开销ENUM 内部是数字排序时容易出现“按定义顺序”而非“按字典序”的诡异结果插入时如果给了不在枚举列表里的值严格模式直接报错非严格模式会把它变成空字符串。如果一个状态集合变化频繁我倾向于用TINYINT CHECK 应用层枚举类做映射。数据库只存数字业务逻辑负责解释数字含义。这样既保住约束又避免数据库频繁动结构。当然如果状态集合几年都不变ENUM 也不是不能用只是要意识到它是“类约束”真正的标准检查约束还是要靠 CHECK。5. FOREIGN KEY物理外键是一把有倒刺的钥匙5.1 外键的默认行为和删除策略外键约束要求子表的某个列值必须能从父表的主键或其他唯一键里找到对应值。它可以防止“订单表的 user_id 指向一个不存在的用户”这类问题。删除父表数据时外键还会按你配置的规则联动子表。MySQL 里常用三种策略策略行为适用场景RESTRICT / NO ACTION父表存在被引用数据时不允许删除或更新默认行为要求严格一致CASCADE父表删除或更新子表对应记录同步删除/更新父子生命周期强绑定例如文章与其标签关联SET NULL父表删除后子表引用列置为 NULL保留子表记录但解绑关系且子表列允许 NULL举个例子订单明细表 order_item 引用订单表 ordersCREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id BIGINT NOT NULL ); CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, CONSTRAINT fk_order_item_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE ON UPDATE CASCADE );这样设计后删除订单时其明细会自动清掉应用层少写很多妥协代码。但要清醒认识到CASCADE 是数据库自动执行的权限校验、审计日志都绕过了应用层。一旦误删父表数据子表数据连带着被清掉回滚窗口又极短这种事故很难恢复。所以在需要强管控的场景比如财务单据里我更倾向用 RESTRICT 或者软删除不搞物理级联删除。5.2 为什么很多高并发团队选择“禁用外键”这是数据库圈里最有争议的话题之一。物理外键在理论上是好东西它保证了引用完整性。但到真实业务中不少团队会主动禁用它原因可以归纳为三类。第一个原因是锁和性能问题。在高并发写入场景下外键检查需要在父表上加共享锁子表插入/更新时又要去检查父表中对应主键是否存在这会让写路径变长锁竞争更容易出现。尤其是批量导入、高频小事务的场景外键可能导致死锁或者锁等待放大调优起来比普通索引问题更棘手。第二个原因是分库分表后外键直接失效。一旦用户表和订单表被拆到不同的库、甚至不同的实例一个跨库的物理外键根本无法声明。业务发展到一定体量就必然要考虑拆库这时候物理外键会变成迁移的障碍。与其到那时候痛苦改表结构不如一开始就用逻辑外键——应用层保证引用关系正确数据库只负责业务数据本身的合法性。第三个原因是运维和迁移成本。修改外键涉及的 DDL 往往需要锁表大表上做一次外键变更可能要停写服务数据迁移或读写分离时外键还会影响导入顺序得先导父表再导子表流程上多出一堆约束。当然这不是说外键是坏东西。如果是中小系统、并发不高、数据关系稳定物理外键带来的保护价值远大于成本。我刚接触项目的那些年反而总是提醒团队“没有外键的表在数据一致性上全靠自觉出问题只是时间问题。”问题的关键在于认识自己的系统处在什么阶段。5.3 外键列上的索引问题InnoDB 在检测外键时如果发现外键列上没有合适的索引通常会自动创建一个索引——你可以把它理解为数据库“隐性帮忙”。这个默认行为解决了“完全不管索引导致删父表时全表扫描”的尴尬但别高兴得太早。对于多列外键、大表高并发场景自动创建的索引往往不是最优设计它可能只是简单地在所有外键列上建了一个索引未必符合你的查询模式。比如你可能希望一个覆盖索引同时包含 child_id、order_id、status而不是只有一个 order_id 的裸索引。所以我建议建外键时顺手检查执行计划确认实际查询路径用到了你自己设计的最优索引而不是依赖 InnoDB 的兜底索引。这一点平时没人注意一旦订单量上来差距立刻就能看出来。6. 约束的运维课加约束、改约束、删约束都不只是写一条 SQL6.1 给已有大表加约束之前请先做“数据体检”很多约束在空表上添加非常顺利但放到已经有几年历史的生产表上一个 NOT NULL 或 UNIQUE 分分钟让整库停摆。原因很简单违反约束的数据已经躺在那儿了MySQL 要硬加约束时会直接报错或者需要重建表耗费数小时。我最常见的操作流程是三步。第一步预估要走多长时间。第二步先跑一条统计 SQL确认存量数据是否满足条件。第三步在业务低峰期执行 ALTER并且设置超时或使用在线变更工具托管。-- 检查存量数据是否违反“库存非负”的约束 SELECT COUNT(*) AS violation_count FROM product WHERE stock 0; -- 确认符合后再真正添加约束 ALTER TABLE product ADD CONSTRAINT chk_stock_not_negative CHECK (stock 0);这个体检看起来简单但能避免你在凌晨两点被 DBA 叫醒。我在处理某个旧系统迁移时就是因为漏了这一步结果在 200GB 的流水表上加唯一约束跑了几个小时中途还差点把复制搞失效。从那以后我再没有不加检查就动大表结构的习惯。6.2 约束名的命名规范别等到要删的时候才后悔每个约束都应该有可读的名字。MySQL 8.0 的 CHECK 约束支持命名主键、外键、唯一键也都支持。规范化的命名不是洁癖而是实操刚需。删约束的时候必须通过名字来定位否则只能靠 SHOW CREATE TABLE 慢慢翻。我常用的命名规则是主键pk_表名唯一约束uk_表名_列名多个列用下划线连接外键fk_表名_列名检查约束chk_表名_含义例如uk_user_email、fk_order_item_order_id、chk_order_amount_positive。这套规则不复杂但能让任何人看到一行 DDL 就知道这条约束是干嘛的省下大量沟通成本。查看一个表当前的所有约束可以用SHOW CREATE TABLE user_like\G -- 更系统的查询方式 SELECT CONSTRAINT_NAME, CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA your_db AND TABLE_NAME user_like;6.3 在线 DDL 并不万能别在高峰时段赌运气MySQL 5.6 之后支持了 Online DDL 算法很多约束操作可以在线执行不阻塞写入。但“可以”和“总是可以”不是一回事。在 InnoDB 里添加 UNIQUE 约束通常需要重建表或至少重建索引可能用到ALGORITHMINPLACE但如果无法执行MySQL 会退回到COPY算法对全表加锁这是非常危险的。稳妥策略是把约束变更当作一场小型的运维事件。先看目标表大小再看当前主从复制延时常挑选业务低谷期执行执行中用监控观察线程运行状态。如果表特别大且业务几乎不能停写那就应该考虑通过专门的在线变更工具来托管操作把变更拆成增量追赶的方式降低锁的持有时间。6.4 软约束也是一种约束管理最后补充一个经常被忽略的点不一定所有约束都要用物理 DDL 实现。业务里有些规则变化极快比如“24 小时内每个用户最多下 5 单”这类约束如果做成物理 CHECK 或触发器改起来极其痛苦。合理做法是应用层 缓存计数实现逻辑约束。逻辑约束的缺点是同一个业务如果在多个代码入口都有写操作很容易漏掉校验。所以我会建议能用数据库物理约束解决的稳定规则一定要用数据库兜底频繁变化的规则用逻辑约束但必须集中收口到一个校验服务或方法里别散落在各个业务片段中。说实话我在很多项目里见过“约束意见”两极分化一边是“加什么约束都嫌碍事”另一边是“什么都要数据库管”。两种都有失偏颇。数据库约束应该用来承载稳定、通用、简单的完整性规则而那些复杂多变的业务规则还是得交给应用层。把握好这个边界建表时多想一步后面维护的人真的可以少掉很多头发。如果让我给一个最实用的落地建议每次建表前把每一列都问一遍“允许为空吗允许重复吗取值范围是什么是否引用其他表”然后把答案直接翻译成约束写进 DDL。这四问坚持两年你做过的项目数据质量会明显比同行高一个档次。
返回列表