☰
MySQL事务从原理到实战:ACID、隔离级别与死锁排查全解析
2026/10/22 5:16:06 网站建设 项目流程

先说一个我真实接手过的案例。某电商后台,用户下单后,系统先扣了库存,然后生成订单记录。结果订单表写入时抛了个异常,库存却已经扣掉了。客户那边反复刷新,显示库存少了,但订单根本没有落库,后台一团乱。A同学跑过来问我:为什么两个操作绑不到一块?

根源就是当时那两段SQL没有放到同一个事务里。小白可能觉得事务就是个“要么全成功、要么全失败”的东西,但在实际项目中,事务的范围、隔离级别的选择、回滚的时机,任何一个细节没处理好,都会变成线上事故。这篇我不打算讲空理论,会从一次真实操作出发,把事务的四大特性拆开看底层实现,再带你从头到尾跑一遍开启、提交、回滚、保存点、隔离级别切换的完整流程,最后把事务实战里最容易踩的坑和排查手段一并过一遍。适合刚接触数据库事务的初学者,也适合写了好几年SQL但仍被锁、死锁、长事务困扰的人。

1. 先搞清楚事务是什么——从一次库存扣减事故说起

1.1 一次典型的数据不一致现场

上面那个案例在开发环境很少复现,因为单条数据、单次请求很难暴露问题。但在并发高、步骤多的业务接口里,这个问题几乎必然出现。

当时的代码逻辑大概是这样的:先写一条“库存变更记录”,再更新库存表的数字,最后插入订单表。三个动作看起来是顺序执行,但第二个动作失败了,MySQL并不会因为后面有一步没走,就把前面的UPDATE撤销。默认情况下,每条SQL执行完就被自动提交了,库存数字已经固化在磁盘上,再想“撤销”只能人工拿SQL去补,而且不好补。

这种情况放到账务系统里更严重。转账时,扣款账户余额减少、收款账户余额增加,如果右边成功左边失败,账面就对不上了。程序员给这种操作起的名字是“分布式事务问题”,但先别想那么远,单机MySQL里就有个更基础的解决办法:把多个SQL包进一个事务。要么一起提交,要么一起回滚。

1.2 事务其实就是一组“要么全做、要么全不做”的操作

事务(Transaction)的定义很朴素:它是一组数据库操作,这些操作被视为一个逻辑工作单元。事务里的SQL要么全部执行成功并且最终提交(COMMIT),要么全部不生效,回到执行前的状态(ROLLBACK)。

可以理解成一份外卖订单。你点了三个菜,商家必须等到三个菜都做完才能一起出餐。如果其中一个菜原料没有了,整单都取消,已经炒好的菜也不能单独送到你手里。数据库事务就是那个后厨的规矩:不允许半份订单上桌。

在MySQL里,事务是由存储引擎实现的,最常用的是InnoDB。MyISAM这类老引擎根本不管事务,出现问题只能靠人工修数据。所以建表时,如果业务需要事务能力,记得选InnoDB。

2. 四大特性(ACID)逐个拆解,底层各自靠什么实现

学界把事务需要满足的规则总结成四大特性:原子性、一致性、隔离性、持久性,缩写ACID。很多人背了概念但不知道底层怎么实现,面试时被问一句“InnoDB靠什么保证原子性”就卡住了。这里逐个讲。

2.1 原子性(Atomicity)——undo log 是幕后功臣

原子性要求事务里的操作不可分割,要么全部成功,要么全部回滚。从用户视角看,它就是“保证不会出现扣了款但对方没收到钱”的机制。

InnoDB靠的是undo log,也就是“撤销日志”。每一条UPDATE、DELETE、INSERT,在真正修改数据页之前,都会先把操作前的旧值写进undo log。比如把某个用户余额从100改成50,undo log里会留下“100”这个旧值。如果事务中途失败,InnoDB就拿着undo log逐个回放,把余额从50改回100,把插入的记录删掉,把删除的记录插回来。

这里有个很容易被误解的点:回滚不是通过“反向SQL”实现的,而是直接对数据页做恢复操作。InnoDB用版本链管理这些undo信息,每条记录上还带着事务ID,MVCC也会复用这条历史版本链。后面讲隔离性时会再碰到它。

