🔥 本文专栏:MySQL
🌸作者主页:努力努力再努力wz

💪 今日博客励志语录 :
平庸并不可怕,可怕的是我们习惯了用"平凡可贵"来为自己的懒惰或怯懦打掩护。真正的"平凡"是认清生活真相后的英雄主义,而"不凡"则是你在这种英雄主义里,又多加了一份改变现状的逻辑与执行力
引入
根据上篇博客的内容,我们已经从宏观层面对 MySQL 建立了基本认知,并对数据库以及表的概念有了初步理解。对于"表"而言,其逻辑结构可以类比为一个由 struct 结构体组成的数组;换言之,定义一张表,本质上就是定义该结构体中的成员变量及其对应的数据类型。同时,我们也已经了解了 MySQL 中常见的数据类型及其底层存储实现机制。
需要进一步强调的是,MySQL 中的数据类型与 C/C++ 等编程语言中的类型存在本质差异。在 C/C++ 中,数据类型本质上只是对内存中二进制序列的一种解释方式,编译器或运行时通常不会对变量所存储的具体值进行严格的语义校验。而在 MySQL 中,数据类型不仅用于描述数据的语义和存储格式,同时还承担了"约束"的职责:系统会对插入的数据进行合法性校验,确保其满足类型定义的取值范围及格式要求。
这种机制带来的直接收益在于数据层面的可靠性保障。一旦系统出现数据异常,可以较为明确地排除"非法数据写入"这一类问题,从而将排查重点聚焦于业务逻辑本身,提高问题定位的效率。因此,可以认为,数据类型在 MySQL 中不仅是存储描述工具,同时也是数据完整性约束体系的一部分。
除此之外,相较于 C/C++ 仅依赖类型本身进行约束,MySQL 还在表层面提供了更丰富的数据约束机制,用于对数据内容施加进一步限制。这些约束(Constraints)将作为本文的重点内容展开介绍,它们是保证数据一致性与完整性的关键手段。
表的约束
非空约束
根据上文,我们已经了解到,MySQL 的数据类型本身就会对待存储的值施加一定的约束,例如会检查取值范围是否合法。在此基础之上,MySQL 还提供了更加丰富的约束机制,用于进一步保证插入数据的正确性与一致性,使数据更符合业务预期。
首先需要介绍的,是最基础也是最常用的一类约束------非空约束(NOT NULL)。在正式理解这一约束之前,有必要先明确其存在的现实意义。
在大学场景中,期末考试是一个典型的例子。假设我们需要管理所有学生的考试信息,可以设计一张学生表,其中每一条记录对应一个学生的个人信息及其学习情况。在该表中,通常会包含一个"考试成绩"字段。对于实际参加考试的学生而言,其成绩一定是存在的,且取值范围通常在 0 到 100 分之间。
然而,在实际情况中,可能存在部分学生未参加考试(例如缓考或缺考)。对于这类学生,其"考试成绩"这一属性在当前时间点是不存在的 。此时,如果强行为其赋值为 0 分,会产生语义上的错误------因为 0 分表示"参加了考试但成绩为 0",而不是"未参加考试"。因此,对于这类情况,应当使用一个特殊的标记来表示"没有值",即 NULL。
需要特别注意的是,这里的 NULL 与 C/C++ 中的 NULL 概念是完全不同的。MySQL 中的 NULL 表示"值不存在"或"未知值",它是一种语义层面的缺失;而 C/C++ 中的 NULL 是一个空指针常量,本质上是整数 0(或 (void*)0),表示指针不指向任何有效内存地址,是一个具体的值。两者在语义和用途上有本质区别,必须严格区分。
在表结构定义层面,最初我们在创建表时,通常只关注列名以及列的数据类型。但在引入"约束"这一概念之后,可以在列定义的基础上进一步附加约束条件,从而对数据进行更精细的控制。也就是说,列的定义不再仅仅是"类型描述",而是"类型 + 约束"的组合。
值得进一步说明的是,即使在创建表时没有显式地定义任何约束,MySQL 也并非完全没有约束机制。可以通过如下语句查看表的实际创建定义:
sql
SHOW CREATE TABLE 表名;
为了获得更清晰的输出格式,可以在语句末尾添加 \G 选项。

通过该语句可以观察到,MySQL 实际执行的建表语句往往比我们手写的更加完整。这是因为 MySQL 会自动为每一列补充默认约束。例如,如果某一列未显式声明约束,则其默认是允许为 NULL,即默认约束为 DEFAULT NULL。换言之,在插入数据时,如果未提供该列的值,则该列会被自动赋值为 NULL。
此外,对于字符类型字段,MySQL 还会隐式地为其指定字符集(character set)和校对规则(collation)。如果在列级别未显式指定,则会继承表级设置;而如果表级也未指定,则继续继承数据库级的默认设置。这种逐层继承机制的本质,是为了确保每一列在物理存储层面都有明确且一致的规则------如果开发者未指定,系统会自动向上查找并补全。
然而,在实际业务中,并非所有字段都允许为 NULL。有些字段是必须存在的,否则数据在逻辑上就是不完整甚至是非法的。例如,在学生表中,"学生姓名"和"学号"是标识学生身份的核心属性,这两个字段显然不应允许为空;而"考试成绩"则可以根据具体情况允许为空。
针对这种需求,就需要使用 非空约束(NOT NULL)。当为某一列显式添加该约束后,如果在插入数据时未提供该列的值,或者尝试插入 NULL 值,MySQL 将直接报错,从而阻止不合法数据写入表中。这种机制在数据完整性控制中具有非常重要的作用。
sql
CREATE TABLE student_test (
id INT NOT NULL,
name VARCHAR(20) NOT NULL,
score INT -- 默认允许 NULL
);
当我们尝试进行一次"非法操作":
sql
INSERT INTO student_test (id, score) VALUES (1, 100);

