“为什么我 UPDATE 一条记录,数据库卡了十几秒才返回?明明是行锁,怎么会把整张表锁住?”这是我一个朋友前几天踩的坑。他在一张千万级的订单表上直接 UPDATE,没走索引,结果 InnoDB 从行锁升级成表锁,后面的请求全堵住了。你看,MySQL 的增删改和事务,账面是几条 SQL 语法,但一到真实业务里,背后全是锁、事务隔离级别、MVCC 这些机制在起作用。
这篇内容我写了很久,把 MySQL 的表的增删改(DML)和事务相关的核心知识串在一起,用实际能跑的例子和踩坑记录来讲。适合正在学 MySQL 的开发者、刚接手业务库需要写更新语句的后端,以及那些被线上锁表、事务失效问题折磨过的运维和 DBA。读完你至少能搞清楚三件事:增删改语句到底有哪些隐藏细节、事务隔离级别和锁是如何影响你的每条 UPDATE、以及线上事务失效和锁等待该怎么排查。我用的是 8.0 版本的 MySQL,所有 SQL 都实测跑过。
1. 一张用户表带你走完增删改的全部动作
先不说高深的理论,我们从一个最实际的场景切入。假设业务里有一张用户表,结构大概长这样:
sql复制CREATE TABLE `user` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(32) NOT NULL DEFAULT '',
`age` int NOT NULL DEFAULT '0',
`balance` decimal(10,2) NOT NULL DEFAULT '0.00',
`status` tinyint NOT NULL DEFAULT '1' COMMENT '1-正常 0-停用',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_age` (`age`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
别小看这张表,后面锁升级、事务失效的例子都会用到它。下面把增删改三个动作拆开细讲,重点不是背语法,而是理解语句背后的行为。
1.1 INSERT 的几种姿势:单条、批量与 INSERT...SELECT
INSERT 最常见,也最容易被低估。很多新人只知道最简单的 INSERT INTO user (name, age, balance) VALUES ('张三', 25, 100.00),但真正到业务里,高频场景其实是另外两种。
批量插入:一次性插入多条,减少客户端与数据库的往返次数。
sql复制INSERT INTO user (name, age, balance) VALUES
('张三', 25, 100.00),
('李四', 30, 200.00),
('王五', 28, 150.00);
别一条一条 INSERT 了,一次批量插入 1000 条,比循环执行 1000 次单条 INSERT 快一个量级。但注意,单条 INSERT 语句有 max_allowed_packet 限制,默认一般是 64MB,批量插入的数据量不要超过这个值,建议每批 500~2000 条,看单条记录的大小而定。
另一种高价值姿势是 INSERT ... SELECT,把一张表的数据直接灌到另一张表:
sql复制INSERT INTO user_bak (name, age, balance, status, created_at)
SELECT name, age, balance, status, created_at FROM user WHERE status = 1;
这个在数据归档、分表迁移的场景里特别常用。注意两点:目标表和源表的列要能对上,最好显式列出列名,别用 INSERT INTO user_bak SELECT *,一旦表结构变动,线上直接就炸了。另外,如果源表数据量非常大,这种操作会持有源表的共享锁,可能导致源表业务写入被阻塞,建议分批加 WHERE 条件导。
还有个细节值得说:INSERT 时有重复主键或唯一键时,可以用 ON DUPLICATE KEY UPDATE 来实现"不存在就插入,存在就更新"的 upsert 语义。这个在用户签到、流水幂等等场景里极其好用。
sql复制INSERT INTO user (id, name, age) VALUES (1001, '赵六', 26)
ON DUPLICATE KEY UPDATE name = '赵六', age = 26;
我第一次在实际项目里看到有人手动先 SELECT 再判断 UPDATE 还是 INSERT,我当时这么干被 mentor 说了好久。数据库本身就提供了 upsert 能力,不需要你写三步逻辑,那样既慢又容易在并发下出问题。
1.2 UPDATE 的两个重灾区:忘记 WHERE 和跨表更新
UPDATE 这个命令,我在培训新人的时候反复强调一句话:写 UPDATE 之前,先写等价的 SELECT 确认范围。 这不是保守,是真的能救命。你不带 WHERE 条件执行一次 UPDATE user SET status = 0,全表状态全改掉,如果 binlog 是 ROW 格式,还可以闪回或从备份恢复;但如果没开 binlog,那真的是回天乏术。
UPDATE 的基本语法是:
sql复制UPDATE user SET age = 26 WHERE id = 1002;
但这个语句有一个隐藏行为值得注意:就算 SET 的字段值没有变化,比如 UPDATE user SET age = 26 WHERE id = 1002 而 id=1002 的 age 本来就是 26,MySQL 依然会返回 Rows matched: 1 Changed: 0,不会产生实际写入和 binlog 记录。所以判断一个 UPDATE 到底有没有影响数据,要看 Changed 而不是 Rows matched。
另一个高频需求是"用一张表的值更新另一张表"。这里有个语法坑。很多人会写:
sql复制-- 这行在 MySQL 里其实不支持标准 SQL 的 UPDATE FROM 写法
UPDATE user SET balance = (SELECT balance FROM user_new WHERE user_new.id = user.id);
在 MySQL 里,你可以直接用关联子查询,也可以语法上写两张表:
sql复制UPDATE user u
JOIN user_new un ON un.id = u.id
SET u.balance = un.balance;
这个 JOIN UPDATE 的写法是 MySQL 特有的,比子查询性能好很多。子查询方式在 user_new 表数据量大时,需要额外创建临时表,JOIN UPDATE 直接走连接执行。
还有一个坑:UPDATE 语句里做计算时,字段类型是 INT 的用法要特别注意。比如热词里提到的 "mysql中int+5" 这类问题:
sql复制UPDATE user SET age = age + 5 WHERE id = 1002;
这没问题。但
