“为什么我 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;
这没问题。但如果你写成 SET age = '5' + age,MySQL 会做隐式类型转换,字符串 '5' 被转成数字 5,结果倒是一样。但如果字段是 VARCHAR 存着 'abc',你用 status 字段去加,就要注意隐式转换可能导致索引失效。整数类型加字符串,只要有字母开头,转换后就是 0,还会触发告警。
1.3 DELETE 与 TRUNCATE:删错数据后的逃生通道各不相同
DELETE 和 TRUNCATE 的区别,很多人背概念背得滚瓜烂熟,但实际操作时不一定拎得清。
DELETE 是 DML,逐行删除,每行都会记 binlog,可以加 WHERE 条件,可以在事务里有回滚的机会。TRUNCATE 是 DDL,整体重建表,速度很快,但不能加 WHERE 条件,不能回滚。前者是"可反悔的删除",后者是"直接推倒重建"。
我建议在业务代码里永远不要用 TRUNCATE。TRUNCATE 不仅不能回滚,还会重置 AUTO_INCREMENT 自增计数。如果你的业务里 id 有连续性要求(虽然一般不建议依赖这个),TRUNCATE 后自增 ID 从头开始,可能造成主键冲突或业务感知混乱。
DELETE 还有一个性能陷阱:DELETE FROM user WHERE age BETWEEN 20 AND 30 如果命中了 10 万行,InnoDB 会一条条加锁删除,行锁持有期间所有涉及这些行的其他事务都会阻塞,容易引起锁等待甚至死锁。上线前务必评估删多少数据,大范围删除建议分批:
sql复制DELETE FROM user WHERE age BETWEEN 20 AND 30 LIMIT 1000;
循环执行直到受影响行数为 0。但注意,MySQL 的 DELETE 语句 LIMIT 子句不能配合多表删除使用(8.0 版本之前单表可以,多表不支持)。另外,每次执行之间建议加个 SLEEP 或者业务层 Thread.sleep(100),给其他事务留出窗口,别把 binlog 写爆也别一直占着锁。
DELETE 后磁盘空间不会立刻归还,InnoDB 的表空间文件不会变小。原因是删除的数据只是被标记为"可复用",物理空间还在文件里。如果你删除大量数据后想“缩容”表空间,要执行 OPTIMIZE TABLE 或 ALTER TABLE ... ENGINE=InnoDB 重建表。这个在很多生产系统里属于 DBA 操作,业务侧一般不用管。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么说事务是增删改的安全带
单条增删改其实每条 SQL 都可以看作一个自动提交的事务。但真实业务里,往往是一连串增删改要作为一个整体执行。这时候,事务的价值就出来了。理解事务之前,先看一个真实案例。
2.1 从一次“转了两次账”的线上事故说起
有个做支付的老哥跟我说过一件事。他们的转账接口原来是这么写的:
java复制// 伪代码
deductFromAccount(fromUser, amount);
addToAccount(toUser, amount);
两行代码之间没有任何事务控制。一开始并发量小,没出问题。后来某个促销活动流量一上来,deduct 成功之后、add 之前,应用突然抛了异常。结果是:扣款成功,入账没执行,用户钱凭空少了。这还不是最惨的,最惨的是排查日志要同时翻应用日志和数据库 binlog,定位是哪一笔扣了没补。
如果把两行包在同一个事务里:
java复制@Transactional
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
userMapper.deductBalance(fromUserId, amount);
userMapper.addBalance(toUserId, amount);
}
任何一步失败,整个事务回滚,不会出现扣款成功但入账失败的状态。这就是事务最基础也最核心的价值:把多个增删改变成一个“原子操作”。
2.2 四大特性逐个拆解:原子性、一致性、隔离性、持久性
事务的四大特性 ACID,网上到处都是概念,但结合具体的数据库行为讲一遍会更好懂。
原子性(Atomicity):事务里的所有操作,要么全部成功,要么全部失败,不存在中间状态。InnoDB 通过 undo log 来实现这一条。每次修改数据前,会先写一条相反的 undo 记录到 undo log 里。事务回滚时,根据 undo log 把数据恢复成修改前的样子。你可以理解为数据库给自己留了一本“回放账本”,出错就按账本倒着恢复。
一致性(Consistency):事务执行前后,数据要满足所有的约束规则。比如外键约束、唯一索引、非空约束,以及业务自定义的规则。一致性是最终目标,原子性、隔离性、持久性其实都在为一致性服务。一致性并不完全靠数据库完成,业务逻辑本身就负有重大责任。举个例子:转账事务里,如果业务逻辑本身就写错了,把加钱写成了扣钱,那数据库再怎么遵守 ACID,金额也是错的。
隔离性(Isolation):多个事务并发时,彼此不能看到对方未提交的中间状态。数据库通过锁和 MVCC 机制实现隔离性。这个部分我会在后面用专门章节深讲,这是很值得花精力理解的机制。
持久性(Durability):事务一旦提交,数据修改就永久保存在磁盘上,不会因为数据库重启或崩溃而丢失。InnoDB 通过 redo log 实现持久性。每次提交事务时,会把修改写入 redo log 文件(其实是 redo log buffer 刷到磁盘),即使数据页还没来得及刷盘,数据库崩溃后重启也能根据 redo log 重新恢复。这里有个细节你可能听过:“innodb_flush_log_at_trx_commit = 1”表示每次事务提交都强制把 redo log 刷入磁盘,是最安全的配置,但性能开销最大,等于多一次磁盘 fsync。如果你的业务对数据丢失极其敏感,比如支付、订单,必须保持这个配置为 1。
2.3 事务的三种开局方式:隐式、显示、自动提交开关
MySQL 里事务常见的操作方式有三种。
- 隐式事务:执行单条 DML 语句自动提交。比如我直接执行
UPDATE user SET age = 26 WHERE id = 1,它自己就是一个事务,执行完自动提交。 - 显式事务:手动控制:
START TRANSACTION; ... COMMIT;或ROLLBACK; - 自动提交开关:
SET autocommit = 0之后,所有 DML 语句都不会自动提交,必须手动 COMMIT 才会真正生效。这个模式下如果忘了 COMMIT,事务会一直开着,持有的锁一直不释放,线上很容易出 lock wait timeout。
sql复制START TRANSACTION;
UPDATE user SET balance = balance - 100 WHERE id = 1;
UPDATE user SET balance = balance + 100 WHERE id = 2;
COMMIT;
举个例子:上面这段,如果第二条 UPDATE 因为某些原因失败了,你直接执行 ROLLBACK,两条语句都不会生效,余额一分没动。这在转账场景就是标准的安全姿势。
我个人的建议:在代码使用数据库连接池的时候(比如 HikariCP、Druid),每拿到一个连接就手动执行 SET autocommit = 1,保证从池子里拿到的连接状态是干净的。否则上一个请求如果设置了 autocommit = 0 没还原,连接还回去后下一个用户拿着这个连接,SQL 全不自动提交,那场面会非常失控。这个坑我确实踩过,排查了一下午才定位到是连接池里残留了 autocommit=0 的状态。
3. 四种隔离级别:你的事务到底读到的是哪份数据
隔离性是有不同等级的区别的。MySQL 提供四种隔离级别,从低到高分别是读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE)。
在讲隔离级别之前,必须先搞明白三个“读”问题的定义。
3.1 三个经典的并发读问题:脏读、不可重复读、幻读
脏读:事务 A 读到了事务 B 还没提交的数据。如果事务 B 回滚了,事务 A 读到的就是“临时但不存在”的数据。最典型的脏读场景:事务 A 查询到了事务 B UPDATE 但未提交的新余额,借给用户看了,结果事务 B 回滚,用户看到的余额和实际库里的不一致。
不可重复读:事务 A 内两次读取同一条数据,读到不同的值。原因是事务 B 在这期间修改并提交了这条数据。说白了,就是同一事务里,前后读同一行,结果不一样。注意,事务 B 已经提交,读到新值不算脏数据,但对事务 A 来说,自己事务内数据“不稳定”。
幻读:事务 A 两次执行同一范围查询,第二次查询多出了第一次没见过的行。原因是事务 B 向这个范围插入了新数据并提交。幻读和不可重复读的区别:不可重复读针对一条已存在的记录,值是变了;幻读针对一个范围的记录,记录条数变了。
搞清楚这三个问题,我们看四种隔离级别各防住了哪些。
3.2 隔离级别对比:一张表把四种级别全看明白
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现机制 | 默认数据库 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 直接读最新版本 | 极少使用 |
| READ COMMITTED | 不会 | 可能 | 可能 | MVCC 每个快照读生成新 ReadView | Oracle、PostgreSQL 默认 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB 通过间隙锁基本解决) | MVCC 一个事务用同一 ReadView | MySQL 默认 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 所有读都加锁,串行执行 | 性能最低,几乎不用 |
MySQL 默认的隔离级别是 REPEATABLE READ。为什么 MySQL 选择这个作为默认?相比 Oracle 默认的 READ COMMITTED,REPEATABLE READ 的隔离能力更高,而且 InnoDB 用间隙锁(Gap Lock)在 REPEATABLE READ 下解决了大部分幻读问题,代价也不算夸张,所以直接把它定为默认级别。这也是 MySQL 与 Oracle 一个很有标识性的差异。
3.3 READ COMMITTED 与 REPEATABLE READ 的行为差异
这个部分要展开讲一下,因为在面试和实际排查里都很有用。
READ COMMITTED 下,事务内每次 SELECT 都会生成一个新的 ReadView 快照。也就是说,每次读都是“最新已提交版本”。举个例子:
sql复制-- 事务 A
START TRANSACTION;
SELECT balance FROM user WHERE id = 1; -- 读到 100
-- 此时事务 B 将 id=1 的 balance 改为 200 并提交
SELECT balance FROM user WHERE id = 1; -- 再读,读到 200
COMMIT;
两次读结果不同,这就是不可重复读。
REPEATABLE READ 下,事务首次进行快照读时生成一个 ReadView,整个事务期间都用这个版本快照。上面的例子改成 REPEATABLE READ:
sql复制-- 事务 A
START TRANSACTION;
SELECT balance FROM user WHERE id = 1; -- 读到 100
-- 此时事务 B 将 id=1 的 balance 改为 200 并提交
SELECT balance FROM user WHERE id = 1; -- 再读,依然读到 100
COMMIT;
同一个事务里,读到的始终是事务开始时的快照。这就是“可重复读”的核心含义。
这里有一个容易混的点:对于 UPDATE、DELETE、SELECT...FOR UPDATE 这类“当前读”,读取的都是最新已提交版本,即使 REPEATABLE READ 下也会读到新数据。举个能看到的例子:
sql复制-- 事务 A
START TRANSACTION;
SELECT * FROM user WHERE id = 1; -- 快照读,balance = 100
-- 事务 B 将 balance 改成 200 并提交
SELECT * FROM user WHERE id = 1 FOR UPDATE; -- 当前读,balance = 200
SELECT * FROM user WHERE id = 1; -- 快照读,balance 还是 100
COMMIT;
这就是为什么你会在一个 REPEATABLE READ 事务里,同一行数据用普通 SELECT 和 FOR UPDATE 读到不一样的值。初看有点分裂,但理解了快照读和当前读的区别就不会再迷惑了。
4. 锁和 MVCC:并发事务不被搞乱的两根支柱
前面讲了事务的隔离级别,但隔离级别只是承诺,真正实现这些承诺的是两套机制:锁(Lock)负责处理写写冲突,MVCC(多版本并发控制)负责处理读写不互斥。这是 InnoDB 在并发性能上领先的核心原因。
4.1 行锁、表锁、间隙锁到底锁的是什么
InnoDB 的锁可以按粒度分成行锁和表锁,但行锁是主体。还有一个常被提起的是间隙锁。
行锁(Record Lock):锁住索引记录本身。注意,InnoDB 的行锁是加在索引上的,如果表没有普通索引,会用隐藏的聚簇索引(也就是主键)锁。所以没有主键的表,InnoDB 会生成一个隐式主键,每个行锁实际锁的是主键索引上的记录。这就是为什么强烈建议每张表都要有主键,而且主键最好是自增或有序的,避免页分裂和锁浪费。
间隙锁(Gap Lock):锁住索引记录之间的“间隙”,防止其他事务在这个区间插入新记录。这是它和幻读的对抗武器。比如:
sql复制-- user 表 age 字段有索引 idx_age,现有 age 值为 20, 30, 40
START TRANSACTION;
SELECT * FROM user WHERE age BETWEEN 20 AND 30 FOR UPDATE;
这时 InnoDB 会锁住 age=20 和 age=30 这两条记录,同时锁住 (20,30) 这个间隙。其他事务想插入一条 age=25 的记录,会被阻塞,直到这个事务提交。这样在 REPEATABLE READ 下,同范围查询不会因其他事务插入新数据而“幻”出多一行。这就是 InnoDB 下幻读“基本解决”的含义。
表锁(Table Lock):InnoDB 里也有表锁,比如 ALTER TABLE、OPTIMIZE TABLE 这些 DDL 操作会锁表。同时 InnoDB 引入了意向锁(Intention Lock)来快速判断一张表里有没有行锁被占用。行锁和表锁之间存在兼容性矩阵,意向锁的作用就是告诉其他事务“我这表里可能已经有人锁了行,你先别锁表”。日常开发里,业务 SQL 很少直接触发表锁,但如果 UPDATE 或 DELETE 没走索引,行锁就可能升级成表锁。这个在下一节展开。
还有自增锁(AUTO-INC Lock):InnoDB 在插入包含自增列的数据时,需要获取自增锁,保证自增值不重复。8.0 之前,自增锁是表级锁,插入完成后释放。8.0 开始引入了可配置的 innodb_autoinc_lock_mode,默认是 2(交错模式),性能更高,但 binlog 如果用 STATEMENT 格式,并发插入时自增值可能不连续,这会引起主从数据不一致,所以 8.0 里 binlog_format 默认已经改成 ROW,避免这个问题。
4.2 索引失效如何让行锁升级成表锁
这一节是很多人都会踩的实际坑。先说原理:InnoDB 行锁是“锁索引记录”。如果查询条件不能利用索引,InnoDB 只能扫描全表,那它就必须把扫描过的所有行都锁住,防止其他事务修改出现不一致。扫描到的行多了,效果就跟锁表一样,实际排查时往往也会直接归结为“表锁”。
最典型的是对没有索引的字段直接 UPDATE。比如 user 表给 name 字段加索引之前,执行:
sql复制UPDATE user SET status = 0 WHERE name = '张三';
如果 name 没有索引,这条 UPDATE 会全表扫描,把所有行都加上锁。线上效果就是,所有写操作全部阻塞,严重的直接打挂数据库。
所以设计表结构的时候,凡是经常出现在 WHERE、ORDER BY、JOIN ON 条件的字段,都应该评估加索引。加索引要控制数量和长度,不要无脑加。索引不是越多越好,每个索引都会占用额外磁盘空间,写入时要更新索引,写入性能会下降。一般单表索引控制在 5 个以内,联合索引要遵循最左前缀原则。
顺带说一个排查锁问题的实用方法。执行下面这句,能直接看到当前有哪些锁等待:
sql复制SHOW ENGINE INNODB STATUS;
里面有最新的死锁和锁等待信息。也可以通过 information_schema.innodb_trx、innodb_locks、innodb_lock_waits 这几张系统表来查当前的事务和锁等待关系。我在排查线上锁表问题时,先查 innodb_trx 看哪个事务跑的时间最久、状态是 RUNNING 还是 LOCK WAIT,再加锁等待表定位是哪条 SQL 堵住了谁,基本一把梭。
4.3 MVCC:让读不阻塞写、写不阻塞读
MVCC 全称 Multi-Version Concurrency Control,多版本并发控制。它解决的问题是:读操作和写操作并发时,不互相阻塞。比如事务 A 正在写某一行,事务 B 可以同时读这行,读到的是 A 修改前的旧版本。如果没有 MVCC,读操作只能等写操作释放锁,那数据库并发能力会断崖式下降。
MVCC 的核心实现是 undo log 版本链和 ReadView。
每行记录上其实隐藏着几个字段:DB_TRX_ID(最近修改该行的事务 ID)、DB_ROLL_PTR(回滚指针,指向该行在 undo log 里的旧版本)。修改一行时,旧值会写到 undo log,新行上的 DB_ROLL_PTR 指向旧版本,形成一条版本链条。
ReadView 是“当前事务能看到哪些版本”的判定依据。ReadView 里记录了几个关键信息:m_ids 表示生成 ReadView 时活跃的事务 ID 列表;min_trx_id 是活跃事务里的最小 ID;max_trx_id 是下一个将要分配的事务 ID。判断规则是:
- 如果被访问版本的 DB_TRX_ID 小于 min_trx_id,说明这个版本是已提交的,可见。
- 如果 DB_TRX_ID 大于等于 max_trx_id,说明这个版本是在 ReadView 之后才产生的,不可见。
- 如果 DB_TRX_ID 在 min_trx_id 和 max_trx_id 之间,且不在 m_ids 列表中,说明事务已提交,可见;如果在 m_ids 里,说明事务还活跃,不可见。
这句话初看很绕,但你可以打个比方:ReadView 是站在事务开始时的一张“已提交名单”,所有后来才提交的事务,你的事务一概不认,只认名单里已提交的数据。这样,不管其他事务后来怎么改,你的事务里看到的始终是事务开始那一刻的世界。
MVCC 的快照读和当前读在前面也提到了。快照读是普通 SELECT,不加锁,走 MVCC;当前读是 INSERT、UPDATE、DELETE、SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE,读取最新版本并加锁。因为 UPDATE 必须先找到最新的行再加锁,所以即使 REPEATABLE READ 下,当前读也能看到其他事务已提交的最新修改。
5. 实战中那些让你崩溃的事务坑:失效、超时、锁表
理论知识铺垫完了,接下来是压轴的实战环节。这些坑基本都是从网上和实际项目中收集来的,每一个拿出来都能让一个新手排查半天。
5.1 Spring 事务失效的典型场景
热词里提到了 “springboot 事务失效场景”,这个确实是后端开发的高频问题。这里列几个最典型的失效场景,再讲解原理和规避方案。
事务方法被同类内部调用。这是最经典的失效场景。原理是 Spring 事务基于 AOP 代理,只有通过代理对象调用的方法,事务切面才会生效。同类内部 this.method() 调用,走的是原生对象,没有经过代理,事务注解自然不生效。
java复制public void processA() {
// 这里不是通过代理调用,事务切面不会生效
this.updateData();
}
@Transactional
public void updateData() {
// do something
}
解决办法:注入自身代理对象,或者把 updateData 抽到另一个 Bean 里再注入调用。
事务方法不是 public。Spring 事务切面对非 public 方法不生效。CGLIB 代理对 protected/private 方法的拦截行为在 Spring 5 之前完全无效,Spring 5 之后 protected 在部分场景能用,但官方文档明确建议只对 public 方法加事务注解,其他一律不保证。这个别抱着侥幸心理,统一用 public。
异常被 catch 住了。事务方法里 try-catch 住了异常,没有抛出到事务切面,Spring 就感知不到异常,不会回滚。
java复制@Transactional
public void doSomething() {
try {
jdbcTemplate.update(...);
} catch (Exception e) {
log.error("插入失败", e);
// 异常被吞掉,事务不会回滚
}
}
应该把异常重新抛出去,或者手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
抛出的是受检异常。Spring 默认只在 RuntimeException(非受检异常)和 Error 时回滚,受检异常默认不回滚。解决办法是注解里显式指定 @Transactional(rollbackFor = Exception.class)。这个每个团队都应该在代码规范里要求写死,避免默认行为差异导致线上数据不一致。
数据库存储引擎是 MyISAM。MyISAM 引擎本身不支持事务,START TRANSACTION 执行后 CREATE、DROP 等 DDL 自动提交,UPDATE 等 DML 语句执行后也会立即提交,事务注解形同虚设。现代 MySQL 8.0 默认引擎已经是 InnoDB,但之前从低版本迁移过来的表可能还是 MyISAM。可以查一下:
sql复制SELECT table_schema, table_name, engine FROM information_schema.tables WHERE engine = 'MyISAM';
如果发现业务表是 MyISAM,强烈建议迁移到 InnoDB:ALTER TABLE table_name ENGINE=InnoDB;。MyISAM 不支持行锁、不支持崩溃恢复,全表锁的并发瓶颈在今天的业务场景下已经不可接受了。
5.2 事务超时、锁等待和大事务的危害
线上最常出现的等待事件是 Lock wait timeout exceeded。默认 innodb_lock_wait_timeout 是 50 秒,超过这个时间会报错并回滚当前语句。
常见原因:一个事务长时间不结束,锁一直不释放,其他事务等锁超时。比如代码里对某条数据 SELECT ... FOR UPDATE 之后,后面又调了远程 HTTP 接口,挂了 10 秒,那这 10 秒锁一直握着。如果这期间有其他请求也要操作同一行,只能等。
大事务的危害比想象中严重。一个事务执行时间很长、改动数据很多,会带来一连串问题:持有的锁时间过长,阻塞大量并发操作;undo log 不断膨胀,回滚段占用大量磁盘和内存;主从延迟,因为从库要等一个事务完整执行完才能应用 binlog。所以设计事务时,尽量做到“快进快出”:事务里不要包含远程 RPC 调用、不要循环批量处理太多数据、不要在事务里做复杂的业务计算。
排查事务超时的标准链路大致是:
sql复制-- 1. 查看当前有哪些事务在跑
SELECT * FROM information_schema.innodb_trx\G;
-- 2. 看锁等待关系
SELECT * FROM information_schema.innodb_lock_waits;
-- 3. 看当前持锁的语句和事务
SELECT * FROM sys.innodb_lock_waits;
根据查询结果,定位到长时间运行的事务对应的 connection 和 SQL。如果是测试环境或非核心业务,可以直接 KILL 掉卡住的事务连接 KILL <thread_id>,释放锁。根治方案是优化业务逻辑,缩短事务时间。
5.3 死锁:两个事务互相等对方释放锁
死锁的经典案例是两个事务各持有一把锁,然后互相请求对方手里的锁。比如:
事务 A:UPDATE user SET age=1 WHERE id=1; UPDATE user SET age=2 WHERE id=2;
事务 B:UPDATE user SET age=3 WHERE id=2; UPDATE user SET age=4 WHERE id=1;
如果两个事务恰好交错执行:A 锁了 id=1,B 锁了 id=2,然后 A 请求 id=2 的锁,被 B 堵住;B 请求 id=1 的锁,被 A 堵住。死锁就产生了。
InnoDB 的死锁检测机制会自动发现并回滚其中一个事务(一般是 undo 量小、代价低的事务)。反馈到应用端就是一条 Deadlock found when trying to get lock; try restarting transaction 异常。
规避死锁的实用手段:
- 多个事务按相同顺序访问表和行。比如上面的例子,都先更新 id=1 再更新 id=2,就不会死锁。
- 尽量缩短事务操作时间,减少持锁窗口。
- 不同业务访问同一张表时,尽量控制并发度。
- 在应用程序层做重试机制。死锁发生后,捕获异常,重新执行一次事务。大部分死锁重试一次就能成功。
5.4 分布式事务:本地事务解决不了跨库问题
最后简单提一下分布式事务,因为这个也是热词“分布式事务”相关的。如果你只有一个 MySQL 实例,那所有更新都在同一个数据库里,本地事务就能保证原子性。但微服务拆开以后,业务操作可能要同时更新多个数据库,甚至跨 MySQL、跨 RocketMQ、跨缓存。
比如下单操作:订单库建单、库存库扣库存、发一条消息给积分服务。如果订单库成功了,库存库失败,本地事务已经救不回来了。这时候需要分布式事务方案。
常见方案有:
- 二阶段提交(2PC):数据库层面支持,但实现复杂、性能差,日常业务很少直接用。
- TCC(Try-Confirm-Cancel):业务补偿,灵活性高,但开发成本也高。
- 本地消息表:把消息和业务操作放在同一个事务里,先写业务数据,再写一条消息记录,用定时任务扫描发送,保证最终一致。
- MQ 事务消息:RocketMQ 的事务消息基本就是本地消息表的思路,但把消息发送和业务操作放进了同一事务。
分布式事务没有银弹,每种方案都要在一致性、性能和开发成本之间做取舍。如果你们团队没有专门的中间件基础设施,建议先从本地消息表或 MQ 事务消息做起,TCC 和 2PC 一般不要轻易上。
最后说点实在的。我自己用过一段时间的 MySQL 总结下来,增删改和事务是分不开的整体:你写每一条 UPDATE 时,心里都要有锁的概念;你设计每个事务时,都要想隔离级别和超时时间;你上线每个新功能前,都应该检查一遍索引是否命中。特别是新手阶段,容易觉得“SQL 不报错就是成功”,但这个认知在并发场景下特别危险。建议有条件的话,把 SHOW ENGINE INNODB STATUS、information_schema.innodb_trx 这两张表上的查询练熟,线上出锁问题了你能第一时间定位。
还有一个小技巧:所有 UPDATE 和 DELETE 语句,写完先在测试库上跑一遍,看一眼 rows affected,再在事务里执行并 ROLLBACK,可以完整验证 SQL 的影响范围,不影响真实数据。这套习惯我坚持了很久,帮我挡掉了至少三次误伤全表的事故。MySQL 的核心知识其实不算多,把增删改和事务这几个点吃透了,后面再看索引优化、主从复制、分库分表就顺很多。
