一周前的凌晨,一台跑了三个 WordPress 站的服务器弹出磁盘告警:使用率 97%,剩余空间不到 3GB。追查下去,吃掉磁盘的既不是日志也不是上传文件,而是 MySQL 的二进制日志——一天净增 2.8GB。更蹊跷的是,配置文件里明明设置了自动过期,binlog 却只增不减。这篇文章完整复盘这次排障:从取证、止血、定位元凶,到修复后的增速复测方法。所有命令都在 MySQL 5.7 + 宝塔面板环境实测通过。

zxcyw-binlog封面

一、取证:先看清楚是谁在吃磁盘

磁盘告急不要第一反应就删文件,先做归因。一条命令看清大头:

du -ch /srv/mysql/data/mysql-bin.* | sort -h | tail
ls /srv/mysql/data/ | grep -c mysql-bin

这次实测结果:binlog 文件 400+ 个,总量占满分区大头。再核对配置:

# my.cnf
expire_logs_days = 3

设置了 3 天过期,文件却越积越多——这就是本案第一个知识点。

二、为什么 expire_logs_days 形同虚设

MySQL 5.7 的经典坑:expire_logs_days 只在两个时机被真正执行——MySQL 重启时,或下一次 binlog rotate 由持有 SUPER 权限的客户端触发时。

宝塔环境里,PHP 站点全部用业务账号连库,业务账号没有 SUPER 权限。结果就是:过期策略永远没有执行者,binlog 无限堆积。

止血方案用官方命令,绝不允许直接 rm mysql-bin.*(会破坏 binlog index,MySQL 下次 rotate 直接起不来):

PURGE BINARY LOGS BEFORE NOW() - INTERVAL 1 DAY;

PURGE 会自动维护 index 文件,这是它和手删的本质区别。

PURGE 前的三个安全前提(缺一不可)

  1. SHOW SLAVE STATUS 为空——确认没有复制下游在消费 binlog;
  2. 备份策略只做 mysqldump 全量,不依赖 binlog 做 PITR(时间点恢复);
  3. 保留窗口内的业务低峰执行。

这次实测:业务账号在该 5.7 环境有执行 PURGE 的权限,无需 root;执行完磁盘 97% → 84%,回收 9.3GB。

zxcyw-binlog-expire

三、止血只是开始:2.8G/天 的增量必须找到凶手

PURGE 完第二天,binlog 又开始肉眼可见地涨。purge 是抽血,凶手还在造血。定位方法是对当前正在写入的文件做事件统计:

mysqlbinlog --no-defaults --base64-output=decode-rows -v /srv/mysql/data/mysql-bin.000471 \
  | grep -E "^UPDATE |^### (UPDATE|INSERT)" \
  | cut -c1-60 | sort | uniq -c | sort -rn | head

输出立刻见分晓:wp_options 表的 rewrite_rules 一行以压倒性频次排第一。

回到代码层,WordPress 站的高频凶手长得很有辨识度——某个插件把 flush_rewrite_rules() 挂在了 init 钩子上:

// 反面教材:每请求重写 39KB 的 rewrite_rules option
add_action('init', function () {
    add_rewrite_endpoint('quiz-iframe', EP_PERPOST);
    flush_rewrite_rules();   // ← 每刷新一次页面,39KB 全量 UPDATE 进 binlog
});

一行代码,每次页面请求产生一次 39KB 的全字段 UPDATE;行格式 binlog 记录整行前后镜像,日均五万 PV 就是 2.8GB。用 grep 扫插件目录直接点名:

grep -rn "add_action('init'" /srv/app/wp-content/plugins/ \
  | xargs -I{} sh -c 'grep -l flush_rewrite_rules {}'

四、修复:删之前先确认规则已在库里

flush_rewrite_rules() 不是无害代码,它的职责就是把内存里算好的重写规则写回数据库。直接删的前提是:规则已经在 wp_options 里躺着了。先验证:

SELECT option_value LIKE '%quiz-iframe%' FROM wp_options WHERE option_name = 'rewrite_rules';

返回 1,说明该插件的 endpoint 规则已在库中;此后 WordPress 每次请求本来就会从库里读取这份缓存,删掉函数体内那行 flush_rewrite_rules();、保留 add_rewrite_endpoint(...) 调用,行为完全不受影响。

操作纪律:改动前 cp -a 留备份(class-plugin.php.bak-noflush-日期),改完 php -l 语法复验,回滚 = 还原备份文件,十秒收工。

zxcyw-binlog-culprit

五、修复后怎么复测:别采样错对象

这是最容易翻车的一步:复测必须对着”当前写入文件”采样,而不是目录里最大的文件。旧的大文件是历史尸体,数字再大也不增长。

# SHOW BINARY LOGS 最后一行 = 正在写的文件
SIZE1=$(stat -c %s /srv/mysql/data/mysql-bin.000472)
for i in $(seq 1 50); do curl -s -o /dev/null https://example.com/; done
SIZE2=$(stat -c %s /srv/mysql/data/mysql-bin.000472)
echo "增量: $((SIZE2 - SIZE1)) bytes / 50 请求"

修复前:每请求约 80KB 写放大(含前后镜像与元数据),50 请求就是 4MB 级增量。修复后:应接近 0。这次实测从”每请求 80KB”降到平均不足 500 字节,按日均流量折算,日产量从 2.8GB 降到 22MB——128 倍的降幅。

六、顺手项的边界:别贪刀

排障时看到旁边还有几处”看起来能优化”的东西,克制很重要:

  • 碎片整理:全库 data_free 只有 27MB,其中超过 5MB 阈值的表没有——OPTIMIZE 收益为零,跳过;
  • 大表慎动:一张 610MB 的副标题表,OPTIMIZE 意味着长时间锁风险,收益不到 100MB,不动;
  • slow log 画像:用 Python 正则聚类 Query_time,不要用 bash grep 抽指纹——会被 SET timestamp 噪音骗得一塌糊涂;
  • 再生缓存:/tmp 下的搜索缓存目录可清,但先确认它不是业务写入目标。

zxcyw-binlog-retest

七、验收三连

  1. 站点核心页面 200;
  2. 业务 API 返回真实数据(不是空壳 200);
  3. df -h 前后对比确认回收量,且第二天 binlog 目录不再涨。

小结

环节 关键动作 陷阱
止血 PURGE BINARY LOGS 严禁 rm 物理文件
前提 无下游复制 + 备份不依赖 binlog 缺一不可
定位 mysqlbinlog 事件频次聚类 采样对象必须是当前写入文件
修复 确认规则已入库再删 flush 先备份、php -l 复验
复测 50 请求增量法 别对旧文件测增长

binlog 暴涨类问题的本质通常是”某个每请求执行的重量级写操作”,PURGE 给你争取时间,找到并拔掉那行代码才是治本。