返回博客

MySQL 并发写入 fsync 悬崖:rows_examined=0 却慢 1s 的根因

某个内部后台系统,一次普通的页面操作触发了十几个并发 API,每个 API 末尾都有一个简单的写库动作。EXPLAIN 一看索引完美,SQL 也不复杂。但慢查询日志每隔一会儿就蹦出一批 INSERT,扫描行数 rows_examined=0,却 query_time > 1s

更诡异的是:这些慢条目的时间戳都挤在同一毫秒内——三四条 ts_iso 末尾一模一样。直觉告诉我这不是 SQL 的问题。

现象

# Query_time: 1.041  Lock_time: 0.000  Rows_sent: 0  Rows_examined: 0
INSERT INTO some_table (...) VALUES (...) ON DUPLICATE KEY UPDATE ...;

# Query_time: 1.038  Lock_time: 0.000  Rows_sent: 0  Rows_examined: 0
INSERT INTO another_table (...) VALUES (...);
  • 慢条目 rows_examined 均为 0 或个位数,SQL 本身是"零成本"查询
  • 多条慢日志时间戳聚在同一 1ms 窗口内,像是"集体超时"
  • 单连接串行跑同一 SQL,p95 只有 17ms;但把并发连接数拉到 16,p95 直接跳到 1040ms

根因

MySQL 的提交路径大致是:执行语句 → 写 redo log → 写 binlog → fsync 落盘 → 返回客户端 OK

默认配置下:

  • innodb_flush_log_at_trx_commit=1:每次 commit 都 fsync redo log
  • sync_binlog=1:每次 commit 都 fsync binlog

在单连接时,每次提交等一次 fsync,没有竞争,约 10-20ms,感知不到。

但当 12-16 个连接同时到达提交阶段,MySQL 的 group-commit 机制会把它们排成一个队列,由队头负责 fsync,其余等待。商用 SSD 的 fsync 耗时约 1-10ms,但队尾的事务要等前一轮 fsync 完成才能进入下一轮。在并发足够高时,某些事务就被迫等了整整一个 fsync 周期——这就是"悬崖"(cliff)。

query_time 计的是整个提交等待时间,不只是 SQL 执行时间。rows_examined=0 的语句跑了 1s,那 999ms 都是在 commit fsync 队列里等的。

坑:rows_examined=0 的慢查询不能用 EXPLAIN 排查

慢查询日志的 query_time 包含 commit 阶段的 fsync 等待时间,不只是执行时间。rows_examined=0 意味着 SQL 执行本身几乎零成本,慢的是 commit 落盘路径。用 EXPLAIN 分析这类慢查询是无效的,根因在 MySQL 持久化参数配置,而非查询计划。

诊断——串行 vs 并发基准测试

用应用侧的连接库(mysql2/JDBC)跑以下微基准,模拟不同并发度下单次 INSERT 的延迟分布:

import mysql from 'mysql2/promise'
 
async function concurrent(conns, opsPer) {
  const pool = mysql.createPool({ uri: process.env.MYSQL_URL, connectionLimit: conns + 4 })
  // 建探针表
  const c0 = await pool.getConnection()
  await c0.query(`DROP TABLE IF EXISTS _fsync_probe`)
  await c0.query(`CREATE TABLE _fsync_probe (id INT AUTO_INCREMENT PRIMARY KEY, v INT) ENGINE=InnoDB`)
  c0.release()
 
  const times = []
  await Promise.all(Array.from({ length: conns }, async (_, ci) => {
    const c = await pool.getConnection()
    for (let i = 0; i < opsPer; i++) {
      const t0 = process.hrtime.bigint()
      await c.query('INSERT INTO _fsync_probe (v) VALUES (?)', [ci * 100 + i])
      times.push(Number(process.hrtime.bigint() - t0) / 1e6)
    }
    c.release()
  }))
  await pool.end()
  return times
}

有问题的服务器(默认严格持久化,普通 SSD)

1 conn  × 10 op    p50=10ms   p95=17ms    ← 正常
8 conn  × 10 op    p50=15ms   p95=20ms    ← 还好
16 conn × 10 op    p50=17ms   p95=1040ms  ← 悬崖
32 conn × 10 op    p50=18ms   p95=1050ms  ← 悬崖更深

p50 全程健康,p95 在 8→16 并发之间断崖——这就是 fsync 悬崖的典型指纹。

正解

四个参数,两步操作(内网开发 / 内部 staging 适用,生产金融场景不适用):

第一步:立即生效(无需重启)

SET GLOBAL innodb_flush_log_at_trx_commit = 2;
SET GLOBAL sync_binlog                    = 100;
SET GLOBAL binlog_group_commit_sync_delay = 500;       -- 单位 microseconds
SET GLOBAL binlog_group_commit_sync_no_delay_count = 10;

所有四个变量在 MySQL 8 中都是 dynamic globalSET GLOBAL 对已有连接立即生效。

第二步:持久化到 docker-compose(防重启丢失)

mysql:
  image: mysql:8.0
  command:
    # 其他已有参数...
    # 并发提交调优:放宽持久性换取 p95 延迟
    # 代价:mysqld 崩溃可能丢 ≤1s 已提交事务(内网开发可接受)
    - --innodb-flush-log-at-trx-commit=2
    - --sync-binlog=100
    - --binlog-group-commit-sync-delay=500
    - --binlog-group-commit-sync-no-delay-count=10
正解:四参数组合拳消除 fsync 悬崖
  • innodb_flush_log_at_trx_commit=2:每次 commit 写 OS page cache,每秒 fsync 一次(OS crash 才丢数据,mysqld 重启不丢)
  • sync_binlog=100:每 100 个事务才 fsync binlog,减少 95% 的 binlog 同步开销
  • binlog_group_commit_sync_delay=500μs:等待 500μs 让更多事务进同一 group,提升并发吞吐
  • binlog_group_commit_sync_no_delay_count=10:积攒够 10 个事务则提前 fsync,避免 delay 拖慢小并发场景

调整后重跑基准,p95 在 16 并发下从 1040ms 降回 ~15ms。

代价透明:接受什么风险

innodb_flush_log_at_trx_commit=2 意味着:如果宿主机内核崩溃(断电、OOM killer 杀 kernel),最多丢失最近 1 秒内已经向应用返回 OK 的事务。mysqld 进程崩溃(段错误、OOM kill mysqld)则不会丢,OS 会将 page cache 刷盘。内网开发机、内部 staging 完全可以接受;涉及资金流水、合规存储的生产环境则不能放开。

一句话外卖

慢查询日志里 rows_examined=0 却超时的 INSERT,先看时间戳是否聚集在同一毫秒——那是 group-commit fsync 悬崖,不是 SQL 问题,调 sync_binloginnodb_flush_log_at_trx_commit 即可消除。