1. 增删改查:为什么人人都会,却总在项目里翻车
我做过不少次技术面试,有一个很深的感触:几乎所有候选人都会在简历上写“熟练掌握MySQL增删改查”。可真到上手写SQL的时候,能把一条UPDATE写对的人,十个里未必有三个。这不是夸张——我就亲眼见过有人因为忘了加WHERE条件,一条UPDATE把整个表的某个字段全部改成了同一个值,那天下午整个办公室都在加班恢复数据。
增删改查这四个字说起来太轻巧了,好像就是INSERT、SELECT、UPDATE、DELETE四句话的事。但真正落到实际业务里,每一类操作都有大量的细节藏着:事务怎么开、索引怎么走、锁怎么加、批量操作怎么控制粒度、误操作怎么预防。这篇文章我不打算给你抄一遍文档,那东西官方手册写得比我清楚。我想做的是把手头的经验捋一遍,从一个实际项目的角度,把这四类操作讲透,让你看完之后写出来的SQL像干过几年活的人写的,而不是像刚从课本上抄下来的。
适合看这篇的人,我大概分三类:一类是刚入门、准备把增删改查从“会用”变成“用得好”的初学者;一类是转行过来、对MySQL只停留在Navicat里点点点的朋友;还有一类是做全栈开发、需要在业务代码里写大量数据操作的人。这篇文章里的经验和坑,都是我实际踩过的,你照着来,能少走不少弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手之前的准备:MySQL环境与学习库的设计
2.1 本地装MySQL还是用Docker,怎么选
很多人一上来就问“MySQL怎么安装”,但我建议你先想清楚安装场景。
如果你只是为了学习增删改查,我强烈推荐用Docker起一个MySQL实例。原因很简单:干净、可摧毁、可重建。你在学习过程中难免会做一些破坏性操作,比如删库、改错数据、把表结构搞乱。Docker容器两秒钟就能重新拉起来,完全不影响你继续练手。我自己当年学的时候就是在本地拿虚拟机折腾,一个配置改错了,卸载装、装完卸载,浪费了大量时间。
bash复制# 拉取MySQL 8.0镜像并启动容器
docker run --name mysql-study \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=123456 \
-d mysql:8.0
这条命令跑完,一个MySQL 8.0就起来了。如果你是Windows本地想装原生版本,去官网下载安装包就行,但记得选“Server only”组件,别把那些没用的Connector、Documentation全勾上——除非你明确知道自己需要。
注意:MySQL 8.0和5.7在不少行为上有差异,比如认证插件和窗口函数支持。如果是照着老教程学,遇到密码认证不通过,先在命令行执行
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '123456';试试。
2.2 建一个像样的学习库:学生选课成绩系统
增删改查不是拿来背的,得有一张合理的表,才能感受到每个语句存在的意义。我拿“学生-课程-成绩”这个经典场景来建表,因为这也是面试题里的常客,热搜词里也有“学生课程成绩信息实体表设计mysql”,说明这是大家普遍关心的场景。
先分析业务需求:一个学生可以选择多门课程,一门课程可以被多个学生选择,学生选了课之后会产生一个成绩。这个关系对应于数据库里最典型的关联关系:学生表和课程表是基础数据,中间还需要一张选课成绩表来记录“某个学生选了某门课、考了多少分”。
sql复制-- 学生表
CREATE TABLE `student` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '学生ID,自增主键',
`student_no` VARCHAR(20) NOT NULL COMMENT '学号',
`name` VARCHAR(50) NOT NULL COMMENT '姓名',
`gender` TINYINT DEFAULT '0' COMMENT '性别:0未知,1男,2女',
`birth_date` 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 '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_student_no` (`student_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表';
-- 课程表
CREATE TABLE `course` (
`id` INT NOT NULL AUTO_INCREMENT,
`course_code` VARCHAR(20) NOT NULL COMMENT '课程编号',
`course_name` VARCHAR(100) NOT NULL COMMENT '课程名称',
`credit` DECIMAL(3,1) DEFAULT '2.0' COMMENT '学分',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_course_code` (`course_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';
-- 选课成绩表
CREATE TABLE `student_course` (
`id` INT NOT NULL AUTO_INCREMENT,
`student_id` INT NOT NULL COMMENT '学生ID',
`course_id` INT NOT NULL COMMENT '课程ID',
`score` DECIMAL(5,2) DEFAULT NULL COMMENT '成绩,NULL表示未考试',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_student_course` (`student_id`, `course_id`),
KEY `idx_course_id` (`course_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课成绩表';
这里有几个设计细节值得注意:
- 主键用自增ID,但唯一业务标识用UNIQUE KEY。学号、课程编号这些真实的业务编码虽然唯一,但不适合做关联外键,因为有可能调整。你实际做项目时,主键尽量保持“无业务含义”,这样表结构才能稳定。
- 外键要不要建?在这三张表的设计里,我没有添加物理外键约束,只建了普通索引。在实际项目里我倾向于不建物理外键,原因后面会在“删除”那一章详细说。
- utf8mb4而不是utf8。utf8在MySQL里最多存3个字节,遇到emoji表情符就直接报错。从5.5以后官方推荐就是utf8mb4,现在新项目直接用utf8mb4,别问为什么,用就完了。
有了这三张表,后面的增删改查才有承载的土壤。你学任何一句SQL,都可以在这套业务逻辑里面找到对应场景,这就比打印一句SELECT 'hello'有意义多了。
3. 插入数据(C):从单条INSERT到批量导入的实战细节
3.1 单条插入的正确姿势
最基本的INSERT语句长这样:
sql复制INSERT INTO student (student_no, name, gender, birth_date)
VALUES ('20240001', '张三', '1', '2005-03-15');
写这条语句时有几个细节:
第一,字段列表最好写全,不要省略。INSERT INTO student VALUES (...) 这种写法依赖表中字段的顺序,一旦表结构调整,语句就废了。更可怕的是,如果字段顺序和你记的不一样,数据会插错列。我就见过有人把学号和姓名插反了,排查了半天才发现语句里没写字段名。
**第二,自增主键千万别写进字段列表。**你没必要插id,让数据库自己生成就行。如果你插入了指定的id值,后续的自增计数器会受到影响,可能出现主键冲突。
第三,时间字段能交给默认值就别自己写。created_at建表时已经设置了默认值,不传它会自动取当前时间。除非有特殊业务要求,否则你手动写时间反而容易出错。
3.2 批量插入:一条语句,还是多条?
在业务代码里,最常见的操作就是循环单条INSERT。新手往往这样做:
java复制for (Student stu : studentList) {
jdbcTemplate.update("INSERT INTO student ...", stu.getStudentNo(), ...);
}
这条路在数据量小的时候没问题,但数据量一大,性能瓶颈立刻暴露——每执行一条INSERT,都要经历一次网络往返、一次SQL解析、一次事务提交。
更好的方案是用批量插入:
sql复制INSERT INTO student (student_no, name, gender, birth_date) VALUES
('20240001', '张三', '1', '2005-03-15'),
('20240002', '李四', '2', '2005-07-22'),
('20240003', '王五', '1', '2004-11-02');
一次INSERT多条记录的效率,通常比循环单条快一个数量级。在JDBC里,还可以配合addBatch() + executeBatch()来做预编译批量提交,数据库端还能对同一条SQL的重复执行做优化。
注意:批量INSERT的单条SQL语句长度也不要无限大。
max_allowed_packet默认通常是64MB,但单条SQL太大,网络传输和binlog写入压力都会剧增。我个人的经验是每批500条到1000条比较合适,这个量级既快又不至于太占内存。如果你的数据有几十万行,可以考虑用LOAD DATA INFILE,那是另一个话题,以文本文件方式导入会比INSERT快得多。
3.3 插入时最容易忽略的几种情况
**重复数据怎么防?**这是实战里特别常见的一个问题。学生选课表里的UNIQUE KEY uk_student_course (student_id, course_id),就是在数据库层面防重。你光在代码里判断“这个学生有没有选过这门课”是不够的——并发请求过来的时候,两条线程同时查到“未选课”,同时执行INSERT,其中一条就会报主键冲突。
处理这种冲突,通常有两种思路。一种是优雅处理,用INSERT ... ON DUPLICATE KEY UPDATE来更新某些字段,但要注意:这会把“插入失败”变成“更新操作”,实际执行的是UPDATE语义,对于选课这种场景,你想要的可能是“已存在就直接忽略”:
sql复制INSERT INTO student_course (student_id, course_id) VALUES (1, 101);
-- 唯一键冲突时不做任何事
INSERT IGNORE INTO student_course (student_id, course_id) VALUES (1, 101);
如果希望冲突时更新成绩,就用:
sql复制INSERT INTO student_course (student_id, course_id, score)
VALUES (1, 101, 88.5)
ON DUPLICATE KEY UPDATE score = VALUES(score);
注意MySQL 8.0.20以后,VALUES() 这种写法被标记为废弃,建议用别名语法:
sql复制INSERT INTO student_course (student_id, course_id, score)
VALUES (1, 101, 88.5) AS new
ON DUPLICATE KEY UPDATE score = new.score;
4. 查询数据(R):90%的业务代码都绕不开的SELECT细节
4.1 先学会克制:SELECT * 是性能毒药
查询是增删改查里最复杂、也最考验功力的部分。很多初学者最喜欢写 SELECT * FROM student,控制台一跑,所有列都出来了,爽,然后就此养成了习惯。
但实际项目里,SELECT * 带来的问题非常具体:
- 浪费网络带宽和内存。如果一个表有20个字段,但业务只需要其中3个,剩下的17个字段白白从数据库传到应用服务器,再传到前端,纯属浪费。
- 可能让覆盖索引失效。MySQL的InnoDB存储引擎支持二级索引“覆盖”,意思是如果查询的列都包含在索引里,就不需要回表查数据行。但
SELECT *要求所有列,必然要回表,性能折扣很大。
所以写查询的第一步是学会只查你需要的列:
sql复制-- 不推荐
SELECT * FROM student WHERE gender = '1';
-- 推荐
SELECT id, student_no, name FROM student WHERE gender = '1';
4.2 WHERE条件的陷阱:索引失效的那些坑
WHERE子句是最容易出现“语法对了,性能拉了”的地方。我把常见的几个坑集中说一下。
**在索引列上做函数运算,索引会失效。**比如你在birth_date上建了索引,然后这么查:
sql复制SELECT id, name FROM student WHERE YEAR(birth_date) = 2005;
这个语句虽然能查出结果,但YEAR()函数会让索引失效,全表扫描。正确的写法是:
sql复制SELECT id, name FROM student
WHERE birth_date >= '2005-01-01' AND birth_date < '2006-01-01';
把函数挪到参数这一侧,让索引列保持“干净”,优化器才能走索引。
**隐式类型转换也是个大坑。**假如student_no是VARCHAR类型,你写:
sql复制SELECT ... WHERE student_no = 20240001;
MySQL会把字符串列转换成数字比较,导致索引失效。所以字符串类型的等值查询,写的时候一定要带引号:WHERE student_no = '20240001'。
LIKE模糊查询要小心前缀通配符。 LIKE '%张%'这种写法无法用索引,因为查询条件要求知道通配符后面的值,索引的B+树结构没办法从中间开始搜索。如果业务确实需要这种模糊查询,可以考虑搜索引擎或者反过来存冗余字段,比如存一个拼音首字母的字段,这样就能做前缀匹配。
4.3 ORDER BY与LIMIT:排序分页的正确细节
MySQL里排序和分页是无处不在的需求。热搜词里也有“mysql排序”,这里展开讲一下。
sql复制SELECT id, student_no, name FROM student
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;
这个写法逻辑是正确的,意思是“按创建时间倒序排,跳过20条,拿10条”——也就是取第21到第30条记录。但这里埋着一个深水区问题:深度分页性能极差。
当OFFSET到一千万的时候,MySQL要先把前面一千万条记录全部扫描、排序完,然后扔掉,只返回最后那10条。这中间的浪费是巨大的。优化方案常见有两种:
第一种是“子查询延迟关联”:
sql复制SELECT s.id, s.student_no, s.name
FROM student s
JOIN (SELECT id FROM student ORDER BY created_at DESC LIMIT 10 OFFSET 200000) tmp
ON s.id = tmp.id;
第二种是“记住上一页的位置”:
sql复制-- 第一页
SELECT id, student_no, name FROM student ORDER BY id LIMIT 10;
-- 第二页,记住上一页最后一条的id
SELECT id, student_no, name FROM student WHERE id > 100 ORDER BY id LIMIT 10;
第二种方式在业务上很适合瀑布流、滚动加载这类场景,大数据量翻页用这个方式,性能非常稳定。
4.4 多表查询:JOIN的时候脑子里要有张图
回到学生选课的场景。我现在要查询“每个学生的姓名和他选的课程名称”,按道理数据在student和course两张表里,怎么关联?通过student_course这个中间表搭桥:
sql复制SELECT
stu.name AS student_name,
cou.course_name,
sc.score
FROM student_course sc
JOIN student stu ON sc.student_id = stu.id
JOIN course cou ON sc.course_id = cou.id
ORDER BY stu.id, cou.id;
这种JOIN叫内连接(INNER JOIN),表示只返回能匹配上的记录。如果我想知道“哪些学生一门课都没选”,就要用LEFT JOIN + WHERE IS NULL:
sql复制SELECT stu.id, stu.name
FROM student stu
LEFT JOIN student_course sc ON stu.id = sc.student_id
WHERE sc.id IS NULL;
这个场景在实际项目里太常见了:查订单但没付款的、查用户但没有绑卡的、查部门但没人的……核心思路就是“以某张表为基准LEFT JOIN,然后判断另一端有没有匹配上”。
**写JOIN时我有一条自己的经验:多表JOIN最好保持在两张到三张以内。**当你需要JOIN超过三张表时,先停下来想想是不是表结构设计有问题,或者是不是有冗余字段可以提前存进去。多表JOIN的SQL相当难优化,执行计划一旦走错,性能就是灾难。
4.5 聚合查询:GROUP BY与HAVING的边界
查询里还有一类绕不开的就是聚合:统计每个学生选了几门课、每门课的平均分是多少。比如:
sql复制SELECT stu.id, stu.name, COUNT(sc.course_id) AS course_count
FROM student stu
LEFT JOIN student_course sc ON stu.id = sc.student_id
GROUP BY stu.id, stu.name;
这里有三个实用建议:
- GROUP BY的字段最好是主键或唯一字段。如果你只
GROUP BY stu.id,然后SELECT里带了stu.name,在MySQL的ONLY_FULL_GROUP_BY模式下会直接报错。所以要么把name也加到GROUP BY里,要么用ANY_VALUE(name)绕过去。反正规范做法就是:SELECT里的非聚合列,必须出现在GROUP BY里。 - WHERE和HAVING的分工要拎清。WHERE是在分组之前过滤原始记录,HAVING是在分组之后过滤分组结果。比如“统计选课超过2门的学生”,过滤条件是分组后的数量,就得用HAVING:
sql复制SELECT student_id, COUNT(*) AS cnt
FROM student_course
GROUP BY student_id
HAVING cnt > 2;
- 去重统计用COUNT(DISTINCT ...)。比如“每门课有多少个学生选了(按学生去重)”,直接
COUNT(DISTINCT student_id)。顺带说一句,COUNT(1)和COUNT(*)在MySQL里性能几乎一样,没必要纠结用哪个——真正有区别的是COUNT(某字段),它不统计NULL值。
5. 更新数据(U):一条UPDATE引发的思考
5.1 先查后改,还是直接改?安全第一
更新操作是所有增删改查里最危险的动作,因为它的破坏力是不可逆的——一旦提交了事务,数据就很难恢复了。我开篇提到的那位同事,就是一条UPDATE没加WHERE,把所有学生性别都变成了“男”,带来的麻烦远超想象。
在写UPDATE之前,我强烈建议你养成一个习惯:先用SELECT验证WHERE条件。
sql复制-- 第1步:先看看要影响哪些行
SELECT id, name, gender FROM student WHERE id = 10086;
-- 第2步:确认无误后,再执行更新
UPDATE student SET gender = '2' WHERE id = 10086;
两步操作看起来多了一步,但它能避免“手一抖WHERE条件写错”这种灾难。尤其在操作线上数据时,这个习惯救过我很多次。
5.2 UPDATE语句的完整语法与常见用法
UPDATE的基本结构是:UPDATE 表名 SET 列=新值 WHERE 条件。
它可以一次更新多个字段:
sql复制UPDATE student
SET name = '张小明', birth_date = '2005-06-18'
WHERE id = 1;
也可以基于当前字段值来更新:
sql复制-- 把每门选修课的学分加0.5
UPDATE course SET credit = credit + 0.5 WHERE id = 10;
这种基于当前值更新的写法有原子性的优势。它会直接在数据库端对当前行的值做修改,不经过“先查出来-程序里算好-再写回去”的中间环节,避免了并发下两个请求互相覆盖的问题。类似的场景非常常见:库存扣减、账户余额加减,都适合直接写SET balance = balance - 100,而不是先查余额再更新。
5.3 UPDATE与事务:为什么必须显式开启
MySQL默认自动提交,也就是说你执行一条UPDATE,它马上生效,无法回滚。但在实际业务里,一个操作往往涉及多张表:比如“录入学生选课成绩”,需要同时更新student_course表和student的统计信息。这两个更新要么都成功、要么都失败,否则数据就不一致了。
sql复制START TRANSACTION;
UPDATE student_course SET score = 92.5 WHERE student_id = 1 AND course_id = 101;
UPDATE student SET total_score = total_score + 92.5 WHERE id = 1;
-- 确认无误后提交
COMMIT;
-- 如果中途出错,执行 ROLLBACK;
这个时间点我要特地提一下行锁的问题。在事务里执行UPDATE时,被更新的行会被加锁(默认的行级排他锁),直到事务提交或回滚才会释放。这就意味着:
- 你开了一个事务,UPDATE了一行,但又没提交;
- 另一个连接也想UPDATE同一行,它就会一直阻塞等待;
- 如果迟迟不提交,就可能引发“锁等待超时”(Lock wait timeout exceeded)。
所以事务里尽量快,别把耗时的远程调用塞在事务中间,能提前算好的值先算好,然后一口气执行完SQL立刻提交。
5.4 批量更新:CASE WHEN是神器
有时业务需要根据多条记录的不同条件更新不同值。比如给不同性别设置不同的默认成绩:
sql复制UPDATE student_course
SET score = CASE student_id
WHEN 1 THEN 85.0
WHEN 2 THEN 90.0
WHEN 3 THEN 78.0
ELSE score
END
WHERE student_id IN (1, 2, 3);
这种方式一次UPDATE就能完成多条数据的差异化更新,比循环单条UPDATE快得多,而且减少了对表的多次锁竞争。
我在实际项目里见过一些团队为了提高批量更新性能,在代码里拼这种动态SQL。可行,但你必须非常小心SQL注入,字段值必须全部走预编译参数化,不能直接拼字符串。
6. 删除数据(D):物理删除与逻辑删除的博弈
6.1 DELETE操作的基础与陷阱
DELETE是最直观的“删数据”,语法也最简单:
sql复制DELETE FROM student_course WHERE student_id = 1 AND course_id = 101;
但这里有几个实际问题需要回答。
没有WHERE的DELETE会清空全表——这个大家都知道了,但我更想强调的一点是:DELETE删的是“行”,不是“表”。如果你想清空全表,有更快的方式叫TRUNCATE TABLE,它直接重建表结构,速度远快于逐行DELETE,但它无法按条件删除,而且不会逐行触发删除动作和原生事务回滚。所以这两者的选择很简单:
- 删部分行:
DELETE FROM 表 WHERE 条件; - 清空全表:
TRUNCATE TABLE 表。
DELETE操作与自增主键。DELETE删掉行之后,自增计数器的值不会回退。比如当前最大id是100,删掉这行后,下一条INSERT出来的主键是101,而不是100。对于内部系统这无所谓,但对于某些需要“紧凑编号”的业务场景,你要提前设计好方案。
6.2 物理外键与DELETE的冲突
前面建表时我没建物理外键,这里专门解释一下为什么。假如student表被student_course表用外键关联,当你想要删除某个学生时:
- 如果业务规则是“学生选课记录也同时删除”,那外键的
ON DELETE CASCADE能自动级联删除; - 但实际业务里,“删除学生”往往是逻辑删除——只是改一个
status字段标记为失效,而不是真的把物理行抹掉。
外键约束一旦建立,删除顺序就非常严格:必须先删子表数据,再删主表数据。在高并发场景下,这种强约束反而容易引发锁竞争和死锁。我的习惯是:外键约束在数据库层面不建,关联关系的完整性由应用代码来保证,但相关字段必须建普通索引。这样既保证了查询性能,又保留了删除的灵活性。
6.3 逻辑删除:用UPDATE假装DELETE
真正在生产环境里,绝大多数业务系统的“删除”都是逻辑删除,而非物理删除。做法是在表里加一个deleted或status字段:
sql复制-- 逻辑删除:把记录的deleted标记为1
UPDATE student SET deleted = 1 WHERE id = 10086;
-- 查询时永远带上过滤条件
SELECT id, name FROM student WHERE deleted = 0 AND id = 10086;
这样做的核心价值是数据可追溯。用户的订单、交易记录、操作日志,一旦物理删了,出了纠纷你拿什么说话?所以法律和财务相关的数据,一律逻辑删除。成本是每次查询都要记得过滤deleted字段,忘了加就可能把“已删除”的数据捞出来。有些团队会把deleted字段放进唯一索引里,防止重复数据,但这需要配合NULL值处理,因为MySQL唯一索引允许多个NULL——所以实际常用deleted_at DATETIME DEFAULT NULL,删除时写入时间戳,未删除就是NULL,这样既能实现逻辑删除,又天然避免了唯一索引冲突。
6.4 批量删除与IN子句的限额
批量删除在后台管理系统中很常见,比如“勾选多条学生记录,批量删除”:
sql复制DELETE FROM student_course WHERE student_id IN (1, 2, 3, 4, 5);
这里有一个容易踩的坑:IN列表太长。虽然MySQL对IN列表长度没有硬性限制,但过度膨胀的IN子句会拖慢执行计划生成,也可能超出SQL长度限制。稳妥的做法是分批删,比如每批500个:
sql复制DELETE FROM student_course WHERE student_id IN (...) LIMIT 500;
注意:MySQL里DELETE配合LIMIT时,不能像SELECT那样用OFFSET指定跳过多少行,它只会删除LIMIT指定的行数。所以“分批删除”要通过不断缩小范围或配合
min(id)/max(id)来实现,不能简单写LIMIT 500 OFFSET 0然后期待第二波删的是下一批。
7. 从增删改查走向性能优化:索引、事务与常见错误排查
7.1 索引:查询快慢的分水岭
很多初学者学完增删改查,感觉自己会了。但同样的语句,数据量从1万涨到1000万,性能天差地别。这里面最关键的因素就是索引。
索引就像书的目录。没有目录,你想找一句话得一页页翻;有了目录,直接定位到页码。MySQL InnoDB用的是B+树索引结构,查询的时间复杂度从全表扫描的O(n)降到树高相关的O(log n)。
以student表为例,你经常按学号student_no查学生,那就应该给这个字段建唯一索引——我们在建表的时候已经建了uk_student_no。为什么要用唯一索引?因为学号本身唯一,唯一索引既能加速查询,又能保证不重复,一举两得。
但索引也不是建得越多越好。每一个索引都会占用磁盘空间,并且在INSERT、UPDATE、DELETE时要额外维护索引结构,导致写操作变慢。常见的实践原则是:
- 区分度高的列才值得建索引。比如性别这种只有几个值的列,建了索引也几乎不会减少扫描行数,反而增加维护成本。
- 联合索引要遵循最左前缀原则。比如在student_course表上建了
(student_id, course_id)联合索引,那么WHERE student_id = ?能用到它,但WHERE course_id = ?用不到。所以设计联合索引时,把最常用的等值字段放最左边。
用EXPLAIN可以看一条SQL是否走索引:
sql复制EXPLAIN SELECT id, name FROM student WHERE student_no = '20240001';
重点看type列——如果你的查询显示ALL,那就是全表扫描;如果显示的是const、ref、range,说明索引在起作用。我写SQL的习惯是:执行查询前先EXPLAIN一遍,尤其是复杂的查询。这个习惯帮我避开了大量性能问题。
7.2 MySQL 8.0的窗口函数:让复杂查询变简单
MySQL 8.0开始支持窗口函数,这是一个非常强大的工具,特别适合做“分组排名”“累计求和”“和同组比较”这类逻辑。以前需要写子查询做的SQL,现在几行就能表达。
比如要查“每门课的最高分学生”:
sql复制SELECT
course_id,
student_id,
score,
ROW_NUMBER() OVER(PARTITION BY course_id ORDER BY score DESC) AS rn
FROM student_course;
这条SQL把数据按course_id分组,组内按score倒序编号,编号为1的就是该课程的最高分。如果只需要拿到每个分组的第一名,外面套一层子查询过滤rn = 1即可。这种场景在传统写法里要靠变量或者复杂的自连接,窗口函数的可读性好得多。
7.3 常见错误与排查路径
我收集了几个新手高频遇到的问题,按实际发生频率排个序。
ERROR 1064(语法错误)。这是新手最常碰到的错误。实际经验告诉我,绝大部分1064错误不是SQL本身写错,而是中英文字符混用——中文输入法下不小心把逗号、括号、引号打成了全角字符。检查方式很简单:把整条SQL复制到记事本里看,全角符号一眼就能看出来。
ERROR 1130(Host is not allowed to connect)。这是权限问题。MySQL的权限控制既管“谁能连”,也管“从哪连”。如果从远程连不上,要执行授权语句:
sql复制GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' IDENTIFIED BY '123456';
FLUSH PRIVILEGES;
ERROR 2002(Can't connect to local MySQL server through socket)。这个热搜词排在比较靠前的位置,说明很多人都遇到过。它意味着客户端连不上本地的MySQL服务,常见原因是:
- MySQL服务没启动。Windows下到服务管理器找MySQL服务启动;Linux下
systemctl start mysqld。 - socket文件路径不对。客户端默认去找
/tmp/mysql.sock,但MySQL服务端实际用的可能不是这个路径。解决办法是改my.cnf里的[client]的socket配置,或者在连接参数里指定。
锁等待超时(Lock wait timeout exceeded)。前文提过,事务里UPDATE的行会加锁。排查方法是用SHOW PROCESSLIST看当前有没有长时间未提交的事务。经验方案是:检查代码中是否在事务里执行了慢查询或远程调用;把事务粒度缩小;确保每个事务都及时COMMIT。
7.4 存储过程的取舍
热搜词里也有“mysql存储过程”。我在真实的业务开发里,对存储过程的态度一直是**“能不用就不用”**。原因很实际:
- 存储过程把业务逻辑写进了数据库,出了问题不好调试,也不方便代码审查;
- 存储过程的版本控制很难做,迁移到其他数据库更是灾难;
- 在MySQL里,存储过程的优化和排错体验远不如在应用层写清晰的SQL。
但有一种场景我会考虑用存储过程:就是一条事务里执行非常多的、逻辑固定的批量操作,比如月初批量生成账单。这种情况下,把数据放在数据库内部处理,可以减少大量网络往返,提升整体效率。但即便如此,我也会要求团队把存储过程的代码纳入版本管理,并做严格的review。
8. 别忘了数据安全:权限、备份与恢复的底线思维
8.1 最小权限原则
增删改查虽然只是四个动词,但权限控制上必须区分清楚。生产环境绝对不要所有应用都用一个root账号连接数据库。我见过不少项目,一个root账号跑在所有环境里,谁都能删库,出一次事故后悔都来不及。
合理的做法是给不同角色分配最小权限:
- 应用程序账号:只有业务库的增删改查权限;
- 报表查询账号:只读权限;
- 运维和管理员账号:才拥有DDL权限。
sql复制-- 创建一个只读账号
CREATE USER 'readonly_user'@'%' IDENTIFIED BY 'readonly_pwd';
GRANT SELECT ON school_db.* TO 'readonly_user'@'%';
FLUSH PRIVILEGES;
8.2 备份与恢复:唯一能兜底的方案
不管增删改查写得再熟练,都不如一个可靠的备份策略重要。误删、误改、服务器磁盘损坏,这些事发生时,备份就是你的救命稻草。MySQL里最常用的逻辑备份工具是mysqldump:
bash复制mysqldump -u root -p school_db > school_db_backup.sql
恢复也很简单:
bash复制mysql -u root -p school_db < school_db_backup.sql
但备份策略的关键不在于命令,而在于定期与自动化。我的经验是至少做到每日全量备份,加上重要的binlog日志保留,这样即使数据库在某个时间点崩溃了,也能恢复到“最近一次备份之后”的状态。生产环境的备份一定要定期做恢复演练——备份文件如果从来没恢复过,那它就等于不存在。这句话我反复跟团队强调。
8.3 一次典型的误操作复盘
最后给大家讲一个我亲历的真实事故。
那天下午,一个同事在测试环境跑SQL,想更新一个测试学生的手机号,结果连接的是生产库,而且WHERE条件写错了,导致3000多个学生的手机号全被更新成了同一个测试号码。还好团队早有备份策略,我们用前一天晚上的全量备份加当天的binlog日志,把数据恢复到了误操作前几分钟的状态,整个过程花了不到一小时,最终没有造成不可挽回的损失。
复盘下来,有三个环节可以避免这次事故,我把它列出来,也是给你提个醒:
- 生产环境禁止直接执行UPDATE/DELETE,除非经过审批和双人复核。现在我们团队要求危险操作必须走工单系统,两个人在场确认。
- 连接配置分离。测试环境、生产环境的连接字符串必须分开,并且生产账号不允许有DELETE等危险操作的权限。
- 先查后改。更新前先SELECT确认影响行数,这看起来多一步,却能把风险降一个量级。
我自己经历过这些事以后,对增删改查的态度一直很明确:语法是最简单的,真正的功夫都在安全、性能和细节判断上。希望这篇文章不只是让你学会四个动词,而是让你从一开始就建立起一套正确的操作习惯。等你实际接手的项目越来越大,数据越来越多,你会发现这些习惯才是你最值钱的经验。
