☰
MySQL增删改查实战:索引、事务与锁的避坑指南
2026/10/20 5:37:04 网站建设 项目流程

接触 MySQL 的人,第一次动手写的语句,八成绕不开增删改查这四个字。可真正让我觉得“这玩意没那么简单”的,是我第一次帮人排查一条UPDATE把整张表锁死的问题。当时表只有几万行,条件字段没建索引,语句一执行,写操作全堵住了。那次以后我才意识到,数据库新手阶段最容易被“不就是几句 SQL 嘛”带过去,但实际到了线上环境,一条INSERT、SELECT、UPDATE、DELETE背后牵扯到表结构、索引、事务、锁、日志……任何一个环节没想清楚,都会以很奇特的方式反馈回来。

这篇文章不做那种从入门到劝退的大而全教程,只把我日常最常用的 MySQL 增删改查经验拆开讲一遍:什么时候该怎么写,核心原理是什么,哪些坑我替你踩过了,以及为什么要这么做。内容偏实战,适合刚接触 MySQL 的同学,也适合写过一段时间但想梳理思路的初级后端开发者。你不需要背下来,只需要在下次写语句的时候,多停 5 秒想一想“这句 SQL 到数据库里会怎么执行”。

1. 从一张订单表说起:为什么增删改查要先设计表结构

增删改查只是数据操作的四种动词,真正决定它们效率和安全性的,往往是执行前就已经存在的那张表。很多新人上手就是INSERT INTO ...或SELECT * FROM ...,完全不看字段类型、索引、字符集,结果线上跑出问题才回来改表。MySQL 的增删改查能不能走索引、会不会锁很多行、会不会造成主从延迟,在你建表那一刻已经埋下伏笔。

1.1 新手最容易跳过的步骤:先定义字段再写SQL

我见过不少同事把时间字段存成VARCHAR,把金额存成FLOAT,把状态存成一个带逗号的字符串,比如"0,1,2"。短期看也能查,但后面一旦要排序、统计和范围查询,就特别痛苦。金额用DECIMAL而不是FLOAT/DOUBLE,这是为了避免二进制浮点带来的精度误差;状态用TINYINT而不是字符串,查询时干净又省空间;时间用DATETIME而不是VARCHAR,这样才能走范围索引,也能直接用日期函数。

这听起来很基础,但基础往往决定上限。表结构设计得好,后面的增删改查就是“顺水推舟”;设计得随意,越到后面越像是在一堆乱麻里找线头。

1.2 我常用的一张演示表结构

为了方便后面讲例子,我先建一张订单演示表。没有特殊原因,不用太复杂的业务字段,够说明问题就行:

CREATE TABLE demo_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键', order_no VARCHAR(64) NOT NULL COMMENT '业务订单号', user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID', goods_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '商品名称', amount DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0新建 1已支付 2已发货 3已取消', deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0未删除 1已删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单演示表';

order_no用唯一索引,是因为订单号的业务含义就是“一个订单只能出现一次”。idx_user_id是为了按用户维度查订单,这是最常见的查询路径。idx_create_time是为了时间排序和范围查询。没有谁会真的在每张表建一大堆索引,但索引本身是增删改查提速的基石。

1.3 字符集、排序规则和存储引擎如何影响后续操作

utf8mb4是绝大多数 MySQL 场景的默认选择,它能存 emoji 和完整的 Unicode 字符。排序规则选utf8mb4_0900_ai_ci还是utf8mb4_general_ci,在多数字符串比较场景里差异不大,但尽量保持全库统一,否则 JOIN 时两张表的字符集不同,MySQL 可能会做隐式转换,索引就用不上了。

引擎则强烈建议选InnoDB。它支持事务、行级锁和崩溃恢复,这些能力直接决定了你之后的UPDATE和DELETE能不能回滚、会不会锁表。MyISAM查询在某些场景快,但写入时是表级锁,带回滚和崩溃恢复的成本也高,现代业务系统已经很少用了。

2. INSERT不只是“往里塞数据”:一次写入的完整链路

很多人觉得INSERT是最没技术含量的一条语句。确实,语法上它可能是最简单的,但到了并发场景,插入同样要考虑批量大小、唯一键冲突、事务边界和锁范围。一次插入从客户端发出,到真正落盘,中间要经过解析、权限检查、优化器决策、存储引擎写入、事务日志和binlog记录。你看到的是“一秒执行完”,实际上数据库替你做了很多事。

