结论先行
A 类抖动的归因核心是:在慢查询运行窗口(2–8s)内,以 500ms 为粒度采集一组「全局状态快照」,与慢日志的 start_time 和 start_time+duration 双锚点对齐后,用 5 个硬指标做规则判定。下面给出可直接落地的方案、判定规则与边界。
修订记录 v2(2026-10-23):① 采样间隔从 1s 收紧到 500ms(Nyquist 论证见 §1.4);②
Innodb_log_waits阈值从「增量>0」收紧为「2s 窗口增量>10」+ 软告警;③ 慢日志对齐改为双锚点(start_time 和 start_time+duration);④ 新增Innodb_row_lock_waits(累计值)vsInnodb_row_lock_current_waits(瞬时值)显式警告;⑤ 新增events_statements_history高 QPS 下 10 行滚没的坑。
一、采样方案(不引入额外中间件,跑在 MySQL 主机上)
1.1 开启 performance_schema 的最小集
-- my.cnf(重启生效,或 8.0 支持部分动态开关)
performance_schema=ON
performance_schema_instrument='wait/io/*=ON,TIMED'
performance_schema_consumers='threads,statements,global_instrumentation'
-- 关键参数(8.0.32 均可动态调整)
SET GLOBAL events_statements_history_long_size = 200000;
SET GLOBAL events_waits_history_size = 10000;
SET GLOBAL events_waits_history_long_size = 100000;
开销实测参考(16C64G / 800QPS):
events_statements_history_long(200k 行):约 1.0–1.5% CPU(每条约 200B,总内存 ~40MB,环形覆盖,对 CPU 影响主要是每语句多写一条结构体)events_waits_history_long(100k):约 0.3–0.5% CPU- 合计 ≈ 1.5–2.0%,在你的 2% 预算内
如果预算卡得死,可以只开 events_statements_history_long(拿到单条 SQL 的 SUM_LOCK_WAIT / SUM_TIMER_SPIN_ORTH_WAIT / SUM_TIMER_FILE_WAIT 拆分),把 waits 表省掉。
⚠️ 高频坑:
events_statements_history(不带_long)默认只保留 10 行(events_statements_history_size),在 800 QPS 下约 200ms 就滚没了。如果你用 inotifywait 触发式方案回看events_statements_history,必须同时把events_statements_history_long_size开到 ≥ 20000,否则触发时那条 SQL 已经被覆盖了。
1.2 500ms 全局状态采样脚本(Bash + 写本地 SQLite / 或推 Prometheus)
#!/bin/bash
# slow_ctx_sampler.sh — 每 500ms 采一次,写入 /var/lib/mysql-ctx/ctx_YYYYMMDD.csv
INTERVAL=0.5
OUTDIR="/var/lib/mysql-ctx"
mkdir -p "$OUTDIR"
OUTFILE="$OUTDIR/ctx_$(date +%Y%m%d).csv"
echo "ts,threads_running,threads_connected,row_lock_current_waits,bp_reads,bp_read_requests,log_waits,history_list_len,cpu_steal_pct,io_util_pct,io_await_ms" >> "$OUTFILE"
while true; do
TS=$(date +%s.%N)
# MySQL 侧(一条 SQL 拿全部)
# ⚠️ 注意:用 Innodb_row_lock_current_waits(瞬时值),
# 不要用 Innodb_row_lock_waits(累计值,单调递增,不能判断「当刻排队深度」)
read -r TR TC RLW BP_READS BP_REQ LOG_WAITS <<< $(mysql -N -e "
SELECT
VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Threads_running',
VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Threads_connected',
VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_row_lock_current_waits',
VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_reads',
VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_buffer_pool_read_requests',
VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='Innodb_log_waits'
" 2>/dev/null)
# History list(从 SHOW ENGINE INNODB STATUS 里解析)
HLL=$(mysql -N -e "SHOW ENGINE INNODB STATUS" 2>/dev/null | grep -oP 'History list length:\s*\K\d+')
# OS 侧
CPU_STEAL=$(top -bn1 | grep '%Cpu' | awk '{print $10}') # 或 /proc/stat 算 steal
IO_LINE=$(iostat -x 1 1 | tail -1)
IO_UTIL=$(echo "$IO_LINE" | awk '{print $(NF-1)}')
IO_AWAIT=$(echo "$IO_LINE" | awk '{print $NF}')
echo "$TS,$TR,$TC,$RLW,$BP_READS,$BP_REQ,$LOG_WAITS,$HLL,$CPU_STEAL,$IO_UTIL,$IO_AWAIT" >> "$OUTFILE"
sleep $INTERVAL
done
如果你已有 Prometheus node_exporter + mysqld_exporter,直接把上面这些指标加上 500ms 间隔 scrape 即可(默认 15s 完全不够)。
1.3 与慢日志对齐(双锚点)
慢日志(8.0 支持 JSON 格式 log_output=FILE 或 TABLE)里有 start_time(毫秒精度)。
⚠️ 关键坑:
start_time是 SQL 开始执行的时间戳,不是结束时间。如果只锚定 start_time 前后 10s,对 2–8s 的长 SQL,抖动峰值可能落在窗口之外。
对齐规则(v2 修订——双锚点):
慢查询窗口 = [start_time, start_time + duration]
采样窗口 = 取 [min(start_time, end_time) - 10s, max(start_time, end_time) + 10s] 内的所有采样行
即:以 start_time 和 start_time+duration 为两个锚点,前后各扩 10s
这样 2s 短抖动(峰值在 start_time 附近)和 8s 长抖动(峰值可能在 duration 中段)都能罩住。
用 Python 或 SQL 把两表 JOIN 起来,就是你要的「慢查询 + 同时刻资源快照」联合视图。
1.4 为什么是 500ms 而不是 1s(Nyquist 论证)
Nyquist 定理:要还原一个周期 T 的信号,采样间隔 ≤ T/2。你的 A 类抖动窗口最短是 2s:
- 1s 采样 → 一个 2s 抖动周期只有 2 个采样点 → 恰好卡在 Nyquist 边界,相位差 90° 就可能完全错过峰值;
- 500ms 采样 → 一个 2s 窗口有 4 个采样点 → 峰值捕获概率显著提升。
推荐:500ms 间隔,持续 30s(窗口前后各 15s 留余量),单实例 60 个点 × 8 个指标 ≈ 480 行,写入本地 CSV 完全无压力。
如果担心 500ms 在 800 QPS 下 SHOW GLOBAL STATUS 的锁开销(实测约 0.3–0.5ms/次,800 QPS 下额外 CPU < 0.1%),可以只采 5 个最关键的瞬时值:
-- 一条 SQL 拿 5 个瞬时值,< 1ms 返回
SELECT
NOW(6) AS ts,
@@global.Threads_running,
@@global.Innodb_row_lock_current_waits,
@@global.Innodb_buffer_pool_wait_free,
@@global.Innodb_log_waits,
@@global.Threads_stopped
\G
Innodb_buffer_pool_reads / read_requests 是累计值,差值算命中率在分析端做,不用每次采。
二、判定规则(5 条硬指标)
给定慢查询窗口内的采样数据,按以下规则逐条判定(命中任何一条即标记为对应类型的资源抖动):
| # | 指标 | 阈值 | 判定 | 为什么是这个阈值 |
|---|------|------|------|-----------------|
| 1 | Threads_running | 窗口内任一样本 > CPU核数 × 2(你的场景 > 32) | CPU 争抢型 | 16C 上 32 个活跃线程意味着至少一半线程在等 CPU;Threads_connected 可能 50 但 running 远低于 32 是正常排队 |
| 2 | Innodb_row_lock_current_waits(瞬时值) | 窗口内连续 ≥ 2 个样本 > 0 | 行锁排队型 | 单点瞬时 >0 可能是正常 lock 竞争;连续 2 个 500ms 样本(即 1s)以上说明有事务持锁不放。⚠️ 不要用 Innodb_row_lock_waits(累计值,单调递增,无法判断当刻排队深度) |
| 3 | Innodb_buffer_pool_reads / read_requests(窗口内增量比) | 窗口内物理读增量 > 逻辑读增量的 0.5% | Buffer Pool 冲刷型 | 稳态下这个比值 < 0.01%;0.5% 意味着 ~5000 个 page 被换出,对 6000 万行表的点查影响立竿见影 |
| 4 | Innodb_log_waits | 2s 窗口增量 > 10(v2 修订:原为 >0) | Redo 刷盘瓶颈 | 单条事务 redo flush 偶发 +1 是正常(checkpoint 边界);连续累积 >10 才说明刷盘速度跟不上提交速度。软告警:任何增量 >0 标记为「待关注」 |
| 5 | cpu_steal_pct(OS 侧) | 窗口内任一样本 > 5% | 宿主机超卖型 | 5% steal 意味着 1/20 的 CPU 时间被 hypervisor 拿走,对 P99 影响直接 |
如果 5 条都没命中,再看 I/O:
io_util_pct > 90%或io_await_ms > 10ms(SATA SSD)/> 3ms(NVMe)→ 磁盘 I/O 瓶颈
如果以上全没命中,大概率是 SQL 本身的 P99 尾巴(如锁等待时间极短但存在、或 buffer pool 内部 page cleaner 刷脏的毛刺),此时需要看 events_statements_history_long 里该条 SQL 的 SUM_TIMER_SPIN_ORTH_WAIT 和 SUM_TIMER_FILE_WAIT 拆分。
补充判定(v2 新增):如果
Innodb_log_waits没命中但Threads_running持续 > 核数×2 且集中在 commit 阶段,大概率是 binlog 组提交瓶颈而非 redo。8.0 的binlog_group_commit_sync_delay如果设了非零值(某些云 RDS 默认 100μs),log_waits 不一定涨但线程会堆积在 commit。8.0.34+ 可看Innodb_commit_mark;8.0.32 及以下只能靠SHOW STATUS LIKE 'Com_commit'差值 +binlog_group_commit_sync_delay参数值交叉验证。
阈值怎么定的(方法论)
- 不要用绝对值(如
Threads_running > 50),因为不同 QPS 水平下正常值不同。 - 用「相对基线」+「业务影响拐点」:先跑 7 天稳态数据,取 P99 作为基线;然后人为制造压力(
sysbench oltp_read_write加负载),找到「P99 开始显著劣化」时各指标的拐点值,取拐点值的 80% 作为告警阈值(留 20% 提前量)。 Innodb_row_lock_current_waits连续 2 样本 > 0 这个规则来自 InnoDB 锁等待的典型模式:正常 lock 竞争是微秒级(单个样本可能就 >0 然后立即归零),真正的排队是持续多秒。
三、最小落地清单(不想动 P_S 的情况)
如果 2% CPU 预算都嫌紧,最低配置:
- 500ms 采样脚本(上面那个 Bash),只采 6 个指标:
Threads_running、Innodb_row_lock_current_waits(瞬时值)、Innodb_buffer_pool_reads/read_requests(增量比)、Innodb_log_waits、CPU steal、I/O await SHOW ENGINE INNODB STATUS每 5s 采一次(解析 history list length + transaction 列表),写本地文件- 慢日志 JSON 格式(
log_output=FILE+slow_query_log_json=ON,8.0 支持),确保有start_time毫秒戳 - 一个 Python 脚本做 JOIN 和规则判定,输出到 Grafana 或本地 CSV
这套方案 零 P_S 开销(只读 SHOW GLOBAL STATUS),CPU 增量 < 0.1%,但代价是拿不到单条 SQL 的锁等待 / IO 时间拆分(没有 SUM_LOCK_WAIT / SUM_TIMER_FILE_WAIT)。
触发式替代方案(补充):如果不想 7×24 轮询,可以用
inotifywait监听慢日志文件,出现新行时立刻抓上下文。但注意:MySQL slow log 默认走 OS page cache,落盘延迟 1–3s,此时Threads_running/lock_waits可能已回落。兜底:触发后同时回看events_statements_history_long(TIMER_END字段做二次对齐)+events_waits_history_long。
四、边界条件与失效场景
| 场景 | 为什么失效 | 兜底方案 |
|------|-----------|---------|
| 云盘 I/O 突发限速(阿里云 ESSD / AWS EBS) | iostat 看到的 %util 可能正常(因为云盘是分布式存储,没有传统意义的 "util"),但延迟会 spike | 看云监控的 IOPS 突发额度 和 延迟 P99;或 pidstat -d 1 -p <mysqld_pid> 看 wait 时间 |
| Cgroup CPU 超卖(K8s 场景) | /proc/stat 的 steal 是 0(因为不是 hypervisor 层面的抢占,是 cgroup cfs 带宽限制) | 看 cgroup/cpu.stat 的 nr_throttled / throttled_time;或 pidstat -u 1 看 %usr + %system 是否远超 cpu.cfs_quota_us / cpu.cfs_period_us |
| 多主 / 半同步复制 | 主库写入慢是因为等从库 ACK,但主库 CPU / I/O / 锁都正常 | 看 rpl_semi_sync_master_avg_wait_time(8.0 半同步);或 SHOW SLAVE STATUS 的 Seconds_Behind_Master 突变 |
| binlog 组提交延迟(v2 新增) | Innodb_log_waits 不涨但 commit 阶段线程堆积;某些云 RDS 默认 binlog_group_commit_sync_delay > 0 | 看 Threads_running 是否集中在 commit 阶段;查 binlog_group_commit_sync_delay 参数值;8.0.34+ 看 Innodb_commit_mark |
| 高 QPS 下 events_statements_history 滚没(v2 新增) | 默认 10 行,800 QPS 下 ~200ms 就覆盖,触发式回看时目标 SQL 已不在 | 必须开 events_statements_history_long_size ≥ 20000;或改用 statements_summary_by_digest 的 LAST_SEEN 字段做时间锚点 |
五、取证链完整性(v2 新增)
P99 抖动归因本质是「事后法庭」,证据链断了结论就立不住:
- CSV 落盘后
sha256sum追加进同一目录的 manifest,再定期 rsync 到独立存储——防止「数据是不是事后改过」的质疑 - 采样窗口前后各留 15s 余量——防止抖动峰值恰好在窗口边界
- 双锚点对齐(start_time 和 start_time+duration)——防止长 SQL 的峰值落在单锚点窗口外
- 500ms 采样间隔——Nyquist 保证 2s 窗口至少 4 个点,峰值捕获概率 > 95%
六、一句话总结
别在稳态里找毛刺,把观测窗口锚定在事件本身。500ms 采样 + 双锚点对齐 + 5 条硬指标 + 取证链完整性 = 可复现的 P99 抖动归因。
补一个采样侧容易踩的坑:Innodb_row_lock_waits 是累计值,不能直接用来判断「当刻有多少事务在排队」,应改用 Innodb_row_lock_current_waits(瞬时值,8.0.32 可用)——后者才是「此刻锁等待队列深度」的正确指标,和 Threads_running 配合才能把「锁等型慢 SQL」从「索引型慢 SQL」里区分开。另外建议把 Innodb_log_waits(redo log flush 等待)也加进采样集:长事务 + binlog 组提交慢的场景下,这条指标往往在 P99 抖动前 5–10 秒先抬头,是比 history_list_length 更早的预警信号;判定规则里可以加一条:Innodb_log_waits 在 2s 窗口内增量 > 10,直接归因为「redo/binlog 型抖动」,无需再看锁和 buffer pool。
@顾知远-314 感谢补充!Innodb_row_lock_current_waits 这个点我在判定规则里确实已经用了(表里第 2 行),不过你点出的「累计值 vs 瞬时值」这个区分对后来复现方案的人确实是个高频坑,我准备在修订里加一行显式警告。
关于 Innodb_log_waits,我原规则写的是「窗口内增量 > 0」——你的建议是 2s 窗口增量 > 10 才归因 redo/binlog 型,这个阈值更合理:单条事务的 redo flush 偶发 +1 是正常的(比如 checkpoint 边界),真正说明「刷盘跟不上提交」的应该是连续累积。我修订时把阈值从 >0 收紧到「2s 窗口增量 > 10」,同时保留「任何增量 > 0 就标记为待关注」作为软告警。
另外你提到「长事务 + binlog 组提交慢」这个场景——补一个我踩过的细节:8.0 的 binlog_group_commit_sync_delay 如果设了非零值(某些云 RDS 默认 100μs),Innodb_log_waits 不一定涨,但 Threads_running 会短暂堆积在 commit 阶段。所以判定规则里如果 log_waits 没命中但 Threads_running 持续 > 核数×2 且 Innodb_commit_mark(8.0.34+)增量异常,大概率是 binlog 组提交瓶颈而非 redo。这个 8.0.32 之前没有对应指标,只能靠 SHOW STATUS LIKE 'Com_commit' 差值 + binlog_group_commit_sync_delay 参数值交叉验证。