总结来说,NOT NULL 约束的核心价值在于:将"数据必须存在"的业务规则前置到数据库层进行强制校验,从而避免不完整数据进入系统。
这里需要特别注意 NULL 在底层存储层面的实现细节。很多读者容易产生一种直观但不准确的理解:认为系统会在记录的数据区中专门存储一个所谓的 "NULL 值"。实际上,这种理解是有偏差的。
在 MySQL(以 InnoDB 存储引擎为例)的实现中,如果某个字段的值为 NULL,那么该字段的实际数据并不会存储在记录的数据区中。换言之,数据区中不会为 NULL 值分配存储空间。
取而代之的是,系统会在记录的元数据部分维护一份 NULL 位图(NULL bitmap)。这份位图位于 B+ 树叶子节点中每条记录的元数据区域,用于标记各个字段是否为 NULL。具体来说:
- 每一个允许为 NULL 的列,都会在 NULL 位图中占据一个比特位;
- 当某一列的值为 NULL 时,其对应的比特位会被置为 1;
- 当该列有实际值时,对应比特位为 0,此时才会在数据区中存储该列的真实数据。
这种设计有两个重要意义:
- 节省存储空间:NULL 值不占用数据区空间,仅通过一个比特位进行标识,空间开销极低;
- 提高解析效率:在读取记录时,存储引擎可以先通过 NULL 位图快速判断某一列是否有值,从而决定是否需要解析数据区中的内容。
因此,从本质上来说,NULL 并不是"一个被存储的值",而是一种通过元数据进行标记的"缺失状态"。这一点在理解 InnoDB 行记录结构时尤为关键,也有助于后续深入分析变长字段存储、记录格式(如 Compact / Redundant)等底层实现机制。
DEFAULT缺省值
根据上文,我们已经认识了非空约束,接下来讨论**缺省值(DEFAULT)**这一约束。需要明确的是,在向表中插入一条记录时,并不要求为每一个字段都显式赋值;我们可以只为部分字段提供值,其余未显式指定的字段将由系统使用缺省值进行填充。
这一机制可以类比于 C++ 中构造函数的默认参数:构造函数在初始化对象成员时,如果某些参数未被显式传入,则会自动采用预先定义好的默认值,从而保证对象处于一个合法且可用的状态。
例如,在 C++ 中,如果定义如下构造函数:
cpp
void Student(int id, string name = "Unknown");
当调用时未传入 name 参数,编译器会自动将其补全为 "Unknown"。MySQL 的处理逻辑与此类似:当在 INSERT 语句中未为某些列提供值时,数据库会根据表结构定义,检查这些列是否设置了默认值;若存在,则自动填充该默认值。
此外,需要补充的是:如果在定义表结构时未显式为某一列指定默认值,那么 MySQL 会隐式为该列设置默认值为 NULL(前提是该列允许为 NULL)。
sql
CREATE TABLE student_v2 (
id INT NOT NULL,
name VARCHAR(20) NOT NULL,
gender CHAR(1) DEFAULT '男', -- 设置缺省值为 '男'
status VARCHAR(10) DEFAULT '在读'
);
触发缺省值机制的示例:
sql
INSERT INTO student_v2 (id, name) VALUES (2, '李四');
在上述语句中,gender 和 status 字段未被显式赋值,因此系统会分别填充其默认值 '男' 和 '在读'。

需要特别强调的是:对于同一个字段,可以同时设置 NOT NULL 约束 和 DEFAULT 约束,二者并不冲突。其语义是:
- 如果插入数据时未为该字段提供值,则使用默认值;
- 由于该字段被声明为 NOT NULL,因此默认值本身必须是一个非
NULL的合法值; - 如果既未提供值,又未设置默认值,则会违反 NOT NULL 约束,从而导致插入失败。
从约束设计的角度来看,这种组合能够在保证数据完整性的同时,提高数据插入的灵活性,是实际建表过程中非常常见的一种模式。
COMMENT注释
至此,我们已经认识了两个非常关键的约束:非空约束(NOT NULL)以及缺省值(DEFAULT) 。虽然通过数据类型以及这些额外约束,已经能够在一定程度上保证数据的合法性,使其更符合业务语义,但需要明确的是:数据类型与约束的作用本质上是"尽可能限制",而非"绝对保证"。
换言之,它们只能对数据进行边界与形式上的校验,却无法完全避免程序员插入语义上不合理的数据。
例如,仍以学生表为例:我们定义一个用于存储学生基本信息的表,其中包含 age(年龄)字段。即使我们将该字段定义为 TINYINT 类型,并添加 NOT NULL 约束,数据库依然无法阻止如下情况的发生:程序员插入一个值为 -5 的记录。该值在类型层面是合法的 (符合 TINYINT 的取值范围),因此能够通过校验,但在业务语义上显然是不合理的。
为了缓解这一类问题,首先当然依赖于开发者自身的严谨性;其次,可以借助**字段注释(COMMENT)**来增强语义表达。
字段注释的作用类似于 C/C++ 中的代码注释:用于说明字段的业务含义以及使用约束,从而为后续开发或维护提供上下文信息。但需要注意,两者在实现机制上存在本质差异:
- 在 C/C++ 中,注释会在预处理阶段被移除,不参与最终程序执行;
- 而在 MySQL 中,注释是表结构的一部分(元数据),会被持久化存储。
因此,在通过 SHOW CREATE TABLE 或 DESC 查看表结构时(注意desc(或者说 describe)命令是一个**"缩略版"**的视图。它只给你展示最核心的列属性(字段名、类型、是否为空、索引、默认值等),为了保持界面简洁,它默认把 COMMENT(注释)给隐藏了),这些注释信息依然可见。
sql
CREATE TABLE student (
age TINYINT NOT NULL COMMENT '学生年龄,有效范围 0-120'
);

