1. 从“谁能写入数据”谈起:约束的真实角色
几个月前,我在某公司做数据库设计评审,看到一张用户表,居然连最基本的唯一约束都没加。业务负责人解释说:“我们程序里已经做了手机号校验,不会重复的。”可我随手翻了下日志,就看到两条手机号完全相同的记录,时间戳只差了几百毫秒。典型的高并发场景下,两个请求同时通过了应用层的if判断,然后一前一后写进了库。这种纯靠应用层代码防重复的路子,早早晚晚会给你上一课。
MySQL表的约束,本质上就是把“什么数据允许进来”这个裁决权,从散落在各处的业务代码分支里,交还给数据库引擎本身。你只要在建表或后期变更时把这些规则清清楚楚地声明出来,之后无论是应用写入、后台脚本修复数据、DBA临时改数,还是接进来的新报表服务,所有入口都会统一按这套规则逐条校验,不合格的直接拒绝执行。数据量小、业务简单的时候,这种能力看起来可有可无;团队一扩大、接口一多、报表一多,约束带来的安全感比什么代码规范都实在。
这篇文章适合三类人:刚学MySQL、想彻底搞明白约束用法的新手;天天写INSERT语句却没认真琢磨过表结构的业务开发;以及需要帮团队建立数据库规范的技术负责人。我会把常用约束的用法、设计取舍、实际坑位和排查思路串起来讲,内容基本来自我在项目里真正踩过的地面,而不是教科书上的概念复述。
1.1 数据完整性,落到数据库头上才算数
数据完整性这四个字听起来很虚,拆开看其实就三件事:该有的字段必须有值、业务上不该重复的数据不能重复、记录之间的引用关系不能悬空。很多团队喜欢把这三件事全部写进服务端代码,比如Java里用Validation注解,PHP里堆一堆if判断,前端表单再拦一道。表面上层层设防,实际上漏了个大窟窿:数据库是多个应用共享的底层基础设施。你的主系统校验了,可运营后台直连修改、数据订正脚本、定时任务补偿逻辑、DBA手动处理线上数据,这些路径不一定全走你那一套代码。
把约束建在表上,等于在最后一道关卡设了安检口。数据从哪个门进来不重要,重要的是进门之前必须过了这一关。我见过一个最典型的案例:某系统因为业务调整要批量把用户积分翻倍,运营提了个SQL让DBA直接执行,结果UPDATE语句把不该改的行也连带改了,幸好表上有外键约束和CHECK约束,数据库在提交时拦截了一部分异常数据,才没有酿成全表数据污染的惨剧。从那以后我就特别坚定一个原则——能在数据库层面拦截的错误,绝不指望应用层自觉。
1.2 约束不是越多越好,用多了同样伤身
必须说清楚一个容易被误解的真相:约束能保数据质量,但每一条约束背后都有代价。UNIQUE约束和主键必然伴随索引创建,每次写入都要额外维护索引结构;外键约束让子表的每一条写入都要去父表确认对应记录是否存在;CHECK约束在高频更新的字段上也会增加校验开销。这些成本在数据量小的时候完全无感,可一旦核心表到达千万级行数、写入TPS冲到几百上千,约束叠加起来的影响相当明显。
所以设计约束要分场景。用户手机号该不该唯一?该,这是硬规则,必须用UNIQUE死死锁住。订单状态字段该不该加CHECK?看情况,如果业务经常要扩展新状态,CHECK反而会成为变更的绊脚石。真实项目里最忌讳的行为是照抄某份“标准表结构”,把所有约束全堆上去,最后发现一个高频写入的流水表被三四个外键拖得疲软不堪,删又不好删,改又不敢改。约束不是装饰品,每加一条都得问自己:这个规则是否真的不可违背?违背的后果是否无法承受?
2. MySQL常用约束逐一拆解
2.1 NOT NULL:给字段立个底线规则
NOT NULL恐怕是MySQL里最基础也最容易被忽略的约束。很多人觉得它不就是“字段不能为空”嘛,有啥好说的?但你细想一下,一张表几十个字段,哪些必须给值、哪些允许留空,这本身就是很重要的业务声明。比如用户表的用户名,注册流程里必填,数据库里就该标记NOT NULL;而用户昵称这种可以后补的字段,就允许为空。
实际操作里有个很常见的分歧点:业务上“不能为空”和数据库里“不能为NULL”其实不是一回事。很多时候你希望字段不填时给个默认值,而不是真正存一个NULL。比如订单的支付时间,刚下单还没支付时不应该为空,但也不应该是什么伪日期,这时候用DEFAULT配合业务逻辑处理更合理。我个人的经验是,对于所有主流程的关键字段(手机号、用户标识、金额、状态等),一律加上NOT NULL,宁可让程序报错,也不要让NULL悄悄混进数据里。
还有个细节容易被新手忽略:MySQL里NULL和空字符串是两回事。NULL代表“未知、不存在”,空字符串代表“我知道这是个空值”。在唯一索引下,NULL值可以重复出现,但空字符串会被唯一约束拦住。这个差异曾经坑过不少人——你以为给字段加了UNIQUE就万事大吉,结果两条记录的字段值都是NULL,照样能插入成功。所以当你说“这个字段不能重复”的时候,先想清楚NULL允不允许存在,如果字段本身也不能为NULL,就再加上NOT NULL。
2.2 UNIQUE与PRIMARY KEY:唯一性的两个层级
PRIMARY KEY是表里最重要的一组约束,它的本质是唯一且非空,而且一张表只能有一个主键。主键不仅是约束,还是InnoDB存储引擎组织数据的方式——聚簇索引就是按主键构建的。这也是为什么我总劝别人别用随机字符串当主键,插入时页分裂严重,性能容易崩;用自增整数或雪花算法生成的有序ID要友好得多。
UNIQUE约束则灵活得多,一张表可以有很多个。它解决的核心问题是“哪些字段的组合在业务上不允许重复”。最常见的例子:用户表的手机号字段要唯一;订单表的订单号要唯一;某个关联表里,用户ID和商品ID的组合要唯一。注意“组合唯一”这个用法,很多人只会写单字段唯一,却在遇到“一个用户不能重复收藏同一个商品”这类需求时,跑去程序里做先查询再插入。其实一个联合唯一索引就完事了。
CREATE TABLE user_favorite ( user_id BIGINT NOT NULL, product_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_product (user_id, product_id) ) ENGINE=InnoDB;这条联合唯一约束加好以后,同一对user_id和product_id重复插入时数据库会直接报错,应用层连判断都不用写。唯一约束还有个副产品它自带索引,如果你后面写查询经常要按user_id + product_id查找,这个索引顺手就覆盖了。
在PRIMARY KEY和UNIQUE之间做选择时,我的建议是:主键尽量用与业务无关的代理键(比如自增ID),把业务上真正要保证唯一的字段用UNIQUE约束来表达。千万别把手机号设成主键——一旦业务支持用户换绑手机号,主键变更的代价会让你哭都哭不出来。
2.3 DEFAULT:让字段在缺省时也有默认姿态
DEFAULT本身不算严格意义上的“校验型约束”,它更像一道兜底逻辑:插入数据时如果没提供这个字段的值,MySQL就自动填上默认值。别小看这个动作,它解决的是“字段到底该不该为空”以及“空值的语义是什么”这两个问题。
最常见的使用场景是记录创建时间和更新时间。MySQL 5.6以后支持了DATETIME类型的默认值,5.6.5版本之前只能靠TIMESTAMP,这个版本差异曾经绊倒过一批从老项目迁移过来的人。新项目里你可以放心这么写:
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMPON UPDATE CURRENT_TIMESTAMP这个属性最实用,只要行记录被更新,这个字段就会自动刷新成当前时间,不用在应用层手工维护“最后修改时间”。我遇到过不少项目,初版表结构没加这一句,后来为了做增量同步,被迫在几十个业务方法里手动update_time = now(),改得痛不欲生。
用DEFAULT还有一个隐形好处:它配合NOT NULL使用,可以彻底杜绝NULL进入表里。比如订单状态字段,默认给个pending,后续业务在流程中把它改成paid或者canceled,表里永远不会出现“我也不知道这个单现在什么状态”的NULL。
2.4 FOREIGN KEY:保护引用关系的双刃剑
外键约束可能是被讨论得最多的约束,也是企业级应用里最容易被“战略放弃”的一个。它的作用是保证子表里的某个字段取值必须在父表主键或唯一键中存在,典型场景就是订单表里的user_id,必须能对应用户表里真实存在的用户ID。加了外键,你就不会出现“订单属于一个不存在的用户”这种谔谔的数据。
但外键在互联网高并发场景下口碑两极分化。一方面它确实能在数据库层面守护引用完整性,避免了业务代码卸载后再写脏数据;另一方面,InnoDB的外键会自动在子表上建索引,插入和删除时都要额外做一次关联检查,父表删除时如果带上了ON DELETE CASCADE这种级联动作,还可能连锁删除大量子记录,搞出性能事故和误删风险。
我个人的实践原则是:核心业务表之间的强引用关系,外键该用就用,尤其是那些不允许出现“孤儿数据”的场景,比如支付流水与订单的关联、账号与用户基本资料的关联。而一些高吞吐、低延迟的分库分表场景,外键基本没法跨库使用,这种情况下就只能在应用层做引用完整性兜底。还有一个折中做法:表结构里不写FOREIGN KEY,但照样给关联字段建普通索引,同时依靠定时任务扫描孤立数据。这个方法没有外键的实时性,但胜在灵活,适合分布式架构。
如果你决定用外键,请一定注意父表和子表字段类型必须完全一致,包括unsigned这种修饰符都要对齐。我曾经见过一个故障:父表主键是BIGINT UNSIGNED,子表字段写成BIGINT,结果建外键时报“Cannot add foreign key constraint”,排查了半天才发现是字符集和字段定义不一致。这类报错信息不会直接告诉你原因,只能靠经验逐项排查。
2.5 CHECK:从MySQL 8.0才真正硬起来的校验约束
CHECK约束的逻辑很简单:给字段或整行定义一个布尔表达式,写入或更新时必须满足表达式才允许操作。比如年龄必须大于0小于150,库存不能为负数,订单金额必须大于0。这个功能在很多数据库里是老朋友了,但在MySQL里经历了一段尴尬岁月——8.0.16版本之前,MySQL会解析CHECK子句,却不会真正执行校验。换句话说,你加了等于没加,数据照样能插入非法值。
所以如果你还在用MySQL 5.7或者更早的版本,千万别指望CHECK约束能帮你干活,它就是个花架子。升级到8.0.16之后,CHECK约束才真正开始执行。用它的时候有几个坑需要注意。
CREATE TABLE inventory ( product_id BIGINT PRIMARY KEY, quantity INT NOT NULL, CHECK (quantity >= 0) ) ENGINE=InnoDB;这条约束能挡住负数库存写入。但如果你后来想把这个字段改成允许“负数表示退货冲抵”,就不得不先删除CHECK约束再重建,属于典型的需要前瞻性设计的场景。另外CHECK约束可以取名字,强烈建议命名规范一点,比如chk_quantity_non_negative,否则删除约束时要通过SHOW CREATE TABLE去一个个猜那句没名字的CHECK是什么。
3. 实操:从零设计一个订单表,把约束一次建全
3.1 业务需求梳理与约束规划
理论讲了一堆,不如直接来一个完整案例。假设你现在要设计一张电商订单表,先理清楚有哪些硬性规则:
订单号全局唯一,不能为NULL,而且生成后不能修改;下单用户必须来自用户表,不允许出现“幽灵用户”;订单金额必须大于0,打了折扣也不能白送;订单状态只能在若干合法状态里流转;创建时间必须自动填充,不允许业务代码随意传一个乱写的过去;收货手机号格式上至少不能为NULL,虽然MySQL本身没有内置手机号校验,但可以用CHECK把它限制为11位数字。
把这些规则一条条列出来,再对应到应该用哪种约束上,建表的时候就不是拍脑袋了。我自己习惯用一个约束清单表来规划,明确每条业务规则用主键、唯一键、外键还是CHECK来表达,哪些规则不适合用数据库约束、需要应用层处理。这个文档虽然简单,但在后期评审表结构或排查脏数据时,省了很多沟通成本。
3.2 建表SQL完整实现
下面直接给出一个可以在MySQL 8.0.16以上版本执行的建表语句:
CREATE TABLE `order_info` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键', `order_no` VARCHAR(32) NOT NULL COMMENT '业务订单号', `user_id` BIGINT NOT NULL COMMENT '下单用户ID', `total_amount` DECIMAL(12,2) NOT NULL COMMENT '订单总金额', `status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '订单状态', `receiver_mobile` VARCHAR(20) NOT NULL COMMENT '收货手机号', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), CONSTRAINT `fk_order_user` FOREIGN KEY (`user_id`) REFERENCES `user_info` (`id`), CONSTRAINT `chk_order_amount` CHECK (`total_amount` > 0), CONSTRAINT `chk_order_status` CHECK (`status` IN ('pending', 'paid', 'shipped', 'completed', 'cancelled')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单主表';这里有几个设计点值得解释。订单号虽然全局唯一,但我没有把它设为主键,因为订单号的生成规则以后可能调整(比如加入仓库编码、业务线标识),而主键是极其稳定的存在,不适合跟着业务规则变来变去。自增主键简单可靠,聚簇索引顺序写性能好,唯一的遗憾是分布式架构下跨库生成会有冲突,那种场景可以换雪花算法。
金额字段用DECIMAL而不是FLOAT或DOUBLE,这是无数踩坑换来的经验。浮点数在MySQL里存储的是近似值,0.1加0.2可能得到0.30000000000000004,账目对不平。DECIMAL是精确小数存储,加上CHECK (total_amount > 0),负数和零金额都进不来。
外键我加在了user_id上,这是最典型的强引用场景。订单离不开用户,用户删了订单不能变成孤儿,所以我把它作为硬性外键约束。但要注意FOREIGN KEY创建时会在子表自动创建一个索引,这里我手动写的KEY (idx_user_id)和外键自动创建的索引其实是同一个,策略上可以保留其中一个,避免冗余索引。实际生产里,如果这张表查询压力大,后续可能还要加联合索引,比如(user_id, status, create_time),那就得根据慢查询日志再调整。
3.3 ALTER TABLE在线变更约束的正确姿势
建表时把约束一次建全是理想状态,但现实总是不断变化。可能上线三个月后,产品说要支持“退款金额可为0”的场景,那CHECK (total_amount > 0)就挡路了;也可能业务调整,订单号要从32位扩展到64位。这种时候就需要ALTER TABLE来变更约束。
删除一个CHECK约束的SQL如下:
ALTER TABLE order_info DROP CHECK chk_order_amount;注意MySQL 8.0里删除CHECK必须用约束名。如果当初建表时没给CHECK约束起名字,MySQL会自己生成一个像order_info_chk_1这样的名字,你得先执行SHOW CREATE TABLE order_info看清楚了再删。这事我干过好几次,每次都忍不住嘟囔一句“命名不规范,DBA两行泪”。
如果只是调整CHECK的表达式,那就需要先删除再新增,因为MySQL没有ALTER CONSTRAINT这种直接修改的操作。操作顺序上有个经验:先加新约束再删旧约束,可以避免中间窗口期出现非法数据进入。虽然在线DDL在InnoDB下大部分操作不会锁全表,但涉及数据校验和索引重建的步骤仍然会有性能影响,所以生产环境最好放在低峰期。
加外键约束同理,但要注意一个前提:现有数据必须全部满足约束条件,否则加约束会直接失败。比如你已经有一堆user_id不存在的订单数据,再执行外键约束的ALTER TABLE就会报错,需要先把脏数据修干净再动手。
ALTER TABLE order_info ADD CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user_info(id);这种操作在小表上没问题,在千万级大表上还要考虑锁时间和校验成本。我经历过的方案是:先在一个临时表上建好完整约束,通过数据校验工具对比数据一致性,然后改名切换,整个过程分三步走,风险可控得多。
3.4 验证约束是否生效的几种方式
加完约束不等于万事大吉,我见过有人建了CHECK约束却完全没生效的,上面说过,版本问题。所以验证是必须的环节。
最简单的验证方式是故意插入一条违反约束的数据,看它是否被数据库拦截:
INSERT INTO order_info (order_no, user_id, total_amount) VALUES ('TEST001', 999999, -1);这条SQL如果执行成功,说明约束没起作用;正常情况下MySQL会报出类似“Check constraint 'chk_order_amount' is violated”的错误。INSERT失败可不代表UPDATE就能被拦下,所以还得测一下更新路径,比如把一个原本合法的金额改成一个负数。我自己的习惯是写一个简单的验证脚本,把每一条约束的反例都跑一遍,确保全部被拦截。
更全面的巡检方式是直接查表结构信息:
SHOW CREATE TABLE order_info;这个命令会输出完整的建表语句,所有约束一目了然。如果你管理的是多个环境(开发、测试、生产),定期对比各环境的SHOW CREATE TABLE输出,能帮你及时发现环境之间的表结构漂移。很多诡异的数据问题,追到最后都是某个环境漏加了一条约束导致的。
4. 约束维护中的常见问题与排查技巧
4.1 报错代码与应对方案速查
约束相关的报错里,有些信息一眼就能看懂,有些则要绕几个弯子。我整理了一个高频问题速查表,基本覆盖了日常运维里八成以上的情况。
| 报错关键字 | 含义 | 常规解决办法 |
|---|---|---|
| Duplicate entry ‘xxx’ for key ‘uk_xxx’ | 唯一约束冲突 | 先查重复数据,确认是业务允许重复还是程序逻辑问题 |
| Cannot add foreign key constraint | 外键添加失败 | 检查字段类型、字符集、父键是否存在、父表是否有对应唯一索引 |
| Check constraint ‘xxx’ is violated | CHECK约束校验失败 | 检查写入值是否违反表达式,多数是应用层没处理好 |
| Column ‘xxx’ cannot be null | NOT NULL约束被违反 | 检查插入语句或默认值配置,确定该字段是否确实需要必填 |
| Multiple primary key defined | 重复定义主键 | 一张表只能一个主键,改用UNIQUE约束 |
| Field ‘id’ doesn’t have a default value | 严格模式下非空字段无默认值 | 补值或加DEFAULT,或者检查是否没走指定插入列 |
Duplicate entry这个问题最常出现在并发写入场景,特别是先查后插的方式,永远存在两句话之间的时间差。正确解法是先做唯一约束,让数据库来兜底,应用层捕获重复键异常后做出友好提示。我看见过太多团队在应用层用分布式锁来防重复,绕了一大圈,最后并发一上来照样漏。
4.2 关于约束的两个版本坑
MySQL里有两个特别容易踩的版本相关坑,遇到的频率相当高。
第一个是CHECK约束的生效问题。上面已经提过,8.0.16之前MySQL里的CHECK只是语法上接受,实际完全不做校验。如果你的项目跑在MySQL 5.7或更早版本上,必须在其他层面补上规则,比如用触发器或者干脆全靠应用层。很多从PostgreSQL或者Oracle转过来的同学,习惯性地在MySQL写CHECK,数据照样写进去了,还以为一切正常。
第二个是严格模式。MySQL的sql_mode设置直接影响约束行为。在非严格模式下,很多原本该报错的写入会被MySQL自动“纠正”,比如超长字符串被截断、非法日期变成0000-00-00,然后带着警告写进表里。这不是约束失效,而是MySQL按你的sql_mode放行了。建议生产环境把sql_mode设置成包含STRICT_TRANS_TABLES和NO_ZERO_DATE,让该报错的及时报错。我排查过不少脏数据,最后都揪出是某人把某台测试机的sql_mode调松了,同步数据之后把脏数据带了进来。
这三个模式相关参数建议写进规范的初始化脚本里,不要靠运气:
SET GLOBAL sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';4.3 生产环境修改约束的流程建议
生产环境加约束或者删约束,绝不能直接拿一条ALTER TABLE就往线上怼。我不知道有多少次在半夜被拉起来处理这种事,结论永远是:先想清楚影响面,再决定怎么动手。
我的标准流程是这样的。首先,确认目标表的数据量,如果超过几百万行,先别说加不加约束,得先评估DDL执行时带来的锁和I/O压力,尽量选在业务低峰期;其次,先在一台从库上执行一遍同样的DDL,观察耗时和是否触发报警,顺便验证约束是否真的能拦住问题数据;接着,在主库操作前做好表备份,最好用备份工具先快照一份;最后,操作执行完,立刻运行校验脚本和SHOW CREATE TABLE,确认约束在,数据还在,性能没崩。
有一个特别值得强调的坑:有些ALTER TABLE操作在8.0里是支持在线DDL的,但还是会消耗额外空间和I/O。如果你是在磁盘快满的实例上做大表加约束,很可能直接触发空间不足的情况。我之前就碰到过一次,一个600GB的表要加唯一索引,执行到一半实例空间亮红,最后只能先扩容再继续。所以机器资源充足度也要纳入变更前检查清单。
5. 做了几年数据库设计,我想多说几句
约束这东西,看上去是建表时顺手写几个关键字,可它背后代表的是你对数据的态度。我见过不少表结构,字段随意、约束稀少,看起来“自由奔放”,实际上写业务的同事每天都在和各种奇怪数据搏斗。与其让团队的每个人都小心翼翼地在代码里防御,不如把你能想到的硬规则写到数据库里,让引擎替你执行。
我个人这几年做下来,体会有三条,也许对你有参考价值。
第一条,字段是否允许NULL这件事,一定要有明确的默认立场。我现在的习惯是,默认给字段加NOT NULL,业务上确实可选的字段再特殊处理。这个习惯直接让团队的数据质量提升了一个档次,很多之前莫名其妙的统计问题都消失了。
第二条,约束的命名一开始就要规范。主键就叫PRIMARY,唯一键用uk_前缀,外键用fk_前缀,CHECK用chk_前缀,后面跟上业务含义。别嫌麻烦,等你要维护几十上百张表的时候,统一命名能省下大量时间。MySQL生成的约束名可读性真的不行,靠它猜约束含义,跟在黑盒子里摸东西差不多。
最后一条,再强调一次:版本决定行为。你写SQL之前,先确认你跑了MySQL 8.0还是5.7,尤其涉及CHECK约束和默认值表达式的时候。很多在文档里理直气壮的写法,在老版本里根本不干活,甚至静默吞掉错误。生产环境里出问题不可怕,可怕的是你修了半天,结果发现是版本差异作祟。
约束只是数据库设计里的一小部分,但这一小部分做扎实了,后面很多坑都能提前避开。希望这篇从实践中长出来的文章,能让你在下次建表或者维护表结构时少走几步弯路。