2.1 最基础的INSERT写法与执行过程

最推荐的写法是显式列出字段,而不是省略字段列表直接VALUES:

INSERT INTO demo_order (order_no, user_id, goods_name, amount, status) VALUES ('202501010001', 10001, '示例商品', 99.90, 0);

显式列名的好处是:即使以后表加了字段,这条语句也不会因为列顺序变化而插入错数据。省略字段列表的写法很依赖当前表结构,一旦字段调整,线上语句很可能就报错或者插到错列上。

当这条语句执行时,InnoDB 会根据主键id使用自增逻辑生成新值,然后把记录写入聚簇索引。如果还有二级索引,比如这里的uk_order_no、idx_user_id,也需要同步维护。字段越多、索引越多,插入成本就越高,这也是为什么索引不是越多越好。

2.2 批量插入:一条语句还是多条语句该怎么选

一次插入多条,最简单的写法是:

INSERT INTO demo_order (order_no, user_id, goods_name, amount, status) VALUES ('202501010001', 10001, '商品A', 99.90, 0), ('202501010002', 10002, '商品B', 199.00, 0);

批量插入的价值在于减少客户端与 MySQL 之间反复通信的损耗。我实测过一个很朴素的数据导入场景:同样插入一万行,单条语句逐个插入需要几秒到十几秒,一次批量插入则往往在几百毫秒到一两秒之间。差别主要来自网络往返次数和 InnoDB 刷盘策略。

但批量也有限度,不是一次塞一百万行就好。单条过长的 SQL 会占用大量内存,也会在binlog里生成超大事件,主从复制时容易把从库拖慢。我自己的习惯是每批控制在 500 到 1000 行左右,数据量大的时候循环分批。如果是在事务里分批,还要注意每批的提交粒度,避免一个事务长时间攥着大量行锁。

2.3 插入冲突与幂等性:处理重复数据的三种方式

业务里经常遇到“数据可能已经存在,我需要保证重复插入不报错”的需求。MySQL 给了几种不同语义的写法,用错会有完全不同的结果。

第一种是INSERT IGNORE:

INSERT IGNORE INTO demo_order (order_no, user_id, goods_name, amount, status) VALUES ('202501010001', 10001, '重复订单', 99.90, 0);

它的特点是,遇到唯一键冲突时直接忽略本次插入,不报错但也不更新原数据,适合只想要“没有就插入,有了就跳过”的幂等逻辑。

第二种是INSERT ... ON DUPLICATE KEY UPDATE,适合“有了就更新某几个字段”的场景:

INSERT INTO demo_order (order_no, user_id, goods_name, amount, status) VALUES ('202501010001', 10001, '商品A', 109.00, 1) ON DUPLICATE KEY UPDATE amount = VALUES(amount), status = VALUES(status);

需要注意,在 MySQL 8.0.20 之后,官方开始弃用VALUES()这种写法,推荐给行起别名:

INSERT INTO demo_order (order_no, user_id, goods_name, amount, status) VALUES ('202501010001', 10001, '商品A', 109.00, 1) AS new ON DUPLICATE KEY UPDATE amount = new.amount, status = new.status;

第三种是REPLACE INTO。它做的事情是:发现唯一键冲突时,先把旧记录删除,再把新记录插入。它确实很方便,但代价也明显:旧记录被删了,如果这条记录有关联外键或者流水日志,数据链路可能被破坏;同时删除再插入会重新生成主键,对自增消耗和索引维护都不友好。我很少在核心业务表上用REPLACE,它隐蔽的危险远大于那一点语法便利。

2.4 插入语句常见误区

插入的最大误区之一是“以为插入失败只是报错”。实际上,一次批量插入如果其中一条违反唯一键,默认情况下整条语句会回滚,之前的行也全不生效。如果你想要部分成功部分跳过,那就得用INSERT IGNORE,或者把批量拆小,逐批判断结果。

另一个误区是忽略隐式事务。默认autocommit=1时,每条 INSERT 都会自动提交,看起来没问题;但如果你在程序里手动开了事务,却忘了在批量循环后提交,连接池里的连接可能一直攥着未提交事务,索引、锁、undo log 都会被拖累。增删改查里很多“卡死”,其实根本不是 SQL 本身的问题,而是事务没闭合。

3. SELECT是门手艺活:从全表扫描到索引命中