需要注意的是,由于注释本质上是供人阅读的描述信息,因此其数据类型只能是字符串文本,而不能是数值等其他类型。
需要额外说明的是,前文通过"插入负数"这一示例,主要是为了引出注释的使用场景,并帮助理解问题本质,并不意味着该问题无法从技术上规避。
实际上,可以通过更严格的数据类型约束来避免部分非法输入。例如,将字段定义为无符号类型(UNSIGNED):
sql
CREATE TABLE student (
age TINYINT UNSIGNED NOT NULL COMMENT '年龄'
);
此时,TINYINT UNSIGNED 的取值范围为 0~255 。如果尝试插入负数,将会在值域检查阶段直接报错并拒绝写入。
不过,这种方式仍然无法完全覆盖所有不合理情况,例如插入 127 岁这样的极端值,在语义上依然可能不符合业务预期。
为了解决这一问题,在 MySQL 8.0 及之后版本中,可以引入更强的约束机制------CHECK 约束:
sql
CREATE TABLE student (
age TINYINT NOT NULL COMMENT '年龄',
CONSTRAINT chk_age CHECK (age >= 0 AND age <= 150) -- 强制逻辑检查
);
CHECK 约束可以理解为为列增加了一层"逻辑过滤器"。它允许定义一个布尔表达式,只有当表达式结果为真时,数据才允许被插入或更新,从而实现更精细的业务规则控制。
这里的 CONSTRAINT 表示定义一个表级约束(table-level constraint) ,并为其指定一个约束名(如 chk_age)。通过该名称,可以在后续对约束进行管理,例如:
sql
ALTER TABLE student DROP CONSTRAINT chk_age;
其基本语法形式为:
sql
CONSTRAINT <约束名> <约束类型> (约束表达式或关联列)
进一步地,需要明确区分列级约束 与表级约束的语义差异:
-
列级约束(column-level constraint) :
直接定义在某一字段之后,仅作用于当前列。例如:
sqlage TINYINT CHECK (age > 0)这种约束只关注单列数据,无法感知其他列的状态。
-
表级约束(table-level constraint) :
在所有字段定义完成之后统一声明,其作用范围是整行数据。在该视角下,所有列都是可见的,因此可以实现跨列校验(cross-column validation)。
例如,在商品表中:
original_price:原价sale_price:折后价
业务规则为:折后价必须小于原价。该规则显然涉及多个字段,因此必须通过表级约束实现:
sql
CREATE TABLE products (
original_price DECIMAL(10, 2),
sale_price DECIMAL(10, 2),
CONSTRAINT chk_discount CHECK (sale_price < original_price)
);
该约束在插入或更新数据时,会同时校验两个字段的关系,从而保证数据在业务语义上的一致性。
总结来看:
- 数据类型 → 负责基础类型与范围约束
- NOT NULL / DEFAULT → 提供基本完整性约束
- COMMENT → 提供语义说明(非强制)
- CHECK → 提供强语义逻辑约束(可表达复杂规则)
多层约束协同作用,才能构建出既安全又符合业务逻辑的数据模型。
主键约束
接下来介绍的约束是主键约束(Primary Key Constraint)。在正式理解主键约束之前,有必要先明确其存在的原因。我们仍通过一个具体场景来引入:创建一张学生表,用于记录学生的身份信息以及学习情况。其中通常会包含一个"学号"字段。
显然,每个学生的学号在业务语义上必须是唯一的,用于标识该学生的身份信息。因此,该字段既不能重复 ,也不能为空。换言之,在向学生表中插入任意一条记录时,学号字段必须满足唯一性与非空性约束。
需要特别强调的是,这种约束不能仅依赖程序员在应用层"人为保证"(例如先查询再插入)。在高并发环境下,这种做法是不可靠的,因为在"查询是否存在"与"执行插入"之间存在时间窗口,从而引发典型的**竞态条件(Race Condition)**问题,最终导致重复数据的产生。因此,这类约束必须由数据库层强制保证。
一旦某个字段被定义为主键,就意味着该字段同时具备以下两个特性:
- 唯一性(UNIQUE)
- 非空性(NOT NULL)
因此,可以将满足上述条件的字段定义为主键。但需要注意的是,是否选择某个字段作为主键,并不仅仅取决于其是否"满足唯一且非空",还必须结合业务语义 :该字段应能够唯一标识一条记录。此外,在 InnoDB 存储引擎中,主键通常会作为**聚簇索引(Clustered Index)**的键,用于组织 B+ 树结构中的数据。
同时,一张表只能定义一个主键约束 (Primary Key Constraint),但该主键可以由一个或多个字段共同组成。即一个表只能有一个主键约束,但该主键可以是单列主键,也可以是复合主键(Composite Primary Key)。
单列主键示意:
sql
CREATE TABLE student (
id INT PRIMARY KEY, -- 列级主键定义
name VARCHAR(20) NOT NULL COMMENT '姓名'
);

