凌晨两点半,机房的 VPN 刚断开。屏幕上的 Threads_running 指标终于从刺眼的 800 多回落到了个位数。
这原本是一个再平淡不过的深夜,直到告警短信把我叫醒:核心交易库 TPS 突然掉底,连接池被打满。初看现象,这是一起典型的数据库死锁或锁等待超时(Lock Wait Timeout),但顺着线索往下挖,底层却是一场由 Buffer Pool 刷脏机制和 Redo Log 容量引发,最终通过间隙锁(Gap Lock)放大导致的全盘雪崩。
这个问题很有代表性,它把 InnoDB 的内存管理、日志机制和并发控制完美地串联在了一起。趁着现在毫无睡意,把排查过程和底层逻辑梳理一下。
1. 现场:诡异的锁等待
登录数据库,习惯性地先看当前运行的事务和锁状态:
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM performance_schema.data_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_engine_transaction_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id;
结果显示,大量的单条 INSERT 语句被阻塞。顺藤摸瓜找到源头(blocking_query),是一个定时清理历史数据的批处理 SQL:
DELETE FROM trade_orders
WHERE status = 'CLOSED' AND updated_at < '2023-09-01 00:00:00';
updated_at 字段上有二级索引。在默认的 REPEATABLE READ 隔离级别下,InnoDB 为了防止幻读,会在扫描二级索引时加上 Next-Key Lock(Record Lock + Gap Lock)。由于这是一个范围删除,它不可避免地锁住了大段的索引间隙,导致落入这些间隙的新订单 INSERT 被阻塞。
到这一步,看似问题已经找到了:大批量 DELETE 导致的间隙锁阻塞。
但逻辑上说不通。这条清理语句每次只限制删除 1000 条数据,平时执行耗时通常在 50ms 以内。为什么今晚这个事务执行了十几秒还没提交?事务不提交,锁就不会释放。
是什么拖慢了这 1000 条数据的删除?
2. 下沉:被无视的 Checkpoint Age
既然不是死锁,也没有其他事务阻塞这个 DELETE,那瓶颈必然在系统资源或 InnoDB 引擎内部。扫了一眼系统监控,CPU 负载不高,但磁盘 I/O 的 %util 接近 100%,大量的写操作排队。
立刻切到引擎层,查看 InnoDB 状态:
mysql> SHOW ENGINE INNODB STATUS\G
...
---
LOG
---
Log sequence number 14589320145
Log flushed up to 14589319800
Pages flushed up to 14080120000
Last checkpoint at 14080119800
...
这段输出里的四个数字,是解开谜团的钥匙。我们来算一笔账:
Checkpoint Age = Log sequence number (当前 LSN) – Last checkpoint at (上一次检查点 LSN)
= 14589320145 – 14080119800 = 509,200,345 Bytes (约 485 MB)
再看一下线上 Redo Log 的配置:
innodb_log_file_size = 256M
innodb_log_files_in_group = 2
总 Redo Log 容量是 512MB。 由于 Redo Log 是循环写入的,为了防止覆写还未刷入磁盘的脏页日志,InnoDB 定义了两个水位线:
-
Async Watermark (异步刷脏水位):通常是总容量的 75%(约 384MB)。
-
Sync Watermark (同步刷脏水位):通常是总容量的 90%(约 460MB)。
当前的 Checkpoint Age(485MB)已经无情地突破了 Sync Watermark!
3. 底层机制:单线程刷脏的绝望
当 Checkpoint Age 突破 90% 时,InnoDB 会发生什么?
在正常情况下,Buffer Pool 中的脏页是由后台线程(Page Cleaner Thread)异步刷入磁盘的。不管前台有多少高并发的增删改,只要后台刷得够快,Redo Log 就有足够的空间推进,前台线程只管写内存和顺序写 Redo Log 即可,速度极快。
但这批夜间跑批任务包含了大量密集的 UPDATE 和 DELETE,短时间内生成了海量的 Redo Log。256M * 2 的 Redo Log 空间被迅速填满。后台异步刷脏的速度(受限于 innodb_io_capacity 参数)远远赶不上 Redo Log 产生的速度。
当 LSN 推进到 Sync Watermark 时,InnoDB 的保护机制被触发:所有产生 Redo Log 的用户线程(User Threads)被强制挂起,必须参与同步刷脏(Sync Flush)。
这就解释了那个诡异的现象:
-
DELETE事务在执行过程中,遇到了 Redo Log 空间不足。 -
该事务的执行线程被 InnoDB 引擎强行拽去干苦力——等待甚至参与将 Buffer Pool 里的脏页刷回磁盘(通过推进 Checkpoint 来释放 Redo 空间)。
-
这个过程是随机 I/O,且极其耗时。导致原本 50ms 就能完成的
DELETE,被拖长到了十几秒。
4. 雪崩的闭环
现在,整个雪崩的逻辑链条完全闭合了:
-
导火索: 跑批任务触发密集写操作,产生大量 Redo Log。
-
容量瓶颈:
innodb_log_file_size过小,Checkpoint Age 迅速突破 Sync Watermark。 -
I/O 阻塞: 引擎进入同步刷脏模式,用户线程被阻塞,等待脏页落盘。
-
锁放大: 正在执行
DELETE的线程被挂起,但它持有的间隙锁(Gap Lock)并不会释放。 -
雪崩: 大量正常业务的
INSERT请求命中被锁定的索引间隙,进入 Lock Wait 状态。连接池迅速被堆积的挂起线程耗尽,引发全盘宕机。
5. 破局与参数调优
知道了症结,解决起来就不复杂。这种问题,单靠优化 SQL 治标不治本,核心是要调整 InnoDB 的内存与日志 I/O 策略,让存储层能扛住瞬间的吞吐。
第一步:扩容 Redo Log
256M 的单文件大小放在现代的高并发业务中犹如儿戏。直接将其扩容:
# my.cnf
innodb_log_file_size = 2G
innodb_log_files_in_group = 3
注:在 MySQL 8.0.30 之后,这两个参数被废弃,统一使用 innodb_redo_log_capacity。这里由于线上还是 5.7 版本,依然采用老参数。这样总容量达到 6G,给予后台线程充足的缓冲时间来刷脏。
第二步:释放底层 I/O 潜力
既然底层是纯 SSD 阵列,没必要让 InnoDB 表现得像个老旧的机械硬盘。调整后台刷脏的 I/O 能力:
# 告诉 InnoDB 底层存储每秒能处理的 IOPS
innodb_io_capacity = 3000
# 遇到脏页堆积或 Checkpoint 追尾时,最高可以飙到的 IOPS
innodb_io_capacity_max = 6000
# 针对 SSD 关闭相邻脏页合并刷盘特性(该特性只对机械硬盘有意义,SSD 上反而增加开销)
innodb_flush_neighbors = 0
第三步:规避大范围间隙锁
从业务侧,把这种依赖二级索引范围扫描的 DELETE 改造掉。先通过主键查出需要删除的 ID,然后做主键删除,将 Next-Key Lock 降级为精准的 Record Lock,彻底解除对其他正常 INSERT 业务的间隙阻塞:
-- 改造前
DELETE FROM trade_orders WHERE status = 'CLOSED' AND updated_at < '...';
-- 改造后,分批执行
SELECT id FROM trade_orders WHERE status = 'CLOSED' AND updated_at < '...' LIMIT 1000;
DELETE FROM trade_orders WHERE id IN (...);
6. 尾声
很多人在排查数据库阻塞时,一看到锁等待,就死磕业务逻辑和事务隔离级别。但实际上,数据库是一个极其精密的机械体。内存、日志、I/O 以及并发控制锁,是互相咬合的齿轮。一个看起来微不足道的 Redo Log 尺寸配置,在特定的业务波峰下,就能通过间隙锁将阻塞效应放大千百倍,最终酿成灾难。
运维架构的深度,往往就藏在这些基础组件的边界摩擦里。合上电脑,该补个觉了。