事务执行过程中如果崩溃,InnoDB在恢复时也能通过undo log把未提交事务的改动全部撤掉,保证数据库不会留着半个事务的结果。

2.2 一致性(Consistency)——最容易被误读的一个

一致性是ACID里最抽象的那个。很多人以为一致性是数据库“自动保证数据正确”的功能,其实不是。它更像一个“业务规则+数据库机制”共同作用后的结果。

一致性要求事务执行前后,数据库都处于合法状态,不能违反任何约束,包括主键约束、唯一索引、外键、非空、CHECK约束,以及业务逻辑层面自己定义的规则。数据库只能保证不会主动破坏约束;而“转账后双方余额之和不变”“余额不能为负数”这类业务规则,必须靠SQL写对、靠应用逻辑校验,数据库替你做不了。

A、I、D是手段,C是目标。原子性保证操作不半途而废,隔离性保证并发不互相污染,持久性保证成功后的结果不丢失,这几项都做到了,事务结束时数据才能保持一致性。如果业务代码本身逻辑错了,比如把“扣款”写成“加款”,事务再完美也白搭。

2.3 隔离性(Isolation)——锁和MVCC各管一段

隔离性要解决的是并发问题。多个事务同时读写同一批数据时,不能让一个事务看到另一个事务还没提交的中间状态,也不能让两个事务互相覆盖对方的修改。InnoDB通过两套机制配合实现。

第一套是锁。写操作之间是互斥的,事务A没提交前,它修改的那些行通常持有排他锁,事务B想改同一行就得等着。这种“写写冲突”靠锁解决。

第二套是MVCC(多版本并发控制)。它解决的是“读写冲突”:事务A正在读一批数据时,事务B恰好改了其中几行,A不该被阻塞,也不该看到B未提交的值。InnoDB通过每行记录隐藏的事务ID、回滚指针、以及事务的ReadView,构造出了一个“一致性快照”。读操作看到的是一份基于某个时间点的快照版本,不会被未提交的修改干扰。

隔离性分四个级别,从弱到强:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。级别越高,并发能力越弱,但脏读、不可重复读、幻读这些问题就越少。InnoDB默认是REPEATABLE READ,同时用next-key lock进一步抑制幻读,后面的章节我会专门演示。

2.4 持久性(Durability)——redo log 与 WAL 机制

持久性要求事务一旦提交,结果就必须永久保存,哪怕接下来数据库断电、宕机,数据也不能丢。

InnoDB并不是每次提交都直接把数据页刷进磁盘的。直接刷盘太慢,所以它用了一个叫redo log的文件,采用WAL(Write-Ahead Logging)策略:事务提交时,先把修改记录按照日志顺序写进redo log并落盘,成功后才向客户端返回“提交成功”。数据页面本身可以留在缓冲池(Buffer Pool)里,等后续系统空闲或缓冲页被淘汰时再刷进磁盘。

如果事务提交后,在数据页还没刷盘时数据库宕机了,怎么办?恢复阶段InnoDB会扫描redo log,把已经提交但还没落到磁盘的修改重新执行一遍,保证数据不丢。这也解释了为什么提交慢有时不是磁盘慢,而是redo log刷盘策略太保守。

3. 事务的实操:开启、提交、回滚与保存点

3.1 最常用的三句话:BEGIN / COMMIT / ROLLBACK

在MySQL里开启一个事务很简单,写START TRANSACTION或者BEGIN。然后正常写增删改查语句,全部做完后COMMIT提交;如果中途发现不对,ROLLBACK回滚。下面是一段最常见的用法:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 1; UPDATE account SET balance = balance + 100 WHERE user_id = 2; -- 两条都成功后提交 COMMIT;

如果第二条更新因为余额不足等原因失败,代码里通常这样处理:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE user_id = 1; UPDATE account SET balance = balance + 100 WHERE user_id = 2; -- 捕获到异常后回滚 ROLLBACK;

执行ROLLBACK后,两条UPDATE的影响都会被撤销,余额回到事务开始前的状态。这里有个细节:回滚只回滚事务内部已经执行的DML操作,不会撤销事务开始前的其他提交。

