1. 从“这表怎么建”开始:建表前必须想清楚的几件事
很多人学MySQL的第一步就是敲CREATE TABLE,敲完发现字段类型选错了、字符集出问题了、主键设计不合理,回头改表的时候才知道什么叫“年少不知建表贵,改表方知泪两行”。我最初带新人写SQL时最常说的一句话就是:建表建不好,后面所有插入、查询、删除都是在给前面的草率还债。
1.1 先想清楚,再敲键盘:建表前的设计思路
建表之前,第一件事不是打开命令行窗口,而是把这张表要承载的数据想明白。这张表是干什么的?里面会存多少行数据?每天会增加多少量?哪些字段会被频繁用来检索?哪些字段只是记录一下、基本不会被查?这些问题的答案直接决定字段命名、类型选择、索引怎么加。
举个例子,你做一个用户表,里面要存手机号。手机号在MySQL里用不用BIGINT?用CHAR(11)?还是用VARCHAR(20)?很多人第一反应是数字就用BIGINT,但手机号其实不适合用整数类型存储,因为手机号可能会涉及到前导零的问题,比如某些特殊号码,而且将来如果要做区号、分机号的扩展,字符串类型的灵活性高得多。我见过一个系统把手机号存成BIGINT,结果查询时忘了隐式转换,本来走索引的SQL变成全表扫描,几百万行的表硬生生慢了几十倍。
再比如日期类型。MySQL里常见的有DATETIME和TIMESTAMP两种。很多新人混着装,但其实它们的存储范围、时区行为都不一样。TIMESTAMP记录的是UTC时间,会自动根据系统时区转换,而DATETIME存什么就是什么。跨国业务、需要统一时间基准的场景用TIMESTAMP,本地业务、要精确记录用户输入时间的用DATETIME更稳妥。
1.2 数据类型选不对,后面全是坑
建表时的字段类型,我建议重点遵守三条原则:够用就好、宁小勿大、能用数值类型就不要用字符串。这里有个容易踩的坑是INT的显示宽度。
MySQL里写INT(5)并不是说这个整数最长只能存5位,它只是配合ZEROFILL属性起显示补零的作用。在MySQL 8.0.17之后,显示宽度已经被标记为废弃,写INT(5)实际上和INT没区别。有些老项目里能看到INT(11)这种写法,那是受早期习惯了MySQL自带工具默认显示影响的遗留习惯,新项目完全没必要这么写。
字符串类型的选择也要注意。VARCHAR是变长,适合用户名、地址这种长度不固定的内容;CHAR是定长,适合手机号(如果确定用字符串存)、身份证号这种固定长度的场景,查询效率更高,但会消耗额外空间。网上有些资料说VARCHAR(255)是性能分水岭,不太准确,真正需要注意的是VARCHAR的最大长度受行大小限制,在utf8mb4字符集下,一条记录里所有VARCHAR列的长度总和不能超过65535字节,按一个汉字占3字节算,能存大概2万个汉字。有些新人把备注字段设成VARCHAR(1000)甚至更大,其实没问题,但要注意整个表的其他字段不能无限扩大。
小数类型的坑更多。FLOAT和DOUBLE是浮点数,计算会有精度误差。金融、订单金额相关字段千万别用这两个,要用DECIMAL。DECIMAL(10,2)表示总长度10位、小数点后保留2位,能精确表示金额。我见过一个统计系统,金额用DOUBLE存储,月底对账时发现差了几分钱,排查了两天才定位到是浮点精度问题。
1.3 存储引擎和字符集:InnoDB与utf8mb4是默认答案
MySQL默认的存储引擎是InnoDB,新项目直接用默认就行,不需要纠结。有人会提MyISAM,它查询确实快,但不支持事务、不支持外键、锁粒度是表级锁,在并发写入场景下会非常痛苦。除非你是在做一个只读的报表库、全表数据一次性加载、完全不需要并发写,否则别选MyISAM。
字符集这块,建议直接用utf8mb4。utf8mb4是utf8的超集,能存储emoji表情和一些生僻字。如果你用了utf8字符集,插入一条带emoji的数据就会报错,这种问题在对接移动端用户昵称时特别常见。顺便说一句,数据库实例的默认字符集、客户端连接字符集、表字符集、字段字符集要尽量保持一致,否则会出现“乱码三连”。
1.4 一张完整的CREATE TABLE语句长什么样
我放一个实际项目里比较典型的学生信息表建表语句,字段和注释都写清楚,你可以直接照着改:
sql复制CREATE TABLE `student` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`student_no` VARCHAR(20) NOT NULL COMMENT '学号',
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
`gender` TINYINT NOT NULL DEFAULT 0 COMMENT '性别 0未知 1男 2女',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`birthday` DATE DEFAULT NULL COMMENT '出生日期',
`address` VARCHAR(255) DEFAULT NULL COMMENT '家庭住址',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1在读 0离校',
`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_student_no` (`student_no`),
KEY `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生信息表';
这张表里有几个设计细节值得展开说:
id字段用BIGINT UNSIGNED AUTO_INCREMENT,这是最常见的单表主键做法。为什么不用INT?因为很多表在数据量上来后,INT的上限21亿会被耗尽,一旦到了天花板,你再想改成BIGINT就要锁表,非常痛苦。起步阶段直接上BIGINT,几乎不会踩到上限的坑。gender字段用TINYINT而不是VARCHAR(10)存“男”“女”,好处是存储空间小、查询速度快,业务层做一次映射就行。类似这种“状态码”字段,都用数值类型加注释的方式,这是数据库设计的通识做法。create_time和update_time给默认值,插入的时候就不用手动维护时间。update_time在更新时自动刷新,省了很多代码。这一点对很多新手来讲是黑科技,其实只是ON UPDATE CURRENT_TIMESTAMP的功劳。- 索引并不需要把每个字段都加一遍。这张表里,学号加了唯一索引,因为学号天然不重复;姓名加了普通索引,因为员经常按名字查询。像gender、status这种低区分度的字段,加了索引也没什么效果,反而拖慢写入速度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 记INSERT不只是往里塞数据:从单行到批量,以及自增主键的坑
建表完成后,接下来的操作就是把数据写进去。INSERT的语法本身很简单,但实际使用中经常遇到各种莫名其妙的问题。
2.1 单行插入与多行插入的写法差异
最基础的INSERT,一句话就能说清:
sql复制INSERT INTO student (student_no, name, gender, phone, birthday, address)
VALUES ('2024001', '张三', 1, '13800000001', '2000-01-15', '北京市朝阳区');
这里有个容易忽略的点:如果某列有默认值,插入时可以省略它。比如status默认是1、create_time默认是当前时间,你不在INSERT语句里写它们,MySQL会自动填充。但如果字段完全没有默认值,插入时又没写,MySQL就会报错。
带多个值的批量插入,很多人知道写法,但不知道它为什么值得用:
sql复制INSERT INTO student (student_no, name, gender, phone) VALUES
('2024002', '李四', 2, '13800000002'),
('2024003', '王五', 1, '13800000003'),
('2024004', '赵六', 2, '13800000004');
批量插入不仅代码量少,最关键的是性能差距。一次INSERT带3行值和三次INSERT各带1行值,在数据库层面完全是两个概念。每个INSERT都是一条独立SQL,都要经过解析、权限校验、事务提交等流程。像这种小批量数据,性能差距还不明显,但如果是几千行几万行的初始化数据,多行VALUES的方式比分多次单行插入能快一个数量级。
2.2 批量插入为什么会快:原理与注意事项
批量插入快的原因主要有两个:一是减少了客户端和MySQL服务端的通信次数,原来要发N次请求,现在1次请求就传过去了;二是MySQL在批量插入时内部有优化路径,比如减少磁盘I/O和日志刷盘次数。不过要留意,批量插入也不是越大越好。一条SQL带10万行VALUES,单条SQL的执行时间和锁持有时间都会很长,容易拖垮其他查询。
我的习惯是批量插入控制在1000行到5000行一批,太多时就拆成多批。这个数量级是IO吞吐和执行时间比较均衡的位置,既不浪费连接往返,也不会因为单条SQL过大把binlog撑爆。
sql复制-- 分批插入示例:每批500条
INSERT INTO student (student_no, name, gender) VALUES (...500 rows...);
-- 上一批执行完成后,再执行下一批
2.3 自增主键的连续性与LAST_INSERT_ID
InnoDB的自增主键并不是连续增长的。事务回滚、显式指定ID、批量插入都会导致自增序号出现空洞。比如你插入了三条记录,然后回滚了这次事务,下一条插入的ID可能是5而不是1、2、3,这是正常现象,不叫bug。很多新人看到ID不连续就以为数据丢了或者有并发问题,其实是没搞懂自增主键的分配机制。
新插入一行后如果需要拿到它的ID,用LAST_INSERT_ID(),不要用SELECT MAX(id) FROM student。后者在并发情况下会拿到别人的ID,而且在高并发下性能也不好。正确的做法是借助事务或者在插入后马上调用LAST_INSERT_ID(),它读取的是当前会话最后一次成功INSERT产生的主键值,和其他会话互不干扰。
sql复制INSERT INTO student (student_no, name) VALUES ('2024005', '钱七');
SELECT LAST_INSERT_ID();
有个细节值得提,如果在一条INSERT里插入多行,LAST_INSERT_ID()返回的是这批数据里第一条记录的ID,不是最后一条。比如一次插入5条,返回的是这批数据的第一行ID,而后续的自动编号按这个ID连续递增。跨过这个细节,很多人写批量导入功能时,想拿到每行的自增ID,用LAST_INSERT_ID()做循环,就会得到错误结果。
2.4 插入数据时容易踩的隐式转换坑
隐式类型转换是SQL性能杀手之一。举一个最常见的例子,phone字段是VARCHAR类型,但你查询或插入时传入了整数:
sql复制INSERT INTO student (student_no, name, phone) VALUES ('2024006', '周八', 13800000005);
MySQL会把传入的整数隐式转成字符串再比较,这在写入时一般没问题。但反过来,如果字段是INT类型,你的WHERE条件里用了字符串:
sql复制SELECT * FROM student WHERE id = '2024001';
MySQL会把字符串转成数字,这种情况下还能走索引,不是大问题。真正麻烦的是在查询时对索引字段做了函数操作或运算:
sql复制-- 这种写法导致索引失效
SELECT * FROM student WHERE student_no + 0 = '2024001';
只要你对字段做了计算或函数转换,MySQL就无法高效使用该字段上的索引,只能全表扫描。如果你的表数据量上了几十万行,这种SQL会立刻变慢。
3. 删除数据是门手艺活:DROP、DELETE、TRUNCATE的边界要分清
删除有两种,一种是删表,一种是删数据。很多新人把DROP TABLE、TRUNCATE TABLE、DELETE FROM三者的区别仅记成“有没有WHERE条件”,其实它们的底层机制、锁行为、事务性完全不同。搞错了,轻则恢复半天,重则直接清空库。
3.1 DROP、DELETE、TRUNCATE到底有哪些不同
我用一张表来收场,这张表在很多面试题里也常出现,但面试题讲得比较书面,我按实际踩坑经验来展开。
| 操作 | DROP TABLE | TRUNCATE TABLE | DELETE FROM |
|---|---|---|---|
| 作用对象 | 删除整张表 | 清空表中所有数据 | 按条件删除部分或全部数据 |
| 是否能加WHERE | 不能 | 不能 | 可以 |
| 是否支持事务回滚 | 不支持(DDL自动提交) | 不支持到底,具体看是否在事务里,多数场景不可回滚 | 可以,配合事务可回滚 |
| 是否释放磁盘空间 | 完全释放 | 直接释放表空间 | 不释放,表空间数据页标记为可复用 |
| 自增主键重置 | 表都没了,无所谓 | 重置为1 | 不重置,继续递增 |
| 执行速度 | 极快 | 很快 | 慢,逐行删除或批量删除 |
| 锁影响 | 锁整个表 | 锁整个表 | 行锁,范围可控 |
这张表写下来,最核心的区分是:DROP是“把这个东西整个扔掉”,TRUNCATE是“把盒子里所有东西倒掉、盒子留着”,DELETE是“从盒子里挑出一些东西扔掉,其他的保留”。
3.2 DELETE不写WHERE条件的后果
有一次我带一个实习生处理测试库,他执行了一句DELETE FROM student;然后回头问我“为什么这个表空了”。我说你连个备份都没有吗?幸好是测试库,生产环境这就是事故。MySQL的DELETE如果不带WHERE,就是把所有匹配条件的数据全部删掉。空WHERE等于匹配所有行。
还有一个容易忽视的点:DELETE在没有事务包裹的情况下,一旦执行,数据并不会立刻物理消失,而是被标记为删除。此时可以用binlog或者数据库的Undo信息在一定程度上恢复,但恢复逻辑很复杂,商业环境下更可靠的是备份。所以真正稳妥的操作习惯是,任何删除操作前先确认三件事:有没有备份、有没有事务包裹、有没有WHERE条件。
3.3 TRUNCATE和DROP为什么不能回滚
TRUNCATE和DROP都是DDL操作,MySQL在执行DDL时会自动提交当前事务,不存在“先执行、再回滚”这种操作。也就是说,你执行了TRUNCATE TABLE student;,就算立刻执行ROLLBACK,也救不回来。
DROP和TRUNCATE的执行速度快,是因为它们不是逐行操作。TRUNCATE是直接把这堆数据页置空并释放给操作系统,自增计数器清零。DROP则是把表定义和数据页全部删除,连表结构都不剩。执行完DROP再去查这张表,直接报“Table doesn't exist”。
基于这个特性,我的建议是:删除表结构的DLL操作,必须经过审批,操作前先导出表结构备份,并且尽量在低峰期执行。
3.4 删除数据时的锁与阻塞
DELETE不是瞬间完成的操作,尤其是在大表上。执行DELETE时,InnoDB会给要删除的行加行级独占锁,在没有提交事务前,其他事务对这些行的更新、删除都会被阻塞。如果删除了大量行,锁的数量、Undo日志的规模都很可观,甚至会出现锁等待超时。
生产环境中,清理大表历史数据时,不能一把梭把几百万行一次性DELETE,应该分批删除,每次只删一部分,批次之间停一下,给其他SQL留出时间:
sql复制DELETE FROM student WHERE create_time < '2020-01-01' LIMIT 1000;
-- 循环执行,直到影响行数为0
每次删除1000条,然后让事务提交,释放锁,睡几秒再继续。这个方法看着笨,但能最大程度降低对线上业务的影响。
4. 查询才是日常开发的主战场:从SELECT 1到慢查询排查
建表、插数据、删数据都是铺垫,日常开发里占用时间最多的永远是SELECT。可能有基础的同学觉得SELECT不就是SELECT * FROM student WHERE ...嘛,但这几年的实际体验是,查询写得好不好,在数据量小的时候看不出来,等数据到了百万级、千万级,一条烂SQL可以拖垮整个数据库。
4.1 SELECT的执行顺序比你想象的更重要
SQL的书写顺序是SELECT -> FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY -> LIMIT,但MySQL的执行逻辑并不是完全按这个顺序来的。我简化描述一下真实执行流程:先根据FROM找到表,再通过WHERE过滤行,然后GROUP BY分组,再HAVING过滤分组,然后SELECT选择列,接着ORDER BY排序,最后LIMIT分页。
这个执行顺序的意义在于,当你写SELECT * FROM student WHERE name = '张三'时,MySQL实际上先在student表里通过name索引找到满足条件的记录,再回去取这些行的完整数据。这就是“回表”的概念。如果你查询的字段都包含在索引里,就叫“覆盖索引”,可以直接返回,不需要回表,速度快得多。
sql复制-- 索引是(idx_name),查询name和name字段,属于覆盖索引,不需要回表
SELECT name FROM student WHERE name = '张三';
-- 索引是(idx_name),查询name和phone,需要回表
SELECT name, phone FROM student WHERE name = '张三';
这个细节对于优化查询性能非常关键。日常查询,尽量在WHERE和ORDER BY涉及的字段上建立合适的索引,并且尽量在SELECT列表中只放必要字段,不要无脑SELECT *。
4.2 WHERE条件里最容易忽略的逻辑与性能问题
WHERE条件里常见的坑,我总结了几个高频的:
第一个坑是“OR去重”的问题。SELECT * FROM student WHERE name = '张三' OR phone = '13800000001';这种写法,如果name和phone上分别有索引,MySQL 8.0的索引合并优化可能会把两个索引结果合并,但如果条件更复杂,很容易退化成全表扫描。简单的OR在数据量小的时候无所谓,但在大表上建议拆成两次查询再合并,或者用UNION。
第二个坑是LIKE的前置通配符。WHERE name LIKE '%三%'这种写法无法走索引,因为通配符在最前面,B+树索引的前缀匹配特性直接失效。如果业务确实需要模糊搜索,数据量大的场景应该考虑全文索引或者引入专门的搜索方案,而不是硬扛LIKE。
第三个坑是IN子句中元素太多。IN里面放几百上千个值,SQL解析时间、索引命中率都会受影响。如果真的需要传大量ID,可以考虑拆分条件或者临时表JOIN。
4.3 ORDER BY与LIMIT:排序和分页中的两个经典坑
排序看起来简单,但有两个经典问题。
第一个问题,ORDER BY字段没有索引时,MySQL需要把结果集放入临时表排序,称为filesort。数据量大时,filesort会带来很大的内存和磁盘IO开销。解决方法:给排序字段加索引,或者把排序条件设计成可以走索引的方式。
第二个问题,深分页的性能很糟糕。SELECT * FROM student ORDER BY id LIMIT 1000000, 20;这种写法,MySQL会先扫描到第1000020行,然后丢弃前1000000行。越到后面的页,扫描的数据越多,性能几何级下降。
我常用的优化方法是“延迟关联”或“基于ID分页”:
sql复制-- 延迟关联:先在索引上完成排序和定位,再回表取数据
SELECT s.* FROM student s
INNER JOIN (SELECT id FROM student ORDER BY id LIMIT 1000000, 20) t
ON s.id = t.id;
这种写法让子查询先走索引完成排序和分页,只回表取20条记录,而不是回表取100万条。实测从几秒优化到几十毫秒是常见效果。
4.4 JOIN到底该怎么写:关联查询的基本姿势
多表关联是查询最核心的能力。学习JOIN时,建议把内连接INNER JOIN、左连接LEFT JOIN、右连接RIGHT JOIN这三种基础形态彻底搞清楚,而不是背概念。
实际业务中,绝大多数关联查询,JOIN条件都会放在ON后面,而过滤条件放在WHERE里。比如查询有订单的学生:
sql复制SELECT s.id, s.name, o.order_no
FROM student s
INNER JOIN orders o ON s.id = o.student_id
WHERE o.status = 1;
这里有个常见误解:很多人觉得LEFT JOIN时,把过滤条件写在ON里和写在WHERE里结果一样。其实不一样。LEFT JOIN中,ON条件是在连接时判断是否匹配,WHERE条件是在连接完成后过滤结果。如果把o.status = 1放在ON里,左表中不满足条件的行仍然会出现,只是右表字段为NULL;放在WHERE里,这些行会被直接过滤掉。
4.5 GROUP BY与聚合函数:统计类查询的核心
GROUP BY分组统计是报表类需求最常见的形式。比如统计每个性别的学生人数:
sql复制SELECT gender, COUNT(*), AVG(YEAR(NOW()) - YEAR(birthday)) AS avg_age
FROM student
GROUP BY gender;
这里提醒一个HIVE或MySQL 8.0之前版本的经典问题:SQL模式里如果开启了ONLY_FULL_GROUP_BY(MySQL 5.7.5之后的默认模式),SELECT后面出现的非聚合列,必须出现在GROUP BY子句中。否则直接报错。而关闭这种模式后,MySQL允许你随便查,但查出来的非聚合列并不是“该分组中某一行”的确定值,而是随机挑的,会带来严重的数据正确性隐患。所以我建议项目里开启ONLY_FULL_GROUP_BY,虽然写SQL时麻烦一点,但能避免数据失真。
HAVING和WHERE的区别也在这:WHERE在分组之前过滤行,HAVING在分组之后过滤分组。比如查出人数超过5人的性别分组,就要用HAVING COUNT(*) > 5。
4.6 慢查询是怎么出现的:从执行计划到索引失效
排查查询性能问题时,第一个动作永远是加EXPLAIN看执行计划。我之前带过一个小伙伴,总说“我查得慢”,我让他先把EXPLAIN贴出来,他贴上才发现type列是ALL,也就是全表扫描,索引完全没生效。
sql复制EXPLAIN SELECT * FROM student WHERE name = '张三';
看EXPLAIN时,我最关注几列:type,理想情况下要是不是ALL;key,实际用到的索引;rows,预估扫描行数;Extra,有没有出现Using filesort或Using temporary。
常见的索引失效场景我列一下,你在排查时逐个对照:对索引列做了函数运算、使用LIKE前置通配符、隐式类型转换、OR连接时某个条件列没有索引、NOT IN / NOT EXISTS在某些情况下的优化困难。每种场景都有对应的改写方案。
sql复制-- 反例:对索引列做运算,索引失效
SELECT * FROM student WHERE YEAR(create_time) = 2024;
-- 正例:改写为范围查询,索引能生效
SELECT * FROM student WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';
4.7 两个容易被热词带偏的查询点:逻辑删除和去重
热搜词里有“mybatis plus怎么将逻辑删除的数据也查询出来”,我在这里简单说下。所谓逻辑删除,不是真DELETE,而是在表里加一个字段(比如deleted),删除时把这个字段置为1。MyBatis-Plus默认会在查询时自动追加WHERE deleted = 0,如果想查询逻辑删除的数据,需要自定义SQL或者使用注解忽略逻辑删除。在纯SQL层面,你只需要手动查WHERE deleted = 1就能看到被“删除”的数据。这个设计的好处是数据能够被审计和恢复,代价是每一条查询都要多带一个条件。
另一个容易出问题的是去重。SELECT DISTINCT和GROUP BY都能去重,但DISTINCT作用于所有查询列,GROUP BY是分组统计的附带效果。面试题里有个高频的“MySQL的OR能去重吗”,答案是OR本身不去重,如果一个查询里多个条件产生了重复行,你需要用DISTINCT或者GROUP BY。
5. 实战综合演练:从零搭建一个小型订单查询系统
基础语法单独看不难,但如果不把它们串起来用一遍,印象总是不深。我带大家从头到尾走一个非常简单但完整的场景,把建表、插入、删除、查询全部串起来。
5.1 设计三张表:学生、课程、选课记录
先建学生表(简化和上面相比略有调整)、课程表、选课表:
sql复制CREATE TABLE student (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
name VARCHAR(50) NOT NULL COMMENT '姓名',
age TINYINT NOT NULL COMMENT '年龄'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表';
CREATE TABLE course (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
title VARCHAR(100) NOT NULL COMMENT '课程名称',
credit DECIMAL(3,1) NOT NULL COMMENT '学分'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';
CREATE TABLE student_course (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
student_id BIGINT UNSIGNED NOT NULL COMMENT '学生ID',
course_id BIGINT UNSIGNED NOT NULL COMMENT '课程ID',
score DECIMAL(5,2) DEFAULT NULL COMMENT '成绩',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间',
UNIQUE KEY uk_student_course (student_id, course_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课记录表';
为什么选课表里要加UNIQUE KEY uk_student_course (student_id, course_id)?因为在真实业务里,同一名学生选同一门课不应该存在两条记录。唯一索引从数据库层面就把脏数据挡在门外,比应用层校验更可靠。
5.2 插入演示数据
sql复制INSERT INTO student (name, age) VALUES ('张三', 20), ('李四', 21), ('王五', 22);
INSERT INTO course (title, credit) VALUES ('MySQL入门', 3.0), ('数据库进阶', 4.0), ('数据结构', 3.5);
INSERT INTO student_course (student_id, course_id, score) VALUES
(1, 1, 88.5),
(1, 2, 92.0),
(2, 1, 75.0),
(3, 3, 81.5);
插入过程如果遇到Duplicate entry '1-1' for key 'uk_student_course'这种报错,说明你插入了重复的选课记录,这是唯一索引起作用的正常表现。
5.3 综合查询:选课情况与成绩统计
查询每个学生的选课数量和平均成绩:
sql复制SELECT s.id, s.name, COUNT(sc.course_id) AS course_count, AVG(sc.score) AS avg_score
FROM student s
LEFT JOIN student_course sc ON s.id = sc.student_id
LEFT JOIN course c ON c.id = sc.course_id
GROUP BY s.id, s.name
ORDER BY avg_score DESC;
这个查询用到了LEFT JOIN、GROUP BY、聚合函数AVG、ORDER BY排序,是实际工作中很典型的组合。LEFT JOIN保证了即使某位学生一门课都没选,他仍然会出现在结果里,course_count是0。
查询缺考学生(选了课但没成绩):
sql复制SELECT s.name, c.title
FROM student_course sc
JOIN student s ON s.id = sc.student_id
JOIN course c ON c.id = sc.course_id
WHERE sc.score IS NULL;
这里需要特别说明:判断NULL不能用= NULL,必须用IS NULL。WHERE score = NULL永远查不到任何行,因为NULL代表未知,用等号比较的结果也是NULL,不会被WHERE视为真值。这是MySQL新手最常见的错误之一。
5.4 数据清洗:删除重复选课记录和错误数据
如果发现某条选课记录录错了,可以用DELETE按条件删除:
sql复制DELETE FROM student_course WHERE student_id = 3 AND course_id = 3;
如果只是为了清空测试数据、保留表结构,用TRUNCATE:
sql复制TRUNCATE TABLE student_course;
如果整个选课表都不需要了,再考虑DROP:
sql复制DROP TABLE student_course;
这三句话在实际测试里经常被用错。建议在测试环境把三者的行为差异有意暴露一遍,比如先插入数据再分别执行,观察自增ID是重置还是延续、表结构是否保留,这样印象会深很多。
6. 关于MySQL基础,我最后想说的几件小事
6.1 一个特别值得建立的好习惯:每条DELETE前先SELECT
我工作后自己定的一个铁律是,任何生产环境的删除操作,先执行一模一样的SELECT确认范围,再执行DELETE。比如:
sql复制SELECT * FROM student WHERE create_time < '2020-01-01';
DELETE FROM student WHERE create_time < '2020-01-01';
很多时候失误是因为条件写错,比如日期范围少写了一个等号、字符串截断导致匹配超出预期。SELECT先验证一遍影响范围,虽然多花几秒钟,但能避免整张表被清空这种灾难。这个习惯面对SELECT和DELETE的WHERE条件差异时特别有效。
6.2 学习MySQL基础的路线建议
如果你想在此基础上继续深入,我建议按这个顺序往下走:先学会用EXPLAIN看执行计划,掌握索引的工作原理(B+树),然后学习JOIN的底层逻辑和优化;接着理解事务隔离级别、锁机制,可以应对高并发下的锁问题;后续再学习数据库性能优化、主从复制、分库分表等运维向的内容。
每一步都建立在扎实的SQL基础上。如果你想做后端开发,CREATE TABLE、INSERT、DELETE、SELECT这四个基本操作的高质量写法,是贯穿整个职业生涯的基本功。数据库里90%的产品问题,追根溯源都是最初建表、写SQL时埋下的隐患。
6.3 不要迷信“背命令”,要会用工具
最后提醒一句:命令行熟悉了以后,可以配合MySQL Workbench、Navicat这类图形化工具提高日常开发效率。我在实际项目里客户端工具的主要用途是快速查看表结构、可视化编辑数据、导出报表数据,而真正的生产环境变更、数据库参数调整,还是会回到命令行执行。两种方式互补,都能熟练使用才算基础过关。