SELECT是平时用得最多、最容易被低估的语句。一个 Java 程序员可能天天写SELECT * FROM xxx WHERE yyy,但一条慢查询能拖垮整个业务。SELECT的核心不是“查出来”,而是“怎么查才不慢”。你要知道条件能不能命中索引、排序会不会生成临时文件、分页深了会不会越来越慢、多表 JOIN 的驱动顺序是否合理。

3.1 单表查询的过滤顺序与索引失效案例

用我们前面的演示表,最常见的查询是按用户查未删除的订单:

SELECT id, order_no, goods_name, amount, status FROM demo_order WHERE user_id = 10001 AND deleted = 0 ORDER BY create_time DESC LIMIT 20;

这里user_id有索引,所以 MySQL 能很快定位到该用户的数据,再过滤deleted = 0,最后排序分页。看起来简单,但如果你在后面加一个函数条件,索引就废了:

WHERE DATE(create_time) = '2025-01-01'

因为对索引列做了函数运算,MySQL 基本无法使用idx_create_time进行范围检索,只能先把整表数据取出来算一遍。这种写法我见过很多次,尤其是日期统计场景。解决方式是改成范围条件:

WHERE create_time >= '2025-01-01 00:00:00' AND create_time < '2025-01-02 00:00:00'

另一个典型失效场景是隐式类型转换。如果表的order_no是VARCHAR,你查询时却传了一个数字:

SELECT * FROM demo_order WHERE order_no = 202501010001;

MySQL 会把字符串列转成数字再做比较,索引大概率就用不上了。OR条件也是类似,只要其中一个分支没有独立索引可走,优化器就可能放弃索引改为全表扫描。

3.2 排序、分页和统计:LIMIT深分页怎么解

分页是SELECT里最常见的坑。一个看起来人畜无害的查询:

SELECT id, order_no, amount FROM demo_order ORDER BY create_time DESC LIMIT 100000, 20;

MySQL 在计算这条语句时,会先把前 100020 行数据排好序,然后扔掉前 100000 行,只返回最后 20 行。偏移量越大,排序和丢弃的数据越多,这就是深分页问题的来源。

我自己在后台列表里习惯用“游标分页”替代数字分页。第一次查时返回最后一条create_time或id,下一页带上这个边界:

SELECT id, order_no, amount FROM demo_order WHERE create_time < '2025-01-01 00:00:00' ORDER BY create_time DESC LIMIT 20;

如果排序字段本身有唯一性,比如id,直接拿id做游标是最干净的;如果业务上必须用create_time,就要小心同一秒内大量数据边界重叠的问题,必要时加一个副排序字段,把id作为第二排序条件。

统计类查询也要特别注意。COUNT(*)在 InnoDB 里并不是一个 O(1) 操作,它需要逐行统计。对于大表的常规计数,如果你能容忍近似值,可以考虑单独维护计数表;如果不能容忍,也要确保统计条件的字段走索引,别让每次统计都对全表做扫描。

3.3 联表查询:JOIN别再无脑驱动大表

很多人听过一句口诀:小表驱动大表。这句话本身没有错,但你不能在写 SQL 时自己在那里想“哪张表小,我就把它放前面”。MySQL 优化器通常会重新评估 JOIN 顺序,你手动调整表顺序未必有用。更关键的是连接字段上的索引。

举个例子,我要查订单和用户表中的部分信息:

SELECT o.order_no, o.amount, u.user_name FROM demo_order o LEFT JOIN demo_user u ON u.id = o.user_id WHERE o.status = 1 LIMIT 20;

这里的重点是demo_user.id是主键,o.user_id是普通二级索引。JOIN 时被驱动表如果能通过索引快速定位,整体扫描行数就少;如果被驱动表的连接字段没有索引,MySQL 就不得不逐行去查,产生可怕的“笛卡尔探测”。所以写 JOIN 前,先确认两边的关联字段有没有索引,比口算驱动表更重要。

3.4 EXPLAIN的实际使用:一个执行计划的读法

我真的建议每个写 SQL 的人,遇到任何慢查询,第一件事不是猜,而是执行:

EXPLAIN SELECT id, order_no, amount FROM demo_order WHERE user_id = 10001 AND status = 1;

重点看几个字段:

  • type:ALL是全表扫描,range/ref/const表示能用索引访问。
  • key:实际选择的索引名称。
  • rows:预估扫描行数。
  • Extra:如果出现Using filesort,说明排序没走索引;如果出现Using temporary,说明可能用了临时表。