如果不用事务,两条UPDATE各自成了独立单元。第一条成功、第二条失败,数据就已经“歪”了,这是很多交付现场事故的直接原因。

3.2 autocommit 和隐式提交,很多人栽在这里

MySQL客户端默认开启autocommit,也就是每条单独的SQL语句执行完都会自动提交。这意味着你不写START TRANSACTION,每条UPDATE其实都是一个独立事务,这也是前面那个线上事故的发生条件。

关掉自动提交有两种方式。一种是在会话里设置:

SET autocommit = 0;

设置后,后续执行的DML语句会累积在未提交的事务里,直到你手动COMMIT或ROLLBACK。另一种是显式使用START TRANSACTION,在事务结束前,自动提交对当前事务不起作用。

比自动提交更坑的是隐式提交。有些SQL一旦执行,会强制把当前事务立刻提交掉,就算你正在事务中间也会被“截胡”。典型的有:

  • 所有DDL语句:CREATE TABLE、ALTER TABLE、DROP TABLE、TRUNCATE TABLE
  • GRANT、REVOKE这类权限管理语句
  • LOCK TABLES、UNLOCK TABLES
  • CREATE INDEX、DROP INDEX

我见过有人在一个事务里先插入业务数据,然后顺手ALTER TABLE加了个字段,再执行一条UPDATE。他以为三个操作在同一事务里,失败了能一起回滚。实际执行到ALTER TABLE时,前面的INSERT已经被隐式提交了,后面的UPDATE失败只能回滚UPDATE,INSERT永久留在了表里。这个坑在自动化脚本里特别容易踩。记住一条经验:事务里别混入DDL,除非你清楚它会把事务切断。

3.3 保存点(SAVEPOINT):部分回滚的灵活用法

有时候事务里操作步骤很多,出错后不想全部回滚,只想回退到中间某个标记位置。这时候可以用保存点(SAVEPOINT)。

START TRANSACTION; INSERT INTO orders(order_id, amount) VALUES(1001, 88); SAVEPOINT sp1; UPDATE account SET balance = balance - 88 WHERE user_id = 1; -- 发现这里有问题,回退到sp1 ROLLBACK TO sp1; -- 事务还没有结束,这次更新也不能停 UPDATE account SET balance = balance - 10 WHERE user_id = 1; COMMIT;

执行ROLLBACK TO sp1后,从保存点之后做的所有改动会被撤销,保存点之前的INSERT仍然有效。注意,回滚到保存点并不会结束整个事务,事务还处于活跃状态,后面还可以继续执行SQL,直到COMMIT或ROLLBACK才会真正结束。RELEASE SAVEPOINT sp1可以删掉保存点,但只影响保存点本身,不影响数据改动。

保存点适合在一个事务里做多步骤数据处理,比如循环批量处理明细,遇到某一条数据异常时跳过这步而不是放弃整个批次。不过也别滥用,保存点过多会把事务搞复杂,排查问题时难看清。

4. 隔离级别怎么设、怎么验证,四种级别一次讲透

4.1 四种隔离级别分别防什么

隔离级别决定了一个事务到底能看到多少别的事务的“中间状态”。MySQL默认是REPEATABLE READ。先看一张对比表:

隔离级别防止脏读防止不可重复读防止幻读典型场景
READ UNCOMMITTED否否否几乎不用,风险极高
READ COMMITTED是否否部分系统需要实时读最新提交
REPEATABLE READ是是是(InnoDB加强)MySQL默认值,多数业务选它
SERIALIZABLE是是是并发要求极低、强一致场景

先说脏读。一个事务读到另一个事务还没提交的数据,就是脏读。比如余额原先是100,事务A改成200但没提交,事务B读取到了200。之后事务A回滚,这200根本不存在,B却基于不存在的200做了业务判断。READ UNCOMMITTED允许脏读,所以生产环境基本不碰它。

不可重复读指的是同一个事务里,两次读取同一行数据,结果不一样。事务A先读余额是100,事务B提交改成50后,事务A再读变成50。如果希望一个事务里看到的数据保持一致,就得用REPEATABLE READ。

