事故现场:凌晨1点,磁盘IO被打满了
我们线上是一个 Laravel 11 + PHP 8.3 + MySQL 8.0.35 的商城系统,部署在 8 台 4C8G 的 ECS 上,云盘是 ESSD PL1。活动开始后的第 20 分钟,监控大屏开始报警:
iostat -x 1显示 vdb 的%util稳定在 100%,await从 2.8ms 飙升到 385ms- 系统
iowait平均 91%,LOAD 打到 78 - 订单接口 P99 从平时的 280ms 涨到 8.7s
- RabbitMQ 消费积压 30 分钟,队列长度 21 万
第一反应是加机器,但看着工单审批流程,业务还在持续受损。只能先登录跳板机,手动排查。
排查过程:别凭猜,用数据定位
第一步:确认是哪个设备、哪类IO在打满
# 每隔1秒打印一次磁盘扩展统计,连续5次
iostat -x 1 5
# 结果关键行
# Device r_await w_await aqu-sz %util
# vdb 312.4 401.2 96.8 100.0
vdb 是数据盘,读写等待都超过 300ms,队列深度 96.8。设备级别确认了:瓶颈不在 CPU,不在内存,在磁盘本身。
第二步:定位是哪个进程在打IO
# 按磁盘IO读写排序,实时刷新
iotop -o -P
# pidstat 输出每个进程的磁盘读写和CPU
pidstat -d 2 5
# 12:01:02 UID PID kB_rd/s kB_wr/s kB_ccwr/s Command
# 12:01:02 27 1896 1200.5 24180.3 0.0 mysqld
# 12:01:02 0 28541 0.0 36892.2 0.0 php-fpm
两个明显的元凶:mysqld 每秒写 24MB,php-fpm 每秒写 36MB。一个数据库,一个应用进程,都在疯狂写盘。再结合 lsof 确认 php-fpm 打开的文件:
# 查看某个php-fpm进程打开的文件里,有多少日志文件
lsof -p 28541 | grep -E '(\.log|\.sql)' | wc -l
# 46
这个进程同时打开了 46 个日志文件。再看 MySQL:
mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE '%Slow_queries%';"
# +---------------+--------+
# | Variable_name | Value |
# +---------------+--------+
# | Slow_queries | 2847 |
# +---------------+--------+
活动开始 20 分钟,慢查询 2847 条。慢查询日志文件本身也在同一块盘上,一旦大量写慢查询日志,就会反过来加剧磁盘IO。
第三步:慢查询的 SQL 长什么样
-- 从慢查询日志里捞出来的典型SQL
SELECT id, order_no, total_amount, status
FROM orders
WHERE user_id = 152334
AND pay_time > '2024-11-11 00:00:00'
ORDER BY pay_time DESC
LIMIT 20;
orders 表 5800 万行,这张表的索引只有主键 id 和 idx_status。这个 SQL 要扫描全表,单次执行 3.2s。慢查询日志一旦打开,MySQL 就需要把每条超过阈值的 SQL 写入文件,而写入动作又要排队,形成了正反馈。
方案对比:升级硬件 vs 应用层优化
当时摆在桌面上的两个方案:
| 项目 | 方案A:升级云盘 | 方案B:应用层优化 |
|---|---|---|
| 具体动作 | ESSD PL1 扩容到 PL3,升级实例规格 | 加索引、调MySQL参数、日志异步化、加缓存 |
| 预计耗时 | 扩容操作 + 数据预热,约2小时 | 分批发布,约40分钟 |
| 成本 | 存储费用上涨约280% | 0元,仅人力 |
| 根因覆盖 | 不解决。磁盘变快了,但慢查询、日志风暴依旧存在,只是打满时间从20分钟变成2小时 | 针对根因。减少无效读写,磁盘自然降载 |
| 风险 | 云盘扩容需要重启ECS,可能引发长连接重连 | 参数调整需要验证,可能存在事务丢失风险(可控) |
结论:先做方案B。磁盘升级不是不能用,而是它掩盖问题,不解决问题。即使要升级,也应该在应用层优化之后,基于真实的 IOPS 需求去评估规格。
四步优化实施
第一步:给核心SQL加联合索引
-- 优化前:只有 idx_status,user_id 和 pay_time 都是非索引列
ALTER TABLE orders ADD INDEX idx_user_pay_time (user_id, pay_time) USING BTREE;
-- 验证执行计划
EXPLAIN SELECT id, order_no, total_amount, status
FROM orders
WHERE user_id = 152334
AND pay_time > '2024-11-11 00:00:00'
ORDER BY pay_time DESC
LIMIT 20;
执行计划从 type=ALL 变成 type=ref,扫描行数从 5800 万降到 412。这个查询的执行时间从 3.2s 降到 18ms。MySQL 的慢查询日志瞬间少了一大半。
注意:在 5800 万行的表上直接 ALTER TABLE,会锁表。我们用 pt-online-schema-change 做的在线变更:
# Percona Toolkit 3.5.5
pt-online-schema-change \
--alter "ADD INDEX idx_user_pay_time (user_id, pay_time)" \
--host=127.0.0.1 \
--user=root \
--ask-pass \
D=shop,t=orders \
--chunk-size=500 \
--max-lag=2 \
--execute
第二步:调整MySQL刷盘参数
MySQL 8.0.35 默认 innodb_flush_log_at_trx_commit = 1,每个事务提交都要把 redo log 刷到磁盘。活动期间每秒事务数 2800+,等于每秒至少 2800 次 fsync。在 ESSD PL1 的延迟下,这本身就是巨大的压力。
[mysqld]
# 1=每次事务提交都刷盘(默认,最安全)
# 2=每次事务提交只写到OS缓存,每秒刷一次盘
# 0=每秒刷一次盘,由Master线程控制
innodb_flush_log_at_trx_commit = 2
# 关闭binlog实时刷盘,改为每100次事务刷一次
# 注意:这会增加binlog丢失窗口,最多100个事务
sync_binlog = 100
# 告诉InnoDB底层磁盘能力,避免内部队列过度积压
innodb_io_capacity = 4000
innodb_io_capacity_max = 6000
# 脏页刷新比例,默认75%
innodb_max_dirty_pages_pct = 50
# 线上临时生效,不需要重启
mysql -uroot -p -e "
SET GLOBAL innodb_flush_log_at_trx_commit = 2;
SET GLOBAL sync_binlog = 100;
SET GLOBAL innodb_io_capacity = 4000;
SET GLOBAL innodb_io_capacity_max = 6000;
SET GLOBAL innodb_max_dirty_pages_pct = 50;
"
这个调整让 MySQL 的写盘次数从「每事务一次」变成「每秒一次合并刷盘」,显著降低小文件随机写。代价是极端情况下最多丢 1 秒的事务,或者最多 100 个 binlog 事务。我们业务是电商订单,不能接受丢订单,所以只把 innodb_flush_log_at_trx_commit 设为 2,sync_binlog 留了备份。如果你们是日志类、统计类业务,可以更激进一点,但我们没必要冒险。
第三步:日志异步化,拆掉日志风暴
php-fpm 每秒钟要写 36MB 日志,来自三部分:
- Laravel 默认日志驱动
daily,每个请求至少写 2~3 条 INFO 日志 - 业务代码里手动打点,比如下单成功、支付回调
- Nginx 的
access.log和error.log直接落盘
每个请求都同步写文件,PHP-FPM 进程在等待磁盘IO,导致请求时间变长,时间变长又会让更多请求堆积,进一步增加日志量。这是典型的日志风暴。
改造一:把 Laravel 的日志写入队列,异步落盘。
<?php
// app/Jobs/WriteLogJob.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Psr\Log\LoggerInterface;
class WriteLogJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public array $logData;
public function __construct(array $logData)
{
$this->logData = $logData;
}
public function handle(): void
{
$logger = app(LoggerInterface::class);
$logger->write($this->logData['level'], $this->logData['message'], $this->logData['context']);
}
}
<?php
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Log;
use App\Jobs\WriteLogJob;
public function boot(): void
{
Log::macro('async', function (string $level, string $message, array $context = []) {
WriteLogJob::dispatch([
'level' => $level,
'message' => $message,
'context' => $context,
]);
});
}
改造二:Nginx 访问日志改走 buffer 合并写,不再逐条 fsync。
# nginx 1.24.0
http {
# 访问日志用buffer,攒够64KB或5秒才写一次
access_log /var/log/nginx/shop-access.log main buffer=64k flush=5s;
# 业务接口的访问日志直接关掉
location /api/ {
access_log off;
proxy_pass http://php_fpm_backend;
}
}
改造三:把系统级日志迁出数据盘,放到内存盘。
# /etc/fstab 中添加
tmpfs /var/log/nginx tmpfs defaults,noatime,size=256m 0 0
tmpfs /var/log/php-fpm tmpfs defaults,noatime,size=256m 0 0
# 重新挂载
mount -a
这个操作要谨慎,重启后 tmpfs 内容会清空。我们只把 nginx 和 php-fpm 的日志放进去,MySQL 的慢查询日志和数据文件不动。日志丢了可以接受,服务不能挂。
改造四:Laravel 的日志文件改成「按周切割 + 自动清理」,不要按天。
// config/logging.php
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily'],
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'warning'),
'days' => 7,
'permission' => 0664,
],
],
你们可能注意到了,我把 level 从 debug 提到了 warning。这是成本最低的效果:生产环境压根不需要 INFO 级别的日志。如果非要看调试信息,请上链路追踪(比如 OpenTelemetry),别用日志文件。
第四步:热点查询加缓存降级
优化完上面三步,磁盘IO已经降了一截。还有一个问题:订单接口每次都要查 MySQL,高峰期 QPS 4000+,即使每次查询消耗不高,也会把 MySQL 的 IO 拉高。
<?php
use Illuminate\Support\Facades\Cache;
public function userRecentOrders(int $userId, int $limit = 20): array
{
$cacheKey = "user:{$userId}:recent_orders:{$limit}";
return Cache::remember($cacheKey, 30, function () use ($userId, $limit) {
return DB::select(
'SELECT id, order_no, total_amount, status
FROM orders
WHERE user_id = ?
ORDER BY pay_time DESC
LIMIT ?',
[$userId, $limit]
);
});
}
缓存时间设 30 秒,订单状态允许 30 秒延迟。这里考虑过缓存 5 分钟,但是用户下单后马上想在列表里看到订单,5 秒延迟都不能忍。30 秒是业务方确认过的最长可接受时间。
效果数据:磁盘IO降了 62%,P99 回到 1.2s
优化全部上线后,持续观察 30 分钟的数据对比:
| 指标 | 优化前(活动开始20分钟) | 优化后(上线30分钟后) | 变化 |
|---|---|---|---|
| vdb %util | 100% | 38% | ↓ 62% |
| vdb await | 385ms | 2.1ms | ↓ 99.5% |
| iowait | 91% | 8% | ↓ 83% |
| 订单接口 P99 | 8.7s | 1.2s | ↓ 86% |
| MySQL 慢查询数/分钟 | 142 | 6 | ↓ 95.8% |
| php-fpm 写盘速率 | 36MB/s | 4.8MB/s | ↓ 86.7% |
| MySQL 写盘速率 | 24MB/s | 11MB/s | ↓ 54.2% |
| RabbitMQ 积压 | 21万 | 0 | 已消化 |
这里有个细节值得注意:MySQL 的查询量并没有降多少,因为缓存只覆盖了 30% 的订单查询。真正让磁盘IO降下来的是日志量减少和刷盘频率降低。SQL 索引优化把慢查询从 142 条/分钟降到 6 条/分钟,MySQL 不再需要同时处理「查询」和「写慢查询日志」两个任务。
避坑指南:这些坑我替你踩过了
坑1:iostat 的 %util 在云盘上会骗你
传统物理磁盘里 %util 100% 代表设备饱和。但 ESSD 这类云盘后端是分布式存储,%util 100% 可能只是前端队列积压,后端还没饱和。判断是否真的饱和,要看 await 和 aqu-sz。await 大于 50ms 且 aqu-sz 持续大于 8,才能认定为瓶颈。我因为只看 %util 浪费了半小时去排查根本不存在的补偿任务。
坑2:慢查询日志在故障期间是帮凶
排查完慢查询后,我一度开着 slow_query_log 想继续观察,结果它把高负载下的所有慢 SQL 都写进同一块盘,让磁盘IO雪上加霜。故障期间建议:
# 先用临时表记录,不落盘
mysql -uroot -p -e "
SET GLOBAL slow_query_log = ON;
SELECT SLEEP(20);
SET GLOBAL slow_query_log = OFF;
"
或者把 slow_query_log_file 指向 tmpfs。故障恢复后再长时间开启。
坑3:直接改 innodb_flush_log_at_trx_commit=0 丢了10分钟事务
我们第一次调整时图省事直接设成 0,结果 MySQL 进程因为 OOM 被 kill,redo log 没来得及合并,最后丢了 10 分钟的事务。虽然业务能接受,但客服被投诉打爆了。innodb_flush_log_at_trx_commit=0 是「每秒由 Master 线程刷盘」,不是「不刷盘」,MySQL 崩溃时丢 1 秒数据,但 OOM kill 是不可控的,可能连缓冲池里的已提交事务一起丢。生产环境建议保守一点用 2。不要为了性能牺牲可解释性。
坑4:pt-online-schema-change 不是万能的
在 5800 万行的表上执行时,MySQL 8.0 默认基于坑位复制(row-based),在线变更期间会产生大量 binlog,binlog 写入又加剧磁盘IO。我们当时先把 sync_binlog 调大,再跑 pt-osc。如果你是 MySQL 5.7,要注意 binlog_format 必须是 ROW,否则触发器会失效。
坑5:tmpfs 日志目录重启后文件全没了
我把 nginx 日志挂到 tmpfs 后,有一次服务器自动重启,/var/log/nginx 里的历史日志全部清空。如果你想保留审计,需要加一个启动脚本把日志每天 rsync 到持久盘。
总结
这次磁盘IO高负载事故,本质上不是磁盘性能不够,而是应用写得太狠:MySQL 慢查询、日志风暴、频繁刷盘。优化路径优先级:
- 先消除无效查询(加索引)
- 再合并高频小IO(MySQL刷盘参数)
- 然后降低日志写入量(异步化、buffer、关访问日志)
- 最后对热点数据加缓存
这套组合拳下来,没有增加任何硬件成本。如果你们的磁盘IO也天天报警,先别急着升配,按这个方法走一遍,大概率能省下一笔预算。