1. 建库建表:写 SQL 之前先把表结构设计想明白
1.1 库表命名与字符集选择:这些小坑能让你少加三小时班
刚开始接触 MySQL 的人,通常第一件事就是打开命令行敲 CREATE DATABASE,然后急着写 INSERT。但我在实际项目里见过太多因为表结构设计不合理导致的返工,所以先把建库建表这一步说透。
库名和表名的命名,我的建议是全部小写、用下划线分隔单词、不要用 MySQL 保留字。比如存储学生成绩的库,叫 school 或者 student_db 都比 myDb 这种随意命名要好。保留字问题更隐蔽,如果你把表命名为 order 或 group,写 SQL 时就必须用反引号包起来,每次查询都多一步,还会埋下隐患。宁可多打几个下划线,也不要去碰保留字。
字符集是我最想强调的一个点。MySQL 里的 utf8 其实是 utf8mb3,最多只能存 3 个字节的字符,这意味着 emoji 和一些生僻汉字根本存不了,插入时会直接报错或者变成问号。正确的做法是使用 utf8mb4,它是完整的 4 字节 UTF-8 编码,是 MySQL 8.0 的默认字符集。排序规则方面,我习惯用 utf8mb4_unicode_ci,它在做比较和排序时更符合 Unicode 规范;如果你用的 MySQL 8.0,默认的 utf8mb4_0900_ai_ci 也可以,性能更好。创建库的时候就把字符集定死,避免表建完以后再去改,这一步能省掉后面大量的乱码排查时间。
1.2 学生-课程-成绩三张表的完整建表语句
为了让后面的增删改查有实际的业务场景,我用一个经典的学生选课成绩模型来做演示。这个模型包含三张表:学生表、课程表、成绩表。为什么是三张而不是一张大宽表?因为选课关系是多对多的——一个学生可以选多门课,一门课可以被多个学生选,成绩属于某个学生和某门课的关联。如果全部塞进一张表,会产生大量重复数据,改一个学生姓名就要改几十行,这种设计在工程上是行不通的。
下面是建表语句,每个字段都加了注释,你可以直接在自己本机的 MySQL 里执行。
sql复制CREATE DATABASE IF NOT EXISTS school
DEFAULT CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE school;
CREATE TABLE student (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号',
name VARCHAR(50) NOT NULL COMMENT '姓名',
gender TINYINT NOT NULL DEFAULT 0 COMMENT '性别:0未知 1男 2女',
birthday DATE DEFAULT NULL COMMENT '出生日期',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表';
CREATE TABLE course (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
course_name VARCHAR(100) NOT NULL COMMENT '课程名称',
credit DECIMAL(3,1) NOT NULL DEFAULT 0.0 COMMENT '学分'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';
CREATE TABLE score (
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) NOT NULL COMMENT '成绩',
exam_time DATETIME NOT NULL COMMENT '考试时间',
UNIQUE KEY uk_student_course (student_id, course_id),
KEY idx_course_score (course_id, score)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩表';
注意 score 表最后两个索引。uk_student_course 是联合唯一键,保证同一个学生对同一门课只能有一条成绩记录,这是业务上防止重复录入的重要约束。idx_course_score 是给后面按课程查成绩、排序用的普通索引。索引不是越多越好,但这两个是高频查询路径,值得建。
1.3 字段类型如何取舍:长度、精度与业务含义
字段类型选错是初学者最容易犯的错误,而且往往要到数据量大了才暴露。我逐个说一下这张表里几个关键字段的选择逻辑。
主键用 BIGINT UNSIGNED AUTO_INCREMENT。INT 的最大值约 21 亿,对一张学生表可能够用,但如果是订单、日志这类增长极快的表,INT 很容易触顶。UNSIGNED 可以把上限翻一倍,对于用不到负数的主键来说是合理选择。BIGINT 在这个场景下略显保守,但在工程上多花 4 个字节换取未来的扩展空间,是划算的。
姓名和学号都用 VARCHAR 而不是 CHAR。CHAR 是定长存储,适合本身长度就固定的场景,比如手机号(虽然我更建议用 BIGINT 存,但这就涉及另一层取舍了);姓名长度不定,用 VARCHAR(50) 更合适。VARCHAR 的括号里不是字节数而是字符数,这是很多人会搞混的。
性别用 TINYINT 而不是 VARCHAR(10) 存"男""女"。字符串存储占用大、容易乱码、排序也不符合直觉。用 0/1/2 这样的数字编码,配合 COMMENT 说明含义,既省空间又清晰。
成绩用 DECIMAL(5,2) 而不是 FLOAT。浮点数在计算机里是近似存储,0.1 加 0.2 不等于 0.3 的问题在数据库里同样存在。涉及金额、成绩这类需要精确计算的字段,必须用定点数 DECIMAL。DECIMAL(5,2) 表示最多 5 位数字、其中小数点后 2 位,最大能存 999.99,足够覆盖百分制成绩。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. INSERT 写入:三种写法与自增主键的几个坑
2.1 单行、批量、SET 三种插入的适用场景
表建好以后,就可以开始往里写数据了。INSERT 的基本语法看起来简单,但写法不同,适用场景也不同。
sql复制-- 单行插入
INSERT INTO student (student_no, name, gender, birthday)
VALUES ('20240001', '张三', 1, '2005-03-15');
-- 多行批量插入
INSERT INTO student (student_no, name, gender, birthday)
VALUES
('20240002', '李四', 2, '2006-07-21'),
('20240003', '王五', 1, '2005-11-02'),
('20240004', '赵六', 2, '2006-01-09');
-- INSERT ... SET 语法
INSERT INTO student SET student_no = '20240005', name = '钱七', gender = 1;
第一种和第二种是最常用的。批量插入的本质是一条 SQL 携带多组值,相比逐条执行,它大幅减少了网络往返次数和 SQL 解析次数。我实测过,往一张简单表里插一万行数据,批量插入和单条插入的速度差距可以达到几十倍甚至更高。在实际业务里,比如 Excel 导入、接口批量同步,只要数据量允许,优先用多行 VALUES 一次搞定。
INSERT ... SET 是 MySQL 特有的语法,其他数据库不一定支持。它的优点是字段和值一一对应,写起来更像 UPDATE,适合插入字段不多、逻辑清晰的场景;缺点是每执行一次就只能插一条,没办法批量。我的建议是:日常偶尔插个一两行可以用它,正式的批量逻辑用第一种和第二种。还有一个容易忽略的写法是 INSERT ... SELECT,可以把一张表的数据查询后直接插入到另一张表,做数据迁移或临时表备份时非常高效。
多行插入也不是越多越好。单条 SQL 携带的数据量太大,会超出 max_allowed_packet 限制,也会让事务过大,锁持有时间变长。一般控制在 500 到 1000 行一组,是比较稳妥的经验值。
2.2 中文乱码和连接字符集
插入中文后查询出来全是问号,这可能是初学者遇到最多的一个坑。很多人第一反应是改表结构,其实大多数情况下问题出在连接层。
MySQL 的字符集一共有好几层:服务器层、数据库层、表层、列层、连接层。表结构我们已经在建表时设成了 utf8mb4,但如果你的客户端连接用的还是 latin1,那数据进来的时候就已经被转换成乱码了,存进去的自然也是乱码。这类问题在命令行、图形化工具、程序连接里都可能出现。
在命令行里,连上数据库后可以执行一句:
sql复制SET NAMES utf8mb4;
这条命令会同时修改连接层的 character_set_client、character_set_results 等参数。如果你用 Java 的 JDBC 连接,确保连接串里带上 characterEncoding=utf8,而且注意这个值写的是 utf8 而不是 utf8mb4,因为 JDBC 的 utf8 会映射到服务端的 utf8mb4。如果你用 Python 的 pymysql,连接参数里也有 charset='utf8mb4' 可以设置。
遇到乱码,不要急着 ALTER TABLE,先执行 SHOW VARIABLES LIKE 'character_set_%'; 看清每一层的字符集,问题通常一目了然。
2.3 自增主键与幂等写入:ON DUPLICATE KEY UPDATE
自增主键是日常开发里最常用的主键方案,但它有些行为容易被忽略。第一个是:如果你手动指定了一个很大的 ID 插入,比如插入了 id = 100 的记录,那么后面自增计数器会跳到 101,而不是你删除这条记录以后就回收 100。这是 MySQL 的设计意图,目的是避免并发插入时主键冲突。不要试图去"回收"已删除的 ID,这没有意义,还会带来数据一致性的风险。
第二个是幂等写入。在成绩表这种有唯一键约束的场景里,如果同一条记录重复插入,会直接报 Duplicate entry 错误。业务上我们往往希望达到"有就更新、没有就插入"的效果,这时可以用:
sql复制INSERT INTO score (student_id, course_id, score, exam_time)
VALUES (1, 1, 92.5, '2025-01-10 09:00:00')
ON DUPLICATE KEY UPDATE
score = VALUES(score),
exam_time = VALUES(exam_time);
当 student_id 和 course_id 的组合已经存在时,这条语句会把对应行的成绩和考试时间更新为新值;如果不存在,就是一次普通插入。注意 VALUES() 函数在 MySQL 8.0.20 之后被标记为 deprecated,推荐用新的别名语法,但 VALUES() 在大多数环境里仍然可用,理解其含义即可。这种写法在做数据同步、接口重试时非常实用,能让你的接口天然具备幂等性。
3. SELECT 查询:写好 WHERE 是进阶的分水岭
3.1 条件筛选与运算符优先级
查询是整个增删改查里最核心的部分,也是面试里考得最多的。可以说,前面建表、插入都是准备工作,对用户和业务真正产生价值的,是从 SELECT 开始的。
先看最基本的条件筛选:
sql复制SELECT id, student_no, name, gender
FROM student
WHERE gender = 1
AND birthday >= '2005-01-01'
AND birthday < '2006-01-01';
这个查询找的是 2005 年出生的男生。写 WHERE 时有几个优先级问题必须注意:AND 的优先级高于 OR。也就是说
sql复制WHERE gender = 1 OR gender = 2 AND birthday >= '2005-01-01'
它的真实含义是"男性,或者出生在 2005 年之后的女性",而不是"2005 年之后的男女"。如果你想让前者的语义是"(男性或女性)且 2005 年后出生",必须写成:
sql复制WHERE (gender = 1 OR gender = 2) AND birthday >= '2005-01-01'
这类问题在代码评审里经常出现。我的建议是:只要 WHERE 里同时出现 AND 和 OR,不管优先级是否按预期,一律加括号。括号不消耗任何性能,却能避免歧义,也让读代码的同事不用去翻运算符优先级表。
另外还有一个热搜词是"mysql的or能去重吗"。答案很明确:OR 与去重没有任何关系。OR 是行筛选条件,去重是行集处理逻辑,需要用到 DISTINCT 或者 GROUP BY。如果你想查"选了课程1或课程2的所有学生ID",并且希望学生ID不重复,要这样写:
sql复制SELECT DISTINCT student_id
FROM score
WHERE course_id = 1 OR course_id = 2;
DISTINCT 会对返回的 student_id 做去重。如果不用它,同一个学生选了多门课就会返回多行。
3.2 排序、分页与去重:ORDER BY / LIMIT / DISTINCT
查询结果往往需要排序。排序的语法很直接,但有几个细节值得注意。
sql复制SELECT student_id, score
FROM score
WHERE course_id = 1
ORDER BY score DESC, student_id ASC;
这里 ORDER BY score DESC 表示成绩从高到低;如果成绩相同,按 student_id 升序排。多字段排序时,后面的字段只在前面字段相等时才起作用,这个逻辑要记牢。
其次是 LIMIT 分页。MySQL 的分页和许多数据库不同,它直接在 SQL 里用 LIMIT 实现:
sql复制SELECT student_id, score
FROM score
WHERE course_id = 1
ORDER BY score DESC
LIMIT 10 OFFSET 20;
表示跳过 20 行、取 10 行,也就是第 21 到第 30 条。很多资料里也写成 LIMIT 20, 10,OFFSET 写法更清晰,推荐。分页最大的坑是深分页:当偏移量很大,比如 LIMIT 100000, 20 时,MySQL 仍然要扫描并丢弃前 10 万行,查询会越来越慢。解决思路在后面的索引章节会详细说,这里先记住一个方向:业务上尽量用"上一页最后一条记录的 ID"作为游标来翻页,而不是使用越来越大的偏移量。
去重的第三个场景是聚合统计。比如统计每门课程的平均分:
sql复制SELECT course_id, AVG(score) AS avg_score, COUNT(*) AS cnt
FROM score
GROUP BY course_id
HAVING avg_score >= 85
ORDER BY avg_score DESC;
GROUP BY 本身就能让分组字段的值唯一,比 DISTINCT 更强大,因为它还可以配合聚合函数。HAVING 是分组后的过滤条件,和 WHERE 的区别在于:WHERE 在分组前过滤行,HAVING 在分组后过滤分组。
3.3 三表关联查询:把成绩表变成可读的报表
我们有三张表,而 score 表里存的只是 ID,直接查出来根本不知道是哪个人、哪门课。要得到可读的报表,必须做关联查询。
sql复制SELECT
st.student_no,
st.name AS student_name,
c.course_name,
sc.score,
sc.exam_time
FROM score sc
JOIN student st ON sc.student_id = st.id
JOIN course c ON sc.course_id = c.id
WHERE c.course_name = '数学'
ORDER BY sc.score DESC
LIMIT 20;
这里有个重要概念:JOIN 分好几种。默认的 JOIN 是 INNER JOIN,只返回两边都匹配得上的行。如果用 LEFT JOIN,则会以左边表(score)为基准,即使右边找不到匹配记录也会返回左表行,右边字段填 NULL。在成绩查询这个场景,INNER JOIN 就够了,因为成绩表里的学生和课程理论上都必须存在。但如果查询"所有课程及其平均分,没有成绩的课程也要显示",就得用 LEFT JOIN 从 course 表出发。
关联查询的性能关键在 ON 条件的字段是否有索引。上面的查询里 score.student_id 和 score.course_id 都有索引,所以关联会比较快。如果 ON 字段上没有索引,每一次匹配都要做全表扫描,数据量一大就会出现明显的性能问题。这也是我在建表时给 score 表加了两个索引的原因。
4. UPDATE 与 DELETE:高危操作的安全红线与回滚方案
4.1 不带 WHERE 的后果与三道防线
如果说 SELECT 写不好是性能问题,那 UPDATE 和 DELETE 写不好就是事故。
我在以前的公司处理过一次线上事故。当时要修正一批异常成绩,跑脚本的人写了一句:
sql复制UPDATE score SET score = 0;
他原本想更新某几条数据,但 WHERE 条件忘写了。结果自然是整张表的所有成绩全部清零,而且因为跑了很长时间,undo log 量巨大,回滚也要花很久。这种"一条 SQL 干翻全表"的事故,在业内并不少见。
防御这件事,可以从三层来做。
第一层是习惯:在执行 UPDATE 或 DELETE 之前,先把 WHERE 条件复制到一条 SELECT COUNT(*) 里去跑一遍,确认影响行数在预期范围内。宁多一步查询,不赌一次手气。
第二层是机制:把修改操作放进事务里。
sql复制START TRANSACTION;
UPDATE score SET score = 100 WHERE student_id = 1 AND course_id = 1;
-- 检查影响行数、确认数据
SELECT * FROM score WHERE student_id = 1 AND course_id = 1;
COMMIT;
-- 如果发现不对,执行 ROLLBACK;
事务的 ROLLBACK 是 DML 语句的后悔药。养成"先开启事务、执行完检查、确认后提交"的习惯,能让很多事故在发生前就被拦截。
第三层是工具:MySQL 有安全更新模式,开启后,没有 WHERE 或 LIMIT 的 UPDATE 和 DELETE 会被拒绝执行。
sql复制SET sql_safe_updates = 1;
在测试环境、开发环境,我非常建议默认开启这个模式。生产环境开启可能会影响一些合法的批量更新脚本,需要结合团队规范来权衡。
4.2 DELETE 后表大小不变:底层逻辑与空间整理
还有一个让很多人困惑的现象:删除了大量数据后,查看表文件大小,发现几乎没变化。这不是 MySQL 没删干净,而是 InnoDB 存储引擎的删除逻辑决定的。
InnoDB 使用聚簇索引组织数据,主键索引的叶子节点存储了整行数据。执行 DELETE 时,InnoDB 并不会立刻把物理空间释放掉,而是在被删除记录的位置做一个标记,并把它加入一个"垃圾链表"。这些空间可以被后续插入的新记录复用,但表文件在操作系统层面上不会缩小。只有当这些被标记的空间积累到一定程度,或者执行了空间整理操作时,文件大小才会明显减少。
如果你确实需要释放表空间,可以执行:
sql复制OPTIMIZE TABLE score;
OPTIMIZE TABLE 会重建表,把碎片整理掉、压缩空间,但它会在执行期间对表加锁,影响线上读写。在业务高峰期不要做这个操作。大表整理空间更稳妥的做法是使用在线 DDL 工具,比如 pt-online-schema-change,它通过创建影子表、同步增量数据、切换表名的机制来实现低影响变更,DBA 经常用这类工具。普通开发场景,你只要记住:DELETE 删除数据后,表文件短时间内不缩小是正常现象,并不是"删除没生效"。
4.3 TRUNCATE 和 DELETE 的边界
清空一张表有几种方式,它们的差异非常关键。
sql复制-- DML,可加 WHERE,逐行删除,可回滚
DELETE FROM score WHERE course_id = 1;
-- DDL,清空全表,不可回滚
TRUNCATE TABLE score;
DELETE 是数据操作语言(DML),它逐行删除数据并记录到 binlog,因此在事务内可以回滚,也可以加 WHERE 只删部分数据。TRUNCATE 是数据定义语言(DDL),它直接丢弃整张表的数据页并重建表结构,速度极快,但它是隐式提交的,执行后没有办法通过 ROLLBACK 恢复。同时 TRUNCATE 会把自增主键的计数器重置,下一次插入从 1 开始;而 DELETE 不清空计数器。
所以业务里清空一张表时要想清楚:如果只是从主表删除全部记录但希望保留自增 ID 的历史轨迹,或者可能要回滚,用 DELETE;如果确定整表数据都不需要了,且对回滚没有要求,用 TRUNCATE。
4.4 跨表更新:UPDATE ... JOIN 的特殊语法
实际开发中还有一个常见需求:更新一张表时,条件依赖于另一张表的数据。比如"把数学课的所有学生成绩都加 5 分",如果你先查数学课的 course_id,再更新 score 表,要写两条 SQL,还容易在拼接时出错。MySQL 支持在一个 UPDATE 里直接 JOIN:
sql复制UPDATE score sc
JOIN course c ON sc.course_id = c.id
SET sc.score = sc.score + 5
WHERE c.course_name = '数学';
这条语句把课程名为 数学 的所有成绩一次性更新。它的语义很直观,对初学者也非常友好。类似的写法也适用于 DELETE,比如删除指定课程的所有成绩:
sql复制DELETE sc
FROM score sc
JOIN course c ON sc.course_id = c.id
WHERE c.course_name = '数学';
注意这里 DELETE 后面要指定别名 sc,表示删除的是 score 表的行,而不是两张表都删。如果写成 DELETE FROM sc 会因别名作用域问题报错,这也是一个容易踩的语法细节。
5. 事务、批量写入与防注入:把增删改查放进真实场景
5.1 为什么批量导入要包在事务里
前几章的示例都是单条操作,但在真实业务里,我们经常要面对批量数据导入。比如教务老师给你一个 Excel 文件,里面有几百上千条成绩记录,要写进 score 表。如果循环执行 INSERT,每一条都自动提交一次,整个过程会非常慢。
慢的根本原因是每次提交都要触发一次磁盘写入。InnoDB 的事务提交需要把 redo log 刷到磁盘,这是随机磁盘 IO 中最贵的操作之一。如果一万条数据分一万次提交,就要做一万次 fsync;如果把一万条包在一个事务里,提交时只需要一次 fsync。这之间的性能差距是数量级的。
所以批量写入的推荐姿势是:
sql复制START TRANSACTION;
INSERT INTO score (student_id, course_id, score, exam_time) VALUES
(1, 1, 92.5, '2025-01-10 09:00:00'),
(1, 2, 85.0, '2025-01-10 09:00:00'),
(2, 1, 78.5, '2025-01-10 09:00:00');
-- 检查数据
-- COMMIT;
-- 如果发现问题,ROLLBACK;
但这里也有一个平衡:事务过大,会持有大量行锁,长时间不提交还会阻塞其他会话更新同一张表。一般建议每 500 到 1000 条记录提交一次,把一个大文件拆成多个批次。这样可以兼顾写入速度和锁的粒度。
5.2 事务隔离级别与常见面试问题
既然已经聊到事务,就把几个常考的点一起说清楚。事务有四个特性,简称 ACID:原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。以成绩更新为例:一个事务里把成绩从 80 改为 90,要么成功生效,要么完全不变,不会出现只改一半的情况,这是原子性;数据在事务前后都是一致的状态,这是一致性;多个事务同时操作同一行数据时互不干扰,但要靠隔离级别来界定"互不干扰"的程度,这是隔离性;事务一旦提交,即使数据库宕机,数据也不会丢失,这是持久性。
和隔离性相关的三种异常情况,面试经常考:
- 脏读:事务 A 读到了事务 B 未提交的数据。如果 B 回滚,A 读到的就是脏数据。
- 不可重复读:事务 A 内两次读取同一行数据,结果不同。原因是 B 在 A 两次读取之间提交了更新。
- 幻读:事务 A 内两次执行同一范围查询,结果行数不同。原因是 B 在 A 两次查询之间提交了插入。
MySQL InnoDB 的默认隔离级别是 REPEATABLE READ,它通过多版本并发控制(MVCC)和间隙锁,在大多数场景下能避免不可重复读和幻读。另一个常见的级别是 READ COMMITTED,它允许多次读取看到已提交的最新数据。在高并发、热点行更新的业务里,把隔离级别改成 READ COMMITTED 可以减少间隙锁带来的锁竞争,提升并发度。这属于需要结合业务场景权衡的优化,不要盲目改。
5.3 参数化查询:从源头堵住 SQL 注入
把增删改查放到程序里时,有一个问题不能回避:SQL 注入。初学者最常见的错误写法是字符串拼接:
java复制String sql = "SELECT * FROM student WHERE student_no = '" + input + "'";
如果用户输入的 input 是 ' OR '1'='1,拼出来的 SQL 就变成了:
sql复制SELECT * FROM student WHERE student_no = '' OR '1'='1'
'1'='1' 恒为真,这个查询会把全表数据返回出来。如果再配合 DELETE,后果不堪设想。
正确的做法是使用参数化查询。以 Java 的 PreparedStatement 为例:
java复制PreparedStatement ps = conn.prepareStatement(
"SELECT * FROM student WHERE student_no = ?"
);
ps.setString(1, input);
在 Python 的 pymysql 里对应的是 %s 占位符,在 PHP 的 PDO 里是 ? 或命名占位符。核心思想是:SQL 结构和数据分离,用户输入只作为参数被传递,永远无法改变 SQL 的语义。这是防御 SQL 注入最有效的手段,靠转义特殊字符只是补救,远不如参数化可靠。
6. 索引、EXPLAIN 与慢查询:让增删改查快起来
6.1 索引失效的常见姿势
增删改查写到这一步,功能已经通了,但这只是及格线。真正让一条 SQL 从"能用"变成"好用",靠的是索引。
很多人在写查询时没有意识到索引的存在意义。以 SELECT * FROM score WHERE course_id = 1 AND score > 90 为例,如果没有索引,MySQL 只能对整张表做全表扫描,逐行判断条件;如果 course_id 上有索引,MySQL 可以快速定位到课程 1 的所有记录,再在这批记录里筛 score。这就像查字典时先按拼音定位到某一页,而不是从第一页翻到最后一页。
但索引不是建了就一定生效。常见的不生效场景至少有这几个:
- 对索引列使用函数:
WHERE YEAR(created_at) = 2024,这种写法会导致created_at上的索引失效,正确写法是WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'。 - 隐式类型转换:字段是
VARCHAR,查询条件写成WHERE student_no = 123,MySQL 会把字符串和数字比较时进行转换,可能导致索引失效。 LIKE以通配符开头:WHERE name LIKE '%张',因为无法确定前缀,索引无法使用。WHERE name LIKE '张%'则可以用索引。
这些坑在开发环境数据量小时感觉不到,一到生产环境就会立刻暴露。写 SQL 时多留一个心眼:条件里的字段有没有被函数包住、类型是否匹配。
6.2 EXPLAIN 怎么看
当你怀疑一条 SQL 慢,第一件事不是猜,而是用 EXPLAIN 看执行计划。
sql复制EXPLAIN SELECT sc.student_id, sc.score
FROM score sc
JOIN student st ON sc.student_id = st.id
WHERE sc.course_id = 1
ORDER BY sc.score DESC;
执行结果的几个关键列要会看:
type:访问类型。从好到差大致是const、eq_ref、ref、range、index、ALL。看到ALL表示全表扫描,需要警惕。key:实际使用的索引。如果为NULL,说明没用索引。rows:预估扫描的行数。数值越大,通常越慢。Extra:如果是Using filesort,说明排序没有走索引,数据量大时这个排序可能在临时文件里做,非常慢;Using temporary则代表使用了临时表,也要注意。
上面这条查询,sc.course_id = 1 如果走到 idx_course_score 索引,type 应该是 ref,扫描行数会明显小于全表行数,排序也能直接利用 idx_course_score(course_id, score) 这个联合索引的顺序,避免 Using filesort。联合索引 (course_id, score) 的关键在于:先按 course_id 定位,再按 score 排序,一箭双雕。这也是我当时建这个索引的初衷。
6.3 容易被忽视的锁表隐患
最后聊一个和生产事故相关的话题:锁。InnoDB 默认使用行锁,但如果你在事务里更新了多行数据、甚至进行了全表更新,锁的范围会扩大到多行甚至全表。一个没有提交的长事务,会一直持有这些锁,导致其他会话的更新操作被阻塞。
判断是否有锁等待,可以用:
sql复制SHOW PROCESSLIST;
或者直接查:
sql复制SELECT * FROM information_schema.innodb_trx\G
看到长时间处于 RUNNING 状态的事务、或者语句卡在 Waiting for table metadata lock、Waiting for row lock,基本可以判断有锁问题。处理方法是把这个事务 KILL 掉,或者等待它自然提交/回滚。但更重要的是预防:事务保持短小、避免大事务、不要在事务里做耗时的外部 IO 操作。这些实践经验,比任何技巧都更能保护线上系统的稳定。
从建表到索引优化,增删改查这一套走下来,其实逻辑是通的:建表时想清楚字段和约束,写入时注意字符集和幂等,查询时先想条件再想索引,更新和删除永远把安全放在第一位,最后用事务和 EXPLAIN 做兜底。如果每一条 SQL 都能按照这样的思路去写,你就已经比很多工作两三年的开发要扎实了。
