今天上午老大说监控又挂了,MySQL 数据库 3 个多 G,怀疑是不是数据库太大拖垮的。

我第一反应也是:是不是该 trim 了。

于是 SSH 登上去,先看了下表大小。disk_io 660 万行、traffic 320 万行、cpu_history 320 万行、mem_history 280 万行——加起来确实是 3G 出头。但仔细一看,最早行的 created_at 全是一天前的,说明清理在正常跑,数据没有无限堆积。

3G 对 MySQL 不算大。这个结论先放着。

慢查询日志开口说话

既然 DB 容量不是问题,那什么才是?看了下 long_query_time=3 的慢日志,24 小时攒了 2 万多条慢查询,SQL 总耗时 81307 秒——平均每条要跑 4 秒出头,几乎全天没停过。

看了排序靠前的几个元凶就知道问题在哪了。我一个个说。

真凶一:清理风暴

系统每小时会清理一次历史数据,把超过 24 小时的行分批删掉(DELETE ... LIMIT 5000)。但那段清理代码有个问题:它是在清理全部完成之后才把 last_cleanup 时间戳写回去。

这意味着什么?假设清理过程需要 90 秒才能删完一个表——这 90 秒里,每 5 秒到达的采集请求都会检查 last_cleanup,发现上一次清理是 2 分钟前(「超过 1 小时」的条件满足),于是每个请求都各自再开一轮新的 while(true) DELETE。

几百个 DELETE 在同一个表上互相锁,平均每批耗时就变成了 99 秒(cpu_history)、65 秒(mem_history)。清理本该是辅助操作,结果成了把服务器拖死的元凶。

改法是加了个原子认领:

UPDATE __settings SET v=? WHERE k='last_cleanup' AND CAST(v AS UNSIGNED) < ?

同一时刻只有一个人能抢到清理权,其他人直接跳过。再设了个超时,最多跑 8 秒,跑不完也没事,下轮接着清。

真凶二:5 秒采样改出来的连锁反应

系统之前是 30 秒采集一次数据,老大嫌曲线不够平滑,改成了 5 秒。数据量直接翻了 6 倍,但查询逻辑没跟着动。

最典型的例子是磁盘仪表盘:每次刷新页面,它去扫最近 6 小时的 disk_io 表,做 MAX(id) 子查询 + 自连接。6 小时就是 160 万行,这条查询实测 60 秒都跑不完——而磁盘页每 3 秒自动刷一次,等于每 3 秒往 MySQL 砸一发 60+ 秒的查询。

类似的还有 CPU 图表、内存图表,都用了 LIKE '%名字%' 前导通配查询,索引直接废掉,全表扫 660 万行。

修法其实不复杂:把 SQL 里那几个 LIKE '%名字%' 改成按 vps_name 精确匹配,再给历史表加了个 (vps_name, created_at) 复合索引(在线加的,不锁表),单台 VM 的 1 小时曲线从扫 28 万行降到了 756 行。磁盘累计值直接从实时状态表(450 行)读,不走历史表了。

改完之后 >90 秒 变成了 26 毫秒。CPU 和内存那两处前导通配的查询我暂时没全重构,先靠改匹配加索引顶着了,等哪天空了再收拾——先把告警压下去要紧。

顺手修的那个 2.6G 日志

清日志的时候发现 error.log 已经 2.6G 了。打开一看,全是同一行字:

PHP message: [HV-Monitor] 使用内置兜底密码

翻了代码,发现 config.php 从文件 /tmp/hv_db_pass 读数据库密码,文件不存在就走内置兜底密码——走兜底密码是正常的,但每次还 error_log 一行。200 多台 VM 每 5 秒上报一次,一天就是 2.6G。

修法更简单:

echo -n "密码" > /tmp/hv_db_pass

三秒的事。

结局

全部改完上线,慢查询从每 10 分钟一百多条降到了 3 条。CPU 负载从 5+ 降到 0.5。老大说图表加载变快了——其实不是变快了,是之前太慢了你习惯了。

哦对了,排查的时候顺手还处理了个安全的事。日志里发现 SSH 被波兰和俄罗斯的 IP 爆破了 1100 多次,顺手把 fail2ban 从「猜 30 次才封、封 10 分钟」改成了「猜 5 次就封、封一小时」,顺便把 3306 端口收回了本机——虽然防火墙本来就没放行,双保险。

所以折腾大半天,3G 的库我一行没删,真正把服务器拖死的是那段每个请求都抢着跑的清理。行吧,也不算白忙一场。写完收工。