标签: Redo Log

  • 深入 MySQL InnoDB 高并发雪崩排查:Redo Log 刷盘阻塞与 Buffer Pool 抖动引发的间隙锁死锁惨案

    高并发写入场景下,MySQL TPS 陡降甚至雪崩,根因通常是“底层 I/O 阻塞放大 + 锁冲突”。排查发现,Redo Log 空间不足引发同步刷盘等待,Buffer Pool 脏页回收跟不上导致突发 I/O 抖动。加之业务代码在长事务中执行并发插入,触发 InnoDB 间隙锁(Gap Lock)与插入意向锁的死锁风暴,最终压垮实例。核心解法:重构插入逻辑绕过间隙锁,调优 innodb_redo_log_capacityinnodb_io_capacity,彻底打通内核级 I/O 瓶颈。

    故障现场:一场突如其来的写入停顿

    近期在处理某核心订单系统的高并发大促压测时,数据库发生严重雪崩。监控面板上呈现出典型的“心电图式”崩溃:

    1. TPS 与 QPS 齐降:原本稳定在 6000 的 TPS,周期性跌至 100 以下,随后又缓慢爬升。

    2. 系统负载飙升:MySQL 节点 Load Average 飙破 150,CPU 的 %iowait 持续在 40% 以上震荡。

    3. 海量慢查询与死锁报错:应用侧大量爆出 Deadlock found when trying to get lock; try restarting transaction,且 99 线延迟从 20ms 飙升至 5s。

    登机直奔 MySQL 终端,执行 SHOW ENGINE INNODB STATUS\G,输出中的关键报错日志立刻暴露了底层的挣扎:

    -- 脏页刷盘告警
    InnoDB: page_cleaner: 1000ms intended loop took 6540ms. The settings might not be optimal. (flushed=25000 and evicted=0, during the time.)
    
    -- 日志等待状态
    Log sequence number 14589934251
    Log flushed up to   14589801020
    Pages flushed up to 14581100000
    Last checkpoint at  14580010000
    

    计算一下 Log sequence number(当前 LSN)和 Last checkpoint at(最后检查点 LSN)的差值,已经逼近了当时配置的 Redo Log 总容量。数据库实际上处于一种“憋死”的状态。

    为什么 TPS 陡增时 Redo Log 会成为整个实例的阿喀琉斯之踵?

    要搞懂这个问题,必须从 InnoDB 的 WAL(Write-Ahead Logging)机制说起。任何修改数据的操作,都会先写 Redo Log,再修改 Buffer Pool 中的数据页(脏页)。

    但在极端高并发下,Redo Log 的产生速度远超后台 Page Cleaner 线程将脏页刷入磁盘的速度。Redo Log 是循环使用的(Ring Buffer),如果脏页还没刷盘,对应的 Redo Log 空间就不能被覆盖。

    当未 Checkpoint 的 Redo Log 数据量达到了配置容量的 75%(异步刷盘水位)甚至 90%(同步刷盘水位)时,InnoDB 会触发 Sync Flush 机制。 此时,所有的用户更新线程(DML操作)将被强制挂起,由用户线程去抢占 log_sys->mutex 锁并主动触发脏页刷盘,以推进 Checkpoint LSN 腾出 Redo Log 空间。

    这就是为什么监控上的 TPS 会出现断崖式下跌。在 MySQL 终端使用以下命令可以抓到现场:

    -- 查看 Redo Log 等待次数,如果在高频增加,说明 Redo Log 容量太小
    SHOW GLOBAL STATUS LIKE 'Innodb_log_waits';
    

    Buffer Pool 脏页风暴与 I/O 抖动原理

    Redo Log 告急只是表象,背后的帮凶往往是 Buffer Pool 刷盘策略与底层存储硬件的不匹配。

    排查过程中检查了该实例(MySQL 8.0.32,底层采用企业级 NVMe SSD)的 I/O 配置:

    innodb_io_capacity = 200
    innodb_io_capacity_max = 2000
    

    这是极其保守的默认值。NVMe SSD 的随机写 IOPS 随随便便就能上 50,000。 因为 innodb_io_capacity 设置过低,Page Cleaner 线程在平常认为“我只需每秒刷 200 个脏页就够了”,导致 Buffer Pool 里的脏页越积越多。当 Redo Log 空间告急触发高水位强制刷盘时,InnoDB 突然要求一瞬间刷入数万个脏页,底层 I/O 瞬间飙高,引发系统抖动。

    同时,这还会导致另一个致命问题:Free buffers 耗尽。

    -- 查看请求不到空闲页被迫等待的次数
    SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_wait_free';
    

    当一个查询需要加载新页到 Buffer Pool,但找不到空闲页,且 LRU 尾部的都是脏页时,查询线程就必须先等待脏页同步刷盘,导致读请求也被阻塞。读写双杀。

    间隙锁死锁:压垮骆驼的最后一根稻草

    I/O 阻塞导致了事务执行时间被动拉长。原本 10ms 能提交的事务,现在要拖到 1s 甚至 3s。事务生命周期的拉长,成倍放大了锁冲突的概率。这直接引爆了业务代码中的暗雷:Gap Lock(间隙锁)死锁

    查看 SHOW ENGINE INNODB STATUS 中的 LATEST DETECTED DEADLOCK,发现大量类似以下的死锁日志:

    *** (1) TRANSACTION:
    TRANSACTION 987654321, ACTIVE 2 sec inserting
    mysql tables in use 1, locked 1
    LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
    MySQL thread id 1234, OS thread handle 1403213456, query id 456789 update
    INSERT INTO order_record (user_id, status) VALUES (1001, 'INIT')
    
    *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
    RECORD LOCKS space id 100 page no 200 n bits 72 index idx_user of table `db`.`order_record` trx id 987654321 lock_mode X locks gap before rec insert intention waiting
    
    *** (2) TRANSACTION:
    TRANSACTION 987654322, ACTIVE 2 sec inserting
    ...
    *** (2) HOLDS THE LOCK(S):
    RECORD LOCKS space id 100 page no 200 n bits 72 index idx_user of table `db`.`order_record` trx id 987654322 lock_mode X locks gap before rec
    
    *** (2) WAITING FOR THIS LOCK TO BE GRANTED:
    RECORD LOCKS space id 100 page no 200 n bits 72 index idx_user of table `db`.`order_record` trx id 987654322 lock_mode X locks gap before rec insert intention waiting
    

    底层原理剖析: 在默认的 RR(Repeatable Read)隔离级别下,业务逻辑是经典的“先查后写”:

    1. SELECT * FROM order_record WHERE user_id = 1001 FOR UPDATE; (发现记录不存在)

    2. INSERT INTO order_record (user_id, status) VALUES (1001, 'INIT');

    两个并发事务 A 和 B 同时执行步骤 1:

    • 因为记录不存在,InnoDB 为了防止幻读,会沿着索引找到第一条大于 1001 的记录,并在它前面的间隙加上 Gap Lock(间隙锁)

    • 重点:Gap Lock 相互之间是兼容的! 事务 A 和 B 都能成功获取这个 Gap Lock。

    接着他们同时执行步骤 2(INSERT):

    • 插入操作需要获取 Insert Intention Lock(插入意向锁)

    • 重点:插入意向锁被 Gap Lock 排斥!

    • 事务 A 尝试获取插入意向锁,被事务 B 拥有的 Gap Lock 阻塞。

    • 事务 B 尝试获取插入意向锁,被事务 A 拥有的 Gap Lock 阻塞。

    • 死锁形成! 数据库只能挑选一个代价较小的事务进行 Rollback。在高并发 + I/O 阻塞拉长事务的场景下,这个死锁被无限放大,最终压垮业务。

    核心调优与防御性落地策略

    排查清楚后,我们的破局思路非常清晰:解放 I/O 瓶颈,降低锁冲突,缩短事务时间。

    1. 数据库内核参数调优 (MySQL 8.0.32 环境)

    直接修改 mysqld.cnf,对核心参数进行手术刀式调整:

    # 1. 解除 Redo Log 空间瓶颈
    # MySQL 8.0.30+ 引入了 innodb_redo_log_capacity 动态替代之前的 file_size 算法
    # 依据经验,高并发写入库至少配置 4G-8G,避免频繁 Sync Flush
    innodb_redo_log_capacity = 8589934592
    
    # 2. 解除 Buffer Pool 刷盘限制 (匹配 NVMe SSD 性能)
    innodb_io_capacity = 10000
    innodb_io_capacity_max = 25000
    
    # 3. 优化 Page Cleaner 线程,防止单线程刷盘瓶颈
    innodb_page_cleaners = 8
    innodb_buffer_pool_instances = 8
    
    # 4. 降低死锁探测开销 (高并发极速短事务场景下,死锁探测自身消耗极大CPU)
    # 注意:关闭前提是业务有完善的重试机制,并依赖 innodb_lock_wait_timeout 熔断
    # innodb_deadlock_detect = OFF
    innodb_lock_wait_timeout = 5
    

    注:修改配置后,可通过 SET GLOBAL 动态生效部分参数,但 innodb_buffer_pool_instances 需重启实例。

    2. 业务侧锁机制重构

    在确认业务其实不依赖 RR 级别下的间隙锁防幻读特性后(大部分电商/金融系统依赖分布式锁或唯一索引保证幂等),我们将核心交易库的隔离级别降级为 RC(Read Committed)

    # 禁用绝大部分的 Gap Lock,大幅降低并发死锁概率
    transaction_isolation = READ-COMMITTED
    

    在 RC 级别下,即使 SELECT ... FOR UPDATE 查不到数据,也不会加 Gap Lock,后续的并发 INSERT 就算主键冲突,也只会退化为 Unique Key 冲突报错,而不是灾难性的死锁回滚。

    常见问题 (FAQ)

    Q: 遇到 IO 瓶颈,直接把 innodb_flush_log_at_trx_commit 改成 2 可以解决问题吗?

    改为 2 确实能极大提升 TPS,因为它把 Redo Log 刷盘的动作交给了操作系统的 Page Cache(每秒 fsync 一次)。但在金融/订单等严苛场景下,主机一旦宕机/掉电,会丢失最多 1 秒的已提交事务数据。它能掩盖问题,但不能解决脏页积压引发的突发抖动,且违背了核心系统的持久性(Durability)要求。建议优先调优 Redo 容量和 I/O Capacity。

    Q: 如何精准监控 Buffer Pool 的内存污染与命中率不足?

    不要看粗略的 Hit Rate 比例,直接看内核指标:计算 1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)。如果该值在业务高峰期跌破 98%,说明发生大量物理读。此时需排查是否存在无索引的大表扫描,或者是全表扫批处理任务冲刷了 LRU 链表热端数据。

    Q: 为什么把隔离级别改成了 READ COMMITTED,还会发生间隙锁死锁?

    很多研发以为 RC 完全没有 Gap Lock,这是误区。在 RC 下,如果是进行唯一索引(Unique Key)的批量插入或冲突检测(如 INSERT ... ON DUPLICATE KEY UPDATE,为了保证主键的唯一性约束,InnoDB 底层依然会隐式使用记录锁和部分间隙锁机制。碰到此类问题,必须通过分批提交、或将并发插入逻辑前置为分布式锁排队来解决。