1. 这一章到底在讲什么:查询之外的“基本功”
如果你正在学MySQL,或者已经写了一段时间SQL,大概会有这种感觉:查询的语法花样最多,SELECT能玩出各种花来。但真正到了实际项目里,天天写的最多的,反而不是那些复杂的JOIN和子查询,而是最基础的增删改查——尤其是增、删、改这三件事。它们看起来简单,但恰恰是出错率最高的地方,因为任何一条写错的UPDATE或DELETE,都可能直接改坏线上数据。
这篇文章想聊的,就是“基本查询下”这个阶段最核心的内容:表的增删改查。它通常是在你已经掌握了最简单的SELECT查询之后,紧接着要面对的一整块知识。说白了,就是下面这几件事:
- 增(INSERT):往表里插入数据,单条、多条、指定字段、甚至从另一张表复制数据。
- 删(DELETE):按条件删除数据,以及清空整张表。
- 改(UPDATE):更新已有记录,配合WHERE条件做精准修改。
- 查(SELECT):把前面学过的查询能力,和增删改组合起来,形成完整的日常操作闭环。
我把这一篇定位成“实战入门向”。适合刚学完基础查询、准备开始操作真实数据的读者,也适合那些已经写了一阵子SQL、但有些细节始终没完全搞清楚的开发者。接下来,我会把每个操作的用法、执行逻辑、容易踩的坑,以及我在实际项目中怎么用它们,一次讲清楚。
2. 增删改查的底层思维:先搞清楚每一条SQL到底在动什么
在敲第一条INSERT之前,我建议你先在脑子里建立一个最基本的认知:增删改查的每一条语句,本质上都在操作“表”这个二维结构里的“行”和“列”。
- SELECT:按条件把某些行的某些列取出来,不改动原表。
- INSERT:往表里增加新行。
- UPDATE:把某些行的某些列的值改掉。
- DELETE:把某些行整体移除。
听起来像废话,但很多人出错,就是因为没有把“行”和“列”这两个维度分开想。比如UPDATE语句,你写SET name = '张三',这是改列;但它真正定位到哪些行,靠的是WHERE条件。如果WHERE写错,就会把不该改的行也改了。这就像你要在一面墙上换几块瓷砖,结果拿错了房间号,把别人家的墙砸了。
所以,业内有个非常朴素的经验法则:操作数据之前,先问自己三个问题——我要影响哪些行?我要改哪些列?我有没有办法验证影响范围?如果把这三个问题想清楚了,增删改查基本不会出大乱子。
2.1 先看一眼表结构再动手
不管你接下来要做INSERT还是UPDATE,第一步永远是确认表结构。我见过太多人凭记忆写SQL,结果字段名写错,或者漏了必填字段,报错之后才回头查表结构。
查看表结构的语句非常简单:
DESC 表名;或者更详细一点,直接看建表语句:
SHOW CREATE TABLE 表名;这两个命令在实操里很常用。DESC能快速看到字段名、类型、是否允许NULL、是否有默认值、是不是主键;SHOW CREATE TABLE则能看到完整定义,包括索引、字符集、存储引擎这些细节。
我个人的习惯是:凡是第一次接手的表,先跑一遍SHOW CREATE TABLE。因为DESC看不到默认值以外的约束细节,比如某个字段是否有UNIQUE约束、外键指向哪里,这些信息直接影响你INSERT时怎么构造数据。
2.2 实操准备:建一张练习用的表
为了下面的操作演示,我们先建一张足够简单、但涵盖了常见字段类型的练习表。这张表模拟的是一个最基础的“用户表”,字段包括自增主键、用户名、邮箱、年龄、注册时间。
CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` VARCHAR(50) NOT NULL COMMENT '用户名', `email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱', `age` INT DEFAULT NULL COMMENT '年龄', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';这张表看起来简单,但已经包含了几个关键点:自增主键、非空字段、可空字段、带默认值的字段。后面所有示例都基于它,你可以直接复制到自己的MySQL里跑一遍。
提示:MySQL版本建议在5.7及以上,
DEFAULT CURRENT_TIMESTAMP这个语法在5.6就已经支持了,老版本可能会报错,遇到问题升级就好。
3. INSERT的完整用法:从单条插入到批量导入
INSERT是所有增删改查里最“友善”的一个操作,因为它的失败通常只影响自己,不会波及已有数据。但友善不代表可以乱写,尤其是涉及主键冲突、字符集、必填字段时,照样有一堆细节。
3.1 最基本的单条插入:全字段和指定字段的差别
全字段插入的写法是这样:
INSERT INTO `user` VALUES (1, '小明', 'xiaoming@example.com', 20, '2024-01-01 10:00:00');这种写法要求VALUES里按表结构的字段顺序,一个不落地给出所有值。它看着省事,但特别脆弱——只要表结构一变,比如加了新字段,这条SQL立刻失效。
所以我更推荐写法是指定字段列表:
INSERT INTO `user` (`username`, `email`, `age`) VALUES ('小明', 'xiaoming@example.com', 20);这里有几个关键点:
- 不写的字段
id会自动生成,因为它是自增主键。 - 不写的字段
created_at会自动填当前时间,因为建表时给了默认值。 - 如果有一个非空字段既没有默认值,也没有出现在字段列表里,INSERT会直接报错。
我有一个习惯:所有INSERT都显式写出字段列表,哪怕字段很多。好处是SQL可读性强,语义清晰,后续维护的人一眼就能看出这行数据设置了哪些列,更重要的是不会被表结构调整悄悄坑到。就算哪天往表里加了一个可空字段,老SQL依然能跑。
3.2 一次插入多行:批量插入的性能优势
需要同时插入多条记录时,可以把VALUES后面跟多组值:
INSERT INTO `user` (`username`, `email`, `age`) VALUES ('小明', 'xiaoming@example.com', 20), ('小红', 'xiaohong@example.com', 21), ('小刚', 'xiaogang@example.com', 22);这种方式在实操中非常常见。相比逐条INSERT,它有几个实打实的好处:
- 减少客户端和MySQL之间的网络往返。每条INSERT都是一次单独的语句执行,批量插入只有一次,网络开销小得多。
- 在InnoDB引擎下,批量插入能减少事务日志写入次数,整体速度有明显提升。
- 代码结构更清晰,一组数据堆在一起,方便审查。
在真实项目里,如果你要一次性往表里塞几百条、几千条数据,直接拼一条多行INSERT通常能跑得飞快。但要注意一点:单条SQL不宜无限长。虽然MySQL有max_allowed_packet参数控制单次传输大小,默认通常是64MB,但没必要炫技,一次插个几百行到几千行,性能就已经很理想了。上十万行的批量导入,我建议走LOAD DATA INFILE或分批提交,下一篇可以展开聊。
3.3 插入时处理主键冲突:INSERT IGNORE与ON DUPLICATE KEY UPDATE
日常开发中,我们经常会遇到“这行数据可能已经存在,我需要插入,但不想报错”的场景。比如采集中间表,第一次跑任务要把数据落库,第二次跑任务数据已经在了,怎么办?
MySQL提供了两个实用方案。
第一个是INSERT IGNORE。它的行为是:如果插入的数据和已有数据冲突(最常见的是主键或唯一键冲突),就默默跳过这条,不报错,不影响其他行:
INSERT IGNORE INTO `user` (`id`, `username`, `email`, `age`) VALUES (1, '小明', 'xiaoming@example.com', 20);如果id=1已经存在,这条语句不会插入,也不会报错。适合那种“有就跳过,没有就插入”的幂等需求。
第二个是ON DUPLICATE KEY UPDATE。它更进一步:冲突时不仅不报错,还能把已有行的某些字段更新掉:
INSERT INTO `user` (`id`, `username`, `email`, `age`) VALUES (1, '小明', 'xiaoming@example.com', 20) ON DUPLICATE KEY UPDATE `username` = '小明', `email` = 'xiaoming@example.com', `age` = 20;这个写法的应用场景很典型:同步外部数据源时,数据源那边可能改了某条记录,这边希望“有则更新,无则插入”。这在不少面向业务的系统里称为“upsert”,是个非常高频的需求。
注意:ON DUPLICATE KEY UPDATE不只对主键冲突生效,对任何唯一键冲突都生效。如果一个表有多个唯一索引,要小心这条语句可能更新到你以为不会命中的行。实际项目中,这种表尽量让主键和唯一键语义清晰、数量精简。
3.4 从查询结果直接建表插入:INSERT INTO ... SELECT
有时我们需要把一张表的部分数据复制到另一张表,或者把查询结果保存成一张新表,这时不需要手动拼VALUES,而是可以直接把SELECT的结果喂给INSERT。
INSERT INTO `user` (`username`, `email`, `age`) SELECT `username`, `email`, `age` FROM `user_backup` WHERE `age` > 18;这条语句的逻辑很简单:先执行SELECT子句,然后把结果一行一行插入到目标表。它比“导出数据再导入”快得多,也避免了文件传输的问题。
另一个相关语法是CREATE TABLE ... AS SELECT,但要注意它默认不会复制原表的索引和约束。如果只是做临时表、报表中间表,问题不大;但如果想复制完整表结构,还是得用CREATE TABLE ... LIKE,或者手动补索引。
3.5 我踩过的一个INSERT坑:字符集不一致导致的中文乱码
初学阶段最经典的翻车现场,就是插入中文后查询出来全是乱码。原因往往不是INSERT语句写错了,而是客户端连接字符集、表字符集、字段字符集不一致。
我遇到过一种情况:表是utf8mb4,但我在某个老项目里用的连接字符集是latin1,结果插入的中文直接变成问号。排查起来特别头疼,因为看起来像是数据写坏了,实际上只要调整连接字符集就能解决。
解决办法是在连接建立后执行:
SET NAMES utf8mb4;或者在客户端连接参数里指定characterEncoding=utf8mb4(JDBC连接串常见做法)。只要连接字符集、表字符集、字段字符集统一成utf8mb4,中文基本就不会出问题。
还有个小细节:MySQL里utf8和utf8mb4不是一回事。utf8是最大3字节的字符集,存不了emoji和部分生僻字;utf8mb4才是完整的4字节字符集。强烈建议从建表开始就统一用utf8mb4,省得以后遇到emoji存不进去的尴尬。
4. UPDATE的正确打开方式:改数据之前,先想清楚WHERE
如果说INSERT的失败只影响新数据,那么UPDATE一旦写错,影响的是已经存在的线上数据,而且大概率不可逆。所以我对UPDATE的态度,始终是谨慎再谨慎。
4.1 UPDATE的基本语法和执行顺序
UPDATE的标准写法是:
UPDATE 表名 SET 字段1 = 值1, 字段2 = 值2 WHERE 条件;它的执行逻辑其实是:先根据WHERE条件确定哪些行需要被更新,然后对这些行统一设置SET中指定的列。看起来简单,但很多人没意识到,WHERE决定的是“影响哪些行”,SET决定的是“这些行变成什么样”,两者是独立的。如果只想改一行,但WHERE条件匹配了十行,那就是十行一起改。
来几个实际例子:
-- 修改id=1的用户年龄 UPDATE `user` SET `age` = 25 WHERE `id` = 1; -- 把所有年龄小于18的用户邮箱清空 UPDATE `user` SET `email` = NULL WHERE `age` < 18; -- 把所有用户的年龄加1(没有WHERE就是全表更新) UPDATE `user` SET `age` = `age` + 1;第三个例子尤其重要,它展示了UPDATE中用字段当前值参与计算的写法。这种“在原有值基础上修改”的操作,在计数器、库存、积分等场景非常常见。但要注意,它必须写成age = age + 1这种形式,不能简单写age = 1——否则就是直接把所有行年龄改成1了。
4.2 为什么我坚持“UPDATE必须带WHERE”
不带WHERE的UPDATE会更新整张表的所有行,这在绝大多数业务场景里都是灾难级的操作。
假设一个运营后台,管理员想修正某个用户的邮箱,结果SQL写成:
UPDATE `user` SET `email` = 'temp@example.com';这条语句会瞬间把全表所有用户的邮箱都改成temp@example.com。等发现的时候,可能已经晚了——而且一般来说,MySQL默认开启autocommit,没包在事务里的话,改完就提交了,想回滚都难。
所以我在团队里一直强调一条铁律:上线前检查UPDATE语句,没有WHERE条件的UPDATE必须有明确理由,且要经过双重确认。这不是夸张,很多时候事故就是这么来的。
4.3 用SELECT先验证WHERE条件的命中范围
我非常推荐一个习惯:在真正执行UPDATE之前,先用相同WHERE条件的SELECT看一眼命中哪些行。
比如我想把年龄小于18的用户的邮箱清空,先执行:
SELECT `id`, `username`, `age`, `email` FROM `user` WHERE `age` < 18;确认返回的行确实是我想要修改的那一批,再执行UPDATE。这个方法简单、可靠,几乎不花额外时间,却能把误操作的概率降低一个数量级。
如果数据量很大,SELECT全部记录太大,可以加LIMIT看前几条:
SELECT `id`, `username`, `age`, `email` FROM `user` WHERE `age` < 18 LIMIT 10;虽然只是抽查前10条,但配合其他条件判断,也足够确认WHERE条件的方向对不对。
4.4 用好事务:让UPDATE有机会反悔
在MySQL里,InnoDB引擎支持事务。事务最大的价值之一,就是给UPDATE/DELETE这种危险操作一个“后悔药”。
一条典型的操作流程是:
BEGIN; UPDATE `user` SET `email` = NULL WHERE `age` < 18; -- 检查影响行数,如果发现不对劲,立即回滚 ROLLBACK; -- 如果确认没问题,提交 COMMIT;如果用的是命令行或某些图形化客户端,执行UPDATE后能看到“Query OK, 2 rows affected”,这个影响行数就是判断对错的重要依据。当影响行数和预期不符时,立即ROLLBACK,数据安然无恙。
当然,事务也不是没有代价。长事务会占用连接资源,锁住的行会阻塞其他事务操作。所以实操中,我一般只在“可能搞坏数据”的场景里显式使用事务,日常简单UPDATE就直接执行。
4.5 UPDATE中容易忽略的一个细节:受影响行数为0不代表没执行成功
UPDATE执行后提示“0 rows affected”,通常意味着两种情况:
- WHERE条件没有匹配到任何行。
- 匹配到了行,但SET的值和原值相同,被MySQL判定为“无需实际修改”。
第二种情况字段虽然没变化,但MySQL确实执行了查询和比较的过程。所以,“0 rows affected”不代表SQL写错了,也不代表没执行。它只是告诉你“没有行被实际修改”。
这在判断业务逻辑时要注意。比如你想确认某个用户是否存在,不应该用UPDATE是否影响了行来判断,而应该用SELECT。如果UPDATE后是0行,可能是用户不存在,也可能是数据没变化,两个原因混在一起,容易误判。
5. DELETE的边界感:删数据不是玩游戏,先确认再动手
DELETE是增删改查里“最危险”的操作,因为数据删了,没有后悔药的场景比比皆是。除非你有备份或binlog,否则一次错误DELETE造成的数据丢失,代价可能远超想象。
5.1 DELETE的基本语法:按行删除,不是按列删除
DELETE的语法:
DELETE FROM 表名 WHERE 条件;它的语义是:从表中删除满足WHERE条件的整行。不能只删某一列——要删某一列,应该用UPDATE把该列设为NULL或默认值。
-- 删除id=1的用户 DELETE FROM `user` WHERE `id` = 1; -- 删除年龄小于18的所有用户 DELETE FROM `user` WHERE `age` < 18;不带WHERE的DELETE会清空整张表:
DELETE FROM `user`;这比不带WHERE的UPDATE更可怕,因为UPDATE至少还留着所有行,只是改了值;DELETE直接让表变空。
所以,我给DELETE准备了两条原则,即使写了多年SQL也一直在遵守:
- 第一条:DELETE必须带WHERE,除非是想清空整表,并且确认这是一张临时表或可重建的表。
- 第二条:DELETE之前,先用同条件SELECT看一眼命中行数。这和UPDATE的做法一脉相承,都是为了降低误删风险。
5.2 DELETE和TRUNCATE的区别:不只是速度问题
如果想清空整张表,其实还有一个更快的命令:TRUNCATE。
TRUNCATE TABLE `user`;TRUNCATE和DELETE FROM之间有几个关键区别,在面试里也经常被问到,这里直接讲透:
| 对比项 | DELETE | TRUNCATE |
|---|---|---|
| 是否可带WHERE | 可以,按条件删除 | 不可以,只能全表清空 |
| 删除方式 | 逐行删除,走事务,可回滚 | 快速释放表数据页,DDL操作,默认不可回滚 |
| 自增ID | 继续沿用当前自增值,不会重置 | 自增ID会重置为初始值 |
| 性能 | 数据量大时较慢 | 通常远快于DELETE |
| 触发器 | 会触发DELETE触发器 | 不触发行级DELETE触发器 |
| 对表结构 | 保留表结构和索引 | 保留表结构,重新初始化表 |
如果只是想让表变成一个空壳,并且所有自增ID从1重新开始,TRUNCATE是最合适的。如果只想清掉一部分数据,或者需要留下审计日志,就只能用DELETE。
注意:TRUNCATE在InnoDB引擎下的行为其实和“DROP TABLE + CREATE TABLE”类似,所以在某些情况下的回滚能力极其有限。千万不要以为它和DELETE一样能随时ROLLBACK,这属于高风险操作,执行前确认数据库有可用备份会稳妥很多。
5.3 大表DELETE的实操痛点:一次删太多会锁表
实际项目里,最常见的问题不是DELETE语法写错,而是一次性删太多数据导致锁表、拖垮线上服务。
比如你有一张几千万行的日志表,想删除上个月之前的所有数据:
DELETE FROM `access_log` WHERE `created_at` < '2024-01-01';这条SQL听起来合理,但执行的一瞬间,InnoDB会锁住大量符合条件的行。如果数据量在百万、千万级别,长时间持有锁,在线写入业务会直接阻塞,严重时可能造成连接堆积。
我处理这类需求的惯用套路是分批删除,每次只删一小部分,间隔几秒钟,让其他事务有机会执行:
DELETE FROM `access_log` WHERE `created_at` < '2024-01-01' LIMIT 1000;反复执行这条语句,直到影响行数为0。在脚本或后台任务里,这通常写成循环。每次只删1000行,单条SQL执行时间短,锁持有时间短,对线上影响就小得多。
LIMIT在DELETE里的使用,是很多人没意识到的实用技巧。虽然没有标准SQL的明确规定,但MySQL确实支持DELETE ... LIMIT,这在分批删除中非常重要。
经验之谈:超过几十万行的删除,不要追求一条SQL跑完,分批处理虽然慢,但胜在对线上环境友好。删除任务的执行时间尽量安排在业务低峰期,且务必先备份再动手。
5.4 彻底删除数据表:DROP TABLE的用途
DELETE是删除数据,DROP TABLE则是把整张表连同结构、索引一起删除。
DROP TABLE IF EXISTS `user`;这个命令平时不太常用,一般在重构、清理临时表时才用。它的危害比DELETE更大——删除后表和里面的数据彻底消失,如果没备份,基本等于永久丢失。
所以我个人对DROP TABLE的态度是:必须三思而后行。如果要删的是重要业务表,先备份,再确认没有其他表的外键依赖它,最后在低峰期执行。
6. 把查询和增删改结合:日常数据维护的完整闭环
前面把INSERT、UPDATE、DELETE分别讲了一遍,但真实项目里,它们总是和SELECT组合出现。这一节我用几个常见的业务场景,把“查”和“改”串起来,帮你看清整个过程是怎么运转的。
6.1 场景一:注册新用户,先查重再插入
业务逻辑上,一个用户名通常不能重复。最稳妥的流程是:
- SELECT检查用户名是否存在
- 不存在则INSERT
- 如果并发高,可能还要依靠唯一索引兜底
SQL层面的示意:
-- 1. 检查用户名是否存在 SELECT `id` FROM `user` WHERE `username` = '新用户'; -- 2. 不存在则插入 INSERT INTO `user` (`username`, `email`, `age`) VALUES ('新用户', 'new@example.com', 18);但这两条语句之间有时间差,高并发下可能出现两个请求同时通过第一步的检查,然后同时插入。所以正确的做法是在username字段上加唯一索引,再配合INSERT或INSERT IGNORE,让数据库来兜底。这也是我前面提过ON DUPLICATE KEY UPDATE的典型应用场合。
6.2 场景二:修改用户资料前,先确认目标记录存在
改资料的流程往往是:
- 前台传来用户ID和新资料
- UPDATE前先用SELECT确认用户存在
- 执行UPDATE,并检查影响行数
-- 1. 确认用户存在 SELECT `id` FROM `user` WHERE `id` = 123; -- 2. 更新资料 UPDATE `user` SET `email` = 'new@example.com', `age` = 25 WHERE `id` = 123;如果UPDATE影响行数为0,可能是用户不存在,也可能是字段没变化。为了明确反馈给前端,可以在UPDATE之前先SELECT一次,根据SELECT的结果判断是返回“用户不存在”还是“资料没有变化”。这里的核心思想是:SELECT负责确认状态,UPDATE负责执行变更,两者结合才能形成可靠的业务逻辑。
6.3 场景三:清理无效数据,DELETE配合SELECT确认范围
清数据的场景,比如删除所有未激活且注册时间超过一年的用户:
-- 1. 确认范围 SELECT COUNT(*) FROM `user` WHERE `status` = 0 AND `created_at` < DATE_SUB(NOW(), INTERVAL 1 YEAR); -- 2. 分批删除 DELETE FROM `user` WHERE `status` = 0 AND `created_at` < DATE_SUB(NOW(), INTERVAL 1 YEAR) LIMIT 500;我这里特意先COUNT一下,看看要删多少行。如果结果数量级完全超出预期,说明条件有问题。确认没问题后,再用带LIMIT的DELETE分批执行。
这个流程看着多写了几个步骤,但放到生产环境里,它会保护你远离“误删”“锁表”等各种坑。数据维护从来不是“一句话的事”,而是“确认、执行、验证”的闭环。
6.4 场景四:批量更新,用IN或BETWEEN精准圈定目标
有时需要更新一批特定ID的用户,比如把ID为1、3、5的用户年龄改成30:
UPDATE `user` SET `age` = 30 WHERE `id` IN (1, 3, 5);如果是一个范围,比如2024年注册的用户统一加一岁:
UPDATE `user` SET `age` = `age` + 1 WHERE `created_at` BETWEEN '2024-01-01 00:00:00' AND '2024-12-31 23:59:59';IN和BETWEEN在UPDATE的WHERE条件里用得非常频繁。它们本质上就是查询过滤逻辑的复用。所以“查询学得好,UPDATE也差不了”,这句话是很有道理的——UPDATE/DELETE的WHERE子句,用的就是SELECT的各种条件语法。
7. 增删改查里最常见的五个坑:从实战中总结的避障指南
这一节,我把自己和团队在实际开发中反复踩过的坑集中整理一下。每一个都是真实发生过的问题,不是理论推演。
7.1 字段名写错,却报错未知列
手写SQL的时候,最烦的就是字段名记错了。比如表里字段叫user_name,你写成username,MySQL会报“Unknown column”错误,虽然不影响数据安全,但浪费排查时间。
避免办法很简单:写SQL前先用DESC或SHOW CREATE TABLE看一眼字段名,别靠记忆复现。IDE或数据库客户端的自动补全功能也能大幅减少这类低级错误。
7.2 数值和字符串的隐式转换
MySQL里有一个容易忽略的行为:字符串和数值比较时,会尝试隐式转换。比如:
SELECT * FROM `user` WHERE `age` = '20';这条语句能正常执行,因为MySQL会把字符串'20'转成数字20再比较。但如果字符串不能转成数字,比如age = 'abc',比较结果可能不是你想的那样,甚至导致索引失效。
规范习惯是:数值字段写数值,字符串字段写字符串,不要依赖数据库的隐式转换。这既能避免歧义,也有利于走索引。
7.3 空字符串和NULL的区别
很多新手会把空字符串''和NULL混为一谈,但它们完全不同。
- NULL:表示没有值、未知值。
- 空字符串'':表示一个长度为0的字符串。
在WHERE条件里,email = NULL永远查不出任何行,必须写email IS NULL。同理,email = ''只能查空字符串,查不到NULL。
-- 正确写法 SELECT * FROM `user` WHERE `email` IS NULL; -- 错误写法(无法匹配NULL) SELECT * FROM `user` WHERE `email` = NULL;这个坑在统计“有多少用户没填邮箱”时特别常见,一不留神就把NULL漏掉了,统计结果偏少。
7.4 UPDATE/DELETE影响行数超出预期
很多时候,SQL语法没错,但WHERE条件写得太宽,导致影响范围比预期大。比如只想更新某个用户,却用了一个不够精确的LIKE条件:
UPDATE `user` SET `status` = 1 WHERE `username` LIKE '张%';这句会命中所有张姓用户,如果本意是只更新“张三”,那就误伤了一大片。所以,定位具体记录时,优先用主键或唯一键条件,比如id或email;用模糊匹配做范围操作时,先SELECT确认范围。
7.5 批量插入时最后一行缺少逗号导致语法错误
批量INSERT写多了,最容易出的语法错误是最后一组VALUES后面多了一个逗号,或者少了一个逗号。这种错误一眼很难看出来,尤其SQL很长的时候。我的习惯是写完批量插入后,先在测试库里跑一遍,确认无误再执行线上SQL。
如果批量插入的值很多,建议写一段小脚本生成SQL,而不是手拼。人眼检查几十组值难免漏,脚本生成起码能保证格式一致。
8. 理解InnoDB的锁和事务:为什么增删改查要了解这些概念
别急着跳过这一节。增删改查看起来是纯语法问题,但实际上和底层的事务、锁机制紧密相关。如果完全不懂这些概念,遇到“更新卡住了”“插入超时”这类现象时,会非常被动。
8.1 InnoDB的行锁和表锁:为什么UPDATE会阻塞别的UPDATE
InnoDB默认使用行级锁。也就是说,当你UPDATE某些行时,这些行会被锁住,其他事务在修改同一批行时只能等待,直到你COMMIT或ROLLBACK。
举个例子,事务A执行:
BEGIN; UPDATE `user` SET `age` = 30 WHERE `id` = 1; -- 尚未COMMIT此时事务B再执行:
UPDATE `user` SET `age` = 31 WHERE `id` = 1;事务B会一直阻塞,直到事务A提交或回滚。这不是死锁,只是锁等待。死锁是更复杂的状态:两个事务互相持有对方需要的锁,谁也走不了,最终由MySQL自动选择一个事务回滚来解除。
理解这个机制的意义在于:UPDATE和DELETE的执行时间越短越好,锁的持有时间越短越好。所以,不要在一个事务里做大量计算、调用外部接口、拖很久才COMMIT。这也是前面反复强调“分批删除”的重要原因。
8.2 什么时候会升级成表锁
虽然InnoDB是行锁,但在某些情况下也可能锁住大量行,甚至等同于全表锁定。比如DELETE或UPDATE的WHERE条件没有索引可用,InnoDB为了定位目标行,可能需要扫描很多行,并对这些行加锁。行数一大,锁的范围就跟着扩大,其他业务的写入就会被严重阻塞。
这也是为什么DBA们总说“UPDATE/DELETE的WHERE条件要走上索引”。走索引,锁的范围小,性能好;不走索引,锁的范围大,性能差。建表时合理设置索引,不仅是查询性能的事,同样影响增删改的并发能力。
8.3 理解自增主键和AUTO_INCREMENT
自增主键是InnoDB表的默认选择。它有几个行为值得知道:
- INSERT时不指定该字段,MySQL会自动生成一个新值。
- 如果插入失败、事务回滚,自增ID仍然可能被消耗掉,不会复用。
- DELETE删除某些行后,自增ID不会自动回退;TRUNCATE则会重置自增ID。
所以,如果你发现某张表的id出现不连续(比如1、2、5、9),这通常是正常现象,因为中间有INSERT失败或事务回滚发生过。不要试图人为把ID“补回连续”,这是自增主键的正常特性,不影响业务。
8.4 一个完整的卡片式总结:增删改查+事务的推荐姿势
把前面所有经验浓缩成一张表:
| 操作 | 推荐姿势 | 关键注意点 |
|---|---|---|
| INSERT | 显式字段列表、批量插入 | 注意字符集、唯一键冲突 |
| UPDATE | 必须带WHERE,更新前先SELECT | 优先按主键/唯一键定位 |
| DELETE | 必须带WHERE,分批执行 | 先备份,注意锁范围 |
| 全表清空 | 用TRUNCATE | 会重置自增ID,不可回滚 |
| 危险操作 | 包在事务里,及时COMMIT/ROLLBACK | 长事务会锁行,影响并发 |
9. 一些真实项目中用的补充技巧:AUTO_INCREMENT复位、备份与恢复
增删改查之外,我还想补充几个实操中一定会用到的细节。它们看起来不算“增删改查”,但离了它们,日常操作很容易卡壳。
9.1 手动设置自增ID和自增初始值
如果某张表的自增主键被大量删过,想重置自增值,可以执行:
ALTER TABLE `user` AUTO_INCREMENT = 1;这会改动自增ID的起点。但这个操作绝不能想当然地用在线上核心表,因为如果表里最大ID已经超过1,MySQL会取max(id)和指定值中的较大者作为下一个自增ID。所以,这条ALTER的实际效果取决于当前表里最大的ID。
9.2 清空一张表之后重启自增
TRUNCATE TABLE会重置自增ID,这个前面已经提过。如果你清空数据后希望ID从1重新开始,TRUNCATE是最好的选择。但DELETE FROM不会重置,DELETE后想重置,还是要配合ALTER TABLE。
9.3 在删除前保存备份:CREATE TABLE ... SELECT
为了防止误删,我经常在很关键的数据清理操作前,先做一张备份表:
CREATE TABLE `user_bak_20240101` AS SELECT * FROM `user`;这条命令会把当前所有数据复制到一张新表里,同时保留列结构,但不复制索引和主键。如果需要完整结构,可以先:
CREATE TABLE `user_bak_20240101` LIKE `user`; INSERT INTO `user_bak_20240101` SELECT * FROM `user`;两条语句配合,既复制了表结构(包括索引),也复制了数据。删除前先这样来一发,心安很多。
9.4 误删之后的紧急止损思路
如果真的发生误删,第一时间要做的是:
- 立即停止对表的写入操作,尽量避免新数据覆盖。
- 检查是否开启了binlog,如果有,尝试用binlog定位误删位置,进行恢复。
- 看是否有备份,有备份就选择最近的备份恢复,再配合binlog做增量恢复。
这些操作需要一定DBA技能,不一定初学者能立刻掌握。但至少你要记住:误删之后不要慌张,先冻结写入,再找备份和binlog,这是唯一正确的救援路径。
10. 写在最后的个人经验:从语法熟练到写出让人放心的SQL
如果这篇文章只能留下一个观念,我想把它放在这里:增删改查的难度不在于语法本身,而在于你对数据影响范围的理解和控制。
语法上,INSERT、UPDATE、DELETE、SELECT每一类都只有几种固定写法,花几天就能背熟。但让人放心的是那些习惯:写UPDATE先SELECT确认范围,写DELETE先COUNT一下,危险操作前备份,大表删除用LIMIT分批执行,事务短平快。这些习惯看着琐碎,在关键时刻能救命。
我自己写SQL多年,最深的体会是:不要相信自己不会犯错,要相信流程能拦住错误。把“先查后改”练成肌肉记忆,把“备份先行”当成固定动作,你的SQL就从“能跑”进化成了“能让人放心跑”。
操作数据是一辈子的事,希望你从一开始,就成为一个让数据安全落地的人。