我不会要求你把每个值都背下来,但至少要能一眼看出“是不是扫了几百万行却只返回几十行”。这种落差一旦出现,基本就是索引没建对,要么干脆没走索引。

4. UPDATE最容易出事的环节:行锁、回滚和误操作

增删改查里,UPDATE是我最谨慎的一条。因为它同时改变了现实数据,而且一旦条件写歪,影响范围可能远超预期。很多人在开发环境执行UPDATE哪怕全表更新也无所谓,数据量小、跑得快就忽略了;到了生产环境,一条全表UPDATE可能锁上千上万行,直接拖垮业务。

4.1 一次UPDATE的完整变化过程

一条常规的更新语句长这样:

UPDATE demo_order SET status = 2 WHERE order_no = '202501010001' AND status = 1;

InnoDB 会先根据order_no唯一索引定位到那一行,加上锁,然后修改内存中的行数据,同时把旧值写入undo log,这样事务回滚时可以恢复。如果这条记录所在页原本不在内存里,还要先从磁盘读出来。修改完成之后,还会同步更新二级索引。最后事务提交时,这些变更会写进binlog,并等待刷盘。

我讲这个过程是想说明一件事:UPDATE不是只改一个字段而已,它的成本跟表的索引数量、是否命中索引、页是否在内存里、事务长短都有关系。尤其要注意,如果你的WHERE条件不能命中索引,InnoDB 可能需要扫描大量数据页,并把沿途读到的行都加上锁,因为 InnoDB 默认是“当前读”,它没法确定未来提交时会不会波及这些行。

4.2 条件不严谨导致的全表更新与恢复思路

最经典的翻车现场是这样的:

UPDATE demo_order SET status = 2;

少了WHERE,把整张表的状态全改了。如果事务还没提交,直接ROLLBACK还能救回来;如果已经自动提交了,业务系统就会收到大量错误数据。恢复手段基本只能依靠备份和binlog:先确认数据损坏的时间点,再用全量备份和增量binlog做时间点恢复。没有备份的话,这条路会非常难走。

所以我个人的铁律是:更新前先看一眼WHERE。针对关键业务表,甚至可以先跑一条SELECT把影响行数查出来:

SELECT COUNT(*) FROM demo_order WHERE status = 1;

然后再执行同条件UPDATE。尽管并发场景下“先查再改”不能完全避免数据变化,但至少能从数量级上避免把全表当目标。

4.3 批量更新与少量更新各自要考虑的事务边界

少量更新适合用短事务。开启事务、执行一两句UPDATE、立刻COMMIT,锁的时间越短越好。批量更新则要把大事务切成小批次,避免一个事务长时间占用大量行锁和 undo 空间。

举个例子,如果我需要把某个时间范围内几百万条订单状态改成“已过期”,我不会写一条:

UPDATE demo_order SET status = 3 WHERE create_time < '2024-01-01';

我会在程序里循环,每次按主键范围捞一批id,然后只更新这批,比如:

UPDATE demo_order SET status = 3 WHERE id BETWEEN ? AND ? AND status != 3 LIMIT 500;

其实UPDATE ... LIMIT在某些版本里语法可用,但更稳妥的方式是先在事务里SELECT id ... FOR UPDATE拿到一批目标,再按主键精确更新。这样既控制了锁的范围,也不会让一条 SQL 在事务里跑得又久又重。

4.4 UPDATE与死锁:一个常见的死锁案例

并发更新时,死锁很难完全避免,但有规律可循。常见的例子是两条事务以相反顺序更新两个资源。

事务 A 先更新id=1的订单,再更新id=2的订单;事务 B 先更新id=2的订单,再更新id=1的订单。两边各自持有对方下一步想拿的锁,InnoDB 检测到死锁后,会回滚其中一个小事务。这时候应用层如果没做重试,用户就会看到一条更新失败。

缓解思路是让所有更新语句尽量按照同一个次序访问资源。比如按主键从小到大更新,事务 A 和事务 B 都先碰id=1再碰id=2,冲突就会大大减少。另一个办法是把大事务拆小,让事务持有锁的时间变短。死锁不可能完全消灭,但多数业务场景都能通过这些手段压到很低。

5. DELETE不是“删掉就行”:物理删除与软删除的选择

DELETE看起来是最果断的操作,但它带来的连锁反应不比UPDATE少。尤其在企业级系统里,数据往往不能直接永久消失,删除后还面临唯一键冲突、后续审计、恢复困难等问题。所以我更愿意先聊“到底该不该删”,再聊“该怎么删”。