幻读更隐蔽。事务A按条件查出一批订单,事务B插入了一条新订单并提交,事务A再按同样条件查询,多出一行之前不存在的记录。这个“凭空冒出来”的数据就像幻觉。InnoDB在REPEATABLE READ下用next-key lock锁住扫描区间,很大程度上抑制了幻读,这也是它敢把默认级别设为REPEATABLE READ的原因。

4.2 隔离级别的设置与查看

查看当前隔离级别:

SELECT @@transaction_isolation; SHOW VARIABLES LIKE 'transaction_isolation';

MySQL 8.0使用transaction_isolation,5.7及更早版本变量名是tx_isolation。如果你在8.0里写tx_isolation,会提示变量不存在,这个版本差异经常让人困惑。

设置隔离级别有三种作用范围:

-- 只影响当前会话,之后的事务生效 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 影响所有新连接,需要足够权限 SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 只影响下一个事务,且必须在START TRANSACTION之前设置 SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

SET TRANSACTION这个写法比较容易被忽略。它的有效期只有一个事务,而且必须在开启这个事务之前指定。如果你已经START TRANSACTION了,再执行SET TRANSACTION ISOLATION LEVEL,MySQL会提醒你它不生效。想改当前正在执行的事务,只能先提交或回滚,重新开启。

4.3 用两个终端实操复现脏读、不可重复读、幻读

光看概念记不牢,建议自己开两个MySQL终端,按下面的流程跑一遍。为了清晰,先建一张表:

CREATE TABLE test_account ( id INT PRIMARY KEY AUTO_INCREMENT, balance INT NOT NULL ) ENGINE=InnoDB; INSERT INTO test_account(id, balance) VALUES (1, 100);

复现脏读:终端A执行SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;然后START TRANSACTION;;终端B执行START TRANSACTION; UPDATE test_account SET balance = 50 WHERE id = 1;先不提交;回到终端A执行SELECT balance FROM test_account WHERE id = 1;。如果隔离级别是READ UNCOMMITTED,你会读到50,这就是脏读。把级别改成READ COMMITTED再跑一次,读到的是100,脏读被挡回来了。

复现不可重复读:终端A设为READ COMMITTED并开启事务,先SELECT balance FROM test_account WHERE id = 1;看到100;终端B正常开启事务,UPDATE test_account SET balance = 80 WHERE id = 1; COMMIT;;终端A再执行同样SELECT,在READ COMMITTED下这次读到的就是80,两次结果不一致。如果把终端A改成REPEATABLE READ,第二次读依然是100,因为快照读基于事务开始时的一致性视图,后面的提交不影响它。

复现幻读:终端A设为REPEATABLE READ,开启事务,执行SELECT COUNT(*) FROM test_account WHERE balance > 60;;终端B插入一条balance = 90的记录并提交;终端A再执行同样的COUNT,快照读得到的数量不变。但是要小心,如果终端A是用SELECT ... FOR UPDATE这种当前读来查询,结果会包含新插入的行,因为当前读走的是最新版本,并受next-key lock保护。所以InnoDB“防幻读”主要是针对当前读场景,业务上要避免用不一致的读写混用来做判断。

这一套跑下来,你才算真正理解隔离级别的意义,而不只是背答案。

5. 事务实战中常见问题与排查技巧

5.1 锁等待与死锁的现场处理

锁等待,英文叫Lock Wait。事务A更新了某一行还没提交,事务B也想更新同一行,B就会阻塞等待。默认等待时间是50秒,超过后返回ERROR 1205: Lock wait timeout exceeded。遇到这个报错,第一反应不是去猜哪行数据被锁,而是查正在运行的事务:

SELECT * FROM information_schema.innodb_trx\G;

这个视图会显示当前所有未提交事务的开始时间、执行SQL、等待锁的情况。重点看trx_state字段是RUNNING还是LOCK WAIT,再顺着进程ID去查对应会话在干什么。如果发现有事务开了很久就是不动,那基本就是它把锁握在手里不释放,被锁的业务只能干等。

