标签: InnoDB

  • 深入 MySQL 陷阱排查:微型 Redo Log 引发的 Checkpoint 同步刷新与间隙锁雪崩实战

    近期处理了一起极为典型的数据库生产事故。某核心交易系统的 MySQL 集群在业务高峰期,呈现出诡异的“心跳骤停”现象:TPS 周期性从 4000 瞬间跌零,持续 3-5 秒后恢复,期间伴随海量的 Lock wait timeout exceeded; try restarting transaction 报错,上游微服务大面积阻塞。

    直接抛出结论:这是一起由极不合理的 Redo Log 容量配置引发的连锁雪崩。底层原因是 innodb_log_file_size 依然保持着陈旧的 128MB 默认值,高并发批量更新瞬间撑爆了 Redo Log 的 Checkpoint Age,触发了 InnoDB 最严厉的 Sync Flush(同步刷新)机制。用户线程被强制挂起等待脏页刷盘,而这些被挂起的线程手中正死死捏着二级索引的 Next-Key Lock(间隙锁),最终导致全表范围的插入和更新操作被全部阻塞,演变成全局锁雪崩。

    案发现场与“不可原谅”的默认值

    排查过程中,通过监控面板看到服务器的 NVMe SSD 处于半闲置状态(IOPS 峰值不到 15k),但 MySQL 的响应时间 P99 却飙升到了秒级。

    登入主机,抓取 SHOW ENGINE INNODB STATUS\G,核心日志段暴露了致命问题:

    ---
    LOG
    ---
    Log sequence number          28394058291
    Log flushed up to            28394058291
    Pages flushed up to          28393801000
    Last checkpoint at           28393798000
    Max checkpoint age           204800000
    Checkpoint age target        198000000
    Modified age                 260291    
    Checkpoint age               260291    -- 注意这里的差值
    

    稍微懂点 InnoDB 内核机制的人,看到这里血压就上来了。给一台 32 核 128G、配着企业级 NVMe 盘的物理机部署 MySQL,居然连 innodb_log_file_size 都不改,留着 2 个 128MB 的文件。这就好比给法拉利装了个拖拉机的排气管,油门踩到底只能憋死发动机。

    底层原理解析:当 Redo Log 变成系统瓶颈

    InnoDB 的 Redo Log 是一个环形缓冲区(Ring Buffer)。系统必须保证在这个环里,写入新日志覆盖老日志之前,老日志对应的 Buffer Pool 脏页已经刷入磁盘。这个安全距离的标尺,就是 Checkpoint Age。

    Checkpoint Age = Log sequence number (LSN) - Last checkpoint at

    当高并发的 DML 语句涌入,LSN 飞速推进,而后台 page_cleaner 线程刷脏页的速度跟不上时,Checkpoint Age 就会不断膨胀。InnoDB 为此设定了三道防线:

    1. Async Watermark (75%):超过此阈值,后台线程开始拼命(异步)刷脏页。

    2. Sync Watermark (90%):一旦触及这条红线,灾难降临。InnoDB 会触发同步刷新(Sync Flush),直接挂起当前执行 DML 的用户线程,强行参与脏页刷盘,直到 Checkpoint Age 回落。

    在这个阶段,你的 TPS 会呈现断崖式下跌,直逼 0。更要命的是,用户线程是在持有锁的状态下被挂起的。

    间隙锁(Gap Lock)的推波助澜

    如果仅仅是 TPS 跌零几秒,上游服务顶多重试。但这套系统里,开发写了一个非常经典的“防御性”状态更新逻辑:

    UPDATE orders SET status = 2, update_time = NOW() 
    WHERE status = 1 AND type = 'promotion' LIMIT 1000;
    

    status 字段是一个区分度极低的非唯一索引。在可重复读(RR)隔离级别下,InnoDB 为了防止幻读,会对命中的记录加 Record Lock,并对记录前后的间隙加 Gap Lock,统称 Next-Key Lock。

    当这个 UPDATE 语句跑到一半,刚好触发了 Redo Log 的 Sync Flush,该线程被无情挂起。此时,它持有了一个覆盖极大范围的间隙锁。接下来的 3-5 秒内:

    • 任何试图插入 status = 1 附近间隙的新订单请求,全部被阻塞在 WAITING FOR LOCK。

    • 其他业务流程因为拿不到锁,锁等待队列瞬间堆积,迅速打满 innodb_thread_concurrency,引发系统雪崩。

    破局与调优:拒绝花里胡哨,回归物理本质

    解决这种问题,不需要什么高深的分布式改造,只需要把 InnoDB 的 I/O 管道打通。

    1. 扩容 Redo Log 容量 确保 Redo Log 能够支撑业务高峰期至少 1 小时的写入量。在旧版本中需要优雅停机重建日志文件,如果是 MySQL 8.0.30+,可以直接动态调整:

    -- MySQL 8.0.30 及以上版本动态扩容至 4GB
    SET GLOBAL innodb_redo_log_capacity = 4294967296; 
    

    如果是旧版本,配置 my.cnf:

    innodb_log_file_size = 2G
    innodb_log_files_in_group = 2
    

    2. 匹配硬件的 I/O 吞吐能力 SSD 时代,默认的 innodb_io_capacity = 200 就是在自废武功。让 page_cleaner 能够放开手脚刷脏页,避免 Checkpoint LSN 滞后:

    # 根据 NVMe 性能压测结果调整
    innodb_io_capacity = 10000
    innodb_io_capacity_max = 20000
    # 调整刷脏线程数,通常与 Buffer Pool Instances 保持一致
    innodb_page_cleaners = 8
    innodb_buffer_pool_instances = 8
    

    3. 改造高危的 DML 语句 严禁在 RR 隔离级别下,通过非唯一索引进行大范围的批量更新。将批量操作改为通过主键驱动: 先 SELECT id FROM orders WHERE status = 1 LIMIT 1000,再 UPDATE orders SET status = 2 WHERE id IN (...)。这样只锁具体的行,彻底避开 Gap Lock 的踩踏。

    排查清单:InnoDB I/O 与锁雪崩速查

    1. 查 LSN 推进速率与 Checkpoint 延迟:使用 SHOW ENGINE INNODB STATUS 观察 Log sequence number 与 Last checkpoint at 的差值,若接近总 Log 容量的 80% 以上,存在极高 Sync Flush 风险。

    2. 查 Page Cleaner 状态:若 Error Log 中频繁出现 page_cleaner: 1000ms intended loop took XXXX ms,说明刷脏线程已成为瓶颈,检查 I/O 延迟或调大 innodb_io_capacity。

    3. 查 锁等待链与行锁升级:通过 SELECT * FROM performance_schema.data_locks;(MySQL 8.0)确认阻塞源头,重点排查持有 X, GAP 锁且事务持续时间较长的僵尸线程。

    4. 查 脏页水位线:监控 Buffer pool dirty pages 占比,若长期逼近 innodb_max_dirty_pages_pct(默认 90%),意味着 Buffer Pool 成为 I/O 抖动的蓄水池,需结合 I/O 调优或扩大 Buffer Pool 内存。

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

    高并发写入场景下,MySQL TPS 陡降甚至雪崩,根因通常是“底层 I/O 阻塞放大 + 锁冲突”。排查发现,Redo Log 空间不足引发同步刷盘等待,Buffer Pool 脏页回收跟不上导致突发 I/O 抖动。加之业务代码在长事务中执行并发插入,触发 InnoDB 间隙锁(Gap Lock)与插入意向锁的死锁风暴,最终压垮实例。核心解法:重构插入逻辑绕过间隙锁,调优 innodb_redo_log_capacity 与 innodb_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 底层依然会隐式使用记录锁和部分间隙锁机制。碰到此类问题,必须通过分批提交、或将并发插入逻辑前置为分布式锁排队来解决。