前阵子清理旧硬盘,翻出一份大概十年前整理的MySQL笔记,文件名就叫“mysql基础篇四”。点开之后惊了一下——当年花大量时间手敲的示例、查过的官方文档、还有那些红笔标注的坑,今天大部分依然适用。MySQL版本已经从5.x走到了8.x,但核心的SQL思维、存储过程、事务与索引逻辑,基本没有变。这让我想把这份旧笔记重新梳理成一篇能直接参考的内容,也就是你现在看到的这篇:不追逐新特性,把最容易卡住人的基础进阶点讲透。
如果你已经会建库建表、会写普通SELECT/INSERT/UPDATE,但一遇到存储过程、触发器、视图、行转列、事务锁,或者发现“明明加了索引却还是很慢”这类问题就发怵,这篇笔记就是给你准备的。下面所有示例都用一套学生课程成绩表贯穿,方便你一边看一边在自己的环境里复现。
1. 为什么这套“基础篇四”值得重新整理一遍
1.1 基础系列的定位
通常学MySQL会经历三个阶段。第一阶段是安装配置、建库建表、增删改查,能跑通一个JavaWeb项目的基础功能。第二阶段是开始处理真实的查询需求,比如多表关联、子查询、分组统计,这时候会逐步接触函数、聚合、排序的边界情况。第三阶段才轮到把逻辑写进数据库,用存储过程、触发器、视图把复杂操作固化下来,再通过执行计划去理解查询为什么慢。
这套“基础篇”前几篇正好对应前两个阶段。而这篇“第四篇”当年的定位是:把常见函数和排序特性讲全,引入存储过程和触发器,顺便把视图、事务、索引执行计划这些稍微进阶的东西做一次系统梳理。放到今天看,它依然是一个极好的“从入门到能干活”的过渡材料。
我刚学的时候最深的感触是:网上的教程很多,但大多只讲“怎么用”,很少讲“为什么这么用”。比如DELIMITER这个命令,几乎所有触发器教程都会提,但没人解释为什么要有它。这种知识断层最坑。所以这篇在复盘时会刻意补齐背后的原因,而不是简单罗列语法。
1.2 统一示例表结构
为了让后面的示例不抽象,先建一套最简单但五脏俱全的学生选课成绩库。这套表结构是我当年做课程设计时常用的一种设计,包含学生表、课程表、成绩表三张核心表。
sql复制CREATE DATABASE IF NOT EXISTS school DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE school;
CREATE TABLE student (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键',
sno VARCHAR(20) NOT NULL COMMENT '学号',
sname VARCHAR(50) NOT NULL COMMENT '姓名',
gender CHAR(1) DEFAULT 'M' COMMENT '性别 M/F',
birth DATE COMMENT '出生日期',
class_no VARCHAR(20) COMMENT '班级编号',
UNIQUE KEY uk_sno (sno)
) ENGINE=InnoDB COMMENT='学生表';
CREATE TABLE course (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
cno VARCHAR(20) NOT NULL COMMENT '课程编号',
cname VARCHAR(100) NOT NULL COMMENT '课程名称',
credit DECIMAL(3,1) COMMENT '学分',
UNIQUE KEY uk_cno (cno)
) ENGINE=InnoDB COMMENT='课程表';
CREATE TABLE score (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
student_id INT NOT NULL COMMENT '学生ID',
course_id INT NOT NULL COMMENT '课程ID',
score DECIMAL(5,1) COMMENT '成绩',
exam_time DATETIME COMMENT '考试时间',
UNIQUE KEY uk_sc (student_id, course_id),
CONSTRAINT fk_score_student FOREIGN KEY (student_id) REFERENCES student (id),
CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES course (id)
) ENGINE=InnoDB COMMENT='成绩表';
注意几点。第一,外键约束在互联网业务里并不常用,绝大多数团队更倾向于在应用层保证关联关系,因为外键在高并发写入场景下会带来额外的锁开销和耦合。但在学习阶段保留外键,能直观理解表和表的关系。第二,成绩用DECIMAL不用FLOAT,是因为浮点数会产生精度误差,几十个学生看不出来,累积到上万条统计就会出现对不上账的情况。这是被无数报表人验证过的教训。第三,字符集统一utf8mb4,原因是MySQL的utf8其实只是utf8mb3,存不了emoji和部分生僻字,从源头上避免“明明能存中文却报错”的尴尬。
为了后续说明方便,先插入几条数据:
sql复制INSERT INTO student (sno, sname, gender, birth, class_no) VALUES
('20230001', '张三', 'M', '2004-03-12', 'CS2301'),
('20230002', '李四', 'F', '2004-07-25', 'CS2301'),
('20230003', '王五', 'M', '2003-11-02', 'CS2302'),
('20230004', '赵六', 'F', '2004-01-18', 'CS2302');
INSERT INTO course (cno, cname, credit) VALUES
('C001', '高等数学', 5.0),
('C002', '线性代数', 3.0),
('C003', '数据库原理', 4.0);
INSERT INTO score (student_id, course_id, score, exam_time) VALUES
(1, 1, 88.5, '2024-01-15 10:00:00'),
(1, 2, 76.0, '2024-01-16 14:00:00'),
(1, 3, 95.0, '2024-01-17 09:00:00'),
(2, 1, 55.0, '2024-01-15 10:00:00'),
(2, 3, 68.5, '2024-01-17 09:00:00'),
(3, 2, 92.0, '2024-01-16 14:00:00');
后面所有示例都基于这套数据展开。你在自己电脑上用Navicat或者命令行客户端执行都可以,不影响理解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序、函数和字段运算中的常见误区
2.1 排序不是“想当然”的顺序
很多人刚接触ORDER BY时觉得超级简单,直到遇到一个需求:班上同学名字按“拼音首字母”排序,或者一个班级编号字段里混着CS1、CS2、CS10,排出来却是CS1、CS10、CS2。当年我为了这个CS10排在CS2前面的问题,查了半天资料才明白,字符串排序是字典序,不是自然序。
字典序判断规则是逐字符比较,所以'CS10'和'CS2'比较时,先比C相同,再比S相同,然后'1'和'2'比较,因为'1'小于'2',所以'CS10'排在'CS2'前面。想要按“人类直觉”的数字大小排序,传统做法是让MySQL把字段转成数字再排序:
sql复制SELECT class_no FROM student ORDER BY CAST(SUBSTRING(class_no, 3) AS UNSIGNED);
MySQL还有一些非标准的类型转换技巧,比如直接加0:
sql复制SELECT class_no FROM student ORDER BY class_no + 0;
字符串前面是字母时,class_no + 0只取开头的有效数字,所以也能达成目标。但我不推荐这种写法,太隐晦,换个人维护会看懵。更通用的方案是拆出数字列单独存储,查询时用CAST转成明确类型。
另一个高频坑是大小写排序。默认字符集和排序规则utf8mb4_general_ci或者utf8mb4_0900_ai_ci对大小写不敏感,所以'apple'和'Apple'会被当成同一个值参与排序和去重。如果业务要求大小写敏感,需要把列定义成utf8mb4_bin排序规则,或者在ORDER BY里临时指定COLLATE:
sql复制SELECT sname FROM student ORDER BY sname COLLATE utf8mb4_bin;
2.2 常用函数速查
函数是基础篇里必须掌握的部分。字符串函数、日期函数、聚合函数、条件函数,基本可以覆盖绝大多数报表需求。我整理了一张当年自己经常翻阅的速查表,内容不全但都很实用。
| 函数 | 作用 | 示例 | 结果 |
|---|---|---|---|
| CONCAT | 字符串拼接 | CONCAT('hello',' ', 'world') | hello world |
| SUBSTRING | 截取子串 | SUBSTRING('20230001', 1, 4) | 2023 |
| GROUP_CONCAT | 分组内拼接 | GROUP_CONCAT(sname) | 张三,李四,王五 |
| DATE_FORMAT | 日期格式化 | DATE_FORMAT(NOW(), '%Y-%m-%d') | 2025-01-01 |
| DATEDIFF | 日期差值 | DATEDIFF('2024-02-01', '2024-01-01') | 31 |
| IFNULL | 空值兜底 | IFNULL(score, 0) | 0 |
| CASE WHEN | 条件分支 | CASE WHEN score>=60 THEN '及格' END | 及格 |
| ROUND | 四舍五入 | ROUND(3.14159, 2) | 3.14 |
| COUNT | 计数 | COUNT(DISTINCT student_id) | 4 |
| SUM/AVG/MAX/MIN | 聚合 | SUM(score), AVG(score) | 总和、平均 |
| COALESCE | 返回第一个非NULL | COALESCE(NULL, 0, 1) | 0 |
其中我最想提醒的是GROUP_CONCAT和高版本MySQL的默认长度限制。这个函数能把一组的多个值拼成一行,执行计划里经常会看到它被用于行转列的字符串聚合,但它的默认最大长度是1024字节,超过部分会被截断。如果你发现结果莫名少了一段,可以在会话里执行:
sql复制SET SESSION group_concat_max_len = 102400;
2.3 int+5 与 NULL:一句话带出的编码习惯
热搜词里有个很有意思的搜索:“mysql中int+5”。单独看很莫名其妙,但在实际项目里我遇到过好多次类似需求:给某一个数值字段统一加上5分,或者按名次加上附加分。
这里有个极易踩的坑:如果字段允许NULL,直接UPDATE score SET score = score + 5会把原本NULL的成绩列更新成NULL。因为NULL参与任何算术运算,结果都是NULL,这和“NULL+5等于5”的直觉完全不同。正确的做法是提前过滤或兜底:
sql复制UPDATE score SET score = IFNULL(score, 0) + 5 WHERE course_id = 1;
另一个关于int的细节是溢出。MySQL的INT类型是有符号范围约±21亿的,在严格模式下插入超出范围的数据会直接报错;非严格模式则会静默截断为边界值。很多人不知道MySQL 8.0默认是严格模式,写存储过程做循环累加时,一个不小心就会触发“Out of range value”的告警。
3. 存储过程:把逻辑收进数据库
3.1 什么场景才值得写
存储过程在业务代码和数据库之间划出了一条很微妙的界线。有些人激进,恨不能把所有业务逻辑都写进数据库;有些人保守,认为存储过程是万恶之源。我自己的观点是中间路线:只在以下两类场景里优先考虑存储过程。
第一类是复杂的多步骤数据加工任务。比如报表需要先算临时表,再关联多张表做汇总,最后把结果插入正式表。这样一个多步骤流程如果在应用层写,每一步都要经历网络往返传输数据,效率会明显低于在数据库内部用一条存储过程跑完。第二类是强一致性的批量更新。项目需要在一个事务里同时更新多张表,并做条件判断和数据校验,存储过程可以借助事务和条件分支,让整个流程作为一个原子操作提交。
相反,如果业务经常要改规则,而且数据库账号被管控得很严格,或者团队里多数人SQL并不熟练,那就不适合把核心逻辑塞进存储过程。曾经见过一个后端项目,资历浅的同事连存储过程的调试日志都看不懂,每次接口异常只能找DBA看,协作效率极低。技术选型没有绝对对错,关键是适用场景。
3.2 核心语法拆解
以“给学生某门课的成绩计算等级”为例,先建一张等级日志表,再用存储过程把每个学生的成绩读取并分档写入:
sql复制CREATE TABLE score_log (
id INT PRIMARY KEY AUTO_INCREMENT,
student_id INT NOT NULL,
score DECIMAL(5,1),
grade CHAR(2),
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
存储过程的创建语法包含几个部分:DELIMITER、CREATE PROCEDURE、BEGIN...END、变量声明、条件判断和游标。下面这个例子同时涵盖了其中大部分:
sql复制DELIMITER //
CREATE PROCEDURE sp_calc_grade_by_course(IN cid INT)
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE v_student_id INT;
DECLARE v_score DECIMAL(5,1);
DECLARE v_grade CHAR(2);
-- 定义游标:查找该课程的所有成绩
DECLARE cur_score CURSOR FOR
SELECT student_id, score FROM score WHERE course_id = cid;
-- 游标遍历完时,将 done 置为 1
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
OPEN cur_score;
calc_loop: LOOP
FETCH cur_score INTO v_student_id, v_score;
IF done = 1 THEN
LEAVE calc_loop;
END IF;
IF v_score >= 90 THEN
SET v_grade = 'A';
ELSEIF v_score >= 75 THEN
SET v_grade = 'B';
ELSEIF v_score >= 60 THEN
SET v_grade = 'C';
ELSE
SET v_grade = 'D';
END IF;
INSERT INTO score_log(student_id, score, grade)
VALUES (v_student_id, v_score, v_grade);
END LOOP;
CLOSE cur_score;
END //
DELIMITER ;
注意到两个关键点。第一,变量用DECLARE声明后要和表字段区分开,所以我习惯在变量名加v_前缀。第二,循环体内判断成绩等级时,如果字段 v_score 是DECIMAL,和整数90、75比较时MySQL会做隐式转换,没有问题;但v_score如果是VARCHAR就建议明确CAST,避免隐式转换导致索引失效或者比较结果异常。
执行这个存储过程:
sql复制CALL sp_calc_grade_by_course(1);
然后查询score_log,会看到课程ID为1的成绩被逐条分档写入了。
编写存储过程时最容易翻车的点是分号。由于存储过程内部多条SQL以分号结束,如果直接创建,MySQL客户端会把第一句内部SQL当成完整命令去执行,所以必须用DELIMITER把命令结束符临时改成别的符号。这个机制很多新手不理解,但当你理解了“DELIMITER改变的是客户端解析边界,而不是服务器语法”之后,就不会再弄混。
3.3 游标的流转控制
游标本身不复杂,但理解它的执行顺序很重要。游标并不是把数据一次性拉到应用内存里,而是由MySQL逐条返回,配合HANDLER实现类似“遍历到末尾退出”的逻辑。
在存储过程里,游标通常配CONTINUE HANDLER FOR NOT FOUND使用。上面的例子中,FETCH取不到数据时就会触发HANDLER,把done变量置1,然后LOOP里判断done为1,LEAVE退出循环。
实际开发中还有一种陷阱:HANDLER的定义位置必须在游标定义之后。如果把CONTINUE HANDLER写在CURSOR声明前,MySQL会直接报语法错误。另外,多个游标同时存在时,每个游标都需要独立的done标记,否则一个游标遍历结束会错误地影响另一个游标的循环状态。
多年前我写存储过程时喜欢在循环里嵌套动态拼接SQL,现在回头看这几乎是最不推荐的写法。一旦涉及动态SQL和预处理语句,代码的可维护性就会断崖式下降,还容易产生SQL注入风险。存储过程尽量只做确定SQL的批处理,真需要动态条件时,宁可把条件参数化或者拆分成多个存储过程,也别图省事在内部拼字符串。
4. 触发器与分隔符:自动化的甜头与代价
4.1 适合自动化的现场
触发器是一种在表发生INSERT、UPDATE、DELETE操作时自动执行的存储程序。刚接触时很容易被它吸引,因为看上去可以省掉应用层大量手工编码。比如成绩表新增一条记录时,自动往日志表里写入操作记录;或者商品库存表更新时,自动维护一个操作明细表。
下面用成绩日志做演示。当score表插入一条新成绩时,自动记录该成绩的学号、分数和发生时间:
sql复制DELIMITER $$
CREATE TRIGGER trg_score_insert_log
AFTER INSERT ON score
FOR EACH ROW
BEGIN
INSERT INTO score_log(student_id, score, grade)
VALUES (NEW.student_id, NEW.score, '未分档');
END$$
DELIMITER ;
在MySQL触发器中,NEW代表新插入的行,OLD代表被修改或删除的旧行。AFTER INSERT自然没有OLD可读,只有NEW;而AFTER UPDATE两者都能访问。DELETE时只有OLD。这个细节必须牢记,否则一试就报错。
触发器还有另一种应用是自动维护冗余统计字段。比如课程表里保存一个总修读人数,每次成绩插入或删除时,通过触发器自动加减。很多年前这是很流行的“数据冗余自动同步”方案。
4.2 为什么非要 DELIMITER
只要接触MySQL触发器,一定绕不开DELIMITER。为什么Oracle、SQL Server不强调这个,而MySQL这么爱考?根本原因是MySQL客户端采用分号作为SQL语句的分隔符,但触发器、存储过程内部的SQL语句本身也以分号结束。客户端并不知道这些分号是在定义对象,所以需要临时用一个不常见的符号告诉客户端“在这个符号出现之前的所有内容,是同一个完整对象”。
所以你会看到这样一套标准动作:
sql复制DELIMITER $$
CREATE TRIGGER ...
BEGIN
...
END$$
DELIMITER ;
DELIMITER不是MySQL服务端的SQL命令,而是mysql命令行客户端的指令。因此如果你用Java的JDBC、Python的pymysql直接执行包含DELIMITER的脚本,反而可能报错。原因就是这些驱动根本不识别DELIMITER。正确做法是让Driver把整个存储过程或触发器当做一个语句发送,而不是依赖DELIMITER切分。如果手头没有命令行工具,Navicat这类图形工具会自己处理边界,你在SQL编辑器里写时不加DELIMITER通常也能执行成功。
想通这一点,很多资料里语焉不详的“写触发器要在命令行前先改DELIMITER”就不再神秘了。
4.3 我越来越少用触发器的原因
触发器把自动化做得很“隐蔽”,但恰恰是“隐蔽”成了它最大的问题。
首先是不好排查。应用层调用链出了问题,可以看日志、打断点、看调用栈,唯独触发器就像是后台突然冒出来的暗动作。某天你发现数据不对,排查了半天,最后才发现罪魁祸首是某个几年前的触发器在默默改数。这种“幽灵操作”是最让运维头疼的。
其次是性能开销。每一行变化都会触发FOR EACH ROW逻辑,如果批量插入一万行,触发器就会被调用一万次,远不如在应用层用一条UPDATE汇总。稍微大一点的并发写入场景,触发器往往会放大锁等待时间。
再次是限制条件。MySQL不允许在一个触发器中直接对触发它的同一张表做修改。有些初学者试图用AFTER UPDATE触发器实现在本表内修正错误值,结果会发现递归调用或者直接报错。
我现在只在两类地方用触发器:一类是纯审计型需求,把关键表的增删改日志记录到独立日志表;另一类是简单、低频率、表结构稳定的内部系统。其余需求优先在服务层代码里显式处理。自动化是好事,但自动化变得不可观测就是隐患。
5. 视图、行转列和导出:让复杂查询听话一点
5.1 视图就是给查询套一层壳
视图本质是一个保存好的SELECT查询,每次使用时临时执行。它不占额外数据空间,却能起到三方面作用:屏蔽复杂SQL、隐藏敏感字段、提供统一的逻辑接口。
比如做一个成绩明细,需要关联学生表、课程表、成绩表,这个SQL会写得很长。创建视图后,业务代码只需要简单查询视图:
sql复制CREATE VIEW v_student_score AS
SELECT
s.sno,
s.sname,
c.cname,
sc.score,
sc.exam_time
FROM score sc
JOIN student s ON sc.student_id = s.id
JOIN course c ON sc.course_id = c.id;
-- 使用时直接查询
SELECT * FROM v_student_score WHERE sno = '20230001';
视图有两个常见误区。第一,视图不等于性能提升。MySQL默认并没有“物化视图”,视图每次查询本质上还是执行那条底层SELECT。以前有同事天真地说“用了视图查询就快了”,这是不对的。第二,简单视图虽然可以更新,但对使用了JOIN、GROUP BY、聚合函数的视图,更新会受很大限制。不要在业务上把视图当作可写表来用。
5.2 行转列并不难,难的是看懂 CASE WHEN 聚合
“mysql 行转列”一直是高频搜索词。行转列的场景通常是:每个学生多门课成绩以多行存在,但希望每个学生一行,每门课程的成绩变成该行的一列。
在MySQL 8.0之前的版本,最经典的方法是CASE WHEN配合聚合函数:
sql复制SELECT
s.sname,
MAX(CASE WHEN c.cname = '高等数学' THEN sc.score END) AS '高等数学',
MAX(CASE WHEN c.cname = '线性代数' THEN sc.score END) AS '线性代数',
MAX(CASE WHEN c.cname = '数据库原理' THEN sc.score END) AS '数据库原理'
FROM score sc
JOIN student s ON sc.student_id = s.id
JOIN course c ON sc.course_id = c.id
GROUP BY s.sname;
为什么用MAX?因为分组后,每个学生每个课程只有一条成绩记录,CASE WHEN命中的那一行有分数,未命中的结果是NULL。MAX聚合可以把两行中的有效成绩挑出来,效果等价于取唯一非空值。如果某位学生真有多个同类成绩,结果就会取最高分,这需要结合业务语义小心处理。
MySQL 8.0引入了窗口函数后,行转列还可以用更现代的写法配合一些工具ETL完成,但核心的CASE WHEN + 聚合思路永远不会过时,因为它是理解“分组如何压行”的钥匙。
5.3 把一张表完整导出
搜索关键词里经常出现“mysql 导出一张表数据的命令”,这确实是日常高频需求。最常用的命令行工具是mysqldump,导出单张表:
bash复制mysqldump -uroot -p school student > student.sql
如果需要把数据保存成便于查看或再加工的文本格式,可以用SELECT INTO OUTFILE:
sql复制SELECT sno, sname, gender, birth, class_no
INTO OUTFILE '/tmp/student_output.txt'
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n'
FROM student;
注意MySQL对导出目录有权限限制。8.0版本中secure_file_priv参数默认可能是NULL,禁止导出,需要先查看安全路径:
sql复制SHOW VARIABLES LIKE 'secure_file_priv';
只有该参数指定的目录才能执行INTO OUTFILE,否则会报“The MySQL server is running with the --secure-file-priv option so it cannot execute this statement”。生产环境不建议为了导数据随意关掉这个安全选项,通常DBA会约定一个专门目录作为中转区。
如果只是想在图形工具里看看结果或导出Excel,直接用导出向导更省事。但掌握命令行方式依然是基本功,因为很多生产环境没有图形界面,只有SSH终端。
6. 事务、锁和重复数据的排查体验
6.1 事务的四个隔离级别
事务是InnoDB引擎最核心的能力,它的ACID特性大家都很熟,真正难理解的是隔离级别与锁的关系。MySQL默认隔离级别是REPEATABLE READ,可重复读。它保证同一个事务里,多次读取同一行结果一致。
四个隔离级别从宽松到严格依次是:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB通过间隙锁基本解决) |
| SERIALIZABLE | 不会 | 不会 | 不会 |
面试的时候常被问“MySQL默认隔离级别为什么是REPEATABLE READ”,一般会提到历史原因和BinLog格式兼容性。不过实际操作中,很多互联网团队会刻意改成READ COMMITTED,因为它在某些并发场景下能降低锁冲突。具体取舍要根据业务需求来,不能纸上谈兵。
事务最直观的验证方法是同时开两个MySQL会话,手动开始事务后执行相同查询。第一个会话执行UPDATE但未提交时,第二个会话更新同一行会阻塞等待。这种阻塞如果一直不释放,就会演变成大家常说的“锁表”。
6.2 遇到“锁表”别慌
锁表是生产环境最常出问题的场景之一。表现是某个更新SQL一直不被执行,客户端卡住直到超时,最后报错:
sql复制ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
看到这个错误,先别急着重启数据库。排查思路应该是三步走。
第一步,找出正在运行和等待中的事务:
sql复制SELECT * FROM information_schema.innodb_trx;
这个表会列出当前所有活跃事务,包括事务执行时间trx_started、等待锁状态trx_state、正在执行的SQL片段trx_query。找到持续很长时间未提交的事务,基本就是阻塞源头。
第二步,找出锁等待关系。8.0版本可以查performance_schema.data_locks或者data_lock_waits,5.7及以下版本更多依赖InnoDB状态输出来分析。最简单粗暴又不破坏数据的方式是使用SHOW ENGINE INNODB STATUS,从输出的LATEST DETECTED DEADLOCK一节看到死锁和锁等待线索。
第三步,确认事故源头后,有两种处理方式。如果那个长时间事务是误操作或者僵尸进程,可以把它终止掉,方法是先查到trx_mysql_thread_id,然后:
sql复制-- 从 innodb_trx 找到 trx_mysql_thread_id
SELECT trx_id, trx_mysql_thread_id, trx_state, trx_query
FROM information_schema.innodb_trx;
-- 杀掉阻塞源头连接
KILL 12345;
如果阻塞原因是业务代码忘记提交事务,还要回到代码里去补COMMIT/ROLLBACK逻辑,否则KILL一次之后还会复发。
我自己踩过最大的坑是在一个批量更新脚本里,循环调用更新函数,结果每次更新都手动开启事务但忘记提交。当时还奇怪为什么程序越跑越慢,后来一查innodb_trx,里面躺着几十个未提交事务。这就是典型的“应用没提交,数据库被拖死”。
6.3 已有重复数据时加唯一索引
如果表结构一开始没有唯一约束,业务跑了很久,再想给某列添加唯一索引时,往往发现已经有重复数据了,直接执行ALTER TABLE会失败。比如要给student表的class_no字段加唯一约束,但CS2301班级显然有多个学生,直接加唯一索引会报错。
正确做法是分三步走。第一步,用GROUP BY查出重复数据全貌:
sql复制SELECT class_no, COUNT(*) cnt
FROM student
GROUP BY class_no
HAVING COUNT(*) > 1;
第二步,根据业务规则决定保留哪一条,删除其余重复记录。如果没有任何业务偏好,常见的做法是保留id最小的记录,删除较大id的记录。MySQL里用JOIN DELETE可以做到:
sql复制DELETE s1
FROM student s1
JOIN student s2
ON s1.class_no = s2.class_no
AND s1.id > s2.id;
这条语句的逻辑是:只有存在另一条同班级且id更小的记录时,当前行才被删除,从而保证每个班级只留下id最小的那行。但在真实业务里,这种“删谁留谁”必须结合业务规则,绝不能机械套用。
第三步,确认没有重复后,再添加唯一索引:
sql复制ALTER TABLE student ADD UNIQUE KEY uk_class_no (class_no);
这个坑几乎每个团队都会遇到。最有效的预防方案是设计表结构时就想清楚哪些字段在业务上必须唯一,不要在线上运行半年后才后悔。
7. explain:先读懂查询,再谈索引优化
7.1 重点看哪几列
explain大概是被问烂但又绕不开的优化工具。当年我刚开始接触时觉得很玄,后来发现只要抓住几个关键列,已经能解决八成慢查询问题。
sql复制EXPLAIN SELECT s.sname, sc.score
FROM score sc
JOIN student s ON sc.student_id = s.id
WHERE sc.score > 80;
执行后重点看type、key、rows、Extra四列。
type表示访问类型,从好到坏大致是:
| type | 含义 | 说明 |
|---|---|---|
| system | 表只有一行 | 极少见 |
| const | 主键或唯一索引等值查询 | 非常快 |
| ref | 非唯一索引等值查询 | 通常不错 |
| range | 索引范围扫描 | 好用 |
| index | 遍历整个索引树 | 比ALL好点但要警惕 |
| ALL | 全表扫描 | 大部分慢查询的真凶 |
key表示实际使用的索引;rows是估算扫描行数;Extra里最需要警惕的是Using filesort和Using temporary。出现这两个词,基本代表排序或者分组没法直接利用索引完成,需要在临时表或内存中处理。
一个典型慢查询是成绩表查一个学生的最近一次考试成绩。如果score表没有score_student_id索引,EXPLAIN时type就是ALL,说明每次都在全表扫,就要考虑给外键字段补索引。
7.2 加了索引还是慢的原因
很多人以为加了索引就万事大吉,结果发现查询还是很慢,于是怀疑索引没用,一度产生“索引无用论”。实际上,加了索引但没用上时有发生,最常见的几个原因如下。
第一,查询条件里对索引列做了函数运算或隐式转换。比如WHERE DATE(exam_time) = '2024-01-15'会让exam_time索引失效,因为函数使B+树无法按原值定位。正确写法应该是WHERE exam_time >= '2024-01-15 00:00:00' AND exam_time < '2024-01-16 00:00:00',保留索引的区间扫描能力。
第二,前导通配符导致索引失效。WHERE sname LIKE '%三'无法走索引,而sname LIKE '张%'可以。这个规则对B+树很关键,因为字符串的前缀一旦不确定,就无法按索引顺序定位。
第三,优化器认为走索引还不如全表扫描。一个只有几百行的表,或者某列绝大多数值相同,选择性很低,优化器很可能放弃索引。记住索引不是越多越好,低选择性的列加索引收益极小,反而增加写入开销。
第四,回表次数过多。当查询的列不全在索引中时,InnoDB需要通过主键回到聚簇索引查找完整行。如果一次查出几万行,回表成本可能很高。此时可以考虑覆盖索引,让查询需要的字段都包含在索引里,减少回表。
我之前排查过一个线上问题,任务表按create_time建了索引,但查询语句里把create_time当字符参与比较,字段类型是datetime,条件里却是字符串,结果发生隐式转换,索引没能派上用场。后来把参数类型修正后,查询时间从秒级降到毫秒级。这让我意识到,索引失效很多时候不是数据库的问题,而是开发者的SQL写法问题。
7.3 写查询时的索引友好姿势
结合多年实操经验,写出几个实用的索引友好姿势。
第一,查询等值条件尽量写成WHERE student_id = 1而不是WHERE student_id = '1'。如果字段本身是字符串而传入数字,隐式转换偶尔也能走索引,但规则并不稳定,与其依赖优化器,不如保持类型一致。
第二,ORDER BY和GROUP BY的字段最好和WHERE条件的索引保持匹配顺序。比如创建联合索引(student_id, course_id),然后查询WHERE student_id = 1 ORDER BY course_id就能省掉filesort。但如果是ORDER BY course_id, student_id,顺序不一致,索引可能无法直接参与排序。
第三,控制单表查询返回的字段。很多人写SELECT *,导致回表查询大量无关字段,完全可以通过覆盖索引更高效地完成。尤其在高并发场景,SELECT *会把网络传输和内存缓冲都压满。
第四,分页深翻页也有坑。LIMIT 100000, 20意味着先扫描十万行再跳过,MySQL并没有提供高效的“跳过”方式。可以改成基于主键或唯一键的游标查询:WHERE id > 上次最大的id ORDER BY id LIMIT 20,效率会提升几个数量级。这个方法在定时同步任务里极其常见。
8. 连接不上和密码重置的环境排障
8.1 ERROR 2002 的排查链路
搜索热词里出现频率很高的是“error 2002 (hy000): can't connect to local mysql server through socket '/tmp/mysql.sock'”。这个错误说明客户端通过Unix套接字连接本机MySQL失败。注意这里走的是socket文件输入输出,不是TCP/IP。
碰到这个错误,第一反应不应该是“把mysql.sock找出来”,而是先确认MySQL进程到底有没有跑起来。排查链路如下。
第一步,确认服务是否运行:
bash复制systemctl status mysqld
或者在进程列表里查:
bash复制ps -ef | grep mysqld
如果进程没起来,优先看启动日志,一般位于/var/log/mysqld.log或根据my.cnf配置指定。大部分启动失败原因无非是磁盘满、权限错误、配置参数不合法。
第二步,如果进程正在运行,再看socket文件是否存在。有些系统重启后会清理/tmp目录,导致mysql.sock丢失,而客户端还按老路径去连接,自然报错。可以通过MySQL配置文件确认正确路径:
bash复制mysql -uroot -p --socket=/var/lib/mysql/mysql.sock
第三步,如果压根没配socket信息,可以直接改用TCP协议连接本机的3306端口:
bash复制mysql -uroot -p -h127.0.0.1 -P3306
这里有个小知识:连接localhost时,MySQL客户端优先走socket;连接127.0.0.1时才走TCP。有时你看着本机MySQL明明开着,却连不上,就是因为socket路径不同。
另外还有经典的端口被占用或端口没监听问题。检查监听状态可以用:
bash复制netstat -tlnp | grep 3306
如果是云服务器,还要检查防火墙和安全组是否放行3306。这个坑在远程连接数据库时特别常见,明明本地连得上,远程却超时。
8.2 忘记 root 密码的处理
忘记root密码后怎么做?网上教程很多,但思路和风险各不相同。传统做法是跳过授权表启动MySQL,适合本地开发机;生产环境必须谨慎,因为这时候MySQL完全不校验权限,任何能连上的人都能操作全部数据。
在忘记密码的场景下,一般流程是这样。
第一步,停掉MySQL服务:
bash复制systemctl stop mysqld
第二步,使用跳过授权表方式启动。可以在命令行指定:
bash复制mysqld_safe --skip-grant-tables &
这种方式启动后,MySQL不再加载权限系统。此时使用mysql -uroot就可以无密码进入。
第三步,修改密码。因为权限系统被跳过,通常要执行flush privileges让密码修改生效:
sql复制FLUSH PRIVILEGES;
ALTER USER 'root'@'localhost' IDENTIFIED BY '你的新密码';
注意MySQL 8.0中不存在直接修改mysql.user表password字段的办法,ALTER USER是标准方式。执行完一定要重启MySQL,回到正常的权限校验模式。
我不推荐把这种操作常态化,练手最好在虚拟机和容器中进行。还有一种更贴近上线环境的方案是使用root系统账号+配置文件跳过验证,但最终殊途同归,核心都是“绕过授权表”。真正规范的做法是提前准备好运维账号和sudo权限,不要把希望寄托在事后重置。
8.3 大小写、端口和驱动的细节
MySQL的大小写问题要从两层看。第一层是表名和列名的大小写敏感性,由lower_case_table_names参数决定。Linux上默认是0,表名区分大小写;Windows和macOS上默认是1,不区分。所以同一个建表SQL在Linux能建student和Student两个表,在Windows上就会撞名。跨环境迁移时,这是非常实际的问题。
第二层是字符串内容的大小写敏感性,由排序规则COLLATE决定。utf8mb4_general_ci后缀的_ci表示case insensitive,所以查询WHERE sname = 'zhangsan'能匹配到“ZhangSan”。如果业务需要严格区分大小写,要么把字段排序规则设成utf8mb4_bin,要么查询时加BINARY关键字:
sql复制SELECT * FROM student WHERE BINARY sname = 'ZhangSan';
端口和驱动相关的问题也让人头疼。默认端口3306被占用时,很多教程都是让你改my.cnf的port参数,但改完一定要检查防火墙、安全组、客户端连接参数三处是否同步更新。Navicat连接MySQL 8.0偶尔会报caching_sha2_password相关的认证问题,本质是8.0默认认证插件变成caching_sha2_password,而旧客户端不支持。解决方法是升级客户端,或者把用户改成mysql_native_password认证。长期来看应该升级客户端,而不是把数据库降低安全等级。
这篇“基础篇四”整理到这儿,已经覆盖了从函数、存储过程、触发器、视图到事务锁和Explain执行计划的大部分进阶基础。如果你能把这几个部分亲手在本地环境依次敲一遍,再回到日常工作里看各种报错,会发现很多原来需要“百度一下”的问题,自己已经有了大致的排查方向。个人经验是,学MySQL最忌只收藏不练习,这套表结构和示例,值得花一个周末完完整整跑通一遍。
