MySQL 表设计进阶:约束、范式、连接、索引与事务
会写 SELECT 之后,很快会碰到另一个问题:同样的数据,为什么有人设计出来的表很好查、很好改,有人的表却要靠一堆补丁维持?
差别往往不在 SQL 写得多花哨,而在几个基础决定:
- 哪些字段必须有值,哪些值不能重复;
- 一条信息到底应该放在哪张表;
- 表与表之间怎样建立关系;
- 哪些查询值得加索引;
- 多条写操作怎样保证一起成功或一起失败。
下面继续用学生成绩库做例子。环境按 MySQL 8.0 编写,重点不只是"语法怎么写",也会说明为什么这样设计。
一、先看完整的表关系

这个小系统有四张表:
class:班级;student:学生,每个学生属于一个班级;course:课程;score:成绩,用来连接学生和课程。
建表语句如下:
sql
create database if not exists school_demo
default character set utf8mb4
collate utf8mb4_0900_ai_ci;
use school_demo;
create table class (
id bigint unsigned primary key auto_increment,
name varchar(30) not null,
unique key uk_class_name (name)
) engine = InnoDB;
create table student (
id bigint unsigned primary key auto_increment,
name varchar(20) not null,
age tinyint unsigned,
gender tinyint not null default 0,
class_id bigint unsigned,
created_at datetime not null default current_timestamp,
constraint ck_student_age
check (age is null or age <= 150),
constraint ck_student_gender
check (gender in (0, 1, 2)),
constraint fk_student_class
foreign key (class_id) references class(id)
on update cascade
on delete set null,
key idx_student_class_id (class_id)
) engine = InnoDB;
create table course (
id bigint unsigned primary key auto_increment,
name varchar(50) not null,
credit decimal(3, 1) not null default 1.0,
unique key uk_course_name (name)
) engine = InnoDB;
create table score (
student_id bigint unsigned not null,
course_id bigint unsigned not null,
score decimal(5, 2),
exam_time date,
primary key (student_id, course_id),
constraint ck_score_range
check (score is null or score between 0 and 100),
constraint fk_score_student
foreign key (student_id) references student(id)
on delete cascade,
constraint fk_score_course
foreign key (course_id) references course(id)
on delete restrict,
key idx_score_course_id (course_id)
) engine = InnoDB;
后面的约束、连接和索引,都可以从这四张表里找到对应场景。
二、约束是数据的最后一道防线
1. NOT NULL:必须存在的字段
sql
name varchar(20) not null
学生姓名如果允许为 NULL,这条记录很难再用于点名、查询或展示。对业务上必须存在的字段,应该直接在数据库层限制。
不过 NOT NULL 也不要到处加。比如学生暂时没有分班时,class_id 允许为空就比随便填一个 0 更清楚。
2. DEFAULT:常见初始值
sql
gender tinyint not null default 0
默认值适合"大多数新记录都相同"的字段。它能简化插入语句,但不应该掩盖真正缺失的信息。生日未知时,保存 NULL 通常比写一个虚假的 1970-01-01 更合理。
3. UNIQUE:不能重复的业务值
sql
unique key uk_course_name (name)
同一个系统里不允许出现两门同名课程时,可以交给唯一约束保证。只在 Java 代码中先查一次再插入,并不能完全避免并发请求同时写入重复数据。
MySQL 的唯一索引通常允许多个 NULL。如果字段要求"必须存在且不能重复",需要一起写:
sql
phone char(11) not null unique
4. PRIMARY KEY:一行数据的身份证
单字段主键:
sql
id bigint unsigned primary key auto_increment
联合主键:
sql
primary key (student_id, course_id)
score 表用 (student_id, course_id) 做联合主键,表达的业务规则很明确:一个学生在一门课程下只有一条成绩记录。
主键最好稳定、短小,并且没有业务含义。手机号、身份证号虽然也可能唯一,但它们会变化,也涉及隐私,不适合作为到处传播的主键。
5. AUTO_INCREMENT:方便,但不保证连续
sql
id bigint unsigned primary key auto_increment
自增主键只保证生成唯一值,不保证严格连续。插入失败、事务回滚、批量预分配都可能留下空洞。因此不要拿自增 ID 推算业务数量,也不要因为中间缺了一个编号就手动补回去。
6. FOREIGN KEY:保证引用关系存在
sql
constraint fk_student_class
foreign key (class_id) references class(id)
有了这个约束,student.class_id 不能指向一个不存在的班级。
常见外键动作:
| 动作 | 含义 |
|---|---|
RESTRICT |
有子表记录引用时,拒绝删除父表记录 |
CASCADE |
父表更新或删除时,子表跟着处理 |
SET NULL |
父表记录删除后,把子表外键设为 NULL |
动作要按业务语义选。删除学生时连带删除成绩通常合理;删除课程时直接清空所有历史成绩就未必合理,所以示例中课程使用 RESTRICT。
很多互联网项目会在业务代码中维护关联,而不使用物理外键,主要是为了分库分表、写入性能和发布灵活性。但即使不用外键,也要保留同样的完整性意识,并通过代码、任务和监控处理孤儿数据。
7. CHECK:让取值范围更可信
sql
check (score is null or score between 0 and 100)
MySQL 8.0.16 及以后版本会真正执行 CHECK 约束。旧版本可能只解析语法但不生效,维护老系统时要先确认版本。
三、范式到底在解决什么问题
范式不是为了把表拆得越多越好,而是减少重复数据和更新异常。
1. 第一范式:一列只表达一件事
不推荐:
| id | name | school |
|---|---|---|
| 1 | 张三 | 吉林大学,长春市,0431-xxxx |
school 同时塞了名称、地址和电话,后面很难按城市查询,也很难单独修改电话。
更合理的写法是拆成多个字段,或者进一步拆成 school 表。
2. 第二范式:非主键字段依赖整个联合主键
假设把成绩保存成下面这样:
| 学号 | 课程 ID | 学生姓名 | 课程名 | 学分 | 成绩 |
|---|---|---|---|---|---|
| 1001 | 1 | 张三 | MySQL | 4 | 90 |
| 1001 | 2 | 张三 | Java | 5 | 88 |
主键是 (学号, 课程 ID),但:
- 学生姓名只依赖学号;
- 课程名和学分只依赖课程 ID;
- 只有成绩同时依赖学号和课程 ID。
所以应该拆成:
text
student(id, name)
course(id, name, credit)
score(student_id, course_id, score)
这样修改学生姓名时只改一处,不会出现同一个学生在不同成绩记录里名字不一致。
3. 第三范式:避免非主键字段之间的传递依赖
如果学生表同时保存:
text
student(id, name, college_id, college_name, college_phone)
那么依赖关系是:
text
student.id → college_id → college_name、college_phone
学院电话只依赖学院,不应该在每个学生行里重复。更合理的设计:
text
student(id, name, college_id)
college(id, name, phone)
学院换电话时只改一行。
4. 真实项目不一定追求"绝对范式"
读多写少、对响应时间很敏感的场景,有时会有意保留冗余字段。例如订单明细会保存下单时的商品名称和价格,避免商品后来改名、改价后影响历史订单。
这不等于范式没用。先按范式把数据关系想清楚,再有理由地冗余,和一开始就把所有字段堆在一张表里,是两回事。
四、一对一、一对多和多对多
1. 一对一
用户基础信息和实名认证信息:
text
user(id, username)
user_profile(id, user_id, real_name, id_card)
在 user_profile.user_id 上同时加外键和唯一约束:
sql
user_id bigint unsigned not null unique
2. 一对多
一个班级有多个学生,一个学生属于一个班级。外键放在"多"的一侧:
text
student.class_id → class.id
3. 多对多
学生可以选择多门课程,一门课程也可以被多个学生选择。不要在 student 表里保存 "1,2,5" 这样的课程 ID 字符串,而是增加中间表:
text
score(student_id, course_id, score)
中间表还可以保存这段关系自己的属性,例如成绩、考试时间、选课状态。
五、多表查询:把拆开的数据重新拼起来
范式把数据拆到多张表里,查询完整信息时就要连接。
先插入少量测试数据:
sql
insert into class (name) values ('一班'), ('二班');
insert into student (name, age, gender, class_id)
values
('张三', 18, 1, 1),
('李四', 19, 2, 1),
('王五', 18, 1, null);
insert into course (name, credit)
values ('MySQL', 4.0), ('Java', 5.0);
insert into score (student_id, course_id, score, exam_time)
values
(1, 1, 90, '2026-07-20'),
(1, 2, 88, '2026-07-21'),
(2, 1, 82, '2026-07-20');
1. INNER JOIN:只保留匹配成功的数据
sql
select
s.name as student_name,
c.name as class_name
from student s
inner join class c on c.id = s.class_id;
没有分班的王五不会出现在结果中。
2. LEFT JOIN:保留左表全部数据
sql
select
s.name as student_name,
sc.score
from student s
left join score sc on sc.student_id = s.id;
即使学生暂时没有成绩,也会保留学生这一行,右表字段显示为 NULL。
一个很常见的坑,是把右表过滤条件写进 WHERE:
sql
select s.name, sc.score
from student s
left join score sc on sc.student_id = s.id
where sc.score >= 60;
没有成绩的学生会被 WHERE 过滤掉,效果接近内连接。如果需求是"保留所有学生,只关联及格成绩",条件应该放在 ON 中:
sql
select s.name, sc.score
from student s
left join score sc
on sc.student_id = s.id
and sc.score >= 60;
3. 多表连接
sql
select
s.name as student_name,
cl.name as class_name,
co.name as course_name,
sc.score
from student s
left join class cl on cl.id = s.class_id
left join score sc on sc.student_id = s.id
left join course co on co.id = sc.course_id
order by s.id, co.id;
连接条件用 ON,最终结果的筛选条件用 WHERE,SQL 会更容易读。
4. 自连接
员工和上级都在同一张表:
sql
create table employee (
id bigint unsigned primary key,
name varchar(20) not null,
manager_id bigint unsigned
);
查询员工及其上级:
sql
select
e.name as employee_name,
m.name as manager_name
from employee e
left join employee m on m.id = e.manager_id;
同一张表通过别名扮演了两个角色。
5. 子查询
查询与张三同班的学生:
sql
select id, name
from student
where class_id = (
select class_id
from student
where name = '张三'
);
查询选修了 MySQL 或 Java 的学生:
sql
select id, name
from student
where id in (
select student_id
from score
where course_id in (
select id
from course
where name in ('MySQL', 'Java')
)
);
子查询不一定比连接慢,优化器会做改写。写完后应结合 EXPLAIN 看真实执行计划,而不是只凭语法形式判断。
6. UNION 与 UNION ALL
sql
select name from student
union
select name from teacher;
UNION 会去重,UNION ALL 直接合并结果,通常更快。如果业务上不需要去重,优先使用 UNION ALL。
六、索引:少扫数据,比"让 SQL 看起来短"更重要
可以把索引理解成书的目录。没有目录时,为了找一个用户名,数据库可能要从第一行扫到最后一行;有合适索引时,可以快速定位目标范围。
1. 为什么 InnoDB 常用 B+ 树
B+ 树有几个适合磁盘和范围查询的特点:
- 分支多、树高低,查找需要的页较少;
- 数据有序,适合
BETWEEN、>、ORDER BY; - 叶子节点相连,连续读取范围数据更方便。
Hash 更擅长等值定位,但不适合范围和排序;普通二叉树分支少,数据量大时树会更高,磁盘 I/O 更多。
2. 聚簇索引和二级索引
InnoDB 的主键索引叶子节点保存整行数据,所以主键索引也叫聚簇索引。
二级索引叶子节点保存的是索引列和主键值。查询二级索引未覆盖的其他列时,通常还要拿主键回到聚簇索引再查一次,这就是"回表"。
3. 创建索引
sql
create index idx_student_name
on student(name);
create index idx_score_course_score
on score(course_id, score);
show index from score;
删除索引:
sql
drop index idx_student_name on student;
4. 联合索引的最左前缀
索引:
sql
create index idx_student_class_name
on student(class_id, name);
通常可以支持:
sql
where class_id = 1
where class_id = 1 and name = '张三'
where class_id = 1 and name like '张%'
只按 name 查询时,无法利用这棵联合索引的最左列进行有效定位:
sql
where name = '张三'
联合索引列的顺序要结合实际查询条件、选择性和排序需求设计,不能只按字段出现顺序照抄。
5. 常见的索引失效或收益很低的写法
sql
-- 对索引列做函数计算
where year(created_at) = 2026
-- 前导模糊匹配
where name like '%三'
-- 隐式类型转换,例如 phone 是字符串却拿数字比较
where phone = 13800000001
-- 联合索引跳过最左列
where name = '张三'
日期查询可以改成范围:
sql
where created_at >= '2026-01-01'
and created_at < '2027-01-01'
6. 用 EXPLAIN 验证
sql
explain
select s.name, sc.score
from student s
join score sc on sc.student_id = s.id
where s.class_id = 1;
重点观察:
type:访问方式;key:实际选择的索引;rows:预计扫描行数;Extra:是否出现临时表、文件排序等信息。
索引不是越多越好。每多一个索引,插入、更新和删除时就多一份维护成本,也会占磁盘空间。低区分度的小表字段,建索引后可能几乎没有收益。
七、事务:让一组操作保持完整
转账是最典型的事务场景:A 扣款和 B 加款必须一起成功,否则数据就不可信。
1. ACID
| 特性 | 含义 |
|---|---|
| 原子性 Atomicity | 一组操作全部成功或全部回滚 |
| 一致性 Consistency | 事务前后都满足业务规则和约束 |
| 隔离性 Isolation | 并发事务尽量互不干扰 |
| 持久性 Durability | 提交后的结果不会因进程重启而丢失 |
2. 一个更稳妥的转账示例
sql
start transaction;
select id, balance
from account
where id in (1, 2)
for update;
update account
set balance = balance - 100
where id = 1
and balance >= 100;
-- 应用程序需要检查上一条 UPDATE 是否确实影响了 1 行
update account
set balance = balance + 100
where id = 2;
commit;
中途发生异常时:
sql
rollback;
FOR UPDATE 会对目标记录加锁,减少并发转账时互相覆盖的风险。锁定多行时,所有事务最好按相同顺序访问账户,降低死锁概率。
3. 保存点
sql
start transaction;
update score set score = score + 5 where course_id = 1;
savepoint after_mysql;
update score set score = score + 5 where course_id = 2;
rollback to after_mysql;
commit;
保存点可以只撤销事务后半段,但不能替代清晰的业务边界。
4. 并发事务的三个问题
- 脏读:读到其他事务尚未提交的数据;
- 不可重复读:同一事务里两次读取同一行,结果不同;
- 幻读:同一条件两次查询,记录数量发生变化。
5. 隔离级别
| 隔离级别 | 说明 |
|---|---|
READ UNCOMMITTED |
隔离最弱,可能脏读 |
READ COMMITTED |
只能读已提交数据 |
REPEATABLE READ |
同一事务内重复读取保持一致 |
SERIALIZABLE |
隔离最强,并发能力最低 |
查看当前隔离级别:
sql
select @@transaction_isolation;
InnoDB 默认通常是 REPEATABLE READ。隔离级别不是越高越好,要在一致性要求、锁等待和并发量之间取舍。
八、三个常见延伸点
原稿里还有视图、权限和 JDBC,这三部分可以先抓住最实用的规则。
1. 视图:保存查询逻辑,不是复制一份数据
sql
create view v_student_score as
select
s.id as student_id,
s.name as student_name,
c.name as course_name,
sc.score
from student s
join score sc on sc.student_id = s.id
join course c on c.id = sc.course_id;
查询方式与普通表类似:
sql
select *
from v_student_score
where score >= 90;
复杂连接、聚合、DISTINCT 等视图通常不能直接更新,使用前要确认可更新性。
2. 权限:按最小权限分配
sql
create user 'report_user'@'%'
identified by 'replace_with_strong_password';
grant select
on school_demo.*
to 'report_user'@'%';
show grants for 'report_user'@'%';
报表账号只需要读权限,就不要直接授予 ALL PRIVILEGES。实际部署还要限制允许连接的主机,并妥善管理密码。
3. JDBC:参数一定用 PreparedStatement
java
String sql = """
select id, name, age
from student
where class_id = ? and name like ?
""";
try (PreparedStatement ps = connection.prepareStatement(sql)) {
ps.setLong(1, classId);
ps.setString(2, keyword + "%");
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
System.out.println(rs.getLong("id"));
System.out.println(rs.getString("name"));
}
}
}
不要用字符串拼接用户输入。PreparedStatement 不只是写起来规整,更重要的是把 SQL 结构和参数值分开,降低 SQL 注入风险。