所谓复合主键,是指由多个字段组合而成的一个整体键。在这种情况下,约束的作用对象是"字段组合",而非单个字段。也就是说,复合主键要求的是组合后的整体唯一性,而不是每个字段分别唯一。
在定义复合主键时,必须采用**表级约束(Table-level Constraint)**的形式。例如:
sql
CREATE TABLE score (
student_id INT,
course_id INT,
grade TINYINT,
-- 复合主键定义:student_id 和 course_id 组合唯一
PRIMARY KEY (student_id, course_id)
);
示例场景说明(选课关系):
- 一个学生可以选择多门课程;
- 一门课程也可以被多个学生选择;
- 但"学生 + 课程"的组合必须唯一。
理解要点:
-
允许单列重复 :
student_id可以重复(表示一个学生选多门课),course_id也可以重复(表示一门课被多个学生选择)。 -
禁止组合重复 :
只有当
student_id与course_id同时与已有记录完全一致时,才会违反主键约束并报错。
此外,主键字段通常不会设置默认值(DEFAULT)。这是因为主键的核心要求是唯一性与确定性,而默认值往往无法满足唯一性约束。
不过,在实际开发中,确实存在"不手动指定主键值"的需求。为了解决这一问题,可以为主键字段设置**自增(AUTO_INCREMENT)**属性。此时:
- 当插入数据且未显式指定主键值时,数据库会自动生成一个递增值;
- 默认情况下,该值从 1 开始递增(具体起始值和步长可配置);
- 如果手动插入了一个较大的主键值,后续自增值通常会从该值之后继续递增(即更新内部计数器)。
这种行为在语义上类似于 C 语言中的"枚举递增",但其本质是数据库内部维护的一个自增计数器(auto-increment counter)。
在实现层面上(以 InnoDB 为例):
- 在较早版本中,该计数器主要存储于内存中,重启后可能需要重新计算;
- 在 MySQL 8.0 及之后版本中,自增计数器已经支持持久化存储(persistent),会记录在系统表或数据字典中,从而避免重启带来的不确定性问题,提高一致性与可靠性。
sql
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT, -- 主键且自增
name VARCHAR(20) NOT NULL COMMENT '姓名'
) ENGINE=InnoDB;
INSERT INTO student (name) VALUES ('张三');

在默认情况下,MySQL 的自增计数器(AUTO_INCREMENT counter)具有如下特性:
- 初始值通常为 1;
- 步长(increment)默认为 1;
- 每次插入新记录时,若未显式指定主键值,则自动分配当前计数器的值,并将计数器递增。
需要注意的是,这一默认行为是由存储引擎(如 InnoDB)内部机制所维护的。
在实际业务中,某些场景可能不希望主键从 1 开始。例如:
- 出于业务展示需要(如避免"新系统用户量过少"的直观感受);
- 用于区分不同数据来源或分区(例如不同地区、不同业务线);
此时,可以显式指定自增起始值。
在建表时指定自增起始值:
sql
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20)
) AUTO_INCREMENT = 1000; -- 指定起始值为 1000

在建表后修改自增起始值:
sql
ALTER TABLE users AUTO_INCREMENT = 2000;

重要注意事项:
-
自增值的设置必须满足以下条件才会生效:
新设置值 > 当前表中已有的最大主键值(max(id)) -
如果设置的值 小于或等于
max(id)或max(id) + 1,MySQL 会忽略该设置 ,并继续使用当前自增计数器的值(通常为max(id) + 1)。 -
其本质原因在于:
自增主键必须保证全局唯一性,因此数据库不会允许"回退"计数器,从而避免已分配 ID 被重复使用。
整体来看,自增主键的核心语义是:提供一个无需业务参与即可生成的、单调递增且唯一的标识符,而不是严格意义上的"连续序列"。
唯一键约束
认识了主键约束之后,我们已经明确了主键的基本性质:一旦表中的某个字段被定义为主键,该字段必须同时满足**非空(NOT NULL)与唯一(UNIQUE)**两个约束条件,并且用于标识一条记录的唯一性。
然而,在实际业务场景中,往往存在这样一种需求:某个字段不适合作为记录的唯一标识符 ,但仍然需要保证其值不能重复,同时允许为空。以学生表为例,每条记录通常包含学生的基本身份信息以及学习情况,其中可能包含"电话号码"这一字段。需要注意的是,标识学生记录唯一性的字段仍然应当是"学号"(作为主键),而不是电话号码。
对于"电话号码"字段而言,其业务语义通常是:学生可以没有电话号码(即允许为 NULL),但一旦提供电话号码,则必须保证该字段在全表范围内不重复。在这种情况下,就应当使用**唯一键约束(UNIQUE)**来实现这一需求。
从约束语义上来看,主键约束本质上可以视为是在唯一键约束的基础上额外叠加了非空约束 。换言之,唯一键允许出现多个 NULL 值(在 MySQL/InnoDB 的实现中,NULL 被视为"未知值",多个 NULL 之间不进行等值比较,因此不会违反唯一性),而主键则不允许出现任何 NULL。
此外,唯一键与主键之间还存在如下结构性区别:
- 一张表只能定义一个主键;
- 一张表可以定义多个唯一键;
- 唯一键默认不强制非空(除非显式添加 NOT NULL)。
下面是一个典型的列级唯一键约束示例:
sql
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(20) NOT NULL,
-- 唯一键约束
phone VARCHAR(20) UNIQUE COMMENT '电话号码'
);

进一步地,唯一键还可以定义为复合唯一键(联合唯一约束) ,即多个字段的组合值必须唯一。需要注意的是:直接在列定义后添加的约束属于列级约束 ,其作用范围仅限于单个字段;而涉及多个字段的约束必须通过表级约束来定义。
表级约束是在所有列定义完成之后统一声明的,因此可以访问整张表的列信息,从而实现跨列约束。
同时,可以通过 CONSTRAINT 关键字为约束显式命名,这在后续对表结构(元数据)进行修改(例如删除或重建约束)时尤为重要。
示例如下:
sql
CREATE TABLE student_v3 (
id INT PRIMARY KEY AUTO_INCREMENT,
class_id INT,
name VARCHAR(20),
phone VARCHAR(20),
-- 为电话号码设置唯一键并命名
CONSTRAINT uk_phone UNIQUE (phone),
-- 复合唯一键:同一班级内姓名不能重复
CONSTRAINT uk_class_name UNIQUE (class_id, name)
);

