约束是定义在表列上的规则,用来强制保证数据的完整性和准确性。它们比应用层校验更可靠,因为数据在写入磁盘前就会被数据库引擎拦截,不会因为代码漏掉一个 if 就写入脏数据。
MySQL 的约束体系覆盖了从最基本的数据格式验证到多表之间的引用完整性检查。用好它们,不只是让数据库“更干净”,更是省去大量线上排查异常数据的精力。
7.4.1 非空约束(NOT NULL)
非空约束强制一个列不能为 NULL。在设计表时,凡是在业务逻辑上必须有值的字段,都应该加上 NOT NULL。
在建表时直接指定:
CREATE TABLE user (
id INT NOT NULL,
name VARCHAR(50) NOT NULL,
nickname VARCHAR(50) NULL -- 允许为空,可以省略 NULL
);
已经存在的表也可以通过 ALTER TABLE 修改:
ALTER TABLE user MODIFY name VARCHAR(50) NOT NULL;
为什么要尽量少用 NULL?
- 逻辑复杂:
NULL参与任何比较(包括= NULL)结果都是NULL,需要使用IS NULL或IS NOT NULL判断,容易写出错误的 SQL。 - 聚合函数忽略NULL:
COUNT(列)不统计 NULL 值,AVG也不计入 NULL 值,可能导致非预期的结果。 - 索引存储额外开销:InnoDB 中,
NULL列在记录头需要额外的位标记。 - 业务语义不清晰:是“未知”还是“没有”?用一个魔法值(如空字符串、0)代替
NULL有时更简单。
因此,除非业务确实需要表达“无值”(比如“生日”可能未知),大多数列都应该声明为 NOT NULL,并设置合理的默认值。
7.4.2 默认值约束(DEFAULT)
使用 DEFAULT 可以为一个列指定默认值。在 INSERT 语句中若未显式提供该列的值,数据库会自动填入默认值。这是一种防止遗漏和减少应用代码负担的极佳手段。
CREATE TABLE article (
id INT NOT NULL AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
content TEXT,
status TINYINT NOT NULL DEFAULT 1, -- 默认为1(草稿)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 创建时间默认当前时间
views INT NOT NULL DEFAULT 0
);
插入数据时:
INSERT INTO article (title, content) VALUES ('新闻标题', '正文');
-- status 自动填1,created_at 自动填当前时间,views 自动填0
使用注意点:
- 严格模式下(
sql_mode包含STRICT_TRANS_TABLES),如果列既没有DEFAULT又声明为NOT NULL,插入时必须提供值,否则报错。因此,NOT NULL列最好有默认值。 DEFAULT CURRENT_TIMESTAMP常用于时间列,但一个表中最多只有一列可以用CURRENT_TIMESTAMP作为默认值(在 8.0 之前限制更严格)。通常用在created_at上,updated_at通过应用程序或触发器更新。- 默认值必须是一个常量,不能是函数或表达式(除
CURRENT_TIMESTAMP特例外)。
7.4.3 唯一约束(UNIQUE)
唯一约束保证一列或多列的组合值在表中不可重复,允许空值(多个 NULL 不冲突,因为 NULL 不等于任何值)。
CREATE TABLE user (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(100) NOT NULL UNIQUE,
phone VARCHAR(20) UNIQUE -- 可为 NULL
);
也支持联合唯一约束,确保组合值唯一:
CREATE TABLE follow (
follower_id INT NOT NULL,
followee_id INT NOT NULL,
UNIQUE KEY uk_follower_followee (follower_id, followee_id)
);
-- 同一个人不能重复关注同一用户
实现机制:MySQL 内部会为唯一约束自动创建一个唯一索引。本质上,UNIQUE 约束是通过唯一 B+ 树索引来实现的。因此,查询此类列时天然能走索引,性能很好。但如果约束的列很长(如长文本),索引占用空间会很大,需要斟酌。
与主键的区别:主键不能为 NULL,唯一约束允许 NULL;一个表只能有一个主键,但可以有多个唯一约束。
7.4.4 主键约束(PRIMARY KEY)
主键是一行数据的唯一标识。它自动带有 NOT NULL 和 UNIQUE 属性,每个表必须且只能有一个主键。
InnoDB 中,主键的重要性远超“唯一标识”这一层。因为数据按主键顺序用 B+ 树组织(聚簇索引),主键的选择直接影响整个表的插入性能和查询效率。
定义主键的方式:
-- 列级定义
CREATE TABLE product (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL
);
-- 表级定义(适合复合主键)
CREATE TABLE order_item (
order_id INT NOT NULL,
item_id INT NOT NULL,
PRIMARY KEY (order_id, item_id)
);
主键设计原则(非常重要):
- 强烈建议使用单一自增整数列作为主键。自增 ID 能够保证新数据按顺序追加,减少页分裂,写入性能最佳。
BIGINT比INT更安全,防止 ID 耗尽。 - 避免使用业务字段做主键,比如手机号、邮箱、身份证号。一方面这些字段可能变更(修改主键代价极大),另一方面它们通常是字符串,长度大,随机性高,会导致大量随机 I/O 和页分裂。
- 绝不使用随机值(如 UUID、雪花 ID 字符串版本)作为主键,这会严重破坏插入性能。如果必须全局唯一且分布式生成,可以考虑使用有序的唯一值(如 Snowflake 的 int64 形式,保持趋势递增),或者通过代理键(自增 ID)做主键,业务唯一键用 UNIQUE 保证。
- 复合主键通常用在关联表中(如上面的
order_item),但即便如此,很多团队仍倾向于加一个独立的自增主键,再用联合唯一约束来保证业务唯一性,因为这样关联表自身也便于被其他表引用。
没有主键会怎样? InnoDB 会从第一个 UNIQUE NOT NULL 列中选一个作为聚簇索引,如果没有这样的列,会隐式生成一个 6 字节的 ROW ID 作为聚簇索引。这种隐式行为不可控,应主动指定主键。
7.4.5 外键约束(FOREIGN KEY)
外键约束用于保证两张表之间的引用完整性。它确保子表中的某个列(或列组合)的值必须存在于父表的主键或唯一键中。
CREATE TABLE department (
id INT PRIMARY KEY,
name VARCHAR(50)
);
CREATE TABLE employee (
id INT PRIMARY KEY,
name VARCHAR(50),
dept_id INT,
CONSTRAINT fk_emp_dept FOREIGN KEY (dept_id) REFERENCES department(id)
ON DELETE RESTRICT
ON UPDATE CASCADE
);
引用行为:当父表发生删除或更新时,你可以指定子表的对应行为:
RESTRICT(默认):如果子表有匹配记录,不允许删除/更新父表。CASCADE:自动删除/更新子表中匹配的记录。SET NULL:将子表外键列设为NULL(前提是该列允许NULL)。NO ACTION:语义同RESTRICT,在 MySQL 中等价。
外键约束的优缺点与现实选择:
外键约束能防止数据孤立和违法引用,比如删除一个部门时不会残留失去引用的员工记录。但在互联网公司的大规模生产环境中,往往有意不使用或谨慎使用外键约束,原因如下:
- 性能开销:每次插入或更新子表时,都要检查父表是否存在对应记录,会锁住父表相关行,在高并发下可能导致锁竞争和死锁。
- 级联操作不可控:
CASCADE在复杂业务中可能级联删除大量数据,而开发者不易察觉。 - 分库分表不兼容:一旦对表进行水平拆分,跨库的外键约束无法实现。
- 逻辑维护更灵活:更多团队选择在应用层保证引用完整性,比如先把数据逻辑写好,再通过代码校验。
因此,外键约束适合内部管理系统、企业级 ERP、单体应用等强一致性的场景,对于追求高性能和水平扩展的互联网业务,通常会保留外键作为文档说明,建表时不开启物理外键约束(FOREIGN KEY 定义忽略或用逻辑外键管理)。无论是否使用物理外键,设计表时一定要对外键列建立索引,否则关联查询会走全表扫描。
7.4.6 检查约束(CHECK)
检查约束用于定义列值必须满足的条件。这是 MySQL 8.0.16 才正式支持的约束类型,解决了之前只能通过触发器模拟的痛点。
CREATE TABLE person (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT CHECK (age >= 0 AND age <= 150),
gender ENUM('M','F')
);
-- 或者以表级约束的形式,支持命名
CREATE TABLE account (
id INT PRIMARY KEY,
balance DECIMAL(10,2),
CONSTRAINT chk_positive_balance CHECK (balance >= 0)
);
插入非法数据会直接报错:
INSERT INTO account VALUES (1, -100);
-- ERROR 3819 (HY000): Check constraint 'chk_positive_balance' is violated.
实战注意事项:
- 检查约束只能做行内简单判断,不能引用其他表,也不能用子查询。
- 它主要代替传统的 ENUM 或触发器做数据范围校验,比如年龄、价格、状态码等。
- MySQL 8.0.16 之前虽然语法能被解析,但实际不会强制执行约束,升级后需要重建表才能激活。
开发建议:不必依赖 CHECK 约束替代所有业务校验,它更适合作为数据库层面的最后一道防线,防止极端情况下的非法数据。主力的业务逻辑校验仍放在应用层,因为应用层能提供更友好的错误提示。
7.4.7 约束管理的最佳实践总结
- 主键优先:每个表必须有主键,优先用自增整数或有序 UUID,避免随机主键。
- 非空配默认值:凡是业务上不能为空的列,加
NOT NULL并给出合理默认值。 - 唯一约束保护业务键:如手机号、邮箱等业务唯一标识,加
UNIQUE约束并配索引。 - 外键审慎用:根据项目规模和性能需求,决定使用物理外键还是逻辑外键,但必须建普通索引用于关联。
- 检查约束做补充:对于简单的数值范围、格式校验,可以利用
CHECK减少一个应用防御点。 - 严格模式保底线:确保
sql_mode包含STRICT_TRANS_TABLES等严格选项,让约束真正生效,避免静默写入非法值。
约束不是限制开发的枷锁,反而是省心的工具。在字段定义阶段多花几分钟设置好约束,未来维护阶段就能少踩坑、少背锅。