前阵子帮朋友排查一个库存系统的数据异常,打开后台订单表,第一眼就看到三笔订单的customer_id对应不上客户表里的任何一行。再往后翻,同一张表里还有两条重复的员工编号,只是姓名不一样。排查到最后,根子不在业务代码,而是建表的时候根本没定义约束——数据入口完全敞开,任何绕过前端校验的请求都能把脏数据写进去。
MySQL基础课程里讲到约束这一块,很多人觉得简单:无非就是非空、唯一、主键、外键、检查、默认值这几个关键字。但实际拉过生产环境数据库的人都会明白,约束用得好不好,直接决定了数据会不会在三个月后变成一锅粥。这篇文章就围绕mysql基础中的约束展开,把每个约束类型背后的行为语义、适用场景、常见坑都过一遍,最后给你一套可以直接抄走的建表规划思路。适合刚在学数据库的初学者,也适合那些建表比较随意、经常被脏数据折磨的开发同学。
1. 约束到底解决什么问题:从表结构失序说起
1.1 没有约束时,脏数据是怎么一步步混进来的
很多项目刚上线时表结构干干净净,业务逻辑也正常,脏数据往往不是一天冒出来的,而是从几个“不起眼”的裂缝里慢慢渗进来的。
第一周,运营在后台录入了一批历史订单,为了赶工时直接写SQL导入,少了两个必填字段,导入程序也没拦,两张表的数据对不齐。第二周,开发A在服务端加了参数校验,开发B觉得“前端已经校验过了”,于是没加,同一个页面里两个接口行为不一致,某些字段开始出现NULL。一个月后,报表组拉数发现同一客户有两条档案,团队花了两天去重,然后临时在代码里写了过滤条件,但仓库里的原始脏数据还在。三个月后,数据库里已经没人敢说清楚哪些数据是准的。
这个过程的根源,并不是某个人的失误,而是错误没有在下游建立防线。应用层校验确实能挡住一部分问题,但它挡不住所有入口:定时任务、人工SQL、历史数据迁移、其他团队的服务,只要有一个入口绕过了校验,脏数据就能进来。约束的作用,就是把规则下沉到数据库这一层,让所有写入数据的路径都必须遵守同一套底线规则。
1.2 三类完整性与约束的对应关系
数据库理论里有个经典概念叫“数据完整性”,约束就是它的落地方式。完整性主要分三类,每类对应的约束也很清晰:
- 实体完整性:保证表中每一行能被唯一区分,典型就是主键约束和唯一约束。
- 域完整性:保证字段值在合理的范围内,典型就是非空约束、检查约束和默认值。
- 引用完整性:保证表与表之间的关联关系有效,典型就是外键约束。
还有一个“用户定义完整性”,意思是你自己定义的业务规则,比如“员工的薪资不能高于部门主管”,这类规则用CHECK约束可实现一部分。
用一句话概括:约束不是给数据库找麻烦的功能,而是给数据上加“保险栓”。没有这些保险栓,索引优化再厉害、缓存层再厚,最后服务的是都是不靠谱的数据。
2. 六大约束逐个拆解:定义、行为与适用场景
2.1 NOT NULL 非空约束:最容易忽略的“地基”
NOT NULL是最基础的约束,但它经常被误用、忽视。很多人以为“默认不加NOT NULL,字段就可以接受NULL”,这个认知本身没错,但问题是:你确定业务允许这个字段没有值吗?
先搞清楚NULL和空字符串的区别。空字符串''表示“这个字段有值,只不过内容为空”,而NULL表示“这个字段的值是未知的、缺失的”。两者在数据库里是完全不同的东西。email字段存'',代表用户注册时填了一个空邮箱;存NULL,代表系统根本不知道用户有没有邮箱。语义上差很远。
NULL还有个很麻烦的特性:参与任何计算都会传染。比如salary + bonus,只要salary是NULL,结果就是NULL。写WHERE salary > 5000时,salary为NULL的行也不会被查出来,因为NULL和任何值比较的结果都是“未知”,条件不成立。很多报表数据莫名其妙少了几行,往往就是NULL列在作怪。
业务上怎么决定哪些字段加NOT NULL?我的习惯是:一,唯一业务标识必须有值,比如工号、订单号、流水号;二,只要参与排序、分组、筛选的重要字段,能非空就非空;三,如果某字段语义是“用户没填所以留空”,我更喜欢用NULL而不是空串,因为NULL在数据库语义里更能表达“缺失”,配合COALESCE函数展示默认文案也更清晰。
CREATE TABLE user ( id BIGINT UNSIGNED NOT NULL, nickname VARCHAR(32) NOT NULL, phone VARCHAR(20), PRIMARY KEY (id) );上面phone没加NOT NULL,语义就是:用户可以不填手机号。
2.2 UNIQUE 唯一约束:唯一不代表主键
UNIQUE约束保证列或列组合的值不重复。它可以和PRIMARY KEY同时存在,一张表也允许有多个唯一约束。很多人问:唯一约束和唯一索引有什么区别?从逻辑层面看,约束是一种规则;从物理层面看,MySQL为了实现这个规则,背后建的就是唯一索引。两者在InnoDB里几乎是同一个东西,只是角色定位不同。
一个典型的应用场景是:主键用自增ID,但业务上还需要保证手机号唯一。这时候手机号列就加UNIQUE约束。还可以做组合唯一,比如同一订单下不允许出现重复的商品行,可以用UNIQUE KEY uk_order_product (order_id, product_id)。这种约束在购物车、订单明细、配置表里特别常用。
要注意的是UNIQUE和NULL的关系。MySQL里UNIQUE索引允许存在多行NULL,因为NULL被视为“未知”,多个未知值不认为彼此冲突。这个语义有时正好符合业务:用户可以不填手机号,而且没填手机号的用户可以有多个;但填了手机号的用户,手机号不能重复。如果你希望“所有人都必须填手机号且唯一”,那就得phone VARCHAR(20) NOT NULL UNIQUE,两个约束一起用。
2.3 PRIMARY KEY 主键约束:InnoDB物理存储的核心
主键约束是唯一约束的“加强版”:一张表只能有一个主键,主键列不允许NULL,并且主键列自动创建聚簇索引。
为什么主键特殊?因为InnoDB本身就是索引组织表,数据行物理上就是按聚簇索引的顺序排列的。如果你定义了主键,主键就是聚簇索引;如果你没定义主键,InnoDB会找第一个非空的唯一索引来当聚簇索引;如果连这个都没有,它会在内部生成一个隐藏的6字节rowid来组织数据。也就是说,InnoDB无论如何都会有一个隐式主键,区别只在于你能不能控制它。
这个底层逻辑带来的结论很实际:主键的长短直接影响整棵B+树的大小,也影响所有二级索引的存储空间,因为二级索引叶子节点存的是主键值。主键越短,索引体积越小,写入越快。所以业务上一个常见的建议是:优先使用自增BIGINT作为主键,而不是用很长很随机的字符串。UUID不是不能用,但随机UUID做主键会带来随机插入、页分裂、碎片化等问题,性能上要付出代价,这个话题后面单讲。
2.4 FOREIGN KEY 外键约束:关系数据库的“逻辑粘合剂”
外键约束是引用完整性的核心。它保证子表里的外键值,必须在父表主键里存在。比如员工表里的dept_id,如果引用了部门表,那么数据库会阻止你给员工分配一个不存在的部门编号。
外键可以定义删除和更新时的行为,常见的有:
- RESTRICT / NO ACTION:默认行为,父表被引用时不允许删除或更新。
- CASCADE:父表删除,子表对应记录也被删除;父表主键更新,子表外键跟着更新。
- SET NULL:父表删除或更新时,子表外键设置为NULL(外键列得允许NULL)。
很多人对外键有抵触情绪,主要是怕它影响性能。这种担心有一定道理,但要看场景。外键的代价在于:每次往子表插入或更新数据时,都要去父表检查一次引用是否存在;每次删除或更新父表时,要去子表检查有没有引用。并发高的时候,外键会引入额外的锁开销。
但如果你做的系统还在单体架构阶段,数据量没有到千万级,外键约束带来的“可靠性收益”,远远大于那点性能开销。我见过太多没建外键的项目,应用层代码_delete部门_忘记同步员工,导致一堆孤儿记录,最后加班写脚本修复。所以我现在的原则是:同一个MySQL实例里的核心业务表,能加外键就加;分库分表后,外键确实没法用,再靠应用层弥补。
这里有个MySQL的隐藏行为值得说:创建外键时,如果子表外键列没有索引,InnoDB会自动创建一个索引,这和其他数据库不太一样。这个特性同时也说明,外键列必须能高效查找,否则检查引用时的性能会很难看。
2.5 CHECK 检查约束:从MySQL 8.0开始才真正“硬起来”
CHECK约束用来限定字段值的范围,比如年龄必须大于0、余额不能为负数。这是域完整性里非常直观的规则。
但这里必须划重点:MySQL 8.0.16版本是分水岭。在这个版本之前,MySQL虽然会解析CHECK子句,但实际上是“接受并忽略”,约束根本不生效。什么意思?你写了CHECK (age > 0),照样能插入-5,不报错不拦截。很多人在旧版本上调试半天,以为是自己的SQL写错了,其实是版本没到位。
MySQL 8.0.16之后,CHECK约束才开始真正强制生效。比如:
CREATE TABLE account ( id BIGINT UNSIGNED PRIMARY KEY, balance DECIMAL(10,2), CONSTRAINT ck_balance_nonnegative CHECK (balance >= 0) );这时插入负余额就会直接报错。CHECK约束还有一个限制:不能引用其他表的列,不能包含子查询,表达式结果必须是确定性的。也就是说,你没法用CHECK去检查“这个员工的部门ID是否在部门表里”,那是外键的职责,别混用。
2.6 DEFAULT 默认值:常见却被误用
严格来说DEFAULT不是约束关键字,它是列属性,但它对域完整性的作用非常大,所以通常放在约束家族里一起说。
最常见的默认值用法是状态字段:status TINYINT NOT NULL DEFAULT 1。这样应用层即使忘了传status,数据库也会给一个初始值,避免“NULL状态”这种模糊数据出现。另一个高频场景是时间字段:created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,插入时自动带当前时间。
MySQL 8.0.13之前,默认值只允许常量和少数特殊函数(比如CURRENT_TIMESTAMP),不允许任意表达式;8.0.13之后,支持了带括号的表达式默认值,比如:
CREATE TABLE ticket ( id BIGINT UNSIGNED PRIMARY KEY, expire_at DATETIME NOT NULL DEFAULT (DATE_ADD(NOW(), INTERVAL 7 DAY)) );这里“DEFAULT (表达式)”的括号必须写,否则报错。这类写法在优惠券、邀请链接、免费试用等场景很实用。
3. 建表时的约束组合策略:一个完整案例
3.1 从业务语义反推约束清单
光背约束定义没意义,真正考验人的是如何在建模时把约束组合到位。我拿一个典型的“部门-员工”项目作为例子走一遍。
业务需求是:部门有部门编号,编号全局唯一;部门名称不能为空;员工有工号,工号全局唯一;员工必须实名,姓名不能为空;邮箱自愿填写,但填了就不能重复;员工必须归属某个部门;薪资必须大于0;员工状态默认在职;入职日期必填。
逐条映射下来就是:
- 部门编号:UNIQUE
- 部门名称:NOT NULL
- 员工工号:UNIQUE,作为业务主键也可以
- 员工姓名:NOT NULL
- 邮箱:UNIQUE,可空
- 部门ID:FOREIGN KEY 引用部门表
- 薪资:CHECK(salary > 0)
- 状态:NOT NULL + DEFAULT 1
- 入职日期:NOT NULL
3.2 建表DDL完整演示
CREATE TABLE department ( dept_id BIGINT UNSIGNED AUTO_INCREMENT, dept_code CHAR(4) NOT NULL, dept_name VARCHAR(64) NOT NULL, PRIMARY KEY (dept_id), UNIQUE KEY uk_dept_code (dept_code), UNIQUE KEY uk_dept_name (dept_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE employee ( emp_id BIGINT UNSIGNED AUTO_INCREMENT, emp_no VARCHAR(20) NOT NULL, emp_name VARCHAR(64) NOT NULL, email VARCHAR(128), dept_id BIGINT UNSIGNED NOT NULL, salary DECIMAL(10,2), status TINYINT NOT NULL DEFAULT 1, hire_date DATE NOT NULL, PRIMARY KEY (emp_id), UNIQUE KEY uk_emp_no (emp_no), UNIQUE KEY uk_email (email), CONSTRAINT fk_employee_dept FOREIGN KEY (dept_id) REFERENCES department(dept_id), CONSTRAINT ck_salary_positive CHECK (salary > 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意几点:雇员的dept_id我用了NOT NULL,因为业务要求“员工必须归属某个部门”;邮箱列没加NOT NULL但加了UNIQUE,实现“可填但不能重复”;薪资的CHECK单独起了名字ck_salary_positive,方便后续删除和管理。如果建表时不给CHECK起名,MySQL 8.0会自动生成一个约束名,比如employee_chk_1,后期看了头大。
3.3 约束命名规范和可维护性思维
约束名不是为了好看,是为了能维护。主键、唯一约束、外键、检查约束,我推荐按统一模式命名:
- 主键:
PK_表名 - 唯一:
UK_表名_字段名 - 外键:
FK_表名_关联字段名 - 检查:
CK_表名_字段名
约束名在一个库的范围内必须唯一。如果命名太随意,后面对约束做修改、删除时,你就得靠information_schema去猜哪个名字是干嘛的。查询方式:
SELECT TABLE_NAME, CONSTRAINT_NAME, CONSTRAINT_TYPE FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA = '要查询的库名';查外键的引用关系用:
SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = '要查询的库名';养成给约束起名的习惯,以后排障会节省大量时间。
4. 修改约束的实操细节:ALTER TABLE 避坑指南
4.1 后补约束的完整语法
项目上线后没建约束,后期想补,这是常态。用ALTER TABLE逐个补:
-- 添加唯一约束 ALTER TABLE employee ADD CONSTRAINT uk_emp_idcard UNIQUE (id_card_no); -- 添加检查约束 ALTER TABLE employee ADD CONSTRAINT ck_salary_positive CHECK (salary > 0); -- 添加外键约束 ALTER TABLE employee ADD CONSTRAINT fk_employee_dept FOREIGN KEY (dept_id) REFERENCES department(dept_id); -- 删除外键约束 ALTER TABLE employee DROP FOREIGN KEY fk_employee_dept; -- 删除检查约束,MySQL 8.0.19及以上支持DROP CONSTRAINT ALTER TABLE employee DROP CONSTRAINT ck_salary_positive;这里有个版本相关的坑:MySQL 8.0.19之前不支持通用的DROP CONSTRAINT语句,删除外键要用DROP FOREIGN KEY,删除主键要用DROP PRIMARY KEY,删除唯一约束要用DROP INDEX。8.0.19之后才统一支持DROP CONSTRAINT。你在写脚本时得先确认目标实例的版本,否则会报语法错误。
删除自增主键还有个更隐蔽的坑:如果主键列带有AUTO_INCREMENT属性,直接ALTER TABLE ... DROP PRIMARY KEY会报错。你需要先修改列定义去掉自增属性,再删主键:
ALTER TABLE employee MODIFY emp_id BIGINT UNSIGNED NOT NULL; ALTER TABLE employee DROP PRIMARY KEY;这种操作哪怕在测试库也要先备份数据,上面的步骤顺序一旦中间断了,表状态可能变得很别扭。
4.2 大表加约束的执行代价:为什么不能手一抖就执行
给一张几百万行、甚至上亿行的表加约束,不是跑一条SQL那么简单。加唯一约束本质上是要“扫描全表 + 校验重复值 + 构建唯一索引”;加CHECK约束要“扫描全表逐行校验”;加NOT NULL约束如果列里已经存在NULL,还要准备先做数据清洗。
MySQL 8.0在DDL执行方式上做了很多优化,有INSTANT、INPLACE、COPY几种算法。INSTANT最快,只修改元数据,但支持的操作有限,主要是新增列等场景;INPLACE是在原表上重建,部分操作允许并发DML;COPY是最慢的,要拷贝整张表数据,而且锁表严重。加约束这类操作,很多走的是INPLACE或COPY,无论如何都会有后台扫描和建索引的过程。
我踩过的教训是:大表加唯一约束之前,要先自己查一遍重复值,哪怕代码逻辑上觉得不可能重复:
SELECT id_card_no, COUNT(*) FROM employee GROUP BY id_card_no HAVING COUNT(*) > 1;如果有重复,ALTER TABLE直接报错,还会留下一个半成品操作。更稳的做法是在低峰期操作,提前评估表大小和磁盘空间,必要时借助在线变更工具处理,而不是大白天直接在生产库跑。另外,加完约束不代表万事大吉,还要在监控里观察主从延迟,因为DDL在复制链路里也会产生负载。
5. 真实业务中的约束踩坑记录
5.1 版本不同导致CHECK约束“形同虚设”
我接手过一个老项目,建表语句里写了CHECK (age BETWEEN 1 AND 120),测试环境是MySQL 8.0,一切正常。上生产后,运维说生产库是MySQL 5.7,应用第二天开始收到大量数据异常。查到最后才发现,5.7的CHECK约束根本不强制,插入的年龄甚至可以是“abc”这种字符串——因为age字段定义的是VARCHAR,数据库连类型都没有硬性限制。
这个案例说明两件事:一是生产环境的MySQL版本必须提前确认,不能拿本地8.0的语法和行为去套生产;二是MySQL的约束行为在不同版本之间差异很大,写SQL时要时刻记得版本边界。5.7里唯一靠谱的“检查约束”替代方案是:在应用层校验,或者用触发器强行拦截,但触发器维护成本高,不如直接升级到8.0来得省心。
5.2 外键引发的并发删除死锁和批量处理陷阱
外键用得好是保障,用得不好是灾难。有一次我们做一个清理任务,要按条件删除用户表里的一批用户,同时删除关联的订单表记录。任务用多线程并发跑,结果频繁死锁。
原因是:删除父表记录前,InnoDB需要检查子表有没有引用,这个检查会对子表相关行加锁;多个线程同时删除父表不同记录、又同时往子表加锁,锁等待形成回路,就死锁了。解决思路是调整删除顺序和粒度:先删子表,再删父表;或者减少并发线程数,用单线程分批删除。
另外一个常见坑是:外键列虽然有自动创建的索引,但如果你在批量插入子表时没有按照父表的顺序去操作,外键检查的开销会被放大。生产中如果批量导入数据,我建议先导入父表,再导入子表,并且把大批量拆成小批次,给外键检查留出余地。
5.3 唯一约束遇上NULL的微语义坑
再聊一个容易被业务测试漏掉的场景。用户在注册时可以不填手机号,数据库里phone列是可空的,同时加了UNIQUE约束。测试发现,用户A没填手机号,用户B也没填手机号,两条记录都能插入成功。产品看报告时嘀咕:“不是说要手机号唯一吗,为什么两个空手机号能共存?”
从数据库语义上讲这是正常的,NULL不算重复值。但从产品角度,这个“唯一”并没有覆盖“空手机号”的场景。如果业务就是要“所有注册用户手机号必须唯一”,那唯一约束之前必须加NOT NULL约束,让空手机号根本进不来。这个微语义在很多数据库里都一样,建模时如果没想清楚,等上线后才发现,往往要动表结构,代价很大。
5.4 自增主键与UUID的持久战
最后聊聊主键类型选择。自增BIGINT主键一直是关系库的默认选择,因为它顺序插入,对B+树友好,不会频繁导致页分裂。但它在分布式、多库合并场景下问题也明显:多个节点各自生成自增ID,合并时必然冲突;另外自增ID可被猜测,在某些业务里容易泄露数据量。
UUID作为主键,全局唯一,适合分布式,但32位十六进制字符串太长,而且标准版UUID随机性太强,插入时聚簇索引的页分裂很严重,写性能明显下降。折中方案是使用雪花算法生成有序的BIGINT ID,或者在MySQL 8.0里把UUID转成二进制后存储,配合存储函数生成有序UUID(如UUID_TO_BIN函数的swap_flag参数)。但不管选哪种,这个决策要在建表前定下来,因为主键一旦使用频繁,后面换主键是伤筋动骨的活。
我在实际项目里的选择标准是:单库单实例、数据规模可控,直接自增BIGINT;有分布式合并需求,优先雪花ID;团队对索引原理熟悉,也可以用有序UUID方案。没有银弹,只有取舍。
最后说点个人体会
约束这东西,属于“建表时省五分钟,以后熬夜补几天”的典型。我后来养成一个习惯,每张表建完之后,都习惯性地对照检查一遍:主键定没定,业务唯一字段有没有加唯一约束,关联字段是不是该加外键,数值字段有没有合理的CHECK兜底,状态字段有没有默认值。不需要多复杂,但这些检查真的能拦住绝大多数脏数据。
给正在看这篇文章的朋友一个建议:你可以打开手头项目的数据库,用information_schema查看一下现有表的约束情况。如果发现一半以上的关联表都没有外键、核心业务表连唯一约束都没有,那说明这张表已经在裸奔了。与其等数据乱到无法收拾,不如挑个低峰期,把该补的约束补上。数据库不会替你做业务判断,但它能帮你守住最后一道底线。