1. 先从一句大实话聊起:增删改查到底是什么
做后端开发的人,几乎每天都在跟数据打交道。你写接口、做管理系统、搭报表平台,说到底都是在做同一件事:往数据库里存数据,再从数据库里把数据取出来。这件事落到最底层,就是四个动作——增(INSERT)、删(DELETE)、改(UPDATE)、查(SELECT)。也就是常说的 CRUD。
很多人觉得增删改查太基础,刚入门就会写几条 SQL,没什么好讲的。但我在实际工作里发现,真正能把 CRUD 写得稳、写得快、写得不出问题的人,其实不多。你去看那些线上事故,有不少就是 DELETE 没加 WHERE、UPDATE 误更新了全表、INSERT 没考虑唯一索引冲突导致的。所以这篇不是给你念一遍教材,而是把我这几年排查问题、优化慢查询、设计表结构时积累下来的一套 CRUD 实战经验整理出来,从最简单的语法出发,讲到索引对查询的影响,再聊事务和锁,最后给出一份可以直接抄的排查清单。
不管你是在校学生、刚转行的新人,还是写了两三年业务代码但没系统梳理过 SQL 的开发者,这篇都值得花点时间过一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 增删改查的前置准备:装好 MySQL 并建一张能用的表
在动手写 SQL 之前,你得先有个能跑的 MySQL。这里不展开安装全过程,就说几个容易踩坑的关键点。
2.1 安装 MySQL 的版本与平台选择
现在 MySQL 官方主推 8.0 系列,8.0 以上的版本在窗口函数、CTE、默认字符集等方面都比 5.7 好不少。如果是从零开始学,直接装 8.0 就行,不用纠结 5.7。官网下载一般选 MySQL Installer for Windows 或对应 Linux 发行版的 APT/YUM 源。
安装时有几个容易摔跟头的地方:
- 端口号冲突:默认 3306,如果本机已经装了旧版 MySQL 或者 MariaDB、其它中间件占用了端口,启动会直接失败。安装前先检查端口能不能连通,Windows 下可以用
netstat -ano | findstr 3306看一眼。 - 认证方式:8.0 默认的
caching_sha2_password让很多老客户端(比如某些版本的 Navicat、旧 JDBC 驱动、LabVIEW 的数据库连接工具)根本连不上。报错里经常出现Client does not support authentication protocol requested by server。遇到这种问题,要么把客户端的驱动升级到支持 8.0 的版本,要么在 MySQL 里把用户的认证方式改成mysql_native_password。 - 字符集:建议在安装时或者初始化阶段把默认字符集设为
utf8mb4,排序规则选utf8mb4_general_ci或utf8mb4_unicode_ci。别再用祖传的utf8,它存不了 Emoji。
Linux 下用 Docker 装也很常见,一条命令的事:
bash复制docker run -d --name mysql8 \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=your_password \
-e MYSQL_DATABASE=test \
mysql:8.0
如果用的是飞牛、群晖这类 NAS,里面也有 Docker 管理界面,拉镜像后映射端口和目录就行。持久化数据记得挂载 -v /path/to/data:/var/lib/mysql,不然容器一删数据全没了。
2.2 建一张学生课程成绩表当实验田
增删改查不能只靠背语法,得动手敲。我拿一个最常见的场景——学生课程成绩管理来举例。这套表结构在面试里也经常出现,涉及一对多、多对多关系,非常适合演示 CRUD。
先建学生表:
sql复制CREATE TABLE student (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
student_no VARCHAR(20) NOT NULL COMMENT '学号',
name VARCHAR(50) NOT NULL COMMENT '姓名',
gender TINYINT NOT NULL DEFAULT 0 COMMENT '0-未知 1-男 2-女',
birthday DATE NULL COMMENT '出生日期',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_student_no (student_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生基本信息表';
再建课程表:
sql复制CREATE TABLE course (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
course_no VARCHAR(20) NOT NULL COMMENT '课程编号',
course_name VARCHAR(100) NOT NULL COMMENT '课程名称',
credit DECIMAL(3,1) NOT NULL DEFAULT 0 COMMENT '学分',
UNIQUE KEY uk_course_no (course_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表';
最后是成绩表,把学生和课程关联起来:
sql复制CREATE TABLE score (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
student_id BIGINT UNSIGNED NOT NULL,
course_id BIGINT UNSIGNED NOT NULL,
score DECIMAL(5,2) NOT NULL COMMENT '成绩,百分制',
exam_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '考试时间',
UNIQUE KEY uk_student_course (student_id, course_id),
KEY idx_score (score),
CONSTRAINT fk_score_student FOREIGN KEY (student_id) REFERENCES student(id),
CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES course(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩表';
这张表有几个细节值得注意。主键用自增 BIGINT UNSIGNED,业务上不要用学号当主键,因为学号是业务字段,将来有可能变更。唯一索引 uk_student_course 能防止同一个人同一门课出现两条成绩记录,这个在插入数据的时候非常关键。外键可以加,但我个人在互联网业务里一般不加物理外键,用代码保证数据一致性,这样并发高的时候锁开销更小。不过学习阶段建外键能帮你理解关系型数据库的约束逻辑,这里保留。
3. 新增数据:INSERT 的三种姿势与常见坑
3.1 单条插入与批量插入的效率差异
最基本的插入语句,语法很简单:
sql复制INSERT INTO student (student_no, name, gender, birthday) VALUES ('20240001', '张三', 1, '2002-05-11');
你可以在一条 SQL 里插入多行,用逗号分隔:
sql复制INSERT INTO student (student_no, name, gender, birthday) VALUES
('20240002', '李四', 2, '2002-08-23'),
('20240003', '王五', 1, '2003-01-15'),
('20240004', '赵六', 2, '2002-12-30');
批量插入和单条插入的性能差距不是一点半点。原因在于每一条 INSERT 都要走一遍解析、优化、执行、写入 InnoDB 日志、提交事务的流程。如果一次插入 1000 条,单条插入意味着要产生 1000 次网络往返和 1000 次事务提交;而批量插入只需要很少的几次。日常代码里如果你用 MyBatis 的 foreach 拼接批量插入,或者用 JDBC 的 addBatch(),本质上都是减少交互次数,吞吐量能提升好几倍。
但批量插入也不是越大越好。一次插入几万行,生成的 SQL 文本很长,可能会超过 max_allowed_packet 的限制;同时事务太大,持有锁的时间长,容易引发锁等待。我一般建议每批 500 到 1000 行比较稳妥,实际数量可以压测一下。
3.2 插入时如何优雅地处理唯一键冲突
成绩表上建了 uk_student_course 唯一索引,那么插入成绩时就会遇到一个问题:同一个学生、同一门课,如果已经录过成绩,再插入应该怎么办?直接报错会让前端体验很差;先 SELECT 再判断再插入,又会有并发竞态问题——两个请求同时查到不存在,然后同时插入,后插入的那条必然报错。
业界通常有几种解法。
第一种,用 INSERT IGNORE,遇到冲突直接忽略,不影响其它行:
sql复制INSERT IGNORE INTO score (student_id, course_id, score, exam_time) VALUES (1, 101, 88.5, NOW());
注意 INSERT IGNORE 忽略的不仅是唯一键冲突,还有数据转换错误、非空约束错误等。它会把错误降级成警告,容易掩盖真实问题,所以我只在“丢了也无所谓”的场景里用。
第二种,用 ON DUPLICATE KEY UPDATE,冲突时更新指定字段:
sql复制INSERT INTO score (student_id, course_id, score, exam_time) VALUES (1, 101, 92.0, NOW())
ON DUPLICATE KEY UPDATE score = VALUES(score), exam_time = VALUES(exam_time);
这招很实用。比如成绩重新录入、补考后更新分数,一行 SQL 就能完成“有则更新、无则插入”的逻辑,性能也很好。不过有个坑:如果是批量插入,VALUES() 函数在 MySQL 8.0.20 之后被标记为不推荐使用了,官方建议用别名方式:
sql复制INSERT INTO score (student_id, course_id, score, exam_time) VALUES
(1, 101, 92.0, NOW()),
(2, 102, 85.5, NOW()),
(3, 103, 77.0, NOW())
AS new
ON DUPLICATE KEY UPDATE score = new.score, exam_time = new.exam_time;
第三种,用 REPLACE INTO,我先删除旧行再插入新行。这种方式副作用很大,它会触发 DELETE,导致自增 ID 变化,外键关联也可能出问题,能不用就不用。
3.3 插入数据时容易忽视的字段与索引细节
数据库迁移时业务里常见一个问题:往表里插数据,不指定列名,直接按表结构顺序写值,例如:
sql复制INSERT INTO student VALUES (1, '20240001', '张三', 1, '2002-05-11', NOW(), NOW());
这样写很危险。只要表结构一调整,比如新增一个字段、调整顺序,这条 SQL 就废了,而且数据错位的可能性极大。任何时候都要明确列出字段名。另一个容易忽略的点是时间字段。如果业务代码里用 new Date() 拼到 SQL 里,要注意 JDBC 的时区参数,不然数据库存的时间和实际时间差 8 个小时。更省心的做法是让数据库自己管理创建时间,在 DDL 里用 DEFAULT CURRENT_TIMESTAMP 兜底。
4. 查询数据:SELECT 的进阶玩法与性能陷阱
查询是 CRUD 里最常用、也最考验内功的一环。增删改的语法相对固定,但查询会有各种需求变化:多表关联、聚合统计、分页、模糊搜索、排序、去重。写得不好就是慢查询,用户一多服务器直接扛不住。
4.1 基本查询与 WHERE 条件的使用原则
最简单的 SELECT * FROM student WHERE id = 1 谁都会。但生产环境我强烈建议不要用 SELECT *,只查需要的字段。原因很直接:第一,表里可能有 TEXT、BLOB 这种大字段,SELECT * 会把它们全捞出来,白白消耗内存和网络带宽;第二,没有覆盖索引时,SELECT * 很容易导致回表,影响执行效率;第三,业务发展后字段越来越多,全字段查询会把根本用不到的列也传出去。
WHERE 条件的写法也很有讲究。你自己想想有没有写过这样的 SQL:
sql复制SELECT * FROM score WHERE score + 5 > 90;
这种写法在字段上做了计算,会导致索引失效,因为 MySQL 无法直接使用 idx_score 去快速定位,只能把每一行的 score 都算一遍才知道结果,也就是全表扫描。应该改成:
sql复制SELECT * FROM score WHERE score > 85;
同理,避免对索引字段使用 LIKE '%关键字%',因为前导通配符会让索引失效。搜索场景如果必须做模糊匹配,可以考虑全文索引或者让搜索组件去扛,不要在核心业务表上这么干。
4.2 排序、分页与去重的实用技巧
排序用 ORDER BY,分页用 LIMIT offset, size,这是基础操作。但分页有个深坑:当 OFFSET 很大时,性能会急剧下降。比如:
sql复制SELECT * FROM score ORDER BY id LIMIT 1000000, 20;
MySQL 需要先读取前 1000020 行,然后把前 1000000 行扔掉,只返回后面 20 行。越到后面,越慢。优化方式一般有两种。第一种是延迟关联:
sql复制SELECT s.* FROM score s
JOIN (SELECT id FROM score ORDER BY id LIMIT 1000000, 20) t ON s.id = t.id;
先只查主键,拿到那 20 个 id,再回原表取行,压力就小很多。第二种是记住上一页最后一条记录的位置,用条件翻页:
sql复制SELECT * FROM score WHERE id > 1008500 ORDER BY id LIMIT 20;
这种方案只适合按主键或唯一键排序的场景,但是性能极佳,页数再大也不怕。
去重用 DISTINCT 还是很常见的。关于 OR 能不能去重,严格说要看语境。SELECT DISTINCT student_id, course_id FROM score 是对多条字段的组合去重,不是只对学生 ID 去重。OR 本身没有去重能力,需要把 DISTINCT 放在 SELECT 后面才能对整个结果集做去重操作。举一个容易踩坑的例子:
sql复制SELECT student_id FROM score WHERE course_id = 101 OR course_id = 102;
如果同一名学生考了 101 和 102 两门课,这个查询会返回两条相同的 student_id。要拿到不重复的学生列表,就得写 SELECT DISTINCT student_id FROM score WHERE course_id IN (101, 102)。
4.3 多表关联查询:INNER JOIN 与 LEFT JOIN 的选型逻辑
成绩管理要展示学生姓名、课程名称、成绩,就需要关联三张表。最常用的 JOIN 是 INNER JOIN 和 LEFT JOIN。到底用哪个,取决于你希望结果集包含哪张表的全部记录。
用 INNER JOIN 表示只返回交集:
sql复制SELECT st.name, c.course_name, sc.score
FROM score sc
JOIN student st ON sc.student_id = st.id
JOIN course c ON sc.course_id = c.id
WHERE sc.score >= 60;
用 LEFT JOIN 表示左表是主表,右表没有匹配记录时补 NULL:
sql复制SELECT st.name, c.course_name, sc.score
FROM student st
LEFT JOIN score sc ON st.id = sc.student_id
LEFT JOIN course c ON sc.course_id = c.id;
这个查询会返回所有学生,哪怕他没有任何成绩记录,成绩和课程显示为 NULL。如果你的业务是“列出所有学生及其成绩”,就必须用 LEFT JOIN。如果只要“有成绩的学生”,用 INNER JOIN。搞错方向,结果集的行数就会和预期差一大截。
还有一点,JOIN 本身不慢,慢在没有合适的索引。关联条件的字段上如果没有索引,MySQL 每查一行都得去另一张表全表扫一遍,这谁顶得住。所以外键字段必须建索引,这也是我在建表时给 score 表的 student_id、course_id 都建了普通索引的原因。
4.4 聚合查询与 GROUP BY 的隐藏知识点
“统计每个学生的平均成绩”“统计每门课程的选课人数”,这类需求要使用聚合函数加 GROUP BY:
sql复制SELECT student_id, ROUND(AVG(score), 2) AS avg_score, COUNT(*) AS course_count, MAX(score) AS best_score
FROM score
GROUP BY student_id
HAVING COUNT(*) >= 3
ORDER BY avg_score DESC;
这里有个容易混淆的点:WHERE 在分组前过滤,HAVING 在分组后过滤。如果筛选条件是“只看成绩大于 60 分的记录再分组”,用 WHERE score > 60;如果筛选条件是“分组后平均分大于 80 的组”,用 HAVING AVG(score) > 80。
官方文档里有一条非标准但默认开启的机制:ONLY_FULL_GROUP_BY 模式。在这个模式下,SELECT 列表里出现的非聚合字段必须出现在 GROUP BY 里。比如:
sql复制SELECT student_id, name, AVG(score) FROM score GROUP BY student_id;
如果 name 不在 GROUP BY 里,并且 name 和 student_id 之间没有函数依赖,这条语句在严格模式下会直接报错。你在学习阶段报这个错不用慌,把非聚合字段加进 GROUP BY 或者去掉即可。
5. 修改与删除数据:UPDATE 和 DELETE 的正确姿势
很多人觉得 UPDATE 和 DELETE 不就是一个 SET、一个 WHERE 的事吗?但线上出问题最多的恰恰是这两类语句。
5.1 UPDATE 语法与不踩雷的 WHERE 条件编写
UPDATE 的基本语法是:
sql复制UPDATE student SET name = '张一二' WHERE student_no = '20240001';
它支持一次更新多个字段:
sql复制UPDATE student SET name = '张一二', gender = 2, birthday = '2003-01-01' WHERE student_no = '20240001';
这里第一原则就是:UPDATE 语句必须带 WHERE,除非你明确要更新全部行。为了防止手滑,很多公司会规定生产环境的变更 SQL 必须先经过审核工具,或者在代码层面对 UPDATE 加条件检查。
第二个容易踩坑的地方是:更新条件用的字段,要么是主键,要么是索引字段。如果 WHERE 用的是没有索引的字段,更新时 MySQL 会先全表扫描找到目标行,数据量一大就会把表锁住(或者更新大批行造成长时间锁等待),影响所有读写。
第三个坑是关于数字加减。热搜里有个 mysql中int+5,表达的就是这种需求:把某个字段的值在原基础上加 5。有些新人会先 SELECT 出来,在代码里加完再 UPDATE 回去。实际上 SQL 里可以直接写:
sql复制UPDATE score SET score = score + 5 WHERE student_id = 1 AND course_id = 101;
这里 score = score + 5 是让数据库自己完成“读旧值、算新值、写回”的原子操作。用这种方式比先查后改安全得多,能避免并发下覆盖更新。
UPDATE 还可以配合 JOIN 一起用,在关联表中更新目标表。比如把某学生的所有成绩置零:
sql复制UPDATE score sc
JOIN student st ON sc.student_id = st.id
SET sc.score = 0
WHERE st.student_no = '20240001';
这种写法在需要根据业务条件跨表更新时很好用。
5.2 DELETE 的几种删除策略与软删除建议
DELETE 的语法和 UPDATE 很像:
sql复制DELETE FROM score WHERE student_id = 1 AND course_id = 101;
如果你要清空整张表,直接 DELETE FROM student 会逐行删除,速度慢,而且自增 ID 不会重置。更快的是 TRUNCATE TABLE student,它会把表结构保留、数据清空,自增 ID 也会从 1 重新开始。但 TRUNCATE 无法带 WHERE,也不能在事务里完全回滚(严格说它会隐式提交),所以使用场景很有限。
最想提醒大家的一点是:业务系统里的删除,大多是软删除,不是物理删除。软删除的思路是给表加一个 is_deleted 或者 deleted_at 字段,删除的时候执行 UPDATE:
sql复制UPDATE student SET deleted_at = NOW() WHERE id = 1;
查询的时候默认带一个过滤条件:
sql复制SELECT * FROM student WHERE deleted_at IS NULL AND id = 1;
为什么要这样?因为物理删除的数据无法找回。用户手滑删错了、运营误操作,如果数据直接没了,恢复成本极高。软删除只是打个标记,哪天想恢复数据,把 deleted_at 置空就回来了。当然软删除也不是没有缺点:查询必须带上删除标记过滤;唯一索引也要考虑被删除的历史记录可能会干扰唯一性。所以设计软删除时,可以把唯一索引设计成“业务唯一字段 + deleted_at”组合,甚至用 deleted_at 为 NULL 的多列唯一索引(MySQL 允许 NULL 重复),但要提前想清楚。
还有一种情况需要用 DELETE 配合 JOIN 跨表删除,比如删除某个学生的所有考试记录:
sql复制DELETE sc FROM score sc
JOIN student st ON sc.student_id = st.id
WHERE st.student_no = '20240003';
在写 DELETE 之前,我的习惯是先把 SELECT 写出来,查一遍确认你要删的范围对不对,再改成 DELETE。这一步看着多花几秒钟,但它能帮你拦住 90% 的误删。
5.3 事务与锁:确保修改和删除的安全底线
单表几条数据的增删改,可能感受不到事务的价值。但一旦涉及资金、库存、订单这类强一致场景,事务就是底线。比如给学生转班,既要更新学生的班级字段,又要删除旧班级关联,再插入新班级关联。这三步必须要么都成功,要么都失败,不能执行一半。用事务包起来:
sql复制START TRANSACTION;
UPDATE student SET class_id = 2 WHERE id = 1;
DELETE FROM class_student_mapping WHERE student_id = 1 AND class_id = 1;
INSERT INTO class_student_mapping (student_id, class_id) VALUES (1, 2);
COMMIT;
如果中间某一步出了问题,就 ROLLBACK; 让所有操作回滚。
事务能保证一致性,靠的是锁。InnoDB 在 UPDATE 和 DELETE 时会锁住涉及的行。如果两个事务同时改同一行,后发起的操作会阻塞等待,直到前面的事务提交或回滚。这就是热词里“mysql锁表”的来源之一。很多人遇到“CPU 不高,但所有 UPDATE 都卡住”,大概率就是某条事务忘了提交,把行锁或间隙锁一直攥在手里。
排查这类问题有个固定套路:用 SHOW PROCESSLIST; 看当前线程状态,有没有大量线程处于 Waiting for lock 状态;再查 information_schema.innodb_trx、innodb_locks、innodb_lock_waits 找出持有锁的事务 ID,然后根据 SQL 判断去 kill 或者让业务侧回滚。
6. 进阶实战:索引如何影响增删改查的性能
聊增删改查绕不开索引,因为索引直接决定了 SELECT 能不能快起来,同时也决定了 INSERT、UPDATE、DELETE 会不会引入额外开销。很多初学者不理解索引的意义,我就用一本书来打比方。
6.1 为什么查询要走索引,索引又为什么会让增删变慢
你找一本书里的某个知识点,如果你有目录,几秒钟就能翻到对应页码;如果没有目录,只能从头到尾翻,运气好翻得快,运气不好要翻完全本。索引就是数据库的目录。
MySQL 默认 InnoDB 引擎用的是 B+ 树索引。主键索引的叶子节点存的是整行数据,所以通过主键查一行,速度极快。非主键索引(二级索引)的叶子节点存的是主键值,查询时先通过二级索引找到主键,再回到主键索引拿完整行数据,这个过程叫回表。如果查询需要的列都在二级索引的叶子节点里,就不用回表,这种索引叫覆盖索引,效率更高。
但是索引不是免费的。每建一个索引,在 INSERT 时要额外写一份索引数据,在 UPDATE 更新了索引字段时也要调整索引结构,在 DELETE 时要删除对应的索引记录。所以“索引越多写越慢”是真实存在的。生产环境的设计原则是:不要给每个字段都加索引,只给查询条件中高频使用的字段建索引,同时控制单表索引数量。
6.2 用一个简单命令定位 SELECT 有没有走对索引
想确认一条 SELECT 是否走了索引,有两种办法。
第一种是 EXPLAIN:
sql复制EXPLAIN SELECT * FROM score WHERE student_id = 1 AND course_id = 101;
注意看 type 列和 key 列。如果 type 是 ALL,说明是全表扫描,没走索引;如果 type 是 ref 或者 const,说明用了索引;如果 type 是 range,说明走了范围扫描,也还可以。key 列会显示实际使用的索引名称。
常见索引失效的姿势有:对索引字段使用函数或运算(如 WHERE YEAR(created_at) = 2024);隐式类型转换导致索引失效(如 WHERE student_no = 1001,但 student_no 是 VARCHAR);LIKE '%xxx' 类型的前导模糊查询;用 OR 连接条件时,如果其中一个字段没有索引,可能导致整体不走索引。
第二种是打开慢查询日志,把执行时间超过阈值的 SQL 抓到日志里:
sql复制SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
然后通过分析慢日志,找到耗时 TOP 的 SQL,逐一用 EXPLAIN 检查执行计划。这是我日常调优最常用的方法。尤其是数据量上了百万之后,慢查询基本都出在没走索引的多表 JOIN、大范围排序、深分页这几类场景。
7. 高频场景实战:用 CRUD 完成一个成绩管理闭环
纸上谈兵差不多了,我带着你把一个真实的场景完整做一遍:录入学生、课程和成绩,查询成绩报表,修改成绩,删除记录。整个过程能体现前面所有知识点的串联。
7.1 初始化基础数据
先往学生表里插入 3 个学生:
sql复制INSERT INTO student (student_no, name, gender, birthday, created_at, updated_at) VALUES
('20240001', '张三', 1, '2002-03-10', NOW(), NOW()),
('20240002', '李四', 2, '2002-07-22', NOW(), NOW()),
('20240003', '王五', 1, '2003-01-30', NOW(), NOW());
再往课程表里插入 3 门课:
sql复制INSERT INTO course (course_no, course_name, credit) VALUES
('CS101', '数据库原理', 3.0),
('CS102', '数据结构', 4.0),
('CS201', '计算机网络', 3.5);
然后录入成绩:
sql复制INSERT INTO score (student_id, course_id, score, exam_time) VALUES
(1, 1, 88.0, '2024-06-20 10:00:00'),
(1, 2, 92.5, '2024-06-21 14:00:00'),
(2, 1, 76.5, '2024-06-20 10:00:00'),
(2, 2, 81.0, '2024-06-21 14:00:00'),
(3, 3, 69.0, '2024-06-22 09:00:00');
7.2 查询成绩报表并处理分页排序
需求:列出所有学生的成绩单,包括姓名、课程名、成绩,成绩倒序排列,每页 3 条。
sql复制SELECT st.name AS 姓名, c.course_name AS 课程, sc.score AS 成绩
FROM score sc
JOIN student st ON sc.student_id = st.id
JOIN course c ON sc.course_id = c.id
ORDER BY sc.score DESC
LIMIT 0, 3;
第一页结果就是张三的数据结构 92.5、李四的数据结构 81.0、李四的数据库 76.5。翻页时把偏移量改成 3 就行。
如果数据量很大,记得用前面讲的延迟关联优化分页,避免大偏移量把数据库拖垮。
7.3 修改成绩并保证可追溯
需求:张三在“数据库原理”这门课上有一次补考,成绩由 88.0 改成 91.0,同时保留补考时间。
为了可追溯,成绩表可以加一个 retake_count 字段,或者单独建一张成绩变更日志表。这里简单演示 UPDATE + 唯一键冲突时更新的场景,直接用:
sql复制UPDATE score SET score = 91.0, exam_time = NOW()
WHERE student_id = 1 AND course_id = 1;
如果业务设计成“补考记录也插入一条新成绩”,那就要用 INSERT ... ON DUPLICATE KEY UPDATE 或者增加一个考试批次字段,不能靠覆盖。覆盖更新的前提是业务允许旧值覆盖,比如成绩误录改正。如果有审计需求,一定要有操作日志表。
7.4 删除一门选课记录
需求:王五退掉了“计算机网络”这门课,从成绩表里删除。
sql复制DELETE FROM score WHERE student_id = 3 AND course_id = 3;
删除前先查:
sql复制SELECT * FROM score WHERE student_id = 3 AND course_id = 3;
确认只有一条目标记录,再执行删除。如果设计采用软删除,把这条 DELETE 换成 UPDATE score SET deleted_at = NOW() WHERE ... 就行。
8. 常见问题与排查技巧实录:把 CRUD 期间的坑一次填平
这一部分我整理一下新手和中级开发者最容易碰到的问题,每一条都是我见过或者踩过的,属于“如果早知道能省半天时间”的类型。
8.1 CRUD 高频报错速查表
| 报错或问题 | 原因 | 解决办法 |
|---|---|---|
Unknown column 'xxx' in 'field list' |
字段拼写错误或表结构里没有这个列 | 用 DESC 表名; 或 SHOW CREATE TABLE 表名; 查看表结构 |
Duplicate entry '...' for key 'uk_xxx' |
唯一索引冲突 | 用 INSERT ... ON DUPLICATE KEY UPDATE 处理,或者程序里先排查重复数据 |
Data too long for column |
字符串超长 | 修改列长度,如 ALTER TABLE student MODIFY COLUMN name VARCHAR(100); |
Incorrect integer value: '' for column 'gender' |
空字符串被插入整型字段 | 插入前做数据校验,把空字符串转成 null 或者合法值 |
Lock wait timeout exceeded |
事务持锁过久或死锁 | 检查长事务,及时 COMMIT/ROLLBACK,必要时 kill 阻塞事务 |
Client does not support authentication protocol requested by server |
MySQL 8.0 默认认证插件与客户端不兼容 | 升级驱动,或改用户认证插件为 mysql_native_password |
Data too long for column 'score' |
数值超范围 | 检查 DECIMAL 精度,比如 DECIMAL(5,2) 只能存 999.99 以内 |
ORDER BY clause is not in GROUP BY |
ONLY_FULL_GROUP_BY 模式限制 | 按规则调整 GROUP BY 或聚合函数 |
8.2 增删改查性能优化清单
我每次做项目评审,都会按这么几项过一遍 SQL:
- WHERE 条件字段有没有索引?没索引的 WHERE 在十万级以上数据就是灾难。
- SELECT 有没有多查不必要的列?顺手把
SELECT *改成显式字段。 - 有没有在索引字段上做计算或者函数转换?有就改写成范围条件。
- 分页深不深?深分页有没有用延迟关联或游标方式优化。
- 批量 INSERT 是否过小或过大?调整到 500~1000 行一批。
- UPDATE 和 DELETE 是否缺 WHERE?逐条检查。
- 多表 JOIN 的关联字段是否有索引?执行计划 EXPLAIN 看一眼。
- 事务长度是否过长?一个事务里尽量不要放 RPC 调用、图片处理、大量循环操作。
8.3 和 MongoDB、Qdrant 等其它数据组件的增删改查有啥不一样
热搜词里出现了 mongodb增删改查、qdrant的增删改查,正好顺嘴说一下。MongoDB 是文档型数据库,集合存的是 JSON 文档,增删改查的语法是 INSERT/UPDATE/DELETE/FIND,但字段是动态的,没有严格的表结构约束,性能在非关系型数据的场景下更灵活。Qdrant 则是向量数据库,主要做相似度检索,它的“增删改查”核心是 upsert 向量、查询最近邻,返回的结果是相似度分数,而不是精确匹配的行。
MySQL 的关键词是“关系”,强调的是表与表之间的关联、事务一致性、SQL 的通用性。如果你做的是订单、财务、用户中心这类强一致业务,MySQL 依然是第一选择。如果你做的是全文搜索、日志存储、向量召回,就另选合适的组件,不要在 MySQL 上硬扛所有场景。
9. 个人实操总结:把基础动作练成肌肉记忆
我见过很多同事写 CRUD 时,眼里只有“跑通”,没有“跑好”。比如数据量大了才想起来加索引,被慢查询告警打了脸才去优化 SQL;或者上线前不加 WHERE 直接全表更新,半夜被电话叫起来救火。这些问题不是智商不够,而是基础动作没有形成肌肉记忆。
我自己的体会是,增删改查这件事,值得反复练到不用思考就能写出安全语句。每次写 UPDATE、DELETE 之前,先写 SELECT 确认范围;每次写完批量 INSERT,先确认唯一索引的冲突策略;每次上线新查询接口,看一眼 EXPLAIN;每次改动表结构,想一下有没有影响现有 CRUD 的执行计划。这套习惯看着笨,但就是它们能帮你避免绝大多数低级事故。
再分享一个小技巧:你把 MySQL 官方文档里 INSERT、SELECT、UPDATE、DELETE 四段语法通读一遍,再对着真实业务表把所有子句(LIMIT、JOIN、GROUP BY、HAVING、ON DUPLICATE KEY UPDATE等)各写一遍,基本功就比一半以上的人都扎实了。SQL 没有什么捷径,就是多写、多错、多复盘。希望这篇能帮你在动手练习的时候少走几趟弯路。
