高负载下的磁盘IO排查与优化实战
发布日期: 2026/08/14 阅读总量: 0

事故现场:凌晨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 万行,这张表的索引只有主键 ididx_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.logerror.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,
    ],
],

你们可能注意到了,我把 leveldebug 提到了 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 %util100%38%↓ 62%
vdb await385ms2.1ms↓ 99.5%
iowait91%8%↓ 83%
订单接口 P998.7s1.2s↓ 86%
MySQL 慢查询数/分钟1426↓ 95.8%
php-fpm 写盘速率36MB/s4.8MB/s↓ 86.7%
MySQL 写盘速率24MB/s11MB/s↓ 54.2%
RabbitMQ 积压21万0已消化

这里有个细节值得注意:MySQL 的查询量并没有降多少,因为缓存只覆盖了 30% 的订单查询。真正让磁盘IO降下来的是日志量减少和刷盘频率降低。SQL 索引优化把慢查询从 142 条/分钟降到 6 条/分钟,MySQL 不再需要同时处理「查询」和「写慢查询日志」两个任务。

避坑指南:这些坑我替你踩过了

坑1:iostat 的 %util 在云盘上会骗你

传统物理磁盘里 %util 100% 代表设备饱和。但 ESSD 这类云盘后端是分布式存储,%util 100% 可能只是前端队列积压,后端还没饱和。判断是否真的饱和,要看 awaitaqu-szawait 大于 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 慢查询、日志风暴、频繁刷盘。优化路径优先级:

  1. 先消除无效查询(加索引)
  2. 再合并高频小IO(MySQL刷盘参数)
  3. 然后降低日志写入量(异步化、buffer、关访问日志)
  4. 最后对热点数据加缓存

这套组合拳下来,没有增加任何硬件成本。如果你们的磁盘IO也天天报警,先别急着升配,按这个方法走一遍,大概率能省下一笔预算。