做后端开发的这些年以来,MySQL 的增删改查应该是我写过的数量最多的一类 SQL,也是面试里被问得最没脾气的基础题。但恰恰是这类"人人都会"的操作,线上事故率反而最高:有人一条 UPDATE 忘了 WHERE 把整表状态改错,有人 DELETE 大表把数据库锁到报警,还有人插入时没处理唯一键冲突导致业务数据重复。这篇内容不打算把增删改查的语法从头抄一遍,而是从一张真实业务表出发,把插入、查询、更新、删除背后那些"写对"的细节、容易翻车的场景以及我自己的处理套路完整讲一遍。适合刚入门 MySQL 的读者建立正确习惯,也适合写过一段时间但总在边缘试探的开发者对照自查。
1. 为什么号称最简单的增删改查,事故率反而最高
1.1 语法都会,但"写对"是另一回事
增删改查这四个动作,本质上是任何业务系统的数据生命周期:新增一条记录,读取它,修改它,最后删除它。几乎所有 Java、Python、Go 项目都在做这件事。可一旦把"会写 SQL"等同于"能做好数据操作",问题就来了。
我见过不少把 INSERT 当垃圾桶的写法:不管数据有没有重复,先插进去再说,等到列表页出现两条一模一样的订单才去补去重逻辑;也见过把 DELETE 当橡皮擦的玩法,业务上要"删掉这个用户",就直接物理 DELETE,结果关联表里一堆外键孤儿数据,统计报表全乱套。
这些问题的共性是什么?是只把增删改查当成了"语句",没把它当成"数据操作流程"。一条简单的 INSERT 背后其实藏着几个决策点:你要不要做唯一性校验?要不要捕获主键冲突?批量插入时是一次提交还是循环单条提交?这些问题不思考清楚,光靠语法正确,上线以后照样会把数据库搞出各种状况。
1.2 增删改查真正在考的是数据安全意识
判断一个人是不是真的会用 MySQL 增删改查,我通常不看语法,而是看几个问题:
- 插入前,你想过唯一键冲突吗?还是赌运气?
- 查询时,你只关心能不能查出数据,还是关心走没走索引、回表几次?
- 更新时,你的 WHERE 条件是否足够严谨?有没有先 SELECT 确认过范围?
- 删除时,你分得清物理删除、逻辑删除、TRUNCATE 的适用场景吗?
这些问题的答案,决定了你所写的 CRUD 是"测试环境能用"还是"生产环境可靠"。接下来,我会按照一张实际业务表从建表到删数据的完整链路,逐一展开。
2. 建表:所有增删改查体验的源头
2.1 表结构是 CRUD 的地基
很多人写增删改查的第一反应是打开 Navicat 或 DataGrip 直接建表,然后对着业务接口一顿操作。但我建议你反过来:先想清楚这张表未来几年会被怎么增、怎么查、怎么改、怎么删,再动手写 CREATE TABLE。
因为表结构直接决定了后续所有 CRUD 的写法和效率。举个最直观的例子:如果用户表的主键用的是 VARCHAR 手机号,那么所有外键关联都会继承这个宽主键,二级索引体积会明显大于 BIGINT 主键,插入和查询速度都会受影响;如果字符集用了 latin1,存中文没问题,但要存 emoji 就会报错。这些问题在写增删改查语句之前就已经埋下了,后面怎么优化 SQL 都只是打补丁。
2.2 一张用户表的建表 SQL 与字段设计细节
我这里用一张最典型的用户表来演示,包含账号、昵称、邮箱、状态、创建时间和更新时间。
CREATE TABLE `user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称', `email` VARCHAR(100) NOT NULL COMMENT '邮箱', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1-正常 0-禁用', `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_username` (`username`), KEY `idx_email` (`email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';这里有几个值得说道的细节:
- 主键用 BIGINT UNSIGNED 自增,而不是用业务字段。自增主键在 InnoDB 下是聚簇索引,插入时顺序递增,页分裂概率低,写入性能稳定;业务字段做主键一旦将来变更规则,改动成本极高。
- 字符集必须 utf8mb4。MySQL 的 utf8 实际上最多只能存 3 字节,遇到 emoji 或者某些生僻字会直接写入失败,所以新表一律 utf8mb4,排序规则用 utf8mb4_unicode_ci 或更新一点的 utf8mb4_0900_ai_ci 都可以。
- 唯一索引提前建。用户名这种天然有唯一性要求的字段,一定要用 UNIQUE KEY 兜底。这不是给插入添麻烦,而是给并发场景上保险,后面讲 INSERT 时还会继续聊。
- created_at 和 updated_at 用 DEFAULT 自动维护。ON UPDATE CURRENT_TIMESTAMP 可以让你在更新行时不用手写更新时间,省掉很多应用层代码。
2.3 建表后立刻做这几件事
建表不是写完 DDL 就结束了,我习惯立刻执行几条语句确认表结构:
SHOW CREATE TABLE `user`; DESC `user`; SHOW INDEX FROM `user`;SHOW CREATE TABLE 能帮你确认最终的建表语句是否符合预期,DESC 看字段顺序和类型,SHOW INDEX 检查索引是否生效。这一步看起来多余,但能提前发现字符集被库级别默认值覆盖、索引没建上等奇葩问题,比上线后翻车强得多。
3. INSERT:不只是往里塞数据
3.1 单条插入与批量插入的性能差异
INSERT 是最容易被低估的操作。新手阶段我经常在循环里逐条插入,插入一万条数据要执行一万次 SQL,每次都要做网络往返、SQL 解析、事务提交,慢得让人怀疑数据库是不是坏了。
批量插入的效率要高得多:
INSERT INTO `user` (`username`, `nickname`, `email`, `status`) VALUES ('zhangsan', '张三', 'zhangsan@example.com', 1), ('lisi', '李四', 'lisi@example.com', 1), ('wangwu', '王五', 'wangwu@example.com', 1);把多条记录合并成一条 INSERT 语句,一次网络往返就能完成,InnoDB 还可以针对连续插入做优化。实测下来,同样一万条数据,逐条插入可能需要几十秒,批量插入往往一秒内就能完成。
但批量插入也不是越大越好。一次塞几万条会导致单个事务过大,undo log 膨胀,还容易长时间占用表级锁或行锁,把其他业务的 CRUD 拖慢。我一般建议单批控制在 500 到 1000 条,具体情况根据行宽调整。行宽大、字段多的表,批大小就得调小。
3.2 主键或唯一键冲突时的三条路
插入时最经典的问题就是:记录可能已经存在,你怎么办?三种常见做法各有各的适用场景。
| 方案 | 行为 | 适用场景 |
|---|---|---|
| INSERT IGNORE | 冲突时静默忽略,不报错也不更新 | 只想要"不存在才插入"的场景 |
| INSERT ... ON DUPLICATE KEY UPDATE | 冲突时执行指定的更新 | 需要"存在则更新,不存在则插入"的 upsert 场景 |
| REPLACE INTO | 先删旧行再插新行 | 极少用,会触发删除,可能带来额外的锁和自增消耗 |
举例说明。注册接口里要保证用户名唯一,如果用户已存在,想直接提示"已注册",用 INSERT IGNORE 配合受影响行数判断最合适:
INSERT IGNORE INTO `user` (`username`, `nickname`, `email`, `status`) VALUES ('zhangsan', '张三', 'zhangsan@example.com', 1);执行后检查 MySQL 返回的 affected rows:1 表示插入成功,0 表示冲突被忽略。这种方式比先 SELECT 再 INSERT 更可靠,因为 SELECT 和 INSERT 之间存在时间差,并发场景下两个请求可能同时通过查重,然后都去插入,最终还是靠唯一索引兜底报错。
而 upsert 场景则直接使用 ON DUPLICATE KEY UPDATE:
INSERT INTO `user` (`username`, `nickname`, `email`, `status`) VALUES ('zhangsan', '张三', 'zhangsan@example.com', 1) ON DUPLICATE KEY UPDATE `nickname` = VALUES(`nickname`), `email` = VALUES(`email`);这里有一个 MySQL 8.0.20 之后的注意点:VALUES() 函数在行值语法里已经被标记为 deprecated,官方推荐使用别名方式,写法是AS new_user配合new_user.nickname。如果项目版本较新,建议直接用新语法,避免将来升级时收到一堆警告。
3.3 REPLACE INTO 为什么我不推荐
有些教程喜欢用 REPLACE INTO 做"存在就更新",我建议谨慎。它内部实现是先 DELETE 旧记录再 INSERT 新记录,这意味着:
- 会触发外键的级联删除
- 删除和插入是两步操作,锁范围更大,并发性能更差
- 自增主键的 ID 会被消耗掉,造成主键跳跃
大部分"存在则更新"的需求,用 ON DUPLICATE KEY UPDATE 就够了,它是在原行上做更新,不需要销毁重建。这是我踩过几次 REPLACE 的坑之后换过来的结论,尤其是业务里有审计日志、有外键关联的表,REPLACE 的破坏力是隐形的。
4. SELECT:查询的效能往往在写 SQL 之前就定了
4.1 查询不只是"能查出数据"
SELECT 是增删改查里出现频率最高的操作,但也是很多性能问题的起点。我见过的低效查询五花八门:SELECT * 把几十个字段全查出来;WHERE 条件里对索引列做函数运算导致索引失效;分页查询用 LIMIT 100000, 20 越翻越慢。
先说字段选择。在业务代码里明确列出需要的列,而不是 SELECT *,有几点实际收益:减少网络传输字节数、避免把不需要的大字段(比如 TEXT、BLOB)拖进内存、也能让代码审查的人一眼看出你拿到了哪些数据。只有排查问题时我才会上 SELECT * 快速看全貌,正常业务 SQL 一定写明确列。
再看 WHERE 写法。索引列一旦参与函数运算或隐式类型转换,优化器很可能放弃索引。举例:
-- 这会全表扫,因为对 create_at 用了 DATE 函数 SELECT * FROM `order` WHERE DATE(create_at) = '2024-01-01'; -- 这能走索引 SELECT * FROM `order` WHERE create_at >= '2024-01-01 00:00:00' AND create_at < '2024-01-02 00:00:00';同样的查询意图,第二种写法既不影响正确性,又能利用索引做范围扫描。养成"不在索引列上做无谓包装"的习惯,很多慢查询能少一大半。
4.2 用 EXPLAIN 快速判断查询质量
写一条 SELECT 之后,我几乎强迫自己执行一次 EXPLAIN,这比拍脑袋猜"应该走索引了吧"可靠得多:
EXPLAIN SELECT id, nickname, email FROM `user` WHERE email = 'zhangsan@example.com';重点看几个关键字样的列:
- type:从好到差依次是 system、const、eq_ref、ref、range、index、ALL。出现 ALL 就说明全表扫描,要警惕。
- key:实际使用的索引名,为 NULL 说明没走索引。
- rows:优化器估计要扫描的行数,这个数字和实际扫描量越接近,性能预期越准。
- Extra:出现 Using filesort 或 Using temporary 时,通常意味着排序或去重用到了临时文件,数据量大时性能会很难看。
EXPLAIN 不是银弹,它的 rows 是估算值,不一定精确,但用来排查慢查询、判断 SQL 改写方向已经足够了。我处理线上慢查询的第一动作,永远是先拿慢 SQL 去 EXPLAIN 一遍,看是不是没走索引,再考虑要不要改 SQL。
4.3 JOIN 和子查询的取舍没有绝对答案
多表查询是 SELECT 里最容易产生认知偏差的地方。有些人信奉"一律不用 JOIN",遇到跨表就拆成多次查询在应用层组装;也有人反过来"万事 JOIN",把十几张表连成一大坨。
我的经验是:先看数据量和关联方式,再看索引情况,不要走极端。两张表各自有索引的小表关联,JOIN 完全没问题;但如果一张表几千万行、一张表几万行,JOIN 顺序和驱动表选错,代价会非常大。实际工作里,我更常做的是对复杂业务的查询先小数据集验证结果集,再用 EXPLAIN 看执行计划,必要时用 STRAIGHT_JOIN 或子查询改写来调整关联顺序。
子查询也分情况。MySQL 8.0 的优化器对子查询的优化能力比 5.7 强很多,但并非所有子查询都被优化得很完美。比如 IN 后面跟一个大表的子查询,数据量上涨后效率可能明显下降,改写为 JOIN 反而更高效。碰到具体问题建议用 EXPLAIN 对比改前改后的执行计划,数据说了算。
5. UPDATE:更新数据时,先想后果再动手
5.1 忘记 WHERE 条件的典型现场
更新操作出事故的概率在四个操作里排第一,原因很简单:INSERT 的后果是多了条数据,查询的后果是慢,删除的后果一般有提醒,而 UPDATE 一旦漏了 WHERE,影响面是全部行,有时候直到数据错得离谱才被发现。
我印象很深的一次故障:同事想更新某个测试账号的状态,写的是UPDATE user SET status = 0 WHERE username = 'test01',但执行的时候发现测试库里这个用户已经被删掉了,症状是"更新了 0 行,任务没报错但数据没变"。于是他加了句"算了,直接更新所有测试账号吧",SQL 改成UPDATE user SET status = 0 WHERE username LIKE 'test%'。结果执行时忘了加用户名条件,整张表的用户状态全部变成禁用。那次事故虽然没有线上影响,但给团队上了一课:UPDATE 前必须先在测试环境执行同条件 SELECT,确认影响行数。
5.2 四条保命法则
我个人总结的 UPDATE 安全守则如下:
- 先 SELECT 后 UPDATE:先用完全相同的 WHERE 条件执行 SELECT,确认要影响的行数,再去 UPDATE。
- WHERE 条件必须收窄:能用主键、唯一索引定位就不要用模糊条件,范围越大风险越高。
- 搭配 LIMIT 控制影响行数:如果只打算更新一条,就写
UPDATE ... WHERE ... LIMIT 1。MySQL 的 UPDATE 是支持 LIMIT 的,这能兜住"条件写宽了"的情况。 - 观察 affected rows:执行完看一眼受影响行数,和预期对不上就立刻回查数据。
5.3 批量更新的温和方案
批量更新常见需求是"根据一组 ID 更新各自不同的字段值"。有些人会写循环一条条 UPDATE,性能差;也有人试图拼一条超级 UPDATE,用 CASE WHEN 实现:
UPDATE `user` SET `status` = CASE `id` WHEN 1 THEN 0 WHEN 2 THEN 1 WHEN 3 THEN 0 END WHERE `id` IN (1, 2, 3);这种写法只用一条 SQL 完成多条记录的差异化更新,比循环更新高效得多。但要小心:如果 WHERE 里的 ID 列表漏了某个 ID,那这个 ID 的 status 会被更新成 NULL,因为 CASE 没有匹配到任何分支时返回 NULL。所以写这种 SQL 必须保证 IN 列表和 CASE 分支完全覆盖,并且每条分支都有明确值。
对于更新量更大的场景(比如几十万行),一条 UPDATE 锁定的行太多,容易造成长时间锁等待。我一般会按主键分段循环更新,比如每次更新 1000 条,配合小事务提交,既能控制锁粒度,也能避免 undo log 暴涨。
6. DELETE:数据清理的边界感
6.1 DELETE、TRUNCATE、DROP 要分清楚
删除相关的命令有三兄弟,很多人混着用,实际上适用场景差别很大:
| 操作 | 特点 | 适用场景 |
|---|---|---|
| DELETE | DML,逐行删除,可加 WHERE,可回滚,不重置自增值 | 删除指定业务数据 |
| TRUNCATE | DDL,清空全表,速度极快,隐式提交不可回滚,重置自增值 | 清空临时表、快速重建空表 |
| DROP | DDL,删除整张表结构和数据 | 废弃不再需要的表 |
TRUNCATE 在某些场景下很诱人(比如清空日志表),但它有一个坑:它是隐式提交的,一旦执行无法通过事务回滚,而且会重置 AUTO_INCREMENT。如果只是要清数据但保留表结构给后续使用,一定要想清楚是否能接受不可回滚。
6.2 大表删除的隐蔽代价
DELETE 大表时真正的开销不只是删行本身,还涉及三个层面:
- undo log 膨胀:被删除的行需要记录 undo 信息以支持并发读,删几十万行会产生大量 undo,可能拖慢同一实例上的其他查询。
- 锁范围扩大:InnoDB 默认会对 DELETE 扫描到的行加锁,范围越大锁越多,可能出现锁等待甚至死锁。
- 产生碎片:物理删除后页内出现空洞,后续插入可能造成页分裂,表空间膨胀。
所以线上删除大量数据,我的做法是分批删:
DELETE FROM `user` WHERE `status` = 0 AND `id` < 10000 LIMIT 1000;每批删 1000 行左右,观察删除耗时和数据库负载,再循环执行,直到删除行数影响为 0。这一步看起来笨,但能显著降低对业务的影响。配合在业务低峰期执行,效果更好。
6.3 能被数据恢复兜底的软删除
业务系统里我越来越推荐软删除,也就是加一个字段标记删除状态,而不是物理 DELETE。最常用的是deleted_at字段,为 NULL 表示未删除,非空表示删除时间:
ALTER TABLE `user` ADD COLUMN `deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间';执行软删除只是更新:
UPDATE `user` SET `deleted_at` = NOW() WHERE `id` = 10086;以后所有业务查询默认带WHERE deleted_at IS NULL条件。软删除的优点是:数据还在,误删可以恢复;缺点是:所有查询都要记得加条件,漏加就会出现"已删除用户还出现在列表里"的 bug。为了防漏,我通常会在表上建一个联合索引或者视图,把"未删除"的过滤条件统一封装,让业务尽量走同一入口。
7. 事务:把多条 CRUD 变成一句"要么全成,要么全败"
7.1 为什么单条 UPDATE 也可能需要事务
我评估一条 SQL 是否需要事务,看的不是语句数量,而是逻辑上的原子性。比如转账这个经典例子:A 账户扣 100 元、B 账户加 100 元,这明明是两条 UPDATE,但它们必须同时成功或同时失败。如果不用事务,扣款成功、入账失败,账就对不上了。
就算是单条语句,也可能有隐含的多步逻辑。比如插入订单后还要更新库存,插入日志表后还要更新统计表。只要数据存在逻辑依赖,就应该用事务把它们包成一个整体。
7.2 隔离级别不是越严越好
MySQL InnoDB 默认隔离级别是 REPEATABLE READ,它通过多版本并发控制(MVCC)和间隙锁让普通 SELECT 不会被其他事务的未提交修改影响。理解隔离级别对写 CRUD 很重要,因为不同级别下你看到的数据一致性是不同的。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 基本不用 |
| READ COMMITTED | 否 | 可能 | 可能 | 多数其他数据库默认 |
| REPEATABLE READ | 否 | 否 | 在 InnoDB 下基本防住 | MySQL 默认 |
| SERIALIZABLE | 否 | 否 | 否 | 并发极低 |
业务开发里我几乎不会去改全局隔离级别,默认的 RR 在大多数场景下表现良好。但要注意一点:RR 下的当前读(比如 SELECT ... FOR UPDATE)会加锁,锁的范围可能包括条件涉及的间隙,如果两个事务以不同顺序更新相近的记录,比较容易出现死锁。遇到死锁别慌,错误码是 1213,MySQL 会回滚其中一个事务,业务层做好重试即可。
7.3 一个标准的业务事务示例
把用户下单过程中涉及的插入、更新操作包起来:
START TRANSACTION; INSERT INTO `order` (user_id, amount, status) VALUES (10086, 99.00, 0); UPDATE `user` SET `balance` = `balance` - 99.00 WHERE `id` = 10086; COMMIT;如果中途任何一步失败,执行 ROLLBACK,前面的操作全部回滚。这里有两个容易忽略的地方:
- 事务里不要夹杂远程调用:比如调用第三方支付接口、发短信通知,这些外部操作不受数据库事务控制,一旦外部成功而数据库回滚,会产生对不上的数据。正确做法是数据库事务先落账,再异步执行外部调用,并借助对账任务保证最终一致。
- 长事务是性能杀手:事务里开了事务却迟迟不提交,会一直持有锁、堆积 undo 日志,拖垮整个实例。写代码时尽量让事务体量小、时间短,不要在事务里做耗时的业务计算。
8. 几个印象深刻的线上故障复盘与自查清单
8.1 三个真实故障复盘
第一个故障,是更新条件导致的全表更新。背景是一个管理后台的批量禁用功能,前端传过来一组用户 ID,后端拼 SQL 时 ID 列表为空,代码里做了个if (ids.isEmpty()) return的判断。有一次参数传递异常,ids 为 null,代码走了另一个分支,直接把 WHERE 条件跳过了,一条UPDATE user SET status = 0就把全表用户禁用了。自那以后我要求所有更新接口的 SQL 参数必须做显式非空校验,并且在 DAO 层拒绝"无条件更新"的执行。
第二个故障,是删除历史数据引发的锁等待。当时想把半年前的日志数据清掉,直接跑DELETE FROM operation_log WHERE create_at < DATE_SUB(NOW(), INTERVAL 180 DAY)。日志表几千万行,这条 SQL 锁了大量行,运行了四分多钟,期间一堆业务请求被阻塞,数据库连接数飙到上限,最终直接影响了线上服务。后来改成按天分批删除,每次删 5000 行、间隔几秒,对业务影响降到了几乎可以忽略。
第三个故障,是批量插入把从库拖慢。数据导入任务用一条 INSERT 塞了五万行,主库执行得很快,但从库在应用 relay log 时压力过大,复制延迟飙到几百秒,导致一段时间内读请求查不到刚写入的数据。排查后把批大小调到 1000 行一批,从库延迟立刻恢复正常。批量大小真的不是越大越快,要综合主库性能、从库复制能力、网络带宽一起考虑。
8.2 我现在的 CRUD 自查清单
写了这么多,最后分享一套我每次上线前都会过一遍的自查清单,算是这些年用真金白银换来的习惯:
- INSERT:唯一键冲突有考虑吗?批量插入的批次大小合理吗?字符集能覆盖实际写入内容吗?
- SELECT:查询列明确了吗?WHERE 条件能走索引吗?EXPLAIN 的 type 达到 ref 或 range 了吗?分页大偏移有优化方案吗?
- UPDATE:先 SELECT 确认影响行数了吗?WHERE 条件足够收窄吗?有条件不用 LIMIT 兜底吗?涉及金额、库存的更新是原子表达式吗(比如
balance = balance - 100)? - DELETE:业务上能用软删除吗?物理删除有没有分批?TRUNCATE 之前确认过不可回滚吗?
- 事务:逻辑上需要原子性的操作都包进事务了吗?事务里有远程调用吗?事务体量足够短吗?
- 通用项:字段类型和业务值域匹配吗?时间字段有时区问题吗?字符集排序规则统一吗?
这套清单不一定覆盖所有场景,但能挡住大多数低级事故。增删改查之所以值得反复讲,是因为它是数据系统的最小单元,把每个单元都写稳了,上层业务才能睡得着觉。做 MySQL 开发这几年,我最大的体会就是:越基础的东西,越值得用敬畏心去写——一条 UPDATE 背后可能是整个公司的用户数据,一条 DELETE 背后可能是半年都找不回来的审计记录。把这些小事做严谨,比追求花哨的 SQL 技巧更能体现一个开发者的成熟度。