资讯动态

MySQL事务与隔离级别详解:从ACID原理到锁、MVCC与死锁排查实践

发布时间:2026/10/14 4:37:18 来源:尧图企业网站定制
事务一直是 MySQL 学习里绕不开的话题也是后端开发、DBA 日常排障、面试必问的重灾区。很多朋友对几个隔离级别背得滚瓜烂熟但一到“脏读到底怎么复现”、“为什么 RR 隔离级别还会出幻读”、“死锁日志该怎么看”就卡壳了。这篇笔记就是我的 MySQL 事务学习总结从基础概念一直落到源码层实现和实操踩坑全部配有可以直接执行的 SQL 代码适合刚学完 SQL 基础想深入数据库机制的同学也适合工作了两三年想系统补一遍事务体系的开发者。1. 一个“半完成”的转账为什么说没有事务的数据库不可信1.1 从银行转账场景看事务的原始诉求先假设一个最简单的转账逻辑从 A 账户扣 100 元往 B 账户加 100 元。如果数据库没有事务机制执行完第一条 UPDATE 之后系统断电、进程崩溃、或者代码中途抛异常那账面上就变成 A 的钱扣了B 的钱没到。这个结果显然是错的用户不会接受审计更不会接受。事务要解决的就是这类“多条 SQL 必须打包成功、要么一起生效、要么一起回滚”的问题。在 MySQL 里事务是一组数据库操作的最小逻辑执行单元这个单元内的所有语句被视为一个整体提交给存储引擎处理。实际项目中典型的事务场景包括订单创建时同时扣库存、用户注册时同时建账号和初始化钱包、支付回调更新订单状态与记账流水这些都要求“多步操作之间不允许中间状态暴露给其他会话”。1.2 MySQL 中事务的存储引擎前提不是所有存储引擎都支持事务这是刚入门最容易忽略的点。MyISAM 是 MySQL 历史上非常经典的存储引擎查询速度快但不支持事务、不支持外键崩溃恢复能力也弱。InnoDB 从 MySQL 5.5 版本之后成为默认存储引擎提供完整的事务支持包括 ACID 特性、行级锁、崩溃恢复、MVCC 多版本并发控制。可以执行下面的 SQL 查看当前表使用的引擎SHOW TABLE STATUS LIKE your_table_name\G也可以直接看建表语句SHOW CREATE TABLE your_table_name\G如果发现表是 MyISAM想转 InnoDB用一条 ALTER 就能搞定ALTER TABLE your_table_name ENGINE InnoDB;注意这个转换会重建表对于数据量很大的表要提前在业务低峰期操作并确认磁盘空间足够。锁表期间写入会阻塞。2. ACID 四件事事务的“底线”是怎么定的2.1 原子性要么全做要么全不做原子性强调事务中的操作不可分割。实现依赖 InnoDB 的 undo log回滚日志。事务执行过程中如果出现错误、异常或用户主动 ROLLBACKInnoDB 会利用 undo log 将数据恢复成事务开始之前的样子。举个可以直接实操的例子-- 创建测试表 CREATE TABLE account ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(20) NOT NULL, balance DECIMAL(10,2) NOT NULL ) ENGINEInnoDB; INSERT INTO account(name, balance) VALUES (A, 1000), (B, 1000); -- 开启事务 START TRANSACTION; UPDATE account SET balance balance - 100 WHERE name A; UPDATE account SET balance balance 100 WHERE name B; -- 模拟执行出错 -- 如果此时执行一条非法 SQL例如 -- UPDATE account SET balance abc WHERE name B; -- MySQL 会报错事务仍然保持打开状态 -- 手动回滚 ROLLBACK; SELECT * FROM account;执行完 ROLLBACK 后账户余额仍然和初始状态一样。这就是原子性的表现第二条 SQL 失败后第一条 SQL 的修改不会残留。2.2 一致性数据库规则的前后一致性一致性是指事务执行前后数据库都必须处于合法的状态。这个“合法”不只是数值对得上还包括约束主键、唯一键、外键、CHECK、触发器等规则的满足。还是转账的例子扣款和加款的总金额在事务前后是一致的这就是业务层面的一致性数据库层面任何事务提交后都不能破坏约束。需要特别说明一点一致性本质上不只是数据库靠日志保证的还要靠业务代码正确设计。比如转账逻辑里忘记写扣款语句只写了加款事务照样能提交成功但业务数据就是错的。数据库没法判断“这个事务在业务上是否合理”它只负责保证“事务要么完整执行要么不执行”。2.3 隔离性并发事务之间不能互相干扰隔离性描述的是多个事务并发执行时每个事务内部的数据操作对其他事务的影响程度。如果完全不隔离事务 A 修改了数据还没提交事务 B 就能读到那 B 基于错误数据做后续操作结果不可控。InnoDB 通过锁机制和 MVCC 实现隔离性隔离级别越高并发能力相对越低数据一致性越强这是经典的“一致性 vs 性能”权衡。隔离性也是本篇文章最难的部分后面专门开一章讲隔离级别和底层实现。2.4 持久性提交之后数据就不能丢持久性要求事务一旦 COMMIT对数据库的修改就是永久性的即使系统崩溃、断电恢复之后数据仍然存在。InnoDB 的实现依赖 redo log重做日志。事务提交时会先把事务产生的日志写入 redo log buffer然后根据配置策略刷入磁盘然后再提交。数据页本身因为有 redo log 的存在可以延迟刷盘这样既保证持久性又能利用顺序写日志的高性能优势。想比较直观地理解这个关系可以把 InnoDB 的数据修改流程简化成两步内存中修改数据页记录 redo log。崩溃恢复时如果数据页还没来得及刷盘重放 redo log 就能把数据恢复到崩溃前已提交事务的状态。3. 隔离级别与三个脏问题从理论到可直接复现的 SQL 实验3.1 并发事务下的三个经典问题先说定义后面附复现 SQL。脏读事务 A 读到事务 B 尚未提交的数据。如果 B 回滚A 读到的是不存在的数据。不可重复读同一个事务内两次执行相同查询读到不同的值。原因是期间其他事务提交了 UPDATE/DELETE。幻读同一个事务内两次范围查询返回的行数/行集合不一样。原因是期间其他事务提交了 INSERT。三者之间的关系需要理清楚脏读发生在“读未提交”级别下是最严重的问题不可重复读发生在读已提交级别下幻读也是读已提交级别下会出现的问题但在可重复读隔离级别里InnoDB 通过 MVCC 和临键锁next-key lock在大多数场景下避免了幻读这一点后面细说。3.2 四种隔离级别速查表InnoDB 支持 SQL 标准定义的四种隔离级别隔离级别脏读不可重复读幻读说明READ UNCOMMITTED可能可能可能几乎不用逻辑不可靠READ COMMITTED不会可能可能很多数据库的默认级别REPEATABLE READ不会不会可能InnoDB 通过锁MVCC 基本规避MySQL InnoDB 默认级别SERIALIZABLE不会不会不会直接串行化性能代价高这里必须强调一个认知偏差MySQL 的默认隔离级别是 REPEATABLE READ不是 READ COMMITTED。我刚工作那两年也一直以为默认是“读已提交”因为这个更符合日常开发直觉。实际上 MySQL 官方文档明确写了 InnoDB 的默认隔离级别是 REPEATABLE READ并且在这个级别下通过 MVCC 避免了绝大多数不可重复读问题。3.3 复现脏读开两个 mysql 客户端分别模拟会话 A 和会话 B-- 会话 B将隔离级别设为 READ UNCOMMITTED SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- 会话 A开启事务修改数据但不提交 START TRANSACTION; UPDATE account SET balance 900 WHERE name A; -- 会话 B查询此时读到了 A 尚未提交的数据 SELECT balance FROM account WHERE name A; -- 结果会显示 900这就是脏读 -- 会话 A 回滚 ROLLBACK;执行之后把隔离级别调回默认值避免影响后续实验SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;3.4 复现不可重复读-- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT balance FROM account WHERE name A; -- 假设读到 1000 -- 会话 B UPDATE account SET balance 800 WHERE name A; COMMIT; -- 会话 A 再次查询相同条件 SELECT balance FROM account WHERE name A; -- 读到 800两次结果不一致这就是不可重复读 -- 会话 A 收尾 COMMIT;3.5 复现幻读先准备一份范围数据CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(20) NOT NULL, amount DECIMAL(10,2) NOT NULL ) ENGINEInnoDB; INSERT INTO order_item(order_no, amount) VALUES (SO001, 100), (SO002, 200);-- 会话 A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; START TRANSACTION; SELECT COUNT(*) FROM order_item WHERE amount 50; -- 结果 2 -- 会话 B INSERT INTO order_item(order_no, amount) VALUES (SO003, 300); COMMIT; -- 会话 A 再次查询 SELECT COUNT(*) FROM order_item WHERE amount 50; -- 结果 3和第一次不一致这就是幻读 -- 会话 A 收尾 COMMIT;值得注意如果切换到 REPEATABLE READ 级别下重跑上面的实验第二次查询结果仍然是 2因为 InnoDB 的快照读机制让事务第一次 SELECT 时生成了当前版本快照后续查询都基于这个快照。但幻读并没有被完全消灭后面 4.4 节会聊“当前读”场景下的幻读问题。4. 事务底层到底靠什么撑住redo、undo、锁和 MVCC4.1 redo log 是怎么保证持久性的InnoDB 的数据页默认 16KB写入磁盘是随机 IO性能很差。如果每次事务提交都立刻把修改的数据页刷盘数据库吞吐会非常低。redo log 采用顺序追加写入把事务修改的物理页操作记录下来。事务提交时只要保证 redo log 落盘数据页即使还在内存 buffer pool 里崩溃后也能通过 redo log 恢复。与持久性直接相关的参数是innodb_flush_log_at_trx_commit有三个取值参数值行为持久性表现1每次事务提交都把 redo log buffer 刷入磁盘最强默认值0每秒刷一次磁盘最多丢一秒内已提交事务2每次提交写入操作系统缓存每秒刷盘MySQL 崩溃不丢操作系统崩溃可能丢生产环境建议保持默认值 1。追求极致性能的读多写少场景可以折中考虑 2但要能接受极端情况下丢数据。4.2 undo log 与回滚undo log 记录的是数据修改前的版本用于事务回滚和 MVCC 多版本链。一个事务里包含多条 UPDATE 时undo log 会把它们串联成版本链。回滚时从当前版本往前回溯执行逆向操作即可。这就是原子性的底层依赖。还有一个容易被忽略的知识点即使事务提交成功undo log 不会立即全部清除。因为可能还有其他活跃事务的快照读依赖旧版本数据。只有当系统中不存在比该版本更早的活跃读事务时相应的 undo 历史才能被 purge 线程清理。这也是长事务会导致 undo 膨胀的根因。4.3 锁类型行锁、间隙锁、临键锁InnoDB 锁按粒度分为表级锁和行级锁。行级锁是 InnoDB 的特色类型主要有三种记录锁Record Lock锁定单条索引记录。间隙锁Gap Lock锁定一个区间范围但区间内不包含记录本身。作用是阻止其他事务在间隙内插入新数据从而防止幻读。临键锁Next-Key Lock记录锁间隙锁的组合锁定一个左开右闭区间。InnoDB 默认隔离级别 REPEATABLE READ 下范围查询的加锁使用临键锁。举个加锁范围的具体例子。假设表 t 的 id 索引中有值10、20、30。执行SELECT * FROM t WHERE id 20 FOR UPDATEInnoDB 不只锁住 id20 那一行还会对区间 (10, 20] 和 (20, 30] 加临键锁。这么做的目的就是防止其他事务在 10 和 30 之间插入 id25 之类的新记录避免当前事务后续操作出现幻读。如果把隔离级别改成 READ COMMITTEDInnoDB 会退化为只加记录锁间隙锁取消所以 READ COMMITTED 下幻读问题会重新浮出水面。4.4 快照读与当前读MVCC 的核心是给每个事务分配一个递增的事务 ID每一行数据有多个版本。快照读普通的 SELECT基于事务开始时的 ReadView 读取可见版本的记录不需要加锁。当前读SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT必须读取最新版本并且对涉及记录加锁。这就引出经典问题REPEATABLE READ 下快照读不会再出现幻读但当前读仍然可能碰到幻读。比如事务 A 先执行SELECT * FROM order_item WHERE amount 50 FOR UPDATE此时对符合条件的记录和间隙都加了锁其他事务无法插入新记录但如果事务 A 只是在事务开始执行了一次普通 SELECT快照读之后其他事务插入新数据并提交事务 A 再执行SELECT ... FOR UPDATE或UPDATE就会基于最新版本读到新插入的行产生“更新别人插入的数据”这种类似幻读的效果。这也是为什么网上很多文章说“MySQL RR 隔离级别下幻读并没有完全解决”的原因——准确地说是快照读解决了快照场景下的幻读当前读场景需要依赖临键锁。4.5 死锁是怎么发生的两个事务各自持有一部分资源又都在等待对方释放资源就形成了死锁。经典的 AB-BA 死锁实验-- 会话 A START TRANSACTION; UPDATE account SET balance balance - 100 WHERE name A; -- 此时持有 nameA 这一行的锁 -- 会话 B START TRANSACTION; UPDATE account SET balance balance - 100 WHERE name B; -- 此时持有 nameB 这一行的锁 -- 回到会话 A UPDATE account SET balance balance 100 WHERE name B; -- 等待 B 释放锁 -- 回到会话 B UPDATE account SET balance balance 100 WHERE name A; -- 等待 A 释放锁死锁形成InnoDB 会检测死锁并回滚其中一个事务来打破僵局。被回滚的事务客户端会收到类似Deadlock found when trying to get lock; try restarting transaction的错误。业务层面对死锁的正确姿势就是捕获该错误并重试整个事务。排查死锁的常用命令是SHOW ENGINE INNODB STATUS\G输出信息里 LATEST DETECTED DEADLOCK 段落会显示两个事务分别持有哪些锁、等待哪些锁以及被回滚的是哪个事务。这个输出对定位死锁非常关键。5. 上手实操事务控制语句与参数配置细节5.1 开启事务的两种方式与隐藏坑MySQL 中开启事务有两个等价语句BEGIN和START TRANSACTION。关闭方式则要区分正常和异常场景-- 开启事务 START TRANSACTION; -- 或 BEGIN; -- 一系列 SQL UPDATE account SET balance balance - 100 WHERE name A; UPDATE account SET balance balance 100 WHERE name B; -- 确认无误后提交 COMMIT; -- 或者出错后回滚 ROLLBACK;重点说一下容易被坑到的自动提交机制MySQL 默认 autocommit1也就是每条单独的 SQL 都被当作一个自动提交的事务。只有在显式关闭 autocommit、或执行了 BEGIN/START TRANSACTION 开启事务后多语句才会组成一个事务。-- 查看当前 autocommit SELECT autocommit; -- 会话级关闭自动提交 SET autocommit 0;关闭 autocommit 后所有 SQL 都要手动 COMMIT 才会生效写存储过程或脚本时很容易出现“明明执行了 UPDATE另一个会话却看不到变更”的情况排查时优先确认 autocommit 状态。还有一类“隐式提交”必须警惕执行 DDL 语句CREATE、ALTER、DROP、TRUNCATE 等、SET autocommit 1、START TRANSACTION、LOCK TABLES、管理类语句如FLUSH PRIVILEGES都会导致当前事务被隐式提交。也就是说你事务里写了 10 条 DML中间不小心混入一条 DDL前面的修改就全部提交了后面的语句再出错也没法回滚到事务起点。5.2 SAVEPOINT 回滚点的高级用法复杂事务里想“部分回滚”而不是一把全部回滚可以用 SAVEPOINTSTART TRANSACTION; INSERT INTO account(name, balance) VALUES (C, 500); SAVEPOINT sp1; INSERT INTO account(name, balance) VALUES (D, 600); -- 发现 D 这条插错了只回滚到 sp1 ROLLBACK TO SAVEPOINT sp1; -- 此时 C 还在D 已经消失 COMMIT;ROLLBACK TO SAVEPOINT 不会终止事务也不会释放已经获取的锁这一点要在长事务中特别注意。如果需要真正释放锁还是得 ROLLBACK 或 COMMIT。5.3 事务相关参数与常用查询实践中值得关注的一些参数-- 查看隔离级别 SELECT transaction_isolation; -- 在 MySQL 5.7 之前是 tx_isolation8.0 之后是 transaction_isolation -- 查看锁等待超时时间默认 50 秒 SELECT innodb_lock_wait_timeout; -- 查看事务隔离级别是否自动提交 SELECT autocommit;出现锁等待时可以通过下面的 SQL 快速定位是哪个会话在等待锁、哪个会话持有锁-- 查看当前正在运行的事务 SELECT * FROM information_schema.innodb_trx; -- 查看锁等待关系 SELECT * FROM sys.innodb_lock_waits;这些查询在线上排查“接口卡住”或“数据库线程堆积”的场景里属于基本功。6. 常见事务问题与排查实录6.1 同一行数据并发更新报锁等待超时现象日志报Lock wait timeout exceeded; try restarting transaction。原因事务 A 修改了某一行但没有提交事务 B 去改同一行等待时间超过innodb_lock_wait_timeout就会报错。这类问题大多不是真正的死锁而是事务 A 持有锁时间过长给事务 B 造成阻塞。排查思路第一步执行SHOW ENGINE INNODB STATUS\G查看 TRANSACTIONS 段落里面会列出当前活跃事务、执行状态和等待的锁。第二步查information_schema.innodb_trx看哪些事务已经运行很久且一直处于 OPEN 状态。第三步根据 trx_mysql_thread_id 找到对应的数据库连接业务侧确认是否需要回滚或提交。建议业务代码里统一设置短一点的锁等待超时比如 10 秒以内避免接口长时间悬挂SET SESSION innodb_lock_wait_timeout 10;6.2 一个 UPDATE 卡在原地不动不是死锁而是大事务很多朋友一遇到 UPDATE 卡住就以为是死锁实际更多是“别的会话锁着没放”。我遇到过一个典型的线上问题有个接口里开了事务先查了一遍数据走慢查询又调用远程服务远程服务超时 30 秒导致事务一直不提交半个业务表的数据更新都被堵住。排查时innodb_trx里的差异化信息很管用SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx\Gtrx_started能显示事务已经开了多久。如果一个事务几分钟甚至几小时都没提交几乎可以断定是业务层存在问题。解决思路不是“kill 连接”那么简单而是要在业务设计上避免事务内做远程调用、等待用户输入、复杂计算等耗时操作。事务的黄金准则是小、短、快。6.3 唯一键冲突导致的事务“幽灵锁”插入时如果违反唯一键InnoDB 会给已存在的记录加上共享锁S 锁。这不是一个常见的出坑点但遇到了会很迷惑两个事务同时插入相同的唯一键值一个报错回滚另一个却可能因为等待 S 锁而阻塞。原理是 InnoDB 处理重复键时需要确认索引记录状态重复键冲突的插入会给索引记录加 S 锁来防止其他事务删除这条记录。所以代码里处理唯一键冲突时一定要在事务内考虑“冲突不是瞬间结束”的情况避免一个线程反复插入冲突数据导致其他事务累积等待。更好的做法是在业务入口做幂等控制或者用 INSERT ... ON DUPLICATE KEY UPDATE 由数据库统一处理。6.4 大事务与 undo 膨胀长事务不仅影响并发还会导致 undo log 不断累积。因为事务持有的 ReadView 会阻止 purge 线程清理历史版本事务执行越久需要保留的旧版本数据越多。尤其在频繁 UPDATE 的大表上一个几小时不结束的事务能把 undo 表空间撑得很大。预防手段有几个监控information_schema.innodb_trx里的事务时长超过阈值告警把大批量更新拆成小批次提交避免在事务里做全表扫描和大量数据修改。6.5 死锁日志怎么看死锁时 MySQL 会在错误日志和SHOW ENGINE INNODB STATUS里记录最近一次死锁的详细信息。重点看 LATEST DETECTED DEADLOCK 段落第一部分两个事务各自的线程号、执行状态、访问过的表和行数。第二部分显示事务 1 持有的锁和等待的锁事务 2 持有的锁和等待的锁。第三部分指出哪个事务被回滚WE ROLL BACK TRANSACTION。通过持有锁和等待锁的线索可以还原死锁的加锁顺序。解决方法论也很固定保证并发事务访问多个资源时加锁顺序一致比如都按 id 升序处理在事务里减少锁的持有数量适当缩小事务范围。6.6 一个很容易错的点DELETE INSERT 的幻读组合在 REPEATABLE READ 隔离级别下DELETE和INSERT组合也可能遇到意外。比如事务 A 查询了一批满足条件的记录并DELETE事务 B 又插入了新的满足条件的记录并提交。事务 A 再次查询时会发现还有数据。原因是 DELETE 只对当时存在的数据加锁B 插入时如果插入的间隙没有被锁覆盖就能成功。这个场景提醒我们纯粹依赖隔离级别做并发控制是不够的业务的并发约束要配套设计比如唯一索引、状态字段流转、版本号乐观锁等。7. 我的个人实操体会事务这块内容我第一次学的时候觉得抽象后来在线上环境处理了几次锁等待和死锁问题以后才真正理解它到底是在解决什么问题。现在写 SQL 之前我会先问自己三句话这个操作需不需要事务事务里有多条语句吗事务会不会太久大多数事务相关的线上故障追根溯源都不是 MySQL 本身的问题而是事务边界设计不合理。开始用事务之前先想清楚业务允许看到哪种中间状态再决定隔离级别和事务粒度这句话比记住任何一条命令都有用。这些笔记里的实验代码建议大家在测试环境里亲手跑一遍尤其是脏读、不可重复读、幻读和死锁那几组自己复现出来的印象和别人讲十遍完全不一样。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