做业务系统的人,早晚会在对账报表上看到那种让人头皮发麻的数字:A 账户扣了 100,B 账户只收到 90,中间那 10 块像凭空蒸发了。我第一次碰到类似情况,是帮一个交易类项目做数据修复,日志里每一条 SQL 都执行成功,余额加起来却怎么也对不上。那之后我才真正把MySQL 事务当成一门必须吃透的东西,而不是面试前背四天的 ACID。这篇内容想把事务从“概念”拉回到“现场”:它到底解决了什么问题、InnoDB 靠什么撑起这些保证、隔离级别怎么选、代码里的事务为什么经常悄悄失效、线上锁等待和死锁又该怎么定位。适合已经写过增删改查但被并发问题咬过一口的开发者,也适合想系统梳理一遍事务知识、准备做架构评审或代码重构的同学。文中涉及的参数、SQL 和排查语句都能直接拿去在测试库跑,建议边看边开两个会话动手验证。
1. 事务不是面试题,是账目能不能对上的底线
1.1 一次余额对不上的排查,让我重新理解了 ACID
那次排查的最后结论很朴素:扣款和入账是两条独立的 SQL,中间没有包在同一个事务里,第二条因为某个约束校验失败被应用层捕获后吞掉了,第一条已经落库。数据库本身没有任何毛病,毛病出在“以为两条 SQL 天然是一体的”这个假设上。事务存在的意义,就是把一组逻辑上不可分割的写操作捆成一个原子单元——要么全成,要么全不成,中间态对别的会话不可见。
原子性(Atomicity)靠 undo log 实现,回滚就是把改过的行按 undo 记录还原回去;一致性(Consistency)更像是一个目标而不是机制,它由原子性、隔离性加上业务约束(唯一索引、外键、CHECK、应用校验)共同兜住;隔离性(Isolation)靠锁和 MVCC 一起实现;持久性(Durability)靠 redo log 的刷盘策略保证。很多人背得滚瓜烂熟,但一到写代码就只记得@Transactional一贴,剩下的交给框架。
这里有个关键认知:ACID 不是数据库单方面赠送的,是数据库和应用一起守住的。数据库能保证“我这一组语句不出岔子”,但它不知道你的业务里“扣款”和“发券”哪个必须先做、失败后要不要补偿。所以真正的事故里,纯粹的数据库层面问题(比如 redo 丢了)占比很低,绝大多数是事务边界划错了、事务传播搞混了、异常被吃掉了。
提示:判断一段代码要不要放进事务,标准只有一条——这些写操作之间是否必须同生共死。如果某个操作失败后可以靠补偿或重试单独修复,那它就不该被硬塞进同一个事务里。
1.2 四种隔离级别到底怎么选:从并发异常倒推
隔离级别本质上是在“正确性”和“并发能力”之间做交易。ANSI SQL 标准定义了四种级别,MySQL 的 InnoDB 默认是 REPEATABLE READ(以下简称 RR)。很多人以为 RR 就是“不会幻读”,严格讲是“快照读不会幻读,当前读靠间隙锁也能挡住大部分幻读”,这个区别后面会专门验证。
先把四种级别要解决的问题摆清楚:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 实现要点 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 基本不用,直接读最新版本 |
| READ COMMITTED | 不会 | 可能 | 可能 | 每条快照读都新建 ReadView |
| REPEATABLE READ | 不会 | 不会 | 快照读不会 | 首次快照读建 ReadView,当前读加 Next-Key Lock |
| SERIALIZABLE | 不会 | 不会 | 不会 | 快照读也加共享锁,退化成串行 |
怎么选?我的实际经验是这样:绝大多数互联网业务用 RC 或者 RR 就够,SERIALIZABLE 基本只在极少数对账、清结算的强一致场景里出现,而且往往是通过“单行串行化”或者分布式锁实现,而不是整个库切到 SERIALIZABLE。
选 RR 的理由是默认值、省心,快照读性能好,范围查询的一致性视图很舒服。选 RC 的理由更实际:RC 下间隙锁基本不加(除了外键检查和唯一性检查),锁冲突概率明显下降,批量更新时死锁率会低不少;同时 RC 每条语句都能看到最新已提交数据,对“先查后判再改”这类逻辑更符合直觉。代价是 binlog 必须用 ROW 格式,否则主从可能不一致——这个坑很老但依然有人踩。
我之前在一个批量结算任务里吃过 RR 的苦:两个事务对同一张明细表做范围更新,条件是status = 0,因为 RR 下范围扫描会加间隙锁,两个事务的间隙互相重叠,直接死锁,日志里刷了一屏Deadlock found。后来把这张表的会话级别改成 RC,加上唯一索引约束兜底,死锁率从每天几十次降到个位数。这不是说 RR 不好,而是说默认值不等于最优值,要按表的访问模式来定。
2. InnoDB 撑起事务的四根柱子
2.1 redo log:崩溃恢复靠的是它,不是数据文件
InnoDB 的修改并不会立刻写回磁盘的数据页,而是先改内存里的 Buffer Pool,同时把“我在某个页的某个偏移处改成了什么”记到 redo log。redo 是物理逻辑日志,顺序写、体积小、刷盘快,所以innodb_flush_log_at_trx_commit = 1时,一次事务提交大约只多一次顺序 IO,而不是随机写一堆数据页。这就是所谓的 WAL(Write-Ahead Logging):先写日志,再慢慢刷脏页。
参数上有三档:innodb_flush_log_at_trx_commit = 1每次提交都 fsync,最安全;= 2每次提交写 OS 缓存但不 fsync,实例崩溃不丢、机器断电可能丢一秒;= 0每秒刷一次,性能最好但风险最大。生产环境我一般坚持1,配合sync_binlog = 1。有人会担心性能,实测在普通 SSD 上,单条小事务的 QPS 差异并没有想象中大,真正拖垮性能的是长事务和热点行,而不是这一次 fsync。
redo 和 binlog 之间还有一个绕不开的话题:两阶段提交。事务提交时,InnoDB 先写 redo 并标记 prepare,然后写 binlog,最后把 redo 标记成 commit。如果中间崩溃,恢复时会拿 binlog 的事务列表去比对 redo 里的 prepare 记录:binlog 完整就提交,不完整就回滚。这样才保证“主库数据和 binlog 内容”不会出现一个有一个没有的情况。理解这个机制的实际价值在于:当你遇到主从数据不一致、或者从库重放后数据对不上时,第一反应应该是去核对 binlog 格式和这两步提交,而不是怀疑存储引擎坏了。
-- 查看关键刷盘参数 SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit'; SHOW VARIABLES LIKE 'sync_binlog'; SHOW VARIABLES LIKE 'binlog_format';提示:
innodb_flush_log_at_trx_commit和sync_binlog是可以在线改的(SET GLOBAL),但改完只对新连接生效,且这是典型的“双 1 配置”,除非有明确的性能瓶颈定位到 IO,否则不建议为了压测数字去调它。
2.2 undo log 与 MVCC:为什么我读到的还是旧值
undo log 有两个身份:回滚时它是“撤销脚本”,快照读时它是“历史版本仓库”。每一行记录里都藏着两个隐藏列——DB_TRX_ID(最后修改它的事务 ID)和DB_ROLL_PTR(指向 undo 里的上一个版本)。沿着这个指针往回走,就能拼出一条版本链。
快照读时,InnoDB 会构造一个 ReadView,里面有几个关键字段:当前活跃事务 ID 列表m_ids、列表中的最小值min_trx_id、下一个待分配的事务 IDmax_trx_id、以及自己的creator_trx_id。判断某行版本能不能被看见,规则大概是:自己的修改自己看得见;DB_TRX_ID < min_trx_id说明这个版本在快照前就提交了,可见;DB_TRX_ID >= max_trx_id说明是快照之后才开的事务,不可见,顺着指针找更旧的;落在中间的就看它在不在m_ids里,在说明还没提交,不可见。
RC 和 RR 的差别就在 ReadView 的生成时机:RC 每条快照读都重新生成一次,所以两次查询之间别人提交了就能看到;RR 只在事务里第一次快照读时生成一次,后面一直复用,所以同一个事务里怎么查都是那个时间点。这就是“可重复读”的实现方式,跟锁其实没多大关系。
线上排查时这个知识点很实用。比如有人反馈“我在一个事务里先查了一次,后来更新了一遍,再查发现还是旧值”,先别急着怀疑缓存——如果这个事务是 RR,且前面已经做过快照读,那么后续的快照读看到的还是第一次的视图;但如果他中间做的是UPDATE,那属于当前读,走的不是 MVCC,会把最新版本读出来再改。判断是快照读还是当前读,是搞清楚“为什么值不对”的第一把钥匙。
2.3 锁的图谱:记录锁、间隙锁、Next-Key Lock 怎么加
InnoDB 的行锁是加在索引上的,不是加在“行”上的——这句话新手最容易忽略。如果你的WHERE条件没走索引,那锁的就不是一行,而是整个扫描范围,效果接近表锁。我见过一个深夜报警,起因是一位同学写了UPDATE orders SET status = 1 WHERE order_no = 'xxx',而order_no忘了建索引,几十万行的表被扫了一遍,锁等着一路飘红。
锁的基本类型:记录锁(Record Lock)锁住某条索引记录;间隙锁(Gap Lock)锁住两个索引值之间的“空隙”,不让别人往里插;Next-Key Lock 是记录锁加它前面的间隙锁,左开右闭。RR 下范围查询默认加 Next-Key Lock,唯一索引等值且命中记录时会退化成记录锁,等值没命中时会退化成间隙锁。
插入意向锁(Insert Intention Lock)是一种特殊的间隙锁,多个事务往同一个间隙插入不同值时通常不会互相阻塞,但一旦间隙上有别人持有的 gap 锁,插入就得等。这就是为什么“两个事务同时判断某条记录不存在再插入”会死锁——两边都拿到了间隙锁,又都要插入意向锁,互相等。
-- MySQL 8.0 直接看当前锁情况(5.7 用 information_schema.innodb_locks) SELECT * FROM performance_schema.data_locks\G SELECT * FROM performance_schema.data_lock_waits\G注意:
data_locks里LOCK_MODE为X,GAP或X,REC_NOT_GAP这些组合值,是判断“到底是间隙锁还是记录锁”的直接证据。看到LOCK_MODE里带GAP,基本可以确认是范围条件触发了间隙锁。
3. 亲手验证隔离级别:两个会话就能跑完的实验
3.1 实验环境与准备动作
不需要搭集群,一个 MySQL 8.0 实例开两个客户端就够了。先建一张测试表,主键加一个普通索引,方便观察不同条件下锁的差异。
CREATE TABLE account ( id BIGINT PRIMARY KEY, owner VARCHAR(32) NOT NULL, balance DECIMAL(18,2) NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO account (id, owner, balance) VALUES (1,'A',1000),(2,'B',1000),(3,'C',1000);每次实验前记得把两个会话的隔离级别都设成同一个,并且用BEGIN显式开启事务,别指望客户端默认的自动提交。查看和设置语句如下:
SELECT @@transaction_isolation; -- MySQL 8.0,5.7 用 @@tx_isolation SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;3.2 脏读、不可重复读、幻读逐个复现
先说不可重复读,这个在 RC 下能稳定复现。会话 A 开事务查一次余额,会话 B 更新并提交,会话 A 再查,值变了——同一个事务内两次读结果不一致。切到 RR 再跑一遍,会话 A 第二次读还是老值,因为 ReadView 没重建。
再说幻读。经典场景是“先查范围,再插入同范围的数据”。RR 下如果第一次查询是快照读,那它确实看不到第二次的插入,但如果你在事务里用SELECT ... FOR UPDATE做了范围锁定,另一个会话往这个范围插数据就会阻塞,直到你提交。这就是“快照读靠 MVCC 绕过幻读、当前读靠 Next-Key Lock 挡住幻读”的实际表现。
-- 会话 A(RR) BEGIN; SELECT * FROM account WHERE id BETWEEN 2 AND 3 FOR UPDATE; -- 当前读,加 Next-Key Lock -- 会话 B BEGIN; INSERT INTO account (id, owner, balance) VALUES (2.5,'X',0); -- 会被阻塞第三个值得亲手做的是“锁范围实验”:把FOR UPDATE里的条件换成非索引列,比如WHERE owner = 'A',然后在另一个会话改owner = 'B'的行,你会看到它也阻塞了——因为没走索引,整个扫描路径都被锁住。这个实验比看十篇文章都管用,它把“索引即锁”这件事直接怼到你脸上。
提示:做这类实验时,把
innodb_lock_wait_timeout临时调小到 5 秒,能更快拿到超时报错,省得干等 50 秒。测完记得改回来。
| 实验目的 | 会话 A 语句 | 会话 B 语句 | 预期现象 |
|---|---|---|---|
| 不可重复读(RC) | BEGIN; SELECT | UPDATE 并提交 | A 第二次读到新值 |
| 可重复读(RR) | BEGIN; SELECT | UPDATE 并提交 | A 仍读到旧值 |
| 间隙锁 | SELECT ... FOR UPDATE 范围 | INSERT 范围内 | B 阻塞 |
| 无索引锁放大 | WHERE 非索引列 FOR UPDATE | UPDATE 任意行 | B 阻塞 |
4. 代码层的事务控制:注解不是万能的
4.1 @Transactional 失效的六种典型场景
Java 生态里@Transactional用得太顺手,以至于很多人把它当成“贴上就安全”。它是基于代理实现的,一旦代理不生效,事务也就没了。我排查过的问题里,下面几种出现频率最高。
第一,同类内部方法自调用。A 方法调本类的 B 方法,B 上的@Transactional根本不生效,因为调用没经过代理对象。第二,方法不是public。Spring 的默认代理方式对非 public 方法不做拦截(部分版本会直接抛异常,直接点说:别在 private 方法上贴事务注解)。第三,异常被自己 catch 掉了,代理捕获不到,自然不会回滚。第四,抛的是受检异常,而注解默认只回滚RuntimeException和Error,没写rollbackFor = Exception.class就白搭。第五,多数据源场景下事务管理器配错,事务加在了 A 库上,实际写的是 B 库。第六,在事务方法里开了线程池或CompletableFuture去写库,子线程的连接和事务上下文根本不同,主事务回滚管不到它。
// 反例:异常被吞,事务不回滚 @Transactional public void transfer(Long from, Long to, BigDecimal amount) { try { accountMapper.debit(from, amount); accountMapper.credit(to, amount); } catch (Exception e) { log.error("转账失败", e); // 吃掉了异常,事务照常提交 } } // 正例:明确回滚范围,异常继续往外抛 @Transactional(rollbackFor = Exception.class, timeout = 3) public void transfer(Long from, Long to, BigDecimal amount) { int rows = accountMapper.debit(from, amount); if (rows != 1) { throw new IllegalStateException("扣款失败,余额不足或账户不存在"); } accountMapper.credit(to, amount); }注意正例里的rows != 1判断。只判断影响行数,而不是先SELECT查余额再判断,是避免“查改之间被插队”的基本功。真正的扣减 SQL 应该是UPDATE account SET balance = balance - #{amount} WHERE id = #{from} AND balance >= #{amount},把校验塞进WHERE里,让数据库在加锁的同时完成判断。
4.2 事务粒度和长事务治理
事务粒度这件事,一句话总结:事务里只放必须原子的数据库写操作,任何网络调用、文件 IO、消息发送、第三方接口都挪到事务外面。我见过最夸张的一个方法,事务里套了三次外部接口调用,单次 200ms,事务持有锁一秒钟以上,高峰期直接把连接池占满。
长事务的危害是连锁的:undo 版本链越挂越长,其他事务的快照读要顺着链走很多版本;history list length 持续增长,purge 线程跟不上;持有的行锁时间长,热点行排队;如果涉及大范围更新,主从延迟会被拉高;binlog 单个事务体积膨胀,从库重放时长时间卡住。
治理手段分三层。代码层:拆方法、缩短事务、把查询挪出去、批量操作分片(比如每 500 条提交一次,注意这不是原子操作,要评估业务能否接受)。监控层:定期扫information_schema.innodb_trx,把运行超过 5 秒的事务捞出来。
SELECT trx_id, trx_state, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec, trx_rows_locked, trx_rows_modified, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 5 ORDER BY trx_started;运维层:给业务连接设置max_execution_time(只对 SELECT 生效)或者innodb_lock_wait_timeout,再配合应用层的接口超时。值得注意的是,innodb_lock_wait_timeout只能限制“等锁”的时间,管不了事务本身跑多久,所以它不能替代长事务治理。
注意:
trx_rows_locked这个字段经常被误读为“锁了多少行”。实际上它统计的是被锁定的索引记录数量估算,可能包含间隙锁,也可能因为内部实现而偏大或偏小,只适合做趋势观察,不适合作为精确指标写进报表。
4.3 死锁:从被动重试到主动预防
死锁在 InnoDB 里是常态而非异常,检测到之后会主动回滚代价较小的那个事务,抛Deadlock found when trying to get lock; try restarting transaction。应用层看到这个错误,正确做法是有限次重试,而不是直接抛给用户。重试要加随机退避,避免两个事务节奏一致又撞上。
预防层面,我总结了几条经验,都是踩出来的。第一,固定加锁顺序,比如转账永远按账户 ID 升序处理,这是最简单也最有效的一招。第二,尽量用唯一索引把并发收敛到单行,避免范围锁。第三,减少事务内的语句数量,锁持有时间越短,交叉概率越低。第四,能用INSERT ... ON DUPLICATE KEY UPDATE就别用“先查再插”。第五,热点行(比如库存、计数)考虑用UPDATE ... WHERE stock >= n这种单语句原子更新,或者在上层做请求分片。
-- 原子扣减,不需要先查后改,也不会出现超卖 UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 1001 AND stock >= 1; -- 影响行数为 0 即表示库存不足,直接返回业务失败如果确实需要重试,我会在捕获异常后判断错误码(MySQL 的 1213 是死锁,1205 是锁等待超时),然后Thread.sleep(随机 20~100ms)再重来,最多三次。千万别无限重试,那会把一次小抖动放大成雪崩。
5. 线上问题排查实录与速查表
5.1 三个高频故障的定位路径
第一个现象:接口大面积超时,日志里刷Lock wait timeout exceeded。定位顺序是——先查information_schema.innodb_trx找运行时间最长的事务,看它的trx_query和trx_mysql_thread_id;再查performance_schema.data_lock_waits找到阻塞关系;最后顺着BLOCKING_THREAD_ID找到那个“罪魁祸首”的连接,用SHOW PROCESSLIST看它到底在干嘛。多数情况下会抓到一个没提交的事务,可能是一个忘记commit的脚本,或者一个卡在外部调用上的业务线程。
第二个现象:主从延迟突然飙到几十秒。先看SHOW SLAVE STATUS里的Seconds_Behind_Master和当前重放的位点,再对照主库有没有大事务。大事务在主库上可能几秒钟就跑完了,但在从库上必须整个事务重放完才可见,期间延迟一直涨。解决办法不是调参数,而是把大事务拆小。
第三个现象:同一条更新语句偶尔报死锁,但复现不了。抓SHOW ENGINE INNODB STATUS输出里的LATEST DETECTED DEADLOCK段落,里面会完整列出两个事务的 SQL、持有的锁、等待的锁。这段信息比任何日志都值钱,建议在监控系统里定期采集落盘,出问题时直接翻历史记录。
| 现象 | 首要排查点 | 常用语句 | 常见根因 |
|---|---|---|---|
| 锁等待超时 | 最长运行事务 | innodb_trx+data_lock_waits | 事务未提交、外部调用在事务内 |
| 死锁报错 | 死锁现场日志 | SHOW ENGINE INNODB STATUS | 加锁顺序不一致、间隙锁交叉 |
| 主从延迟 | 大事务与并行复制 | SHOW SLAVE STATUS | 单事务更新行数过多 |
| undo 膨胀 | history list length | SHOW ENGINE INNODB STATUS | 长事务阻塞 purge |
| 连接数打满 | 事务平均耗时 | SHOW PROCESSLIST | 事务粒度过粗、连接池配置偏小 |
5.2 参数与监控指标清单
参数没有“最佳值”,只有“和你的业务匹配的值”。下面这几个是我在评审时一定会确认的:
| 参数 | 常用取值 | 取舍说明 |
|---|---|---|
transaction_isolation | RC / RR | RC 锁冲突少,需配 ROW 格式 binlog |
innodb_lock_wait_timeout | 5~10 | 默认 50 秒偏长,业务接口等不起 |
innodb_deadlock_detect | ON | 热点冲突极高时可评估关闭,但要靠超时兜底 |
innodb_flush_log_at_trx_commit | 1 | 牺牲少量吞吐保持久性,一般不动 |
sync_binlog | 1 | 与上一条配对使用 |
max_execution_time | 按接口定 | 只对 SELECT 生效,防止慢查询拖死连接 |
监控侧重点其实不多,但要坚持采集:活动事务数与最长事务时长、锁等待次数与平均等待时长、死锁次数、history list length、行锁等待的 TOP 表。这几个指标能覆盖八成以上的事务类故障。我个人习惯把“超过 3 秒的活动事务数”做成告警,阈值比大多数人设得低,因为一旦开始堆积,往往意味着某个下游变慢了,早发现比晚发现省事得多。
6. 我在实际项目里坚持的几条事务习惯
写业务代码久了,会形成一些近乎条件反射的习惯,这里分享几条,都是被事故教育出来的。
写更新一定带上“预期影响行数”的校验。UPDATE ... WHERE id = ? AND status = 0如果影响 0 行,说明状态已经变了,这时候要么抛异常要么走幂等分支,绝不能假装成功继续往下走。这个习惯挡掉了我遇到的大部分并发重复提交问题。
跨服务的写操作坚决不放进本地事务。本地事务只能管住自己的库,调用别人的接口失败了,本地回滚也补不回来。要么用本地消息表加补偿任务,要么在业务上设计成幂等可重试,把“最终一致”当成常态而不是例外。
上线前一定做一次并发验证。不用复杂压测,两个线程对同一行数据跑一百次就够了,很多事务边界问题在这个量级就会暴露。我见过太多“单线程测试全绿、上线就超卖”的例子,说到底还是没在开发阶段制造过一次真实竞争。
最后再提一个容易忽略的小点:定期检查innodb_trx里那些trx_state长期处于RUNNING且trx_query为空的记录。它们往往意味着某个连接开了事务却什么都没干,可能是连接池里被借出后没归还,也可能是某个客户端配置了手动提交却忘了提交。这类“静默事务”不会报错,但会一直卡着 undo 和锁,属于最难查的一类问题,只能靠定期巡检发现。