
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改成80InnoDB不只是改数据页还会在undo log里记录一条“反向操作”日志这里存着修改前的旧值100。如果事务中途失败或者主动执行ROLLBACKInnoDB会根据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的写入机制很巧妙它遵循WALWrite-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语句生成新ReadViewREPEATABLE 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下面用一组实际操作来展示事务隔离级别的行为差异。假设有张账户表里面有一条数据id1, balance1000。两个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同样先读一次得到1000A提交后再读一次会发现变成900因为每条SELECT都重新生成ReadView。这就是不可重复读和可重复读在实操中最直观的区别。这个演示也解释了为什么RR级别的默认配置下同一个事务里连续两次SUM查询结果可能不同——不实际上RR级别下两次读是一致的所以很多报表查询在RR级别下跑出来的数据和RC级别下可能不一样这是一个设计选型时需要记住的特性。4.3 行锁、间隙锁、临键锁的行为差异在没有索引的情况下InnoDB的锁会升级到表级锁其实更准确地说是在没有可用索引时它会给多条记录甚至整个表加锁。所以给WHERE条件建立合适的索引不只是查询性能问题也直接影响锁的粒度。行锁Record Lock只锁定单个行记录。比如上面UPDATE account WHERE id1如果id是主键只会锁这一行。间隙锁Gap Lock锁定一个范围但不锁定记录本身主要为了防止其他事务在这个区间插入新记录导致幻读。比如执行了SELECT * FROM account WHERE balance BETWEEN 100 AND 200 FOR UPDATE并且balance列上有索引InnoDB会锁定这个区间内的间隙其他事务就不能往这个区间插入balance150的新行。临键锁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_detecton它会主动回滚其中一个事务来打破僵局。被回滚的事务会拿到一个类似于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、关死锁检测治标不治本。真正稳定的数据层靠的是合理的事务边界、有索引的更新条件和统一的加锁顺序这三点做到位百分之八十的事务问题都会消失。