首页 / 资讯中心 / 文章详情

InnoDB事务进阶:从MVCC、锁到redo/undo log的实战内功

InnoDB事务进阶:从MVCC、锁到redo/undo log的实战内功 ★ FEATURED ARTICLE
先说个我碰到的真实经历。半夜被报警叫起来说某条 update 卡了快十分钟没执行完开发群里已经炸了。上去看的时候锁等待提示显示的是另一笔已经跑了二十多分钟的长事务占着行锁不放。当时第一反应是“杀事务”但真正让我后背发凉的其实不是这一单而是如果当时没有这个长事务这条 update 本身会不会也有问题。处理完现场之后我花了挺长时间把 InnoDB 事务的底层原理过了一遍越看越觉得想把事务用明白、查得清楚光知道 begin、commit 远远不够。这篇内容不是写给第一次接触事务的读者也不是那种“ACID 四特性背一遍”的入门文章。它适合已经知道多版本并发控制MVCC、隔离级别、redo log、undo log 这些名词但遇到实际问题时依然觉得隔着一层纱的同学。比如隔离级别到底怎么选、死锁日志怎么看、长事务怎么定位、为什么 RR 默认还要加 gap lock我会从原理到实操把这些点串起来讲明白。1. 为什么说事务是 InnoDB 最值得进阶的内功1.1 事务四特性背后的四个“执行机构”很多教材喜欢把 ACID 放在一个章节里讲完但落到 InnoDB 内部这四个特性其实分别由不同的机制在支撑不能混为一谈。我自己习惯用一张对应表来记事务特性InnoDB 内部承担者一句话职责原子性Atomicityundo log记录“改前像”事务出错时把数据回滚到起点一致性Consistency约束、外键、事务逻辑本身保证事务从一个合法状态到另一个合法状态隔离性IsolationMVCC 行锁让并发事务互相“看不见”不该看见的中间状态持久性Durabilityredo log binlog提交后即使宕机已提交的修改也不丢真正决定你会不会踩坑的是后面三行隔离性相关的 MVCC 和锁决定了并发场景下事务行为的边界持久性相关的 redo log决定了性能参数怎么调。而原子性的 undo log是 MVCC 版本链的基础物料。可以说InnoDB 事务的内功就藏在这三块里。1.2 进阶到底“进”在哪里事务的基础用法很简单begin 开始、commit 提交、rollback 回滚。但线上问题从来不会按照这个顺序出现。你遇到的往往是这样同一个 SQL 在 A 事务里查到 90 分在 B 事务里却查到 85 分到底哪个是“对的”一个普通的范围 update为什么把其他会话的 insert 也堵住了死锁日志里的 “LATEST DETECTED DEADLOCK” 到底怎么读事务已经提交了为什么主从还是对不上一条批量 update 把同一行改了 10 万次undo 为什么膨胀到几个 G这些问题没有一个能用“begin、commit”回答。它们全都落在一个问题上InnoDB 在并发和崩溃面前是如何设计事务的执行路径的。把这层理解透了你才能真正做到“看报错就知道根因”。2. 隔离级别与并发下的三大“读异常”2.1 三种读异常是什么时候发生的在说隔离级别之前必须先看懂三个经典的“读异常”场景。为了不绕口我用一个具体例子说明。假设有一张学生成绩表某一行的分数初始是 90 分。现在有两个事务同时操作这行。脏读事务 A 把分数改成 80还没提交事务 B 这时读读到了 80。如果 A 回头又回滚了那 B 刚刚读到的是一个“从未真正存在过”的数据。写错一步棋又撤回来旁边看棋的人已经把这一步当成了既定事实。脏读的本质是读到了未提交数据。不可重复读事务 B 先读了一次分数是 90随后事务 A 把分数改成了 80 并提交B 在同一事务里再读一次变成了 80。B 在两次读取之间数据发生了变化这不符合“一个事务内多次读取结果应该一致”的直觉。幻读事务 B 查询某个范围的记录第一次查出 10 条事务 A 往这个范围插入了一条新记录并提交B 再次查询时变成 11 条。“多出来一行”就像幻觉一样。注意幻读针对的是“插入新行”而不是修改已有行。这三种异常从后果严重性来看脏读最不可接受因为它会导致基于错误数据的后续决策不可重复读会破坏事务内的一致性体验幻读则更隐蔽它往往影响范围统计、分页、批量操作这类 SQL。2.2 四种隔离级别怎么选标准 SQL 定义了四种隔离级别InnoDB 也支持这四种。区别就在于允许哪种异常发生隔离级别脏读不可重复读幻读InnoDB 默认实现读未提交允许允许允许基本不用竞态极多读提交禁止允许允许很多业务的实际选择可重复读禁止禁止基本禁止靠锁补Java 生态默认MySQL 默认串行化禁止禁止禁止并发极低仅少数场景注意表格里“基本禁止”的措辞。在标准 SQL 的定义里可重复读是允许幻读发生的。但 InnoDB 做了增强在可重复读级别下快照读靠 MVCC 保证一次快照内看不到新插入的数据当前读靠 next-key lock 锁住范围阻止其他事务往范围内插入。所以你看标准定义幻读在 RR 下其实是被解决了的但也因为这个增强RR 下的范围和间隙锁带来了比读提交更大的锁竞争。2.3 为什么 MySQL 的默认隔离级别偏偏是 RR这个问题挺多人问过。从纯数据库语义角度讲读提交RC往往更符合“读到的都是已提交数据”的直觉也更好理解。那为什么 MySQL 默认选了 RR根源在历史包袱和主从复制的一致性设计上。早期 binlog 使用 statement 格式时主库上执行的是一条 SQL 原文从库也是按这条 SQL 原文重新在从库数据上执行。如果主库在 RC 级别下运行某条范围更新在主库执行过程中其他会话插入的新行是否被这条更新“扫到”是随着执行时间的推进而变化的到了从库重放时数据分布可能已经不同重放结果就和主库不一致。而 RR 级别下 next-key lock 会把范围锁死保证主库执行期间范围内没有新记录进来从而保证 statement 在从库重放时也能得到同样结果。后来 binlog 逐渐以 row 格式为主流row 格式只记录变更前后的行内容不再依赖 SQL 语义所以严格说新版本 MySQL 把默认隔离级别改成 RC 也不会像早年那样容易出主从不一致。但出于兼容性和历史惯性默认依然是 RR。了解这段背景你在决定“要不要把系统切到 RC”时就知道至少要确认 binlog 格式是 row而不是盲目切换。3. 多版本并发控制事务隔离看不见的秘密3.1 版本链是怎么搭起来的MVCC 的核心思想是给每一行保留多个历史版本让读操作可以通过“历史版本”来避开未提交的修改。InnoDB 实现这一点依赖的是每行记录的三个隐藏列隐藏列含义DB_TRX_ID最近一次修改该行的事务 IDDB_ROLL_PTR回滚指针指向 undo log 里该行的上一个版本DB_ROW_ID行 ID没有主键时它充当隐藏主键当你更新一条记录时InnoDB 并不是直接改掉原值而是先把当前行的旧值写入 undo log然后修改当前行并更新 DB_TRX_ID 和 DB_ROLL_PTR。于是同一行的多个版本就从旧到新串成了一条链表HEAD 是最新版本往回走就能看到历史版本。这个“链”就是 undo 版本链。这里有个疑问既然已经有了 undo log为什么 MVCC 还要顺着链查历史版本因为 undo log 不仅要给事务回滚用还要承担“查询时提供旧版本”的职责。两者只是同一份数据的两种消费场景。所以你会发现回滚和快照读共享同一套链路。3.2 ReadView事务的快照“眼镜”版本链一直摆在那里但一个事务在查询时哪些版本能看到哪些版本不能看到需要一个判断机制。这个判断机制就是 ReadView你可以把它理解成事务在某个时间点给自己戴上的“眼镜”。ReadView 的核心内容可以简化成四个字段m_creator_trx_id创建 ReadView 的事务 ID也就是“我自己”m_up_limit_id生成快照时当前活跃事务列表中的最小事务 IDm_low_limit_id生成快照时系统中下一个将要分配的事务 IDm_active_ids生成快照时所有还在活跃未提交的事务 ID 集合判断某个版本是否可见我习惯用一个简化但方向正确的伪代码来记if trx_id m_creator_trx_id: return visible # 自己改的必然可见 if trx_id m_up_limit_id: return visible # 活跃列表之前已提交的事务可见 if trx_id m_low_limit_id: return invisible # 生成快照后才新开启的事务不可见 if trx_id in m_active_ids: return invisible # 生成快照时还在跑的事务不可见 return visible # 剩下情况非活跃但ID比up_limit大可见用一个类比更容易记ReadView 就是你按下快门的一瞬间系统里那些“正在写作业的人”名单。名单上的人就算写完了你也不能看到他们的成果名单外的人已经交卷的可以看到快门之后才入场的人一律看不到。3.3 RC 和 RR 的 ReadView 差异决定了不可重复读MVCC 的细节都是表象真正影响你业务感受的是ReadView 到底在什么时刻生成。在 RC 隔离级别下每次普通 SELECT 都会生成一个新的 ReadView。所以如果事务 A 提交了你正在读取的那行修改你下一次 SELECT 时新的 ReadView 里已经不再包含 A 的事务 ID于是你就能看到新值。这就是不可重复读出现的直接原因——不是数据变了而是你每次查询都换了副新眼镜。在 RR 隔离级别下ReadView 在事务第一次执行 SELECT 时生成之后整个事务期间一直复用。哪怕其他事务提交了修改你的 ReadView 里依然保留着当初的活跃事务列表那些后面提交的内容对你看来说依然是“活跃状态”查不到。这就是 RR 能避免不可重复读的原因。我拿一个具体例子演示一下。假设成绩表一行分数是 90 分事务 A事务 ID100把它改成 85 分但未提交事务 B事务 ID101发起普通 SELECT。B 生成 ReadViewm_active_ids [100, 101]简化起见都算活跃m_up_limit_id100m_low_limit_id102。当前行最新版本是事务 100 改的trx_id100在活跃列表内不可见。B 顺着回滚指针找到上一个版本对应的事务 ID 很小是已提交的小于 m_up_limit_id可见。B 最终读到 90 分。如果 A 随后提交B 在 RC 下再查一次新 ReadView 里已经没有 100 了于是读到 85 分B 在 RR 下再查还是旧快照依然读到 90 分。这个差异就是两个隔离级别的分水岭。3.4 快照读与当前读两条完全不同的执行路径普通 SELECT 走的是快照读不加锁靠 MVCC 读历史版本。而 UPDATE、DELETE、INSERT以及带 FOR UPDATE、LOCK IN SHARE MODE 的 SELECT走的是当前读必须读最新版本并且对涉及的行加锁。这个区分非常关键。你会发现一个现象在 RR 下一个事务里用 UPDATE 修改某行后再普通 SELECT 查同一行是可以看到自己修改后的值的。因为 trx_id m_creator_trx_id自己改的版本对自己永远可见。但如果让另一个事务来查它看不到你这行未提交的新值只能看到旧版本。两条路径各管各的互不干扰。理解快照读和当前读也是后面理解锁机制的前提。快照读解决的是普通查询的并发能力当前读解决的是写操作的一致性和互斥。4. 锁机制并发控制的下半场4.1 从行锁到间隙锁锁的粒度如何影响幻读MVCC 解决了快照读的隔离问题但当前读UPDATE、DELETE、INSERT不能靠快照解决必须用锁。InnoDB 的锁有表级和行级真正需要花心思的是行级锁。行级锁按照对写操作的兼容性分为共享锁S Lock和排他锁X Lock规则很简单读读不互斥读写互斥写写互斥。但行锁并不是只有“锁住一行”这一种形态。InnoDB 的行锁一共分四种锁类型作用触发场景示例记录锁锁定一条具体记录WHERE id 10间隙锁锁定一个区间禁止区间内插入范围查询命中后会锁住记录之间的空隙next-key lock记录锁 间隙锁的组合锁住记录本身及其前方的间隙范围查询在 RR 级别下默认走这种锁插入意向锁多个事务想插入同一区间时的协调队列表示“我准备向这个间隙插入”INSERT 碰到被锁的间隙时触发在 RR 级别下InnoDB 对普通索引的范围查询默认下 next-key lock。举个例子你执行DELETE FROM orders WHERE id 100 AND id 130。除了把 id 在 100 到 130 之间的记录锁住外它还会锁住 100 之前的间隙和 130 之后的间隙让其他事务无法在 id99、id131 等相邻位置插入会落进这个范围内的新行。这样就能保证在执行期间范围内不会“多出”一行。这正是解决幻读的手段。而在 RC 级别下InnoDB 只保留记录锁不保留间隙锁。如果业务数据插入频繁RC 的并发写入能力往往比 RR 好代价是会出现幻读。标准 SQL 里的 RC 本来就不承诺防幻读所以这个取舍是合理的。4.2 当前读会等锁等锁也可能等出死锁锁为什么会导致死锁死锁发生的四个必要条件在 InnoDB 里都能找到对应场景互斥同一行不能同时持有 X 锁、持有并等待事务拿着 A 行锁去要 B 行锁、不可剥夺锁不能强制抢走、循环等待T1 等 T2T2 又等 T1。最常见的死锁场景是两条 SQL 以不同顺序更新同一组记录。比如 T1 先更新 id1再更新 id2T2 先更新 id2再更新 id1。当两者各持一行的锁时谁也没法继续就形成了闭环。查死锁信息第一反应是执行SHOW ENGINE INNODB STATUS\G在输出的 LATEST DETECTED DEADLOCK 段里会看到两个事务各自持有哪些锁、等待哪些锁、最终回滚了谁。不过我建议再配合两个操作把锁信息补全开启参数后死锁日志里会显示具体锁对象SET GLOBAL innodb_status_output_locks ON;用下面这条 SQL 查当前正在发生的锁等待比直接看 status 输出更快定位到具体线程SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread, r.trx_query AS waiting_query, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query FROM information_schema.innodb_trx r JOIN information_schema.innodb_lock_waits w ON r.trx_id w.requesting_trx_id JOIN information_schema.innodb_trx b ON b.trx_id w.blocking_trx_id;不同 MySQL 版本的information_schema字段名会有点差异建议在测试环境先跑一遍确认列名。定位到阻塞线程后再结合应用日志看这个事务到底做了什么而不是立刻 kill。误杀一个长时间跑批的事务代价可能更大。4.3 死锁复盘与预防的实操套路死锁既然要“排”更要“防”。把死锁率降下来核心是控制事务在锁上的时间窗口和顺序缩小事务粒度。一个事务里只放必须的操作查询类操作尽量放到事务外。统一加锁顺序。批量更新时按主键 ID 排序后再执行让所有事务都按同一个顺序拿锁。比如要更新 id in (1,2,3)更新语句写成UPDATE t SET col new WHERE id IN (1,2,3);避免一个事务先更新 1 再更新 3另一个事务先更新 3 再更新 1。减少当前读的范围。能用主键精确命中的不要用范围条件少加间隙锁。关注innodb_lock_wait_timeout超时时间设太大会延长故障感知设太小会导致正常等待的事务大量报错。一般 10 到 30 秒之间比较合理。死锁发生后的处理不要只靠重试。应用层捕获死锁错误码MySQL 错误码 1213后重试整个事务一到两次没问题但如果频繁出现说明不是运气问题而是事务设计本身有缺陷需要从上一步的锁顺序和事务粒度上找原因。5. redo log 与 undo log事务的“账本”和“后悔药”5.1 WAL 为什么能让数据库更快事务提交后要保证持久性最简单粗暴的做法是每次提交都把脏页刷到磁盘。但随机 IO 太贵InnoDB 选择了另一条路先写日志后落数据。这就是 WALWrite-Ahead Logging。配合 WAL 的日志就是 redo log。redo log 记录的是“对某个页做了某个修改”这种物理层面的操作比如把偏移量 8192 处写入一个数值。它本质上是为崩溃恢复准备的“重放账本”只要账本上记了“提交”就算数据页还没来得及写磁盘启动时也能把账重演一遍恢复到提交时的状态。这个取舍非常划算。把每次提交的随机小 IO 换成日志的顺序写 IO大批量写入时的性能提升可以到数量级。你可以理解成“先记账后结账”饭店里顾客点单服务员不可能每点一桌就把账算完落库而是先记在小本子上打烊后统一结算小本子就是 redo log每天最后结账的结果才是最终的磁盘数据。5.2 undo log 如何支撑回滚和 MVCCundo log 和 redo log 完全相反它记录的是“修改前”的数据。当事务需要回滚时顺着 undo log 把旧值恢复回去即可。前面说过undo log 还给 MVCC 提供了历史版本回滚和快照读都靠它。有个细节容易被忽略undo log 本身也需要持久化。因为事务回滚可能发生在宕机重启之后如果 undo 丢失重启后没法判断一个还没提交的事务应该回滚到哪里。这也是整个 InnoDB 崩溃恢复流程的一部分先恢复 redo再根据情况处理未提交事务。RR 隔离级别下如果长期存在大查询旧的 undo 版本链一直无法被清理undo log 文件会持续膨胀。这也是长事务治理里面一个很重要的指标后面实操部分会再提。5.3 redo log 与 binlog 的两阶段提交redo log 是 InnoDB 层面的日志binlog 是 MySQL Server 层面的日志。两者服务于不同目的redo log 保证存储引擎崩溃后不丢已提交数据binlog 负责主从复制和基于时间点的恢复。同一个事务要同时写这两份日志就要保证它们的一致性否则崩溃后主从数据就会错乱。解决方式是两阶段提交。流程可以简化成三步事务提交时先把 redo log 写入并处于 prepare 状态再写 binlogbinlog 写入成功后把 redo log 标记为 commit 状态。崩溃恢复时的判断也很简洁redo 状态binlog 是否完整恢复动作commit任意直接提交该事务prepare已写入且完整提交该事务prepare未写入或不完整回滚该事务规则的本质是binlog 完整了才允许提交因为从库要靠 binlog 恢复主库如果回滚而从库执行了两边就不一致binlog 缺失则必须回滚因为从库没有这笔记录主库提交了同样不一致。5.4 三个日志刷盘参数线上怎么配日志写到内存之后什么时候落盘取决于参数。最常调的一个是innodb_flush_log_at_trx_commit取值范围 0、1、2取值行为可靠性适用场景0每秒刷一次 redo log事务提交不主动刷最弱最多丢一秒提交记录能容忍丢数据的缓存类场景不建议生产核心库用1每次事务提交都刷盘最稳不丢已提交事务默认值金融/订单等核心业务2每次事务提交写到 OS 缓存每秒刷盘数据库进程崩溃不丢主机断电可能丢一秒对性能要求高且能接受极端场景丢数据的业务innodb_log_buffer_size决定 redo log buffer 大小一般默认值足够除非有大量并发大事务需要调大。另一个容易忽略的是innodb_redo_log_capacityMySQL 8.0.30 之后取代了原来的 log file 数量配置。日常维护要关注 redo log 的使用率如果持续较高说明日志产生速度大于刷盘能力需要结合慢 SQL、大事务一起排查而不是盲目调大容量。6. 真实场景里的“事务素养”三个很典型的开发与运维坑6.1 统计报表把 RR 换成 RC性能为什么提升明显我之前帮一个统计类服务调整过隔离级别。服务本身只读历史数据按时间范围做聚合但因为它跑在默认的 RR 下每条普通 SELECT 生成了快照后历史版本链上积压了大量未清理的 undo 数据同时范围查询在 RR 下要加间隙锁多个定时任务一旦重叠执行锁等待频繁。最直观的表现就是慢日志里全是几秒钟的大型 SELECT。当时的处理是把这个只读服务调整为 RC。原因很直接只读场景不需要 RR 那种多次读取结果不变的保证每次查询只要读到“当前已提交的最新数据”就足够。RC 下不放间隙锁写入端的并发能力和整体的查询 RT 都上来了。这里有个前提binlog 必须是 row 格式。MySQL 5.7 以后默认就是 row5.6 环境需要确认。这不是说 RC 就比 RR 好。核心业务如果存在一个事务内对同一数据多次读取并做一致性判断的需求比如“先查订单金额再按这个金额做扣减”这种逻辑那 RR 的稳定性价值就体现出来了。关键是根据场景选不要一条配置走到黑。6.2 库存扣减的防超卖SQL 到底该怎么写库存扣减是并发事务最典型的场景。最容易出问题的写法是先查再改SELECT stock FROM inventory WHERE sku_id A; -- 业务层判断 stock 0 UPDATE inventory SET stock stock - 1 WHERE sku_id A;两步之间如果有两个请求同时查到 stock1两个都判断“大于 0”然后都执行扣减库存就变成负数了。改成一条 SQL 就好很多UPDATE inventory SET stock stock - 1 WHERE sku_id A AND stock 0;这里的 UPDATE 是当前读会直接对命中的行加 X 锁。第二个事务执行同一条 SQL 时必须等第一个事务提交后才能继续并且重新判断stock 0所以不会超卖。执行后检查影响行数等于 0 就说明没抢到。更通用的做法是乐观锁UPDATE inventory SET stock stock - 1 WHERE sku_id A AND version 1;先查 version更新时把 version 作为条件。如果失败则重查重试。但注意乐观锁在超高并发下会产生不少无效重试库存这类短事务场景直接用条件更新 SQL 通常更合适。6.3 大事务怎么识别怎么拆分大事务是 InnoDB 事务事故里最隐蔽的一类。事务启动后长时间不提交可能表现为锁等待、undo 膨胀、从库延迟。判断一个事务是不是“大”不能只看它修改了多少行还要看它持续多久、持有锁多久。查询当前所有未提交事务最直接的方式SELECT trx_id, trx_state, trx_started, TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) AS run_seconds, trx_rows_modified, trx_query FROM information_schema.innodb_trx\G看到run_seconds超过几秒甚至几十秒的就要警惕。trx_rows_modified很大的说明 undo 里积压了大量版本提交后可能产生大段清理动作。在实际项目里我遇到过因为一个循环里每轮迭代都开了事务但循环外层一直没 commit导致总共改了 50 万行、事务跑了 40 分钟的情况。从库延迟超过几个小时处理起来相当痛苦。拆分的思路通常是把一个大批量改成多个小批次每个批次独立提交。比如要更新 100 万行别在一条 UPDATE 里全干可以按主键范围分段领取每处理 5000 行提交一次。这样既不让单事务持有锁太久也不会因为单个事务回滚造成长时间不可用。6.4 一切锁问题的源头多数还是事务边界不清晰最后再提一个观察。很多锁等待和死锁表面看是 SQL 写的不好实际是事务边界没划清楚。最常见的问题是在事务里写了耗时操作比如调用外部 HTTP 接口、循环调用其他微服务把未知延迟引进了数据库事务的锁生命周期里。一个事务里几毫秒的数据库操作本身没什么但一个 HTTP 等待 3 秒事务就持有了 3 秒的锁这时候再来一个并发事务要改同一行就只能等着。我的习惯是事务开始前先明确三个问题——这个事务要跑多久要碰多少行会不会和别的操作在锁上冲突如果有任何一个问题是模糊的就应该先把事务缩小把耗时操作挪出去。把这些边界想清楚大部分事务事故在开发阶段就能拦住。工具方法都放在上面了遇到问题往这几个方向去查基本都能找到根因。
阅读完成 · 觉得有帮助?
咨询建站