5.1 DELETE的执行过程与磁盘层面的影响

你执行:

DELETE FROM demo_order WHERE id = 10001;

InnoDB 并不是立刻把磁盘上的空间归还给操作系统。它先在undo log写下旧值,然后把记录标记为删除,在合适的时机由后台清理或后续复用。这也是为什么一张表数据明明删掉很多,文件大小却不下降。碎片和未回收页面会继续占用空间,但新插入的数据可能复用这些位置。

如果你的业务表经常大量写入和删除,表会慢慢变成“吃内存的大胖子”。这种情况可以做一次表整理,比如OPTIMIZE TABLE demo_order。但注意它会临时锁资源,生产环境一般放在低峰期。

5.2 为什么要经常用软删除

我维护过几个业务系统,最让我省心的设计是给核心表加上deleted标记位,查询时额外加AND deleted = 0,本质上不删数据,只做逻辑标记。这样做的直接好处是数据可以回溯、恢复成本低、审计方便。

但软删除也有代价。第一,所有查询都要记得过滤deleted = 0,漏掉一次就会把“已删除”数据查出来。第二,唯一索引会和软删除冲突。比如order_no有唯一约束,一条订单被软删除后,新订单如果还复用同一个订单号,就会插入失败。常见的解法是把唯一键里加一个时间戳或者删除人字段,比如(order_no, deleted_at),已删除记录写入非空deleted_at,新记录为NULL,利用 MySQL 唯一索引允许多个 NULL 的特性绕过冲突。这个细节很容易踩,提前想好就少折腾一次。

5.3 TRUNCATE、DROP和DELETE三者的区别与使用边界

三者在效果和控制力上差别很大:

操作是否可回滚是否释放空间是否保留表结构典型使用场景
DELETE FROM t在事务内可回滚通常不立即释放保留删除部分行或需要恢复的数据
TRUNCATE TABLE t通常不可回滚立即释放保留快速清空临时表、重置数据表
DROP TABLE t通常不可回滚立即释放不保留彻底删除整张表

TRUNCATE的底层是直接重建表空间,速度很快,但它不是逐行删除,不会逐行触发DELETE相关的业务逻辑,也不能在事务里简单地回滚。DROP更彻底,表结构都没了。所以线上慎用后两者,除非你非常确定这些数据不再需要。很多时候大家都在追求“快”,但删错之后没有任何后悔药。

5.4 数据恢复的几条挽救路径

如果真的误删了,不要马上去动表,先做三件事:立即停掉对表的写入、确认是否开启了binlog、确认最近的备份策略。如果数据都在 InnoDB 里,且开启了binlog和定期全备,可以通过备份 +binlog把数据恢复到误删前的时间点。这个操作比较重,一般由 DBA 完成,但它确实是最可靠的手段。

如果什么都没配,就只能看undo log或一些第三方工具能否从.ibd文件里捞出残余信息。备份的重要性再怎么强调都不为过。很多项目初期数据量小,没认真做备份,可一旦出问题,代价远远超过当初省下的几分钟。

6. 几个让我少写检查报告的实操习惯

这部分不算是复杂技术,但都是我踩过坑之后沉淀下来的习惯。我不讲大道理,只讲我每次碰增删改查时心里那几条固定动作。

第一,查询永远写明确列名,不随手SELECT *。它既能减少不必要的网络传输,也能避免程序因为表结构变化而悄悄多取字段。

第二,UPDATE和DELETE执行前,先跑一遍EXPLAIN。如果发现type = ALL或扫描行数巨大,哪怕语句能跑通,我也会先停下来,因为线上环境可能根本不是同一个数量级。

第三,有条件就开一个小事务,修改完立刻提交。短事务比长事务安全得多,锁的窗口越小,死锁和阻塞的概率越低。

第四,批量操作一定分批。不管是批量插入还是批量删除,我都按 500 到 1000 一批来处理。单个请求不会拖垮数据库,也不会在binlog里留下一个巨大事务。

第五,对删除操作多想一下“软删除是否更合适”。数据是企业最宝贵的资产之一,有些表从设计上就根本不该允许物理删除。

这些习惯谈不上新鲜,但每一个都是我实打实踩坑换来的。如果有人问我,MySQL 的增删改查到底难在哪,我的回答从来不是语法,而是你是否想清楚每一条语句在数据库中会如何行走、锁多少行、留多少日志。想清楚这几件事,增删改查就算真正入门了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询