1. 先弄明白 MySQL 事务到底在保什么
1.1 从一次扣款说起:事务要解决的是四个不确定性
我一直觉得,讲 MySQL 事务最忌讳一上来就甩 ACID 四个英文字母。那些词背下来很容易,但真正在线上出事的时候,脑子里能立刻浮现出"现在这条连接在哪个隔离级别、它的 ReadView 是什么时候生成的、这个 update 加了什么锁",才算真的懂了。
先从最朴素的场景切入。一张账户表,A 扣 100、B 加 100,两条 update 语句。这两条语句中间可能发生的事很多:第一条执行完机器断电了、第一条执行完第二条报错了、两条都执行完了但还没落盘主从切换了、两条同时和另一个转账请求交叉执行了。MySQL 事务存在的唯一理由,就是把这四种不确定性压成一种确定的结果——要么两条都生效,要么两条都不生效,别的连接看到的中间状态必须是完整的、一致的。
这就自然引出四个约束:原子性保证两条语句同生共死,靠的是 undo log 能把改过的行按版本回滚回去;一致性是业务层面的目标,数据库本身不生产一致性,它只提供工具;隔离性负责让并发的事务互相不看到对方的半成品,靠的是锁加 MVCC;持久性保证提交了就不丢,靠的是 redo log 的顺序写加刷盘。这四个词里,只有隔离性是"可调"的,另外三个是底线。
我见过太多人把事务当成一个"包一层就安全"的东西,实际上事务的语义边界远比代码里的 try-catch 复杂。你在业务代码里圈定的范围,和数据库里真正开启事务、真正持锁、真正释放锁的时机,经常对不上。这个错位,是绝大多数线上事务问题的根源。
1.2 ACID 不是四个并列口号,而是几份日志加一套锁协议撑起来的
新手容易把 ACID 理解成四个独立的特性,实际它们是由同一套底层机制耦合出来的。
undo log是逻辑日志,记录的是"这条记录被改成什么样之前是什么样"。它承担两个职责:一是回滚,事务失败时按 undo 记录反向操作;二是 MVCC 的版本链,每行记录的隐藏字段DB_ROLL_PTR指向 undo 里的旧版本,DB_TRX_ID记录最后修改它的事务号。你查到的"旧数据",其实是从 undo 里拼出来的。
redo log是物理日志,记录的是"某个数据页的某个偏移量改成了什么"。它解决的是刷盘性能问题——改一页 16KB 太贵,改成顺序追加几十字节的日志就便宜多了。数据页的修改先在 buffer pool 里做,脏页慢慢刷,只要 redo 落了盘,崩溃后就能重放出来。
binlog是 Server 层的逻辑日志,归档和主从复制靠它。它和 redo log 分属两个模块,所以必须有一个协调机制,这就是后面要讲的两阶段提交。
锁负责隔离性里"写-写"的部分。行锁加在索引上,没有索引就没有行锁,这条规则后面会用一整个章节展开。
这四样东西合起来才能谈 ACID。单独拎任何一个出来吹,都是片面的。我见过有人信誓旦旦说"我们开了 redo 就持久了",结果innodb_flush_log_at_trx_commit设成 0,机器一断电就丢一秒的数据,redo 在内存里还没出去。
1.3 事务边界怎么划:比技术选型更容易翻车的地方
边界这个问题,我给的建议一直是"尽可能短,且必须包住所有不可分的写操作"。听起来像废话,但真正的难点在于判断哪些操作是"不可分"的。
一个典型错误是在事务里做远程调用。用户下单,事务里先 insert 订单,再调支付接口,再 update 订单状态。如果支付接口超时 3 秒,这个事务就持锁 3 秒。高峰期几百个这样的请求叠在一起,行锁等待直接爆炸。正确做法是把远程调用挪到事务外面,或者用本地消息表把"调用"变成"投递一条待处理记录",事务只包住数据库内的写。
另一个错误是事务里做大批量循环。批量导入一万条数据,用一个大事务一口气写完,看起来效率高,实际上 undo 会一直膨胀、锁会一直持有、purge 线程被拖住、主从延迟也会拉大。这种场景必须分批,每批几百到一千条,中途提交。
还有一个隐蔽的问题:只读查询要不要开事务。如果用的是 RR 隔离级别,一个事务里第一次快照读生成的 ReadView 会沿用到事务结束。你在事务里先查了一次,中间隔了 200ms 又查一次,两次结果一样——这在某些报表场景是想要的,但在"查完立刻判断是否存在再插入"的场景就是灾难,因为第二次查到的还是旧快照,你以为不存在,插进去直接主键冲突或者更糟的重复数据。这种地方要么改成当前读(select ... for update),要么把查询挪出事务。
注意:事务里少做非数据库操作,这是铁律。文件读写、HTTP 调用、发消息、加锁,全部挪到事务外面,或者改成异步补偿。
2. 隔离级别和 MVCC:并发问题的取舍账
2.1 四种隔离级别的真实差异,别只看教科书定义
教科书上讲脏读、不可重复读、幻读,讲得都对,但不够具体。我把每种隔离级别在实际业务里的表现整理成下面这张表,对照着看会更清楚:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 里的实现要点 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 基本不用,几乎等于裸奔 |
| READ COMMITTED | 不会 | 可能 | 可能 | 每次快照读都新建 ReadView |
| REPEATABLE READ | 不会 | 不会 | 快照读不幻读,当前读用间隙锁挡 | 首次快照读建 ReadView,全程复用 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 快照读自动加共享锁,并发极差 |
InnoDB 默认是 REPEATABLE READ,这不是随意的选择。MySQL 主从复制在 STATEMENT 格式下,RC 会导致主备数据不一致(因为主库执行顺序和从库重放顺序可能不同),所以历史上默认就是 RR。现在虽然 ROW 格式普及了,但默认值一直没改。
RC 在互联网业务里用得非常多,尤其是那种"每次查询都要最新数据"的场景。RC 的好处是快照新、undo 链短、死锁概率低;坏处是同一事务内两次查询结果可能不同,如果你的业务逻辑依赖"读到的数据在整个事务内不变",RC 会给你埋雷。我个人的取舍是:写多读少、逻辑简单的服务用 RC;涉及复杂判断、需要稳定快照的服务用 RR。这个选择要在建库时就定下来,后面改隔离级别涉及的业务回归成本很高。
2.2 ReadView 和版本链:可重复读是怎么"看见旧数据"的
这块是 MySQL 事务里最值得花时间搞懂的部分,因为所有"为什么我读到的是旧数据"的疑问,答案都在这里。
每行记录有两个隐藏列:DB_TRX_ID记录最后修改它的事务 ID,DB_ROLL_PTR指向 undo log 里的上一个版本。多个事务连续改同一行,就串成一条版本链,链头是最新版本。
ReadView 是判断"某个版本对我是否可见"的快照,它包含四个关键字段:
m_ids:生成 ReadView 那一刻,所有活跃(未提交)的事务 ID 列表min_trx_id:m_ids 里的最小值max_trx_id:生成 ReadView 时系统将要分配的下一个事务 IDcreator_trx_id:当前事务自己的 ID
判断逻辑按顺序走:版本的事务 ID 等于 creator_trx_id,说明是自己改的,可见;小于 min_trx_id,说明这个版本在快照之前就提交了,可见;大于等于 max_trx_id,说明是快照之后才开的事务,不可见;落在中间区间,就看它在不在 m_ids 里,在就不可见(说明当时还没提交),不在就可见。不可见就顺着DB_ROLL_PTR往上翻一个版本,重复判断,直到找到可见版本或者链到底。
RC 和 RR 的唯一区别就是 ReadView 的生成时机。RC 每次执行快照读都重新生成,所以能看到别人刚提交的数据;RR 只在事务里第一次快照读时生成一次,后面全部复用,所以看到的一直是那一刻的世界。
这就解释了一个经典现象:在 RR 隔离级别下,你开一个事务,先select一次(此时 ReadView 已生成),然后另开一个窗口update并提交,你这边再select,结果还是旧的。很多人第一反应是"缓存?主从延迟?",其实都不是,就是 ReadView 被你第一次查询锁死了。
2.3 快照读和当前读:为什么加了条件还是读到新数据
快照读就是普通的select,走 MVCC,不加锁。当前读包括select ... for update、select ... lock in share mode、update、delete、insert,这些语句必须读最新已提交的版本,并且加锁。
这个区分极其重要,因为它决定了你的加锁范围。举个我实际踩过的例子:库存扣减,逻辑是"先查库存够不够,够就更新"。如果查用的是普通 select,那查到的是快照数据,可能早就被别人改过了,更新的时候虽然会重新读最新值,但你的判断是基于旧数据做的,超卖就这么来了。
-- 错误写法:判断基于快照 select stock from item where id = 1; -- 查到 10 -- 中间别人扣了 8,剩 2 update item set stock = stock - 5 where id = 1; -- 变成 -3 -- 正确写法一:当前读,锁住再判断 select stock from item where id = 1 for update; -- 拿到行锁,读到 10 update item set stock = stock - 5 where id = 1; -- 正确写法二:把判断放进 SQL,靠行锁保证原子 update item set stock = stock - 5 where id = 1 and stock >= 5; -- affected_rows = 0 说明库存不足第二种写法我更推荐,一条语句搞定,不需要显式加锁,也不会有"查完再判断"的窗口。affected_rows是判断成功与否的唯一依据,别去查select found_rows(),那是另一个坑。
还有一个高频误区:update ... where 非索引字段 = x也会走当前读,但它加的不是行锁,是扫描到的所有记录都加锁,效果接近于锁表。这个在讲锁的时候详细展开。
2.4 幻读这件事,别被"RR 已经解决了"带偏
很多资料说"InnoDB 的 RR 通过间隙锁解决了幻读",这话只说对了一半。
幻读的定义是"同一个事务内,两次相同条件的查询,第二次多出了别的事务插入的行"。在 InnoDB 的 RR 下:
- 快照读不会幻读,因为 ReadView 固定了,新插入的行事务 ID 大于 max_trx_id,不可见。
- 当前读会幻读,除非加间隙锁。
select ... for update在 RR 下会加 next-key lock(记录锁 + 间隙锁),把区间锁住,别的事务插不进来。 - 但如果你先用快照读查了一批数据,然后基于这批数据做 update,update 是当前读,它会看到新插入的行——这就出现了"查到 3 条,更新了 4 条"的诡异现象。
所以准确的说法是:RR 用 MVCC 挡住了快照读的幻读,用间隙锁挡住了当前读的幻读,但两者混用的时候依然会出现语义上的不一致。真正要防幻读,要么全程用当前读,要么在业务层加唯一约束兜底。
提示:间隙锁在 RC 隔离级别下基本不生效(除了外键检查和唯一性检查),所以从 RR 切到 RC 的时候,一定要重新评估那些依赖
for update防插入的逻辑,它们会失效。
3. 日志体系和两阶段提交:事务持久化的底层账本
3.1 redo、undo、binlog 三者的分工,一张表说清
| 日志类型 | 所属层 | 内容形式 | 写入时机 | 主要用途 |
|---|---|---|---|---|
| redo log | InnoDB 引擎层 | 物理日志,页号+偏移+数据 | 事务执行中持续写,提交时刷盘 | 崩溃恢复,保证持久性 |
| undo log | InnoDB 引擎层 | 逻辑日志,反向操作记录 | 与数据修改同步,事务提交后保留 | 回滚、MVCC 版本链 |
| binlog | Server 层 | 逻辑日志,SQL 或行变更 | 事务提交前一次性写入 | 归档、主从复制、数据恢复 |
redo log 是循环写的,固定大小的一组文件,写满就回头覆盖最老的。这个设计决定了它不能无限保留,只能用于崩溃恢复,不能当归档用。8.0.30 之前用innodb_log_file_size和innodb_log_files_in_group控制,之后统一成innodb_redo_log_capacity。
容量怎么估?我给个粗糙但实用的算法:峰值 TPS × 单事务平均产生的 redo 量 × 希望覆盖的秒数 × 安全系数 2。比如峰值 2000 TPS,每个事务平均产生 2KB redo,希望覆盖 30 秒的写入量(够 checkpoint 追上),那就是 2000 × 2KB × 30 × 2 ≈ 240MB。生产上一般给到 1GB 到 4GB,太小会导致频繁 checkpoint,写性能抖动明显。
undo 的空间问题更容易被忽略。长事务会让 undo 版本链越拖越长,purge 线程没法清理,undo 表空间持续膨胀。你查information_schema.innodb_metrics里的trx_rseg_history_len(history list length),如果这个值持续几万几十万不下降,基本可以确定有长事务挂着。
3.2 两阶段提交:为什么 binlog 和 redo 必须协调
redo log 在引擎层,binlog 在 Server 层,两个独立的写入,中间可能崩溃。如果先写 redo 再写 binlog,redo 写完崩了,重启后主库有数据但 binlog 没有,从库就少一条;如果先写 binlog 再写 redo,binlog 写完崩了,从库多一条主库没有的数据。两种都会导致主从不一致。
MySQL 的解法是内部两阶段提交:
- prepare 阶段:写 redo log,标记为 prepare 状态,同时把事务 ID(XID)写进去。
- 写 binlog:把事务的变更写进 binlog 并刷盘(受
sync_binlog控制)。 - commit 阶段:写 redo log 的 commit 标记。
崩溃恢复时的判断规则很直接:扫描 redo log 里处于 prepare 状态的事务,拿它的 XID 去 binlog 里找。找到且完整,就提交;找不到或不完整,就回滚。这个规则保证了 redo 和 binlog 的最终一致。
理解这个机制的实际意义在于:sync_binlog和innodb_flush_log_at_trx_commit这两个参数必须一起看。最安全的组合是双 1,也就是每次提交都刷 redo 和 binlog,性能损失大概在 20% 到 40% 之间。如果业务能容忍极端情况下丢少量数据,可以设成innodb_flush_log_at_trx_commit=2+sync_binlog=1000,性能能提升不少,但主从切换时可能丢数据。这个取舍要业务方拍板,不是 DBA 能单方面决定的。
# 金融级:绝不丢 innodb_flush_log_at_trx_commit = 1 sync_binlog = 1 # 日志/埋点类:能接受秒级丢失 innodb_flush_log_at_trx_commit = 2 sync_binlog = 1000注意:
innodb_flush_log_at_trx_commit=0表示每秒刷一次,不是每次提交刷,机器掉电会丢最多 1 秒的数据,而且这个参数下 binlog 的两阶段提交语义会弱化,不建议在生产用。
3.3 组提交:高并发下两阶段提交为什么没把性能拖死
有人会问,每次提交都要刷两次盘,性能怎么扛得住。答案是组提交(group commit)。
多个事务同时提交时,第一个到达的充当 leader,负责把一批事务的 redo 一起刷盘、一批 binlog 一起刷盘,其余事务作为 follower 等待。这样磁盘的 fsync 次数从 N 次降到 1 次,并发越高摊薄效果越明显。
组提交分三个阶段:flush 阶段写 binlog 到文件缓存、sync 阶段 fsync binlog、commit 阶段写引擎层提交。binlog_group_commit_sync_delay和binlog_group_commit_sync_no_delay_count两个参数可以人为制造一点延迟,凑更多的事务一起刷,用延迟换吞吐。这两个参数调得好,双 1 配置下的 QPS 能提升一倍以上,但会增加响应时间的毛刺。我的经验是延迟别超过 1ms,数量别超过 100,再大就容易造成业务侧超时。
4. 锁和死锁:并发写入绕不开的现场
4.1 行锁、间隙锁、临键锁到底什么时候出现
先记住一条底层规则:InnoDB 的锁加在索引上。没有索引,就没有行锁,只有全表扫描加锁,效果接近锁表。
三种锁形态:
- 记录锁(Record Lock):锁住某一条索引记录。
- 间隙锁(Gap Lock):锁住两条索引记录之间的开区间,防止插入。
- 临键锁(Next-Key Lock):记录锁 + 它前面的间隙,左开右闭区间,是 RR 下的默认加锁单位。
触发哪种,取决于查询条件和索引情况:
| SQL 条件 | 索引情况 | 加锁结果 |
|---|---|---|
where id = 5 | 主键等值 | 只加记录锁,锁 id=5 |
where id = 5 | 主键等值且记录不存在 | 加间隙锁,锁住那个空隙 |
where age = 20 | age 有普通索引 | 临键锁,锁住匹配区间 |
where age = 20 | age 无索引 | 全表扫描,所有记录加锁 |
where id > 10 and id < 20 | 主键范围 | 临键锁,锁住整个区间 |
最坑的是最后两行。范围查询在 RR 下会锁住整个扫描区间,不只是命中的行。我遇到过一次,一个定时任务用delete from log where create_time < '2024-01-01',create_time上没有索引,直接把整张表锁住,线上所有写入全部阻塞,监控告警刷屏。加上索引之后问题立刻消失。
提示:要查当前加锁情况,8.0 上直接查
performance_schema.data_locks和data_lock_waits,比老的innodb_locks好用得多,能看到锁的类型、锁的索引、等待关系。
4.2 死锁是怎么形成的,怎么从日志里读出来
死锁的产生条件很简单:两个事务以相反的顺序持有并请求对方的锁。
事务 A: update t where id = 1; -- 持有 1 的锁 事务 B: update t where id = 2; -- 持有 2 的锁 事务 A: update t where id = 2; -- 等 B 释放 事务 B: update t where id = 1; -- 等 A 释放InnoDB 有死锁检测(innodb_deadlock_detect,默认开),发现环路会立刻回滚代价小的那个事务,抛出 1213 错误。关掉检测就只能靠innodb_lock_wait_timeout(默认 50 秒)超时,这个时间对线上服务来说不可接受。
出问题的时候,第一件事是看现场:
SHOW ENGINE INNODB STATUS\G -- 看 LATEST DETECTED DEADLOCK 段落 -- 实时看正在发生什么 select * from information_schema.innodb_trx\G select * from performance_schema.data_locks; select * from performance_schema.data_lock_waits;LATEST DETECTED DEADLOCK里会给出两个事务的完整信息:事务 ID、执行时间、持有哪些锁、等待哪个锁、最近执行的 SQL。看这个段落的关键是找"谁先谁后"——哪个事务先拿到了锁,后请求哪个,另一个是怎么反向的。
死锁的根治办法只有两个:统一加锁顺序,减小事务粒度。前者是设计层面的,比如所有涉及多表的更新按主键升序执行;后者是工程层面的,事务别写太长,锁别持太久。重试是兜底,不是解决方案,因为重试会放大冲突。
4.3 索引缺失导致锁范围放大,这是最常见的性能事故
我统计过自己处理过的行锁等待告警,超过六成的原因是索引问题。要么缺索引,要么索引选择性差导致优化器不走索引。
一个典型例子:update orders set status = 2 where order_no = 'xxx',order_no上没建唯一索引。这条语句会走全表扫描,扫到的每一行都加锁。表里有一百万行,就等于锁了一百万行。并发一上来,全部阻塞。
加索引之后,锁范围缩到一行。效果立竿见影,但代价是你要评估这个索引的维护成本——写多读少的表,每多一个索引,写入就多一份开销。
还有一种隐蔽情况:索引失效。比如隐式类型转换,order_no是 varchar,你传数字进去,MySQL 会做类型转换,索引直接失效,全表扫描。这种问题在代码里到处都是,审查 SQL 的时候一定要看执行计划,别只看逻辑对不对。
5. 实操:长事务排查和大事务治理的完整步骤
5.1 长事务定位:三条命令锁定目标
长事务的危害不需要多解释:锁不释放、undo 不回收、主从延迟、连接堆积。线上出事,第一步永远是找长事务。
-- 1. 找出运行超过 60 秒的事务 select trx_id, trx_state, trx_started, timestampdiff(second, trx_started, now()) as duration_sec, trx_mysql_thread_id, trx_rows_locked, trx_rows_modified, trx_query from information_schema.innodb_trx where timestampdiff(second, trx_started, now()) > 60 order by duration_sec desc; -- 2. 关联连接信息,看是哪个应用连过来的 select t.*, p.user, p.host, p.db, p.command, p.time, p.state from information_schema.innodb_trx t join information_schema.processlist p on t.trx_mysql_thread_id = p.id order by t.trx_started; -- 3. 看这个事务持有哪些锁 select * from performance_schema.data_locks where thread_id = <上面查到的 thread_id>;trx_rows_locked这个字段特别有用。一个本该只锁几行的事务,如果显示锁了几十万行,那基本可以确定是索引问题或者范围过大。
如果确认是业务代码的问题,处理顺序是:先kill掉连接止血,再排查代码。kill的时候注意,大事务回滚可能比执行还慢,几百万行的更新回滚起来要几分钟甚至更久,期间这个连接还是在占资源。所以更好的做法是从源头减少大事务,而不是靠 kill 救火。
还有一种长事务是"空闲事务"——连接开了事务但一直不发 SQL。这种事务持锁时间等于业务代码里的耗时,可能是等一个 RPC 返回。这种最难查,因为它不占 CPU,慢查询日志里也看不到。只能靠innodb_trx里trx_state = 'RUNNING'但trx_query为空的记录去找。
5.2 大事务拆分的具体写法
批量操作必须分批,这是常识,但具体怎么写有讲究。
最差的做法是按 offset 分页:
-- 不要这样 delete from log where create_time < '2024-01-01' limit 1000; -- 每批都从 offset 0 开始,越到后面越慢好一点的是按主键游标:
-- 按主键推进,每批都是索引范围扫描 delete from log where id < #{lastMaxId} order by id limit 1000;但删除场景有个更优雅的写法:先查出边界,再按范围删。
-- 查出要删的 id 区间 select min(id), max(id) from log where create_time < '2024-01-01'; -- 按区间分批删,每批 1000 条 delete from log where id between #{start} and #{start + 999} and create_time < '2024-01-01';每批之间 sleep 几十毫秒,给主从复制和 purge 线程喘息的机会。批大小怎么定?我给的经验值是:单批耗时控制在 100ms 以内,单批锁定的行数控制在几千行以内。具体数字要压测,因为跟硬件、行大小、索引数量都有关系。
Java 里的写法大致长这样:
int batchSize = 500; int total = 0; while (true) { int affected = logMapper.deleteBatch(batchStart, batchSize); total += affected; if (affected < batchSize) { break; } batchStart += batchSize; try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } }这里有个坑:Thread.sleep不能放在事务里,否则事务根本没结束,分批就白分了。每个 batch 的删除必须是独立事务,@Transactional要加在单个批次的方法上,或者干脆不用注解,靠 autocommit。
注意:分批操作要考虑中断恢复。如果任务跑到一半失败,下次从哪继续?要么记 offset 到表里,要么每次重新计算起始位置。我倾向于后者,因为幂等性好,不用维护额外状态。
5.3 应用层事务的失效场景,踩过才知道有多隐蔽
这一块我要多花点篇幅,因为 Java 后端用@Transactional出问题的概率,比 SQL 本身写错还高。
最常见的失效原因,按我遇到的频率排序:
第一,方法自调用。同一个类里 A 方法调 B 方法,B 上的@Transactional不生效,因为走的是 this 引用,没经过代理。解决办法是把 B 挪到另一个 Bean,或者注入自己(@Lazy注入自身),或者用AopContext.currentProxy()。
第二,异常被吞了。事务方法里 try-catch 把异常吃了,Spring 感知不到,自然不回滚。这是最隐蔽的,代码看起来完全正常。
第三,rollbackFor没配。Spring 默认只在RuntimeException和Error时回滚,受检异常(比如IOException)不回滚。如果你的业务代码抛的是受检异常,一定要显式写@Transactional(rollbackFor = Exception.class)。
第四,方法不是 public。Spring AOP 基于代理,非 public 方法代理不到。protected、private 上加注解完全无效。
第五,类没被 Spring 管理。new出来的对象上的注解当然不生效。
第六,数据库引擎不支持。MyISAM 没有事务,这个现在很少见了,但迁移老库的时候会碰到。
第七,事务传播行为理解错。REQUIRES_NEW会挂起当前事务另开一个,如果外层事务回滚,内层已经提交的数据不会回滚。有人拿它做"失败也记日志",逻辑上是对的,但要清楚它意味着数据不一致——日志记录成功了,主业务回滚了。
第八,多数据源没配事务管理器。换数据源的时候如果@Transactional没指定transactionManager,可能挂到默认的库上。
// 反面例子:全部踩中 public class OrderService { public void createOrder() { try { this.doCreate(); // 自调用,注解失效 } catch (Exception e) { log.error("创建失败", e); // 异常被吞 } } @Transactional private void doCreate() { // private,代理不到 // ... } }这个例子是我从一次真实故障里抽象出来的,当时排查了两个小时,最后发现是自调用。后来我养成一个习惯:写完事务代码,一定要在日志里打印TransactionSynchronizationManager.isActualTransactionActive(),确认事务真的开了。
5.4 监控指标和告警阈值怎么定
光靠人工查innodb_trx是被动的,必须有监控兜底。我一般配这几个指标:
| 指标 | 来源 | 建议阈值 | 说明 |
|---|---|---|---|
| 最长事务持续时长 | innodb_trx定时采集 | > 30s 告警,> 120s 严重 | 最直观的长事务信号 |
| history list length | innodb_metrics | > 10 万告警 | undo 积压,purge 跟不上 |
| 行锁等待时长 | data_lock_waits | > 5s 告警 | 提前于innodb_lock_wait_timeout |
| 死锁次数 | SHOW STATUS LIKE 'Innodb_deadlocks' | 5 分钟内 > 3 次告警 | 突然升高说明代码有问题 |
| undo 表空间大小 | 文件系统 | 增长超过 20%/小时 | 大事务征兆 |
| 主从延迟 | Seconds_Behind_Master | > 10s 告警 | 大事务的连锁反应 |
采集方式我用过一个很土但很稳的方案:每分钟跑一次innodb_trx查询,把结果写进监控系统。查询本身开销很小(这个表在内存里),不用担心影响性能。
告警阈值要给业务留出反应时间。30 秒这个线是根据经验定的——正常业务事务都在毫秒到几百毫秒级别,能跑过 30 秒的,基本都有问题,哪怕它最后成功了。
6. 常见问题速查和几条踩坑心得
6.1 高频问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 同一事务内两次查询结果不同 | 隔离级别是 RC | 确认@@transaction_isolation |
| 加了 for update 还是插入重复 | RC 下无间隙锁 | 改用唯一索引兜底 |
| update 卡住几十秒 | 无索引导致锁全表 | 看data_locks和慢日志 |
| 主从延迟越来越大 | 大事务导致从库重放慢 | 拆批,开并行复制 |
| undo 表空间疯涨 | 长事务阻止 purge | 查innodb_trx最长事务 |
| 主键冲突但查不到数据 | 快照读 + 并发插入 | 改当前读或捕获重复键异常 |
| 提交了但数据丢了 | 刷盘参数太宽松 | 查flush_log_at_trx_commit |
| 事务不生效 | 代理问题或异常被吞 | 打印isActualTransactionActive() |
| 死锁频繁 | 加锁顺序不一致 | 看LATEST DETECTED DEADLOCK |
这张表我贴在工位上很久了,遇到问题先对一遍,能省下不少瞎猜的时间。
6.2 几个只有踩过才知道的细节
第一,select ... for update在唯一索引等值查询命中时只加记录锁,不命中时加间隙锁。命中加间隙锁的写法在并发插入场景会互相阻塞,两个不同 key 的请求可能因为同一个间隙互相等待。这个行为很反直觉,但确实是这样的。
第二,insert ... on duplicate key update在高并发下容易死锁。它内部会先加间隙锁再判断冲突,多个事务交叉插入相邻的 key 时死锁概率明显上升。能改成先 update 判断affected_rows、再 insert 捕获重复键异常的写法,就尽量改。
第三,count 和事务的关系。select count(*)在事务里也是快照读,它数的是快照时刻的行数。如果你拿它做分页的总数,同时有并发插入,翻页可能出现重复或遗漏。分页场景用游标法(按 id 或时间戳翻页)比 offset 稳得多。
第四,autocommit 不等于没事务。autocommit=1 时,每条语句自己就是一个事务,一样会加锁、一样会写 undo。所以单条 update 卡住的情况,本质还是事务问题,别以为只有显式 BEGIN 才叫事务。
第五,隔离级别可以在会话级设置。set session transaction isolation level read committed;只影响当前连接。有人在客户端里改了一下,以为全局改了,结果应用连上去还是 RR。改全局要用set global并且只对新连接生效,最稳的还是在配置文件里定死。
第六,XA 事务和普通事务不是一回事。跨库场景下有人用 XA,性能损耗非常明显,而且存在悬挂事务的清理问题。我现在更倾向用本地消息表加最终一致,虽然要写更多代码,但可控性好得多。
6.3 关于事务设计,我个人的几条原则
写到这里,想分享几条我在实际项目里反复验证过的原则,都是吃过亏才总结出来的。
事务里只放数据库写操作,远程调用、消息发送、文件操作全部挪出去。如果非要保证"消息一定发出",用本地消息表或者事务消息,别在事务里同步调。
加锁顺序必须统一。任何涉及多行、多表的更新,按主键升序或者按固定表顺序执行。这一条能消掉八成的死锁。
能一条 SQL 解决的,不要拆成两条。update ... where stock >= n比 "先查再更新" 好,insert ... select比 "查出来再插入" 好。一条语句就是一个原子操作,天然没有窗口期。
事务的粒度按"业务上必须同时成功"来定,不按"代码上写在一起方便"来定。这两个标准经常冲突,冲突的时候以业务为准。
别指望数据库帮你做一致性。数据库只保证 ACID 里那三条,业务一致性要靠约束、幂等、补偿来兜。唯一索引、外键、check 约束该加就加,那是最后一道防线。
最后一件事,也是我最近才想明白的:事务的复杂度不在语法,在于你脑子里要有一张图——这个事务什么时候开、持了哪些锁、undo 链有多长、binlog 什么时候写、崩溃了会怎么恢复。这张图画清楚了,线上出问题的时候你就不慌了,看几眼innodb_trx和data_locks就能定位到是哪一环断的。图画不清楚,那再多监控也只是在猜。