1. 事务到底是怎么工作的:先从一个转账场景说起
先说个最朴素的例子。你去某电商平台买东西,账户扣款、订单生成、库存扣减这三件事必须同时成功或者同时失败。如果刚扣完钱,订单系统那边突然挂了,你的钱凭空消失,库存也没减——这不只是体验差,这是事故。事务这套机制存在的意义,就是把这几个操作捆绑成一个不可分割的单元,要么全做,要么全不做。
很多人面试的时候能把ACID四个字母背得滚瓜烂熟,真到了排查线上死锁、分析数据不一致的时候,脑子里还是浆糊。原因很简单:只记住了定义,没理解底层是怎么落地的。我写这篇的初衷,就是把MySQL里事务的操作方式、四大特性的真实实现原理、以及实操中最容易踩的坑一次说透。适合正在学数据库的后端开发者、准备面试的求职者,以及写过不少SQL但从来没认真看过事务日志和锁机制的同行。
MySQL中大家日常用到的基本都是InnoDB存储引擎,它的事务能力是完整的,后面的内容全部以InnoDB + MySQL 8.0为前提来讲。你看完至少能达到三个效果:能解释清楚每条SQL执行完底层发生了什么;能在业务代码里正确使用事务而不只是加个注解;遇到死锁、锁等待的时候知道怎么排查而不是只会重启。
2. 事务操作的完整骨架:开启、提交、回滚的细节
2.1 最小可用的事务操作流程
事务操作的SQL命令其实就三个核心关键字:BEGIN(或START TRANSACTION)、COMMIT、ROLLBACK。但在实际使用里,大部分人忽略了autocommit这个默认配置带来的影响。
MySQL默认每个单独的SQL语句都包在一个隐式事务里,也就是说你执行一条UPDATE,如果成功了,它自动就被提交了,执行一条SELECT也是自动开启又自动结束。想要手动控制多语句的事务,得先处理掉autocommit的干扰。
-- 查看当前autocommit状态 SHOW VARIABLES LIKE 'autocommit'; -- 临时关闭自动提交 SET autocommit = 0; -- 或者使用显式事务 START TRANSACTION;我个人的建议是:别轻易改全局的autocommit配置,而是用显式的START TRANSACTION来开事务。原因后面会讲,隐式提交的坑比想象中多。
完整的手动事务流程应该是这样:
-- 第一步:开启事务 START TRANSACTION; -- 第二步:执行业务SQL UPDATE account SET balance = balance - 100 WHERE user_id = 1; UPDATE account SET balance = balance + 100 WHERE user_id = 2; -- 第三步:业务判断都成功后提交 COMMIT; -- 如果中间任何一步失败,执行回滚 -- ROLLBACK;这里面有个很容易犯的错误思路:把BEGIN当成“开始一条新SQL”,忘了它真正标定的是一个事务边界。事务边界的意思是:从BEGIN到COMMIT/ROLLBACK之间的所有DML操作,被MySQL视为一个整体,这个整体在提交之前,外部会话是看不到这些修改的(除非你用的是最低隔离级别)。
2.2 保存点:事务里的分支回滚
如果事务里已经执行了10条SQL,结果第8条出问题了,你想要的是“只撤销第8条之后的操作,保留前7条”,而不是整个事务全部回滚。MySQL提供了SAVEPOINT来实现这种部分回滚能力。
START TRANSACTION; INSERT INTO orders (order_no) VALUES ('SO20240001'); SAVEPOINT sp1; INSERT INTO order_items (order_no, sku_id) VALUES ('SO20240001', 'SKU1001'); -- 如果这里出错了,不想让第二条插入生效 ROLLBACK TO SAVEPOINT sp1; -- 事务还活着,可以继续执行其他SQL INSERT INTO order_items (order_no, sku_id) VALUES ('SO20240001', 'SKU1002'); COMMIT;注意,ROLLBACK TO SAVEPOINT之后,事务本身并没有结束,它只是撤销了保存点之后的修改,事务还是活跃状态,需要显式地COMMIT或ROLLBACK来收尾。我见过有人回滚到保存点后直接关了连接,结果事务长时间挂起,锁一直不释放,这个细节挺坑的。
2.3 警惕隐式提交:哪些语句会强制结束事务
这是我特别想强调的一个点,也是线上事务失效的高发原因。有一类SQL语句执行之后,MySQL会隐式地提交当前事务,不管你之前做了什么操作。常见的有:
- DDL语句:CREATE、ALTER、DROP、TRUNCATE等
- 管理语句:GRANT、REVOKE、SET PASSWORD等
- 事务控制语句的嵌套:START TRANSACTION之后又执行START TRANSACTION
- 某些锁相关语句
如果业务代码里在事务中间夹了一条ALTER TABLE,而且你没意识到它已经隐式提交了,就会出现诡异的问题:事务前半段的数据已经落盘不可回滚,后半段的SQL如果失败了,回滚只能撤销后半段,最终数据完整性被破坏。
提示:在实际项目里,事务块内尽量只放DML语句,任何DDL操作放到事务外面。这是规避隐式提交最简单有效的办法。
3. 四大特性不只是面试题:底层到底是怎么实现的
3.1 原子性:靠undo log实现“全有或全无”
原子性的定义是事务是一个不可分割的工作单位,要么全部执行成功,要么全部不执行。MySQL里这个保证是靠undo log(回滚日志)实现的。
当你执行一条UPDATE把余额从100改成80,InnoDB不只是改数据页,还会在undo log里记录一条“反向操作”日志:这里存着修改前的旧值100。如果事务中途失败或者主动执行ROLLBACK,InnoDB会根据undo log里的记录把数据页恢复到修改之前的样子。
为什么用日志而不是直接不修改数据?因为事务执行过程中,其他并发事务可能已经读取了中间状态(取决于隔离级别),直接把数据页回滚会造成连锁反应。undo log提供了一种顺序的、可逆的恢复机制。
我把undo log理解成拍电影时的“动作替身”:先记录好替身怎么演,万一主演出了问题,替身可以按照记录重新演一遍,保证片子能继续拍下去。实际去分析一个事务的undo log,可以通过information_schema和performance_schema里的一些表来观察,但日常开发中更常见的场景是:长事务导致undo log膨胀,磁盘空间被大量历史版本占满,这个问题放到后面排查章节详细说。
3.2 一致性:它不是一种技术,而是最终的目的
很多人把一致性理解为一种具体实现机制,其实在ACID里,一致性更像是另外三个特性的“结果”。原子性保证不出现半成品,隔离性保证并发下互不干扰,持久性保证宕机后不丢数据——三者合起来,数据才能始终处于合法、符合约束的状态。
业务上的一致性更直观的例子是银行转账:总金额必须守恒。A给B转100块,A少了100、B多了100,这个“总量不变”的约束就是一致性约束。MySQL层面的约束包括主键约束、唯一约束、外键约束、CHECK约束,如果事务过程中违反了这些约束,整个事务会被拒绝并回滚,这也是一致性在数据库层面的体现。
开发中容易忽略的一个问题是:数据库能保证它自己管理的数据满足约束,但无法保证你的业务逻辑是完全正确的。如果你在事务里算了错误的金额,比如扣了A 200却给B只加了100,数据库不会报错,因为它不知道这个差额是不合法的。所以事务开发的核心思路是:先把业务规则设计对,再用事务保证执行过程的安全,不能反过来指望事务拯救错误的业务逻辑。
3.3 隔离性:MVCC和锁是两套并行体系
隔离性的官方表述是:多个事务并发执行时,彼此之间应该隔离,一个事务不应当看到其他事务未提交的数据。MySQL InnoDB主要是靠两套机制来保证隔离性:MVCC(多版本并发控制)和锁。
MVCC解决的是“读写并发”的问题。它允许一个读操作和一个写操作同时进行而不互相阻塞——读操作读的是某个历史版本的数据,不需要等写操作完成。这个“历史版本”就是基于undo log里的版本链实现的。每一行数据不仅有当前值,还保存着一个指针链向之前的版本,事务读取的时候根据可见性规则找到自己该看到的版本。
锁解决的则是“写写并发”的问题。两个事务同时修改同一行数据时,必须串行执行。InnoDB的行锁、间隙锁、临键锁都是围绕这个目标设计的。MVCC和锁的分工非常明确:读读不阻塞、读写不阻塞(MVCC手段)、写写阻塞(用锁来排队)。
理解这个体系之后,你再看隔离级别就不会觉得它只是个抽象概念了。四种隔离级别实际上是MVCC可见性规则和锁粒度的不同组合,后面的实操章节我会给出一个对比表。
3.4 持久性:redo log和binlog的配合
持久性要解决的问题是:事务提交后,万一MySQL进程崩溃或者服务器断电,已提交的数据也不能丢。InnoDB给出的方案是redo log(重做日志)。
redo log的写入机制很巧妙,它遵循WAL(Write-Ahead Logging)原则,意思是先写日志,再写数据文件。事务提交的时候,InnoDB先把本次修改的记录追加到redo log buffer,然后刷到磁盘上的redo log文件,此时数据页还不一定已经刷盘。如果这时断电了,重启之后InnoDB会根据redo log把已提交事务的数据恢复过来。这样即使数据页没有及时落盘,也不会丢数据。
MySQL里其实有两套日志:InnoDB层的redo log和MySQL Server层的binlog。binlog是逻辑日志,记录的是SQL语句或行变更,主要用于主从复制和数据恢复。redo log是物理日志,记录的是数据页的修改。两套日志之间有协调机制,也就是两阶段提交。事务提交时,先写redo log的prepare状态,然后写binlog,最后把redo log改为commit状态。这个设计是为了保证主库崩溃恢复后,两套日志里记录的数据是一致的。
关于持久性,我常被问到的一个问题是:事务的COMMIT是不是一定意味着数据已经写到磁盘上了。答案是:log file写到了磁盘,但数据页不一定。所以MySQL官方有innodb_flush_log_at_trx_commit这个参数,取值1的时候每次事务提交都刷盘,安全级别最高但性能最差;取值2的时候只写到操作系统缓存,性能好一些但MySQL进程崩溃时可能丢数据。默认值是1,生产环境不建议改。
4. 实操:隔离级别的选择、锁行为与一套完整的演示
4.1 四种隔离级别横向对比
InnoDB支持的四种隔离级别,从宽松到严格依次是:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 直接读最新版本,无MVCC保护 |
| READ COMMITTED | 不会 | 可能 | 可能 | 每条普通SELECT语句生成新ReadView |
| REPEATABLE READ | 不会 | 不会 | 有手段避免 | 事务内首次SELECT生成ReadView,之后复用;配合间隙锁防止幻读 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 所有读都变成当前读,加共享锁串行执行 |
MySQL InnoDB的默认隔离级别是REPEATABLE READ(可重复读)。这里有个跟其他数据库不一样的细节:在RR级别下,InnoDB通过间隙锁/临键锁在很大程度上消除了幻读,但严格来说,只有在快照读的场景下才能完全避免。如果你用的是当前读(比如SELECT ... FOR UPDATE),在一定边界条件下还是可能出现幻读的。
ReadView是MVCC实现的核心数据结构,里面有活跃事务ID列表、最小ID、最大ID等信息。每条记录版本通过事务ID判断是否对当前事务可见。READ COMMITTED级别下每条普通SELECT都生成新的ReadView,所以能看到其他事务刚提交的数据;REPEATABLE READ级别下只在事务内第一次SELECT时生成ReadView,后续复用,所以事务内看到的是一致性的快照。
实际业务怎么选?简单来说:读写分离的场景、绝大多数在线交易系统用默认的REPEATABLE READ就好,不要随意改成READ COMMITTED,除非你非常清楚自己要什么。读多写少且能接受串行的场景才考虑SERIALIZABLE,它会把所有读都变成当前读,并发能力下降明显。
4.2 完整演示:用两个会话观察事务隔离和MVCC
下面用一组实际操作来展示事务隔离级别的行为差异。假设有张账户表,里面有一条数据id=1, balance=1000。两个MySQL会话分别叫会话A和会话B。
在REPEATABLE READ级别下:
-- 会话A开启事务并修改余额 START TRANSACTION; UPDATE account SET balance = 900 WHERE id = 1;此时会话A未提交,在会话B里执行:
-- 会话B查询同一行 SELECT balance FROM account WHERE id = 1;执行结果仍是1000,这就是MVCC在起作用:会话B的当前快照里看不到会话A未提交的修改。然后A提交事务,B再执行一次相同的SELECT,结果依然是1000。因为B的事务第一次读时已经生成了ReadView,整个事务内复用它,所以A提交后B也看不到变化。
换到READ COMMITTED级别,B同样先读一次得到1000,A提交后再读一次会发现变成900,因为每条SELECT都重新生成ReadView。这就是不可重复读和可重复读在实操中最直观的区别。
这个演示也解释了为什么RR级别的默认配置下,同一个事务里连续两次SUM查询结果可能不同——不,实际上RR级别下两次读是一致的,所以很多报表查询在RR级别下跑出来的数据和RC级别下可能不一样,这是一个设计选型时需要记住的特性。
4.3 行锁、间隙锁、临键锁的行为差异
在没有索引的情况下,InnoDB的锁会升级到表级锁?其实更准确地说,是在没有可用索引时,它会给多条记录甚至整个表加锁。所以给WHERE条件建立合适的索引,不只是查询性能问题,也直接影响锁的粒度。
行锁(Record Lock)只锁定单个行记录。比如上面UPDATE account WHERE id=1,如果id是主键,只会锁这一行。
间隙锁(Gap Lock)锁定一个范围但不锁定记录本身,主要为了防止其他事务在这个区间插入新记录导致幻读。比如执行了SELECT * FROM account WHERE balance BETWEEN 100 AND 200 FOR UPDATE,并且balance列上有索引,InnoDB会锁定这个区间内的间隙,其他事务就不能往这个区间插入balance=150的新行。
临键锁(Next-Key Lock)是行锁加上间隙锁的组合,锁定的范围包括记录本身以及记录前面的间隙,是InnoDB RR隔离级别下默认的加锁方式。它的实际效果是既不允许其他事务修改被锁的记录,也不允许在锁范围内插入新记录。
SELECT ... FOR UPDATE和SELECT ... LOCK IN SHARE MODE是两种手工加锁的方式。前者加排他锁,后者加共享锁。共享锁之间兼容,但共享锁与排他锁互斥。实际业务里,如果要先查后改,并且要防止查询结果被别人抢先修改,就应该用FOR UPDATE把这个范围锁住,直到事务提交。
4.4 一套可复用的乐观锁和悲观锁写法
除了数据库内置的事务锁,业务开发里还有两种常见的并发控制风格:乐观锁和悲观锁。
悲观锁的做法是先锁定资源再操作。典型的SQL写法:
START TRANSACTION; SELECT * FROM account WHERE id = 1 FOR UPDATE; -- 业务逻辑处理 UPDATE account SET balance = balance - 100 WHERE id = 1; COMMIT;FOR UPDATE会阻塞其他事务对同一行的修改请求,这就是串行化的保证,适合并发冲突激烈的场景,比如库存扣减。
乐观锁则不锁资源,而是在更新时检查版本号或状态字段:
START TRANSACTION; -- 读取时拿到version SELECT balance, version FROM account WHERE id = 1; -- 更新时携带version条件 UPDATE account SET balance = balance - 100, version = version + 1 WHERE id = 1 AND version = 0; -- 如果影响行数为0,说明版本已变化,需要重试或提示失败 COMMIT;乐观锁适合并发冲突比较少、重试成本低的场景,比如更新个人资料。它的好处是读操作永远不阻塞、吞吐量高,坏处是冲突时需要业务层处理重试逻辑。这里顺便说一个容易被忽略的参数:记录行数多少、版本号是否必须整数、以及更新条件和乐观锁条件混在一起时,SQL的实际影响行数判断非常重要,写错条件会导致死锁或更新失败。
5. 常见问题与排查技巧实录
5.1 死锁:两个事务互相等待对方释放锁
死锁是事务开发中遇到最头疼的问题之一。典型的死锁场景是:事务A锁了行1,想要锁行2;事务B锁了行2,想要锁行1。两边互不相让,就僵住了。
MySQL有一种死锁检测机制,默认开启(innodb_deadlock_detect=on),它会主动回滚其中一个事务来打破僵局。被回滚的事务会拿到一个类似于Deadlock found when trying to get lock的错误码。但要注意,每次死锁检测触发都会消耗额外的CPU资源,当并发量极高且锁冲突严重时,死锁检测本身可能成为性能瓶颈。
定位死锁的常用手段是执行:
SHOW ENGINE INNODB STATUS;重点看LATEST DETECTED DEADLOCK段,它会给出两个事务各自的持锁和等待锁的信息,包括涉及的表、索引、SQL语句。排查思路就是根据这些信息反推业务逻辑,看看是否两个事务以不同的顺序访问相同的资源。解决方案通常是把资源访问顺序统一,或者减少事务范围。
实际业务中,我发现很多死锁不是数据库配置问题,而是代码层拿锁顺序混乱导致的。比如一个事务里先更新订单再更新库存,另一个事务先更新库存再更新订单,这种场景加再多缓冲都没用,唯一的解法是统一顺序。
5.2 锁等待超时:事务迟迟不提交
锁等待超时跟死锁不一样,死锁是相互等待且被检测后回滚,锁等待是某个事务一直持锁不释放,其他事务只能干等,直到触发innodb_lock_wait_timeout参数指定的秒数(默认50秒)。
排查这种问题,第一步是找到持锁的事务和连接。MySQL 8.0里可以通过performance_schema.data_lock_waits查看锁等待关系。大致思路是:先确定当前有哪些事务正在运行,哪个事务在阻塞别人,阻塞方的具体SQL是什么。
常见原因有:
- 程序中开启了事务,处理完业务后忘了COMMIT或ROLLBACK
- 事务中执行了外部HTTP调用,整个事务被拖长
- 长事务积压了大量未提交的修改,导致其他事务一直等
应对措施也很直接:给事务设置合理的超时时间,避免外部调用塞进事务,同时监控长事务,把超过阈值的会话及时发告警。
5.3 大事务和长事务对系统的影响
一个事务运行时间越长、修改的数据越多,它占用的历史版本就越多,undo log膨胀得越厉害,同时持有的锁范围越大,阻塞面越广。
对undo log的影响是:老版本数据需要保留到所有可能的快照不再使用它之后才能清理,这就是为什么长事务会让数据库的undo log持续增长,磁盘使用率快速攀升。很多时候磁盘快满了不一定是有大事务一直在写新数据,而是古老事务不让历史版本清理,间接拖垮了数据库。
对大事务的改进思路:把大事务拆成小事务,比如批量导入的数据,不要一个事务插入一百万行,而是每几千条一个事务提交。看起来每次都写了点日志、偶尔多几个commit,但整体对锁的持有时间、undo log的体积、主从复制延迟的影响都会小得多。
5.4 MySQL 8.0下的排查SQL速查表
下面整理几张我在实际排查中经常用到的查询语句,直接复制就能用:
-- 查看当前隔离级别 SELECT @@transaction_isolation; -- 查看正在运行的事务 SELECT * FROM information_schema.innodb_trx\G; -- 查看锁等待关系(8.0用这个) SELECT * FROM performance_schema.data_lock_waits\G; -- 查看每个连接正在执行的SQL SELECT * FROM sys.processlist WHERE command != 'Sleep' AND conn_id != CONNECTION_ID();再补充一个经验性的操作:拿到阻塞源头的线程ID后,如果确认是某条SQL拖太久了,可以直接KILL掉那个连接,这样事务会被强制回滚,锁自然被释放。但这只是应急手段,根因还是要回到代码层面去优化事务范围和索引设计。
5.5 事务执行顺序不同导致主从数据不一致
主从架构下,事务的提交顺序和binlog记录顺序可能不等于事务在业务上的发起顺序。MySQL复制是异步的,从库按收到的binlog顺序执行,如果两个事务之间有先后依赖,但它们的binlog落盘顺序因为提交时机不同而发生变化,就可能导致从库先执行后一个事务再执行前一个事务,中间状态对从库查询来说就是错的。
这个问题的经典解法是:有依赖关系的写操作放在同一个事务里,或者使用单一写入节点保持严格的串行顺序。对于强一致读的场景,适当考虑把查询也路由到主库。如果不追求强一致,很多业务其实可以容忍秒级的读延迟差异,不需要过度设计。
6. 事务设计上的几条经验
写到这里,关于MySQL事务的技术细节基本都覆盖了。最后分享几个我踩过坑之后沉淀下来的实践原则,不一定是什么高深理论,但非常管用:
第一,事务越短越好。这个短不只是说SQL执行速度快,更关键的是业务处理时间短。一个事务里但凡有网络调用、文件读取、外部接口交互,这个事务基本就不能算是数据库事务了,它更像是分布式事务的雏形。数据库能保证的是单库内的一致性,跨服务、跨库的一致性需要另外一套方案(比如本地消息表、事务消息、SAGA),不能指望一个数据库事务去包揽全局。
第二,更新语句务必用索引。没有索引的更新会让锁范围扩大,而且会拖慢行版本链的查找效率。在账务、库存类的高频更新场景,这个影响是量级的。
第三,隔离级别的选择不能拍脑袋。默认RR级别并不意味着所有场景都要用RR,但任何修改都要经过充分测试。我在一个报表系统里把隔离级别从RR改成RC之后,并发提高了,但部分报表在事务内两次SUM结果不一致,后来是换了一种查询方式才解决问题。没有银弹。
第四,把孩子的大小事务都交给数据库事务并不现实。事务控制的不是逻辑上的业务操作完整性,而是数据库层面的数据操作完整性。一个“创建订单”的业务可能涉及订单表、商品表、库存表、优惠券表等四五张表,它的确应该放在同一个事务里,但如果业务链路还包括发短信、推送消息、调用支付回调,那就得把它们拆出去,否则事务会退化成一个性能黑洞。
最后一点纯个人体会:遇到事务问题,先查日志,再查锁,最后再碰参数。很多人上来就调innodb_lock_wait_timeout、关死锁检测,治标不治本。真正稳定的数据层,靠的是合理的事务边界、有索引的更新条件和统一的加锁顺序,这三点做到位,百分之八十的事务问题都会消失。