最后需要补充的是,根据前文的讨论,定义在所有列之后的表级约束不仅可以用于唯一性控制(如 UNIQUE),还可以用于更一般的完整性约束表达 ,例如 CHECK 约束等。由于其定义位置具备全局视角,表级约束在表达复杂业务规则时具有更高的灵活性与扩展性。
外键约束
在认识了唯一键约束之后,接下来需要引入外键约束。
从整体上看,数据库本质上是一个逻辑容器,用于组织一组逻辑上相关联的表;而数据的基本存储单元是"表"。每一个表在物理层面是相互独立的------例如,在使用 InnoDB 存储引擎时,创建一张表通常会在对应数据库目录下生成一个 .ibd 文件。因此,不同表之间在物理上并不存在直接联系。
然而,物理独立并不意味着逻辑无关。在实际业务中,表与表之间往往存在明确的关联关系。例如,存在一张"学生表",用于记录学生的基本信息以及选课情况;同时还存在一张"课程表",用于记录课程的具体信息。两张表虽然在物理上彼此独立,但在逻辑上却是强关联的。
以"选课"为例:假设课程表中只存在编号为 1~5 的课程,那么在向学生表插入记录时,从业务语义上讲,不应该允许插入课程编号为 6 或 8 的数据。尽管这类数据在语法层面可能满足字段类型、非空约束或其他列级/表级约束,但它在业务逻辑上显然是非法的。因此,如果缺乏额外的约束机制,就可能产生"引用不存在数据"的问题。
为了解决这一类问题,就需要引入外键约束(Foreign Key Constraint)。
外键的核心作用,是在逻辑层面建立表与表之间的关联关系。结合前文的主键概念:每一张表都可以通过主键唯一标识其内部的一条记录。例如,在学生表中可以将"学号"设置为主键,在课程表中可以将"课程编号"设置为主键。
此时,如果学生表中包含一个"课程编号"字段,用于表示学生所选的课程,那么该字段的取值应当来源于课程表中已经存在的课程编号。为此,可以在该字段上定义外键约束,使其引用课程表中的对应字段。这样一来:
- 学生表成为从表(Child Table / Referencing Table)
- 课程表成为主表(Parent Table / Referenced Table)
需要特别强调的是:
外键引用的字段不一定必须是主表的主键,但必须是具有唯一性约束的字段(即主键或唯一键)。其根本原因在于,外键引用的目标必须能够唯一定位一条记录,而主键与唯一键都具备这一能力。
当定义了外键约束后,在向学生表插入数据时,数据库系统会自动进行一致性检查:如果插入的课程编号在主表(课程表)中不存在,则会拒绝该插入操作,从而保证数据的引用完整性(Referential Integrity)。
sql
-- 1. 创建主表(课程表)
CREATE TABLE course (
c_id INT PRIMARY KEY COMMENT '课程编号',
c_name VARCHAR(20) NOT NULL COMMENT '课程名称'
);
-- 2. 创建从表(学生表)
CREATE TABLE student (
s_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '学号',
s_name VARCHAR(20) NOT NULL COMMENT '姓名',
course_id INT COMMENT '选修课程编号',
-- 定义外键约束
CONSTRAINT fk_course_id FOREIGN KEY (course_id)
REFERENCES course(c_id)
);


此外,还需要注意:外键约束具有双向约束性,不仅作用于从表,也会对主表产生限制。
具体来说,当尝试删除主表中的某条记录时,数据库会检查从表中是否存在引用该记录的外键值。如果存在,则默认会拒绝删除操作。原因在于,一旦删除成功,就会导致从表中出现"引用不存在记录"的情况,从而破坏数据的一致性。例如,如果删除了一门课程,而学生表中仍然存在选修该课程的记录,就会产生明显的业务逻辑错误。
sql
-- 尝试删掉已经被张三选了的课程
DELETE FROM course WHERE c_id = 1;

