增删改操作——insert、delete、update——大概是每个写 SQL 的人最早学会、也最常写的东西。但有意思的是,恰恰是这三个最基础的语句,在实际项目里翻车最多:要么数据没写进去还没报错,要么 update 忘带 where 直接把整张表清空,要么一条 insert into select 把线上表锁死。我见过太多开发者在这些"简单"语句上花掉一整个通宵。
这篇内容我就围绕 insert、delete、update 三个核心语句,把标准语法、业务里的进阶玩法、容易踩的坑和排查思路完整过一遍。不管你是刚学 SQL 的新手,还是写了好几年代码但偶尔被数据操作坑一把的老手,都值得花几分钟对照检查一下自己的习惯。
1. 先把路走通:INSERT、DELETE、UPDATE 的标准姿势
很多人写增删改只停留在"能跑"的层面,但从没认真想过这几条语句的完整形态和边界。我建议先把标准姿势吃透,再谈业务优化。
1.1 INSERT 的完整面貌:单条、多条与字段省略
INSERT 最基本的形态是给指定的列插入值。MySQL 的语法大概是这样的:
sql复制INSERT INTO user (name, age, email) VALUES ('张三', 25, 'zhangsan@example.com');
这里有几个值得注意的点。
第一,字段列表不是必须的。你可以直接写成 INSERT INTO user VALUES (...),但这时候值必须按表结构的列顺序完整给出,少一个列就会报错。我的建议是无脑写字段列表,哪怕表只有两列也写上。原因很简单:一旦表结构调整过列顺序,不带字段列表的 insert 就可能把数据插到错误的列里,而且排查成本极高。
第二,一次性插入多行的能力。MySQL 支持一条 INSERT 语句同时写入多条记录:
sql复制INSERT INTO user (name, age, email) VALUES
('张三', 25, 'zhangsan@example.com'),
('李四', 30, 'lisi@example.com'),
('王五', 28, 'wangwu@example.com');
这种方式在减少客户端与数据库的交互次数、提高批量导入效率方面非常有效。如果你在循环里逐条 insert,性能会慢一个数量级,而且每一条都是一个独立事务,中途失败后很难整体回滚。
第三,INSERT 的原子性。一条 INSERT 语句无论插入多少行,要么全部成功,要么全部失败。这在批量插入时要尤其注意,因为如果中间某一行违反唯一约束,整个语句都会报错,前面插入的行也会一并回滚。
1.2 DELETE 不是想怎么删就怎么删:语法边界
DELETE 的标准句式是:
sql复制DELETE FROM user WHERE id = 100;
不过实际操作中,DELETE 是最容易出事的语句,没有之一。原因在于 WHERE 条件不是必填的。如果漏掉 WHERE,DELETE FROM user 会把全表数据清空,而且MySQL默认配置下这个操作不弹任何确认框。
DELETE 的第二个特点是它操作的单位是行,不是列。你想删除某一列的值,应该用 UPDATE 把该列设置为 NULL 或者默认值,而不是 DELETE。
第三个要点是 DELETE 的返回信息。MySQL 的 client 在执行完 DELETE 后会显示 Query OK, 1 row affected,这代表实际删除的行数。这个行数很有价值,我习惯在执行删除后立刻看这个数字:如果原计划删 1 行结果 affected 是 100 行,说明 WHERE 条件写错了,需要马上回滚事务。
关于 DELETE 有个常见的认知误区——很多人以为 DELETE 之后数据就不在了。实际上在 InnoDB 引擎下,DELETE 只是给行记录打了一个删除标记,磁盘空间并不会立刻释放,除非你执行 OPTIMIZE TABLE 这类操作。这也是为什么有时删除大量数据后,表文件大小并没有变化的原因。
1.3 UPDATE 的常见翻车点与正确写法
UPDATE 的语法是:
sql复制UPDATE user SET age = 26 WHERE name = '张三';
同样的问题:WHERE 条件不是必填的。UPDATE user SET age = 26 会把所有用户的 age 都改成 26。这不是危言耸听,我几乎每年都能在同事的工单里看到类似事故。
UPDATE 还有一个容易忽略的点:同时更新多个字段时,用逗号分隔,而不是 AND。新手经常会写成 SET age = 26 AND email = 'new@example.com',这会直接导致 SQL 语法错误或者出现意想不到的结果。
sql复制-- 正确写法
UPDATE user SET age = 26, email = 'new@example.com' WHERE name = '张三';
另外,UPDATE 语句里可以引用原来的字段值。比如给所有用户的年龄加一岁,可以写成:
sql复制UPDATE user SET age = age + 1 WHERE id > 0;
这看起来简单,但很多人会先把数据查出来,在程序里加 1 再写回去。这样做既慢又不安全,因为在并发场景下可能出现丢失更新。
如果你需要 UPDATE 的数据来自另一张表,还可以配合子查询实现:
sql复制UPDATE user SET age = (SELECT avg_age FROM stats WHERE stats.user_id = user.id);
不过这种方式在数据量大时效率不高,更推荐使用后面章节讲到的 JOIN UPDATE 写法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 业务里最常见的进阶玩法:批量、关联与带条件的更新
基础的增删改只是热身。实际业务场景里,你大概率会遇到"从一张表搬数据到另一张表""批量更新几千条记录""修改的数据要依赖另一张表的计算结果"这类需求。这就要用到一些进阶写法了。
2.1 insert into select:从一张表直接搬数据
INSERT INTO SELECT 是数据迁移和报表生成场景的利器。它的作用是把一条 SELECT 语句查出来的结果直接写入目标表:
sql复制INSERT INTO user_archive (id, name, email)
SELECT id, name, email FROM user WHERE created_at < '2020-01-01';
这个语法解决了两个常见问题:一是避免在应用层把数据先查出来再逐条插入,减少网络传输和代码复杂度;二是整个过程可以在数据库内部完成,性能远高于"查出来再插回去"的方式。
使用 INSERT INTO SELECT 有几点要格外注意。
目标表的列数、列类型要和 SELECT 查询出的结果集匹配,否则会报错或者产生隐式类型转换。隐式转换容易出 bug,比如字符串类型字段插入了数字,MySQL 通常不会报错,但会悄悄把值转过去。
在插入前确认目标表是否已有数据。如果目标表存在唯一索引,重复数据会触发 Duplicate Entry 错误。常见做法是先清空目标表再插入,或者使用 INSERT IGNORE 语法跳过重复行,又或者用 ON DUPLICATE KEY UPDATE 做有则更新、无则插入的合并逻辑:
sql复制INSERT INTO user_archive (id, name, email) VALUES (1, '张三', 'zhangsan@example.com')
ON DUPLICATE KEY UPDATE name = VALUES(name), email = VALUES(email);
这里多说一句,VALUES() 函数在 MySQL 8.0.20 之后标记为 deprecated,推荐改用别名语法:
sql复制INSERT INTO user_archive (id, name, email) VALUES (1, '张三', 'zhangsan@example.com') AS new
ON DUPLICATE KEY UPDATE name = new.name;
2.2 一次性插入多条数据时,性能和语法的取舍
批量插入的典型场景是初始化数据、导入历史数据、接收上游系统大批量消息。前面提到一条 INSERT 语句可以带多个 VALUES 元组,这是最高效的插入方式。
但批量插入有一个上限问题。一次插入的行数不是越多越好。我实测过,单条 INSERT 插入 1000 行左右通常表现稳定,超过 5000 行后,SQL 语句本身的解析时间会明显上升,binlog 和 undo log 的写入压力也会增大。
更好的方式是把批量插入放在一个事务里,比如每 500 行一个事务:
sql复制START TRANSACTION;
INSERT INTO log_table (message, created_at) VALUES ('msg1', NOW()), ('msg2', NOW()), ...;
COMMIT;
这样既能利用事务的原子性(一个批次失败全部回滚,不会出现半批数据),又不会因为事务过大导致锁持时间过长,影响其他会话的正常读写。
如果是导入超大文件(几百万行级别),用程序逐条 INSERT 或者拼 SQL 都走不通,应该直接使用 MySQL 的 LOAD DATA INFILE,它的导入速度比 INSERT 快一个数量级,属于另一个话题,这里先不展开。
2.3 多表关联更新:update join 怎么用才高效
单表 UPDATE 很好写,但业务里的更新经常需要参考另一张表。比如根据订单表的支付状态去更新用户表的会员等级,或者根据最新的价格表去更新商品表的价格。
MySQL 支持在 UPDATE 中使用 JOIN 语法:
sql复制UPDATE orders o
JOIN users u ON o.user_id = u.id
SET u.level = 'VIP'
WHERE o.total_amount > 10000;
这条语句会把所有下单总额超过 10000 的用户等级改为 VIP。它的执行逻辑是:先通过 JOIN 把两张表关联起来,再对符合条件的结果集中的行执行更新。
UPDATE JOIN 的效率比"先查后更"好很多,但要注意几个坑。
第一,JOIN 的结果集可能包含重复行。如果 orders 表里有多个订单符合条件,同一个 user 会出现在结果集的多行里,但 UPDATE 对同一行的多次更新只会执行一次,不会出问题,只是性能上可能做了一些无谓的关联。
第二,必须确认 WHERE 条件写对了。JOIN UPDATE 一旦 WHERE 写错,影响范围会比单表更新更大,因为每一条关联结果都可能触发一次写入操作。
第三,如果只需要更新某一张表的字段,JOIN 中的另一张表纯粹是作为过滤条件,要确保 JOIN 类型是 INNER JOIN 而非 LEFT JOIN,否则右表中没有匹配记录的行也会被更新成 NULL——这几乎是事故级的错误。
3. 别让一不留神变成事故:增删改的安全边界与锁机制
前两章讲的是"怎么写",这一章讲"怎么不写错"。增删改直接修改数据,一旦失误轻则返工重来,重则造成线上事故。这里分享几个我长期坚持的原则和踩过的坑。
3.1 不带 where 的 delete/update 就是一颗定时炸弹
我见过太多例子:有人准备更新一条数据,结果手滑没写 WHERE,按了回车之后整张表都变了。数据库没有回收站。MySQL 的 binlog 可以帮你恢复,但如果用的是 ROW 格式,恢复的复杂度足以让 DBA 崩溃。
我的实操建议非常简单:
- 在尝试 DELETE 或 UPDATE 之前,先写一条 SELECT 语句,用相同的 WHERE 条件查一遍,确认命中的行数是你预期的。
- 使用事务包裹 DELETE/UPDATE,执行后先检查影响行数,确认无误再 COMMIT。
- 生产环境建议开启 safe-update 模式(MySQL 的
--safe-updates选项),它要求 DELETE/UPDATE 必须带 WHERE 条件,否则直接拒绝执行。
bash复制mysql --safe-updates -u root -p mydb
safe-updates 模式还会自动限制不带 LIMIT 的 DELETE,等于多了一层保险丝。开发环境可能觉得麻烦,但生产环境至少要有意识的通过账号权限管控这类操作。
3.2 事务到底是什么:为什么说 BEGIN 和 COMMIT 是保命符
事务是保证数据操作可靠性的核心机制。我在这里用一个简单的类比解释:事务就像你在银行转账,要么钱从A账户扣除且B账户到账,两个操作都成功;要么都失败,回到转账之前的状态。不存在"扣了钱但对方没收到"这种中间状态。
在 MySQL 的 InnoDB 引擎下,事务由以下语句控制:
sql复制START TRANSACTION;
UPDATE account SET balance = balance - 1000 WHERE user_id = 1;
UPDATE account SET balance = balance + 1000 WHERE user_id = 2;
COMMIT;
如果在第二条 UPDATE 之后发现余额不对,可以执行 ROLLBACK 回滚整个事务,两条 update 的影响都会被撤销。
这也是我强烈建议"所有写操作务必放进事务"的原因。尤其是在做 DELETE 时,事务是最后一道后悔药。执行 DELETE 后先不要急着提交,用 SELECT 验证一下目标数据是否真的删对了。确认无误再 COMMIT,如果发现删错了立刻 ROLLBACK,数据就能完好无损地恢复。
关于事务隔离级别,可能有些人听过"脏读""不可重复读""幻读"这些概念。实际开发中,MySQL 默认的 REPEATABLE READ 级别在大多数场景下都够用,不用为了炫技去调整。你需要关心的只是开启事务、正确提交或回滚,以及尽量缩短事务的执行时间。
3.3 for update、skip locked 和行锁的正确理解
业务中偶尔会遇到并发控制的需求。比如库存扣减,两个请求同时读到库存是 10,又同时扣减,最终库存可能变成 9 而不是 8。
很多人会想到用 SELECT ... FOR UPDATE 给行加锁:
sql复制START TRANSACTION;
SELECT * FROM inventory WHERE sku_id = 100 FOR UPDATE;
-- 在程序里计算新库存
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 100;
COMMIT;
FOR UPDATE 的意思是:把查出来的行锁住,直到事务提交或回滚才释放。在锁释放之前,其他事务如果执行同样的 SELECT ... FOR UPDATE,会进入等待状态。
这里有一个非常经典的细节问题——FOR UPDATE 的锁粒度。如果 WHERE 条件命中主键或唯一索引,锁住的是精确的一行;但如果 WHERE 条件命中普通索引或者没有索引,锁的范围可能扩大,极端情况下把整张表锁住。这就是为什么 SELECT FOR UPDATE 必须搭配能精准命中索引的查询条件,否则不仅影响性能,还可能导致严重死锁。
至于 LIMIT 1 FOR UPDATE SKIP LOCKED,它在任务队列场景下很实用。比如多个 worker 抢任务:
sql复制START TRANSACTION;
SELECT * FROM task_queue WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;
UPDATE task_queue SET status = 'processing' WHERE id = ?;
COMMIT;
SKIP LOCKED 的含义是:如果某些行已经被其他事务锁住,那就跳过它们,不等待,直接取一条未被锁定的行。配合 LIMIT 1,可以让多个 worker 安全地拿到不同的任务行,不会互相阻塞。它锁定的就是查询条件下满足条件的未被锁定的那一行,配合好的索引条件使用。
但务必要记住一个关键点:FOR UPDATE 只是给行加写锁,不会阻止普通 SELECT 读取。如果其他事务用不带 FOR UPDATE 的普通 SELECT 来读取,依然能读到的(在默认隔离级别下是快照读)。只有同样使用了 FOR UPDATE 或者执行 UPDATE/DELETE 的语句才会被阻塞。
4. 实战排错:为什么我的 insert 没有写进去,也没有报错
前面讲了语法和原理,这一章讲一个非常典型、而且在各类热词里反复出现的实战问题——我的 INSERT 语句执行成功了,返回查询结果正常,但数据库里就是看不到这条数据。
4.1 从一次 MyBatis Plus 的真实事故说起
MyBatis Plus 的 insert 方法走的是 ORM 封装,使用非常方便,但"没写进去也没报错"这个现象,在 MyBatis Plus 场景里出现的频率格外高。
我遇到过的案例是这样的:业务代码调用 userMapper.insert(user),日志打印了返回值为 1(表示插入成功了),但去数据库查却查不到这条记录。这个问题最诡异的地方在于——返回值确实为 1,说明 SQL 确实执行了,而且确实影响了一行。
排查之后发现原因在事务。调用 insert 的方法被 @Transactional 注解包住,方法结尾抛出了一个被 catch 吞掉的异常。在 Spring 的事务机制里,默认只有 RuntimeException 才会触发回滚,但这个异常被 catch 住,事务没有提交、也没有回滚,方法正常返回。而 MyBatis Plus 返回的"1"是 SQL 执行层面的结果,事务本身还处于未提交状态。之后事务被容器回收,自然地回滚掉,数据就消失了。
这是一个非常典型的"数据没写进去但也不报错"的场景。事务未提交带来的数据不可见,比 SQL 语句本身的错误隐蔽得多。
4.2 排查链路:事务、字段映射、隐性截断与日志
遇到增删改"执行成功但数据没生效"的问题,我建议按下面的链路逐层排查,避免瞎猜。
第一层,事务有没有正确提交。检查调用链路里是否有 @Transactional,是否有 try-catch 把异常吞掉导致事务无法回滚或提交。关键判据:在方法内部加一条日志打印事务状态,或者查看数据库的 information_schema.INNODB_TRX 表确认是否有未提交事务。
第二层,ORM 的字段映射是否完整。MyBatis Plus 默认会把实体类的驼峰属性映射为下划线列名,如果数据库字段是 create_time 而实体属性是 createTime,通常没问题,但如果数据库字段上有特殊命名或者使用了 @TableField 注解,映射错位就会导致插入时某些字段变成 NULL,甚至 SQL 被过滤掉部分列。这也可能造成"看似执行了但数据不对"。
第三层,数据库的隐式转换和截断。MySQL 在某些模式下,如果插入的字符串超过字段长度,可能不会报错,而是静默截断。比如一个 VARCHAR(10) 的字段插入了一个长度为 20 的字符串,在非严格模式下会被截断保存。如果恰好整条记录除了这个字段外其他字段都正常,你查到的数据就会是"不完整"的。
第四层,确认 SQL 真正执行的内容。排查思路很简单,打开 MyBatis 的 SQL 日志,看打印出来的 SQL 是不是和你预期一致。很多"莫名其妙"的问题,看到真实 SQL 后瞬间就明白了。
yaml复制# application.yml 配置 SQL 日志
logging:
level:
com.example.mapper: debug
或者直接在 MySQL 端开启通用查询日志,确认实际到达数据库的语句:
sql复制SET GLOBAL general_log = 'ON';
SET GLOBAL log_output = 'TABLE';
SELECT * FROM mysql.general_log ORDER BY event_time DESC LIMIT 10;
4.3 同类坑位:delete 静默失败和 update 影响行数为 0
INSERT 有这种诡异问题,DELETE 和 UPDATE 同样有类似情况。
DELETE 静默失败,常见于 MyBatis Plus 的逻辑删除配置。如果你的实体类字段上加了 @TableLogic 注解,MP 默认会把 DELETE 语句改写为 UPDATE,比如把 deleted 字段置为 1。代码里执行 deleteById,日志显示影响行数为 1,但表里的数据还在——因为所谓"删除"只是更新了逻辑删除标记。如果你用 Navicat 直接查表,数据永远都在。这类问题不是 bug,但如果不理解逻辑删除机制,会在排查时绕不少弯路。
UPDATE 影响行数为 0,则更隐蔽。同样一条 UPDATE 语句,如果设置的值和原值完全一样,MySQL 会返回 0 rows affected,因为没有任何列被修改。这并不是执行失败,而是"没有发生变化"。这在某些 ORM 框架里可能导致返回值异常,比如误判为"更新失败"。
另一个 UPDATE 影响行数为 0 的场景是:WHERE 条件匹配不到任何记录。这往往不是 SQL 的问题,而是传入参数的问题。比如代码里传了一个不存在的 ID,或者更新的数据已经被其他请求删除。遇到这种情况,应该先检查参数,再看数据是否存在,而不是直接怀疑 SQL 写错了。
最后再提一个和 ORM 相关的常见坑:使用 Lombok 的 @Builder 注解时,如果类上没有加 @NoArgsConstructor 和 @AllArgsConstructor,MyBatis Plus 在反射创建对象时可能拿到一个无法正常初始化的实例,导致 insert 时字段全部为 NULL,或者直接跳过部分字段不写入。这个问题报错不明显,但数据就是不对。
5. 数据操作基本功的底层逻辑:这些规范值得背下来
前面四章已经覆盖了 insert、delete、update 的语法、进阶用法、事务与锁、排错思路。最后这一章我把沉淀下来的操作规范和处理经验集中梳理一遍,方便你直接照做。
5.1 增删改的黄金顺序与影响行数检查
我给自己定的铁律是:所有写操作,执行顺序永远是 SELECT 验证 → 写操作 → 检查影响行数 → 事务提交。
以删除为例,完整流程是:
sql复制-- 第一步:确认影响范围
SELECT * FROM user WHERE department_id = 10;
-- 第二步:开启事务
START TRANSACTION;
-- 第三步:执行删除
DELETE FROM user WHERE department_id = 10;
-- 第四步:检查影响行数(应该等于第一步查出来的行数)
-- 如果影响行数和预期不一致,立刻 ROLLBACK
-- 第五步:确认无误
COMMIT;
这套流程看起来繁琐,但能救命的恰恰是"检查影响行数"这一步。数据库客户端在执行完增删改之后都会返回受影响的行数,这不是给你看的装饰信息,而是判断操作是否正常的关键指标。INSERT 影响行数应该等于插入的行数,DELETE/UPDATE 影响行数应该等于你在 SELECT 中看到的行数,如果不一致就停下手里的操作,先排查原因。
5.2 用 SQL_MODE 和日志从源头堵住隐患
MySQL 的行为严格程度很大程度上受 SQL_MODE 控制。默认情况下 MySQL 的 SQL_MODE 不包含 STRICT_TRANS_TABLES,这会导致很多本应报错的写操作被"宽容"处理:
- 字符串超过长度被截断
- 非法日期被替换为 0000-00-00
- 除零操作产生 NULL
这些"宽容"行为会让数据在不知不觉中变脏,而且是静默的。我强烈建议在生产环境开启 STRICT_TRANS_TABLES:
sql复制SET GLOBAL sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO';
开启后,超出长度、非法日期这类问题会直接报错,而不是静默截断。你会觉得"数据库变严格了",但这是好事——问题在写入时就暴露出来,而不是等数据脏了之后花几天去清理。
日志方面,除了开启慢查询日志之外,在有条件的情况下把 binlog 格式设置为 ROW。ROW 格式的 binlog 记录了每一行变更的前后镜像,虽然产生的日志量比 STATEMENT 格式大,但它在数据误操作恢复时几乎是唯一可靠的依据。
5.3 一套可以抄走的自检清单
最后分享一张我每次处理敏感数据操作时都会对照的自检清单,你可以直接抄走贴在自己工位上。
| 检查项 | 操作前确认 | 出现问题时的处理 |
|---|---|---|
| WHERE 条件 | 先用 SELECT 语句确认影响行数 | 立即 ROLLBACK |
| 事务边界 | 确认 START TRANSACTION 和 COMMIT 配对 | 回滚后重新检查业务逻辑 |
| 字段值类型 | 确认插入/更新的值与字段类型一致 | 用 CAST 显式转换 |
| 字符集 | 确认连接字符集与表字符集一致 | 检查 utf8mb4 |
| 影响行数 | 对比预期值和实际 affected rows | 行数不符先别提交 |
| 锁范围 | 确认 WHERE 命中索引 | 查看锁等待状态,优化索引 |
| 日志开关 | 必要时开启 general_log / binlog | 从日志中定位问题原因 |
这套清单不需要每次都走完全部流程。如果你的操作范围非常明确(比如只更新主键 ID=1 的一行),最核心的就是确保 WHERE 条件、事务和影响行数检查这三项。而如果你在操作一张包含数百行的大表且条件复杂,那每一项都值得认真过一遍。
说实话,增删改的操作本身不难,难的是长期保持对数据的敬畏心。我把这些年踩过的坑和积累下来的流程分享出来,希望你能避开这些常见的坑。哪怕只是借鉴了影响行数检查这一条,也能省下不少排查事故的功夫。
