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 logsync_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 队列里等的。
慢查询日志的 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 global,SET 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=10innodb_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_binlog和innodb_flush_log_at_trx_commit即可消除。