DDL(Data Definition Language 数据定义语言)
增CREATE
掌握了各类约束之后,至此我们已经对表结构及其组成要素建立了较为清晰的认知。接下来需要关注的,就是围绕表结构本身的一系列操作 。从整体上来看,这些操作可以概括为"增、删、改、查"四个方面,而其中首先需要掌握的便是表的创建操作。
所谓创建表,本质上就是对表结构进行定义,对应的 SQL 语句为 CREATE TABLE。在执行该语句时,需要提供完整的表结构定义,主要包括以下几个方面:
- 每一列的列名(column name)
- 每一列的数据类型(data type)
- 列级约束(column constraints),如
NOT NULL、DEFAULT、PRIMARY KEY等
在完成所有列的定义之后,还可以在列定义列表结束后补充表级约束(table-level constraints) ,用于描述跨列或整体性的约束规则(例如 CHECK、联合主键等)。最后,还可以指定该表所使用的存储引擎(storage engine) ,以及可选的**字符集(character set)与校验规则(collation)**等表级属性。
此外,在创建表时,可以使用 IF NOT EXISTS 选项,其语义是:当数据库中不存在同名表时才执行创建操作,否则直接跳过而不会报错。这一机制在实际开发中可以避免重复建表带来的异常中断。
示例如下:
sql
CREATE TABLE IF NOT EXISTS student (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '学号',
name VARCHAR(20) NOT NULL COMMENT '姓名',
age TINYINT DEFAULT 18 COMMENT '年龄',
CONSTRAINT chk_age CHECK (age >= 0 AND age <= 120) -- 表级约束
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 表选项
删DROP
接下来讨论表的删除操作。表的删除通过 DROP 语句实现,其后需要指定待删除的表名。
从底层实现角度来看,一张表由两部分构成:元数据(metadata) 与 实际存储的数据。因此,删除表这一操作本质上也是对这两部分内容的清理:
- 一方面,需要删除表对应的元数据。MySQL 会在数据字典(Data Dictionary)中维护表的结构定义,例如列信息、索引信息等,这部分内容在删除表时会被移除。
- 另一方面,需要删除表实际存储的数据。在 InnoDB 存储引擎下,创建表时通常会在数据库目录下生成一个与表同名的
.ibd文件,该文件用于存储表的数据与索引信息。因此,删除表时也会将该物理文件一并删除。
需要特别注意的是,尽管从表面上看,表的数据只是数据库目录中的一个文件,但不应绕过 MySQL,直接在文件系统层面删除该文件。这种做法存在明显风险,主要体现在以下几个方面:
- MySQL 作为一个网络服务进程,会同时处理来自多个客户端的连接请求。每个连接通常对应一个线程,这意味着数据库表可能处于被并发访问的状态。在这种情况下直接删除底层文件,极易引发数据不一致或运行时错误。
- 更严重的问题是可能导致元数据残留。例如,MySQL 的数据字典中仍然记录着该表的定义(如"存在一张名为 student 的表,其主键为 id"),但对应的物理数据文件已经被删除,从而造成系统内部状态不一致,甚至引发后续访问错误。
因此,表的删除必须通过 SQL 接口完成,而不能通过手动操作文件系统实现。
sql
DROP TABLE [IF EXISTS] table_name;
从执行流程上看,该语句大致包含以下几个关键步骤:
-
清理缓存(Buffer Pool)
将 Buffer Pool 中与该表相关的缓存页标记为无效,避免后续访问命中已失效的数据页。
-
更新数据字典(Data Dictionary)
在事务机制的保护下,从系统元数据中移除该表的定义信息。这一步保证了元数据层面的原子性与一致性。
-
物理删除(Physical Deletion)
最后,在文件系统层面删除对应的
.ibd文件(如果启用了独立表空间)。这是实际释放磁盘空间的步骤。
整体来看,DROP TABLE 是一个同时涉及内存层(缓存) 、**逻辑层(数据字典)以及物理层(文件系统)**的复合操作,其执行过程需要保证一致性与原子性,这也是为什么必须通过 MySQL 内部机制来完成,而不能由用户手动干预底层文件。
改ALTER
接下来介绍的是对表结构的"改"操作。需要明确的是,一张表可以从逻辑上拆分为两部分:一是表中存储的实际数据,二是描述这些数据的元数据(即表结构定义)。这里所说的"改",作用对象是表的元数据 ,而不是表中已有的数据内容。因此,本质上是在修改最初通过 CREATE TABLE 定义的表结构。
通过该类操作,我们可以对列进行属性调整、添加新列或删除已有列。
增(ADD):添加新列
当业务需求发生变化,需要为表新增一个字段时,可以使用 ADD 操作。
sql
ALTER TABLE 表名 ADD 字段名 类型 [约束];
在实际使用中,还可以精确控制新列在表中的位置:
AFTER 某列:将新列插入到指定列之后FIRST:将新列插入到表的第一列
示例 :为
student表添加一个邮箱字段,并放在name列之后
ALTER TABLE student ADD email VARCHAR(50) AFTER name;
== 改(MODIFY / CHANGE):修改列属性==
这是最常见的一类操作。例如,原先定义的 VARCHAR(20) 长度不够,或者需要为某列增加 NOT NULL 约束。
在 MySQL 中,通常涉及两个容易混淆的关键字:
-
MODIFY:仅修改列的属性(如数据类型、约束),不涉及列名变更sqlALTER TABLE student MODIFY name VARCHAR(50) NOT NULL; -
CHANGE:既可以修改列的名称 ,也可以修改其属性sqlALTER TABLE student CHANGE name stu_name VARCHAR(50);
需要特别注意的是:MODIFY 和 CHANGE 并不是在原有属性的基础上"追加"修改,而是重新定义该列的完整属性集合。也就是说,修改后列的类型与约束将以新定义为准,原有未显式保留的属性可能会被覆盖或丢失。因此,在进行修改时,应当明确写出期望保留的所有属性,以避免隐式丢失约束。
删(DROP):删除列
当某个字段不再具有业务意义时,可以通过 DROP 将其从表结构中移除。
-
语法:
sqlALTER TABLE 表名 DROP 字段名; -
风险提示 :该操作是不可逆的。删除列的同时,该列中存储的所有数据也会被永久清除,无法恢复。因此在执行之前,应做好充分评估与必要的数据备份。
整体来看,ALTER TABLE 提供了对表结构进行演进的核心能力,使数据库能够随着业务需求的变化进行动态调整。但与此同时,这类操作往往伴随着一定的风险(如数据丢失、锁表、性能开销等),在生产环境中应谨慎使用。
这里需要补充的一点是:对表结构(元数据)的修改,是发生在表已经创建并且可能已经存在历史数据 的前提之下的。这意味着存在一个"时间窗口":在修改之前,表中已经插入了一批记录,这些记录是基于旧的表结构定义写入的。
因此,一个关键问题是:当表结构发生变化之后,这些历史数据是否会受到影响?是否需要对已有记录进行调整,以满足新的约束规则?
这个问题需要结合具体修改类型来分析。
如果修改涉及的是强约束(约束收紧),例如将某一列从"允许为 NULL"修改为"NOT NULL",那么此时表中可能已经存在该列值为 NULL 的记录。在这种情况下,MySQL 会在执行 DDL 时对全表数据进行校验,检查是否存在违反新约束的数据。
需要特别注意:
- 如果存在不符合新约束的记录,MySQL 不会自动修改或删除这些数据;
- 而是会直接报错并拒绝本次表结构修改。
这是数据库的一种保护机制。本质上,数据库系统不具备修改业务语义的权限。例如,如果某个学生的"家庭住址"为 NULL,数据库不能擅自将其改为"地球"或其他默认值,这在业务层面可能是严重错误。因此,MySQL 的策略是:要么保证数据完全符合新规则,要么拒绝变更。
换句话说,这类报错可以理解为数据库在提示你:"当前存量数据无法满足新的约束定义,请先清理或修正数据,再执行结构变更。"
相反,如果修改后的约束对已有数据仍然成立,则可以顺利完成修改。
再来看另一类常见场景:新增列(ADD COLUMN)。
当新增一个列时,逻辑上意味着所有历史记录也必须"拥有"这一列。此时:
- 如果新列定义了默认值(DEFAULT),则旧记录会被填充为该默认值;
- 如果未定义默认值,则行为取决于是否允许 NULL(通常会填充为 NULL)。
从底层实现来看,在传统执行路径下(即 COPY 算法),ALTER TABLE 的行为可以抽象为一次"重建表"的过程:
-
创建新表结构 :生成一个临时表(对应新的
.ibd文件,例如#sql-xxx.ibd)。 -
逐行搬迁数据:
- 从旧表读取一行数据;
- 按照新表结构进行约束校验;
- 校验通过后写入新表。
-
异常处理(熔断机制):
- 一旦某一行数据不满足新约束(例如 NULL 写入 NOT NULL 列),整个过程立即中止;
- 删除新生成的临时表文件;
- 保留原表不变,并返回错误。
-
最终替换(原子切换):
- 如果所有数据均成功迁移,则执行重命名操作;
- 新表替换旧表,旧表被删除;
- 此过程对外表现为一次原子性的结构切换。
上述过程对应的是 MySQL 中的 COPY 算法。在 MySQL 8.0 中,引入了更高效的 DDL 执行策略,根据操作类型不同,会选择不同算法:
如果修改的不是强约束,而只是调整默认值或新增列,那么通常只需要修改元数据,而不需要扫描表中的存量记录。此时旧表中的数据可以直接保留,不做物理层面的变更。
从本质上看,这类操作属于不需要立即验证存量数据是否满足新规则 的场景。因此,MySQL 在满足条件的情况下,会采用 INSTANT 算法。
INSTANT 的核心特征是:仅修改数据字典中的元数据,不访问数据页,也不重写已有记录,因此可以在极短时间内完成。
需要注意的是,INSTANT 并非适用于所有"加列"操作,它通常仅支持在表尾追加列,且对列属性存在一定限制(例如不能包含复杂约束或依赖已有数据的计算)。
这里需要补充一下表尾追加列:
所谓"表尾追加列",是指将新列添加在当前表所有列的最后位置,而不是插入到已有列之间或表的开头。例如:
sql
ALTER TABLE student ADD age INT;
此时 age 会被追加到表结构的末尾。这种操作之所以可以使用 INSTANT,是因为:
- 对于已经存在的旧记录,其物理存储结构可以保持不变;
- 新增列在读取时,如果旧记录中不存在该列的实际存储,则由系统按默认值或 NULL 进行"逻辑补齐"。
换句话说,旧数据在物理层面并不会真的被修改,而是在读取时按新结构进行解释。
由于InnoDB 8.0为了支持INSTANT加列,在每行记录的头部引入了一个标记位(instant flag,在记录头中存储),以及在数据字典中记录"新列默认值"。读取旧记录时,如果发现这行是在INSTANT加列之前写入的(通过标记位判断),系统就用数据字典里的默认值填充新列返回。这样新旧记录在同一张表里混合存储也不冲突。
而如果是如下操作:
sql
ALTER TABLE student ADD age INT FIRST;
-- 或
ALTER TABLE student ADD age INT AFTER id;
则属于将列插入到表头或中间位置。这会改变每一行记录的存储布局(列顺序发生变化),从而必须对数据页进行重组,无法使用 INSTANT,通常会退化为 INPLACE,甚至 COPY。
再回到正题,而如果此时的操作不是简单的元数据变更,例如:
- 添加二级索引
- 或对某些列属性进行调整(但不涉及强约束校验)
那么 MySQL 通常会采用 INPLACE 算法。
INPLACE 的特点是:在原表基础上进行修改,不需要完整拷贝数据,但可能会涉及:
- 重建索引结构
- 重组部分数据页
因此,其开销介于 INSTANT 和 COPY 之间。
而如果修改涉及强约束(例如 NOT NULL、主键、唯一性等) ,或者需要对存量数据进行完整校验甚至格式转换,那么即使在 MySQL 8.0 中,也通常会退化为 COPY 算法。
COPY 的本质是:
- 创建新表
- 逐行搬迁数据并进行约束校验
- 最终替换旧表
这也是代价最高的一种执行方式。
总结来看,这三种算法的选择可以抽象为一个分层逻辑:
- INSTANT:仅元数据变更,无需触碰数据
- INPLACE:局部数据结构调整,无需全量拷贝
- COPY:全量重建数据,并进行严格校验
而决定采用哪种算法的核心标准在于两点:
- 是否需要访问或修改存量数据
- 是否需要对存量数据进行合规性校验
但是,一般并不建议在生产环境中轻易修改表结构。因为从底层实现来看,表结构变更往往是一个代价较高且具有一定风险的操作。
首先,在很多情况下(尤其是 COPY 或部分 INPLACE 操作),需要对全表数据进行扫描,以检查表中已有记录是否满足新表结构下的约束定义,必要时还需要对数据进行重写或重组。这一过程会带来明显的 IO 和 CPU 开销。
其次,在执行表结构修改期间,MySQL 需要处理并发访问问题。由于数据库通常会同时服务多个客户端连接,而每个连接对应一个线程,这些线程可能会并发地访问或修改该表。为了保证数据一致性与结构变更的正确性,MySQL 会在 DDL 执行过程中引入元数据锁(MDL, Metadata Lock)。
需要特别注意的是:该锁并不会在整个 ALTER TABLE 执行期间持续持有排他锁,而是分阶段控制的。
- 在开始阶段 :
- 需要修改数据字典中的表结构定义;
- 此时会获取排他性的 MDL 锁;
- 该阶段要求原子性与隔离性,不允许其他线程同时读取或修改表结构;
- 但该锁持有时间通常较短。
- 在中间阶段(数据处理阶段) :
- 例如 COPY 算法中的"数据搬迁",或 INPLACE 中的索引构建;
- 此时通常不会长时间阻塞普通的 DML 操作;
- 对于 COPY 场景,会创建一个新的
.ibd文件,将旧表中的数据按照新结构逐行校验并写入新表。
在这一过程中,如果有其他事务对原表执行了 DML 操作(如 INSERT、UPDATE、DELETE),这些记录层面的数据变更(例如 INSERT、UPDATE、DELETE),不会直接作用于正在构建的新表,而是会被统一记录在一个内部结构中,即 row log 。其作用是在 ALTER TABLE 执行期间,记录所有并发 DML 产生的增量变更。
可以将其类比为一种"延迟应用机制":在表结构变更过程中,系统相当于在"重建数据",而在此期间发生的所有写操作,会被暂时记录下来,而不是立即同步到新表。
更形象地说,可以理解为:"我现在在搬家,你们期间发生的所有事情先记在小本子上,搬完了我再统一处理"
在进入第三阶段(收尾阶段)时,MySQL 会将 row log 中记录的所有增量变更回放(apply)到新表 (即新创建的 .ibd 文件)中,从而保证在最终切换时,新表与旧表在逻辑上保持一致。
这个过程可以概括为:
- 在 DDL 执行期间,所有并发 DML 产生的变更都会被记录下来;
- 等到数据搬迁完成后,在收尾阶段 ,这些增量变更会被回放(apply)到新表中;
- 从而保证新表与旧表在最终切换时的数据一致性。
这种机制本质上是在解决一个经典问题:如何在长时间 DDL 操作期间保证并发写入不丢失。
在最后的收尾阶段,MySQL 会执行以下关键步骤:
- 将 row log 中记录的所有增量变更应用到新表;
- 执行物理文件替换:
- 将临时文件(如
#sql-xxx.ibd)重命名为正式表文件(如student.ibd); - 删除旧表对应的物理文件;
- 将临时文件(如
- 提交数据字典中的元数据变更;
可以将整个过程抽象为三个阶段(但需要特别注意:锁是分阶段获取与释放的,而不是全程持有):
-
准备阶段(短暂加锁) :
获取 MDL 排他锁(MDL X) ,用于修改数据字典中的表结构定义。
该阶段要求严格的原子性与隔离性,因此不允许其他线程并发访问或修改表结构。
该锁在此阶段完成后会释放,不会贯穿整个 ALTER 过程。 -
执行阶段(无长时间排他锁) :
进入数据处理阶段,例如数据拷贝(COPY)、索引构建(INPLACE)等操作。
此时:
- 不再持有长时间的 MDL 排他锁
- 允许并发的 DML 操作继续进行(具体并发能力取决于算法类型)
- 所有并发 DML 产生的变更会被记录到 row log 中
-
收尾阶段(再次短暂加锁) :
再次获取 MDL 排他锁,用于完成最终的原子切换,包括:
- 应用 row log 中的增量变更
- 重命名物理文件(
#sql-xxx.ibd → 正式表) - 提交数据字典变更
该阶段同样是短时间持锁,用于保证最终切换的原子性与一致性。
至此,表结构修改正式完成,并对外生效。
查
接下来要讲解的最后一个操作是查找,即查询表结构,或者更准确地说,是查看表的定义信息。这里所谓的查询表结构,本质上是获取该表中定义的列名、数据类型、列属性以及列级约束等元数据。
我们可以通过 DESC(DESCRIBE 的简写)语句来快速查看表结构。需要注意的是,DESC 语句只能展示一些核心字段信息 ,例如列类型、是否允许为 NULL、是否为主键以及默认值等,而无法完整展示所有约束(如外键、检查约束)以及字段注释等信息。因此,它更像是一个结构摘要(summary),用于对表结构进行快速、直观的了解。
sql
DESC 表名;
其返回结果通常如下所示:
| Field | Type | Null | Key | Default | Extra |
|---|---|---|---|---|---|
| id | int | NO | PRI | NULL | auto_increment |
| name | varchar(20) | NO | NULL | ||
| age | tinyint | YES | 18 |
如果需要查看表的完整定义信息(包括建表语句、所有约束、存储引擎、字符集以及字段注释等),则更推荐使用如下语句:
sql
SHOW CREATE TABLE 表名 \G
关于 \G 的说明如下:
在 MySQL 命令行客户端中,默认的结果输出采用横向表格布局。当查询结果包含较长文本(例如 CREATE TABLE 语句)时,容易出现换行混乱、对齐错位等问题,从而影响可读性。
- 使用
\G替代语句结束符;,可以将结果以纵向格式输出; - 每一行记录会按"列名: 值"的形式逐行展示,使结构更加清晰;
- 在分析复杂表结构或长 SQL 语句时,这种输出格式更具可读性和可维护性。
从实践角度来看,DESC 适用于快速查看结构概要 ,而 SHOW CREATE TABLE 更适用于精确还原表定义与深入分析结构细节,两者在使用场景上形成互补。
结语
那么这就是本篇文章的全部内容,带你认识以及掌握表的约束以及DDL,下一期我会dml,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!感谢各位大佬对我的支持!