死锁比锁等待更棘手。两个事务各自持有对方想要的锁,形成循环等待。比如事务A先更新id=1的行,再更新id=2的行;事务B正好反过来,先更新id=2,再更新id=1。两边谁都不让,MySQL检测到后直接回滚其中一个事务,抛出ERROR 1213: Deadlock found when trying to get lock。这是正常机制,不是数据库故障,但说明你的业务在并发场景下加锁顺序不统一。

排查死锁靠SHOW ENGINE INNODB STATUS;,在输出里找LATEST DETECTED DEADLOCK区域,里面详细记录了两个事务的SQL和持锁顺序。我的习惯是定期抓死锁日志,看看最近有没有新增的死锁模式。修复方式一般是统一加锁顺序,或者在事务里避免同时操作多张表、多行数据。

5.2 大事务、长事务是怎么拖垮业务的

事务不是越久越好,也不是越大越好。一个事务里塞了几十万行UPDATE,表面看是高效,实际上风险极大。

第一,锁的范围会变大。事务执行时间越长,它持有的行锁、间隙锁就越多,其他会话能操作的数据就越少,系统并发能力直线下降。哪怕单条记录只锁一行,事务里循环处理一千行,两个并发事务如果处理的数据有重叠,互相等待就不可避免。

第二,undo log膨胀。长事务会让undo log一直保留,很多历史版本不能清理。一次大批量更新,加上长事务不结束,undo文件可能涨到好几个G,磁盘空间告急,查询要回滚历史版本时也会变慢。

第三,主从复制延迟。大量事务提交后,binlog量特别大,从库要串行执行这些日志,追不上主库时延迟就会持续扩大。业务高峰期做几万行甚至几十万行的UPDATE,从库可能延迟到分钟级,报表查询读到老数据,误以为丢数据。

我建议的做法是:事务里只放必须放在一起的操作,能拆成多个小事务就不要合成一个大事务。比如全量历史数据的迁移,别一次性UPDATE全表,按主键范围分片,每片几千行一个事务,提交后主动释放锁。这样即使某个分片失败,重新跑这个分片就行,不会影响已经完成的片区,恢复成本也低。

5.3 排查事务问题的几个实用命令

排查事务相关问题,我常用这几条命令:

排查目标命令/视图要点
查看未提交事务information_schema.innodb_trx关注trx_state、trx_started、trx_rows_locked
查看锁等待关系performance_schema.data_lock_waits能看到谁在等谁、持锁事务ID
查看死锁详情SHOW ENGINE INNODB STATUS找LATEST DETECTED DEADLOCK区域
实时看会话状态SHOW PROCESSLIST找state为Waiting for table metadata lock或Statistics的会话
分析SQL执行计划EXPLAIN看是否走索引,避免锁全表

遇到无法解释的锁等待,先跑一下SHOW PROCESSLIST,找到长时间挂起的会话,再用information_schema.innodb_trx确认它是不是开了事务没提交。很多时候,问题不是某个SQL写得多差,而是有同事打开事务后一直不提交,甚至挂着控制台下班了。记得设置合理的lock_wait_timeout,避免业务无限期等待下去。

5.4 长期和事务打交道,我养成的几个固定习惯

最后分享几个个人操作习惯,不通用,但谁用谁知道。第一,所有涉及多表更新的业务代码,事务一定显式开启,绝不依赖隐式的自动提交。哪怕只有一个UPDATE,我也习惯写清楚START TRANSACTION和COMMIT,让后来的人一眼明白边界在哪。第二,事务里除了数据库操作,不写外部调用。网络请求、等待消息队列响应、调第三方接口,这些通通移到事务外。锁是用来保护数据的,不是用来干等外部服务的,否则并发一上来就是大事故。第三,失败回滚后,不是立刻重试,而是先确认前一个事务已经结束,再开启下一个。看着一句废话,实际操作里很多人忘了END,直接同一连接上重跑SQL,导致上一个事务的锁还挂在身上。

事务这个东西,概念不难,难点全在边界和并发的细节里。把四大特性的底层实现摸清,把操作命令和隔离级别亲手验证过一遍,以后遇到线上问题才不会靠猜。希望这篇能帮你少踩几个我踩过的坑。

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

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

立即咨询