PHP慢查询日志分析:从定位到调优实战
发布日期: 2026/08/20 阅读总量: 0

一次订单导出卡死30秒的排查

2024年3月,运营在群里喊:订单导出页面点完一直转圈,30秒都出不来。

当时的环境:PHP 8.3 + Laravel 11 + MySQL 8.0.35,部署在K8s里,Nginx做网关。第一反应查PHP-FPM状态,正常,没有进程堆积。查Nginx access log,那个接口的响应时间确实是32.4秒,HTTP 200但页面就是白屏。

排查链路走到MySQL这层,打开了慢查询日志,发现一条SQL执行了1.2秒

SELECT * FROM orders 
WHERE status = 'paid' 
ORDER BY created_at DESC 
LIMIT 10;

这个接口前后要查10次类似SQL,算下来就是12秒多。再加点别的逻辑,30秒没跑完很正常。

问题定位了。接下来就是两件事:怎么系统地拿慢查询日志怎么把它改快

两种慢查询日志方案对比

拿到慢SQL之后,我们需要一套能持续发现慢查询的机制,不能每次都等人喊卡。

对比项MySQL原生慢查询日志应用层慢查询日志
定位层级DB层,记录所有超过阈值的SQLPHP层,记录应用执行的慢SQL和慢请求
SQL完整度记录参数化前的原始SQL,包含绑定值记录ORM生成的SQL,可能丢失绑定值
性能开销低,MySQL内部直接写日志文件中,每次查询都要做时间比较和日志写入
定位代码能力弱,只能看到SQL,看不到调用栈强,可以记录来源Controller/Job
部署成本改MySQL配置,重启生效改PHP代码,发布上线
适合场景产线排查、DBA日常巡检开发环境、内部系统、复杂业务链路

两者不是替代关系。MySQL慢查询日志负责"面",应用层日志负责"点"。我的做法是:产线开MySQL慢查询日志做全局扫描,应用层给核心接口加慢请求中间件做精确追踪

方案一:MySQL慢查询日志的完整配置

第一步:开启慢查询日志

MySQL 8.0.35,修改my.cnf:

# /etc/my.cnf 追加以下配置
[mysqld]
slow_query_log = ON
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 0.5
log_queries_not_using_indexes = ON
min_examined_row_limit = 100

参数说明:

  • long_query_time = 0.5:超过500毫秒的SQL都记录。生产环境建议从1秒开始,确认没问题后再调到0.5秒,避免日志量过大。
  • log_queries_not_using_indexes:记录全表扫描的SQL,哪怕它执行时间不到阈值。
  • min_examined_row_limit = 100:只记录扫描行数超过100行的,过滤掉小表全扫。

改完重启MySQL或动态开启:

# 不用重启,直接运行时开启
mysql -uroot -p -e "SET GLOBAL slow_query_log = 'ON';"
mysql -uroot -p -e "SET GLOBAL long_query_time = 0.5;"
mysql -uroot -p -e "SET GLOBAL log_queries_not_using_indexes = 'ON';"

确认生效:

mysql -uroot -p -e "SHOW VARIABLES LIKE 'slow_query_log%';"
mysql -uroot -p -e "SHOW VARIABLES LIKE 'long_query_time%';"
mysql -uroot -p -e "SHOW VARIABLES LIKE 'log_queries_not_using_indexes%';"

输出示例:

+---------------------+-----------------------------------+
| Variable_name       | Value                             |
+---------------------+-----------------------------------+
| slow_query_log      | ON                                |
| slow_query_log_file | /var/log/mysql/mysql-slow.log    |
+---------------------+-----------------------------------+
| Variable_name       | Value                             |
+---------------------+-----------------------------------+
| long_query_time     | 0.500000                          |
+---------------------+-----------------------------------+
| Variable_name       | Value                             |
+---------------------+-----------------------------------+
| log_queries_not_using_indexes | ON                        |
+---------------------+-----------------------------------+

第二步:生产环境日志采集

慢查询日志是文本文件,不会自动轮转。我写了一个cron脚本,每天凌晨切割归档,保留30天:

#!/bin/bash
# /usr/local/bin/rotate-slow-log.sh
# 每天凌晨1点执行:0 1 * * * /usr/local/bin/rotate-slow-log.sh

LOG_DIR="/var/log/mysql"
SLOW_LOG="${LOG_DIR}/mysql-slow.log"
YESTERDAY=$(date -d "yesterday" +%Y%m%d)

# 用mv切割日志,MySQL会继续往原路径写
mv ${SLOW_LOG} ${SLOW_LOG}.${YESTERDAY}

# 优雅刷新日志文件句柄
mysql -uroot -p'your_password' -e "FLUSH SLOW LOGS;"

# 只保留30天
find ${LOG_DIR} -name "mysql-slow.log.*" -mtime +30 -delete

脚本放到crontab里:

0 1 * * * /usr/local/bin/rotate-slow-log.sh >> /var/log/rotate-slow-log.log 2>&1

第三步:用pt-query-digest分析日志

mysqldumpslow是MySQL自带的工具,但功能太弱。我推荐pt-query-digest,Percona Toolkit里的神器。版本:Percona Toolkit 3.5.7。

安装(Ubuntu):

apt-get install percona-toolkit -y

分析慢查询日志:

pt-query-digest /var/log/mysql/mysql-slow.log > /tmp/slow-analysis.txt

输出摘要:

# Profile
# Rank Query ID                      Response time   Calls R/Call V/M   Item
# ==== ============================= =============== ===== ====== ===== =====
# 1    0x1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D  128.5231 72.3%   145  886.4ms  0.20 SELECT orders
# 2    0x3F4E5D6C7B8A9F0E1D2C3B4A5F6E7D8C9   18.2342 10.2%    32  569.8ms  0.40 SELECT users
# 3    0x5A6B7C8D9E0F1A2B3C4D5E6F7A8B9C0D1    8.1234  4.6%    12  676.9ms  0.10 SELECT order_items

关键信息解读:

  • Response time:该SQL累计耗时和占比。第一条占了72.3%的总慢查询时间,优先搞它。
  • Calls:执行次数。145次,说明是高频SQL。
  • R/Call:平均每次886.4ms,远超阈值。

再看这条SQL的详细报告:

# Query 1: 0.71 QPS, 0.63x concurrency, ID 0x1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D
# Time range: 2024-03-18T00:00:02 to 2024-03-18T23:59:58
# Attribute    pct   total     min     max     avg     95%  stddev  median
# ============ === ======= ======= ======= ======= ======= ======= =======
# Count         57     145
# Exec time    1796s      2s      3s    886ms    2s    412ms   752ms
# Lock time     12    371ms       0   742us    22us    58us    46us     9us
# Rows sent      3   3.44k       0       30    24.28   28.20    7.06   24.28
# Rows examine  39 354.50k    1.25k    2.62k    2.45k    2.61k  116.11   2.39k
# Query size    10 127.00k     913      930  912.69  921.20    4.51  916.36
# String:
# Databases    order_db
# Hosts        10.0.3.12 (99/145), 10.0.3.15 (46/145)
# Users        php_app
# Query_time distribution
#   1us-10ms: 0
#   10ms-100ms: 0
#   100ms-1s: 12.41%
#   1s-10s: 87.59%
# Tables
# SHOW TABLE STATUS LIKE 'orders'\G
# SHOW CREATE TABLE `orders`\G
# EXPLAIN SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 10\G

Rows examine看,每次查询扫描了约2450行,但只返回24行。这不是典型的全表扫描,更像是status字段区分度太低,MySQL选择走索引扫描大量数据再排序。

方案二:应用层慢日志中间件

MySQL慢查询日志能定位到SQL,但定位不到代码。一次接口慢,可能是10条SQL叠加,每条都没超过阈值,但加起来超过1秒。应用层日志要解决这个问题。

Laravel 11监听慢查询

Laravel提供了DB::listen事件。我写了一个监听器,超过300ms的查询单独记录:

time < self::SLOW_QUERY_MS) {
            return;
        }

        $sql = $event->sql;
        $bindings = $event->bindings;
        $time = $event->time;
        $connection = $event->connectionName;

        // 将绑定值填入SQL,方便直接复制执行
        foreach ($bindings as $binding) {
            if (is_numeric($binding)) {
                $sql = preg_replace('/\?/', $binding, $sql, 1);
            } else {
                $sql = preg_replace('/\?/', "'{$binding}'", $sql, 1);
            }
        }

        // 拿到调用来源
        $traces = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
        $caller = 'unknown';
        foreach ($traces as $trace) {
            if (isset($trace['file']) && !str_contains($trace['file'], 'vendor/laravel')) {
                $caller = basename($trace['file']) . ':' . $trace['line'];
                break;
            }
        }

        Log::channel('slow_query')->warning('Slow Query Detected', [
            'sql'      => $sql,
            'time_ms'  => $time,
            'caller'   => $caller,
            'connection' => $connection,
            'trace_id' => request()->header('X-Trace-ID', ''),
        ]);
    }
}

注册事件监听,在AppServiceProvider里:

handle($event);
        });
    }
}

配置日志通道,config/logging.php里加一个独立文件:

 [
    'driver' => 'daily',
    'path' => storage_path('logs/slow-query.log'),
    'level' => 'warning',
    'days' => 14,
],

再给核心接口加个慢请求中间件

SQL是快了,但接口整体慢可能是外部HTTP调用或Redis拖后腿。这个中间件记录超过500ms的完整请求:

= self::SLOW_REQUEST_MS) {
            Log::channel('slow_request')->warning('Slow Request Detected', [
                'method'   => $request->method(),
                'uri'      => $request->fullUrl(),
                'duration_ms' => $duration,
                'trace_id' => $request->header('X-Trace-ID', ''),
                'auth_id'  => $request->user()?->id,
            ]);
        }

        return $response;
    }
}

注册到app/Http/Kernel.phpapi中间件组:

 [
        \App\Http\Middleware\RequestPerformanceMiddleware::class,
        // 其他中间件...
    ],
];

慢SQL调优实战:从1.2秒到8毫秒

拿日志里那条最慢的SQL开刀。

复现慢查询

-- SQL执行时间:1.2秒
-- 扫描行数:664,832行
-- 表数据量:约66万行
SELECT * FROM orders 
WHERE status = 'paid' 
ORDER BY created_at DESC 
LIMIT 10;

查看执行计划

EXPLAIN SELECT * FROM orders 
WHERE status = 'paid' 
ORDER BY created_at DESC 
LIMIT 10\G

输出:

           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: idx_status
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 664832
     filtered: 30.25
        Extra: Using where; Using filesort
1 row in set, 1 warning (0.01 sec)

问题很明显:

  • type: ALL:全表扫描,66万行全过一遍。
  • Extra: Using filesort:文件排序,因为status索引只过滤了status,但ORDER BY的是另一个字段,导致MySQL要先把结果集加载到内存/磁盘临时表排序,再取前10行。

为什么走了索引还是慢?

orders表已经有了idx_status索引,但优化器算了一笔账:status='paid'这个条件能过滤约55%的行。对优化器来说,与其按索引逐行回表判断,不如直接全表扫描再排序来得快。

这里本质是status区分度太低。如果你用SHOW INDEX FROM orders看Cardinality,会发现idx_status的区分度只有2——就两个值:paid和unpaid。

创建复合索引

解决思路:让索引同时覆盖WHEREORDER BY两个条件。这样MySQL能在索引内部完成过滤和排序,不用filesort,也不用回表。

ALTER TABLE orders ADD INDEX idx_status_created_at (status, created_at DESC);

DESC关键字在MySQL 8.0支持,让索引按created_at降序存储,正好匹配ORDER BY created_at DESC,连反向扫描都省了。

验证效果

重建执行计划:

EXPLAIN SELECT * FROM orders 
WHERE status = 'paid' 
ORDER BY created_at DESC 
LIMIT 10\G

输出:

           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: idx_status_created_at
          key: idx_status_created_at
      key_len: 2
          ref: const
         rows: 10
     filtered: 100.00
        Extra: Backward index scan
1 row in set, 1 warning (0.01 sec)

rows: 10,直接从66万降到10。MySQL知道status过滤后还是有很多行,但索引已经排好序了,从头按顺序扫10条就够。

性能对比

在MySQL 8.0.35下,用BENCHMARKPROFILING对比优化前后:

-- 优化前
SET profiling = 1;
SELECT * FROM orders 
WHERE status = 'paid' 
ORDER BY created_at DESC 
LIMIT 10;
SHOW PROFILE FOR QUERY 1;

优化前Profile关键数据:

+----------------------+-----------+
| Status               | Duration  |
+----------------------+-----------+
| Sending data         | 0.892412  |
| Sorting result       | 0.312578  |
| statistics           | 0.082134  |
| ...                  | ...       |
| Total                | 1.284215  |

优化后:

SELECT * FROM orders 
WHERE status = 'paid' 
ORDER BY created_at DESC 
LIMIT 10;
SHOW PROFILE FOR QUERY 2;

优化后Profile关键数据:

+----------------------+-----------+
| Status               | Duration  |
+----------------------+-----------+
| Sending data         | 0.007812  |
| statistics           | 0.000412  |
| ...                  | ...       |
| Total                | 0.008312  |

单条SQL:1.284秒 → 0.008秒,提升约160倍。

接口整体耗时对比

订单导出接口原来调用10次这条SQL(在循环里查订单明细),加上其他逻辑:

指标优化前优化后提升
接口响应时间980ms37ms26.5倍
SQL总耗时12.8s80ms160倍
扫描行数664,8321066,483倍
sort缓冲使用32MB0--

压测数据

用Apache Bench压一下真实接口,ab -n 1000 -c 50,PHP 8.3 + Laravel 11环境:

# 优化前
ab -n 1000 -c 50 "https://api.example.com/orders/export?date=2024-03-18"

# 结果摘要:
# Requests per second: 24.18 [#/sec] (mean)
# Time per request: 2067.12 [ms] (mean)
# Percentage of requests served within a certain time (ms)
#   50% 1982
#   75% 2034
#   90% 2098
#   95% 2156
#   99% 2265
# 优化后
ab -n 1000 -c 50 "https://api.example.com/orders/export?date=2024-03-18"

# 结果摘要:
# Requests per second: 642.38 [#/sec] (mean)
# Time per request: 77.84 [ms] (mean)
# Percentage of requests served within a certain time (ms)
#   50% 35
#   75% 41
#   90% 55
#   95% 68
#   99% 96

QPS从24涨到642,TP99从2265ms降到96ms。

完整调优流程回顾

这套流程现在是我们团队的标准操作,跑一遍不超过30分钟:

# 1. 看慢查询日志里Top10 SQL
pt-query-digest /var/log/mysql/mysql-slow.log | head -80

# 2. 拿到慢SQL,先EXPLAIN看执行计划
mysql -uroot -p -e "EXPLAIN SELECT * FROM orders WHERE status='paid' ORDER BY created_at DESC LIMIT 10\G"

# 3. 确认索引情况
mysql -uroot -p -e "SHOW INDEX FROM orders;"

# 4. 加索引
mysql -uroot -p -e "ALTER TABLE orders ADD INDEX idx_status_created_at (status, created_at DESC);"

# 5. 再EXPLAIN确认走向新索引
mysql -uroot -p -e "EXPLAIN SELECT * FROM orders WHERE status='paid' ORDER BY created_at DESC LIMIT 10\G"

# 6. 压测确认接口耗时
ab -n 1000 -c 50 "https://api.example.com/orders/export?date=2024-03-18"

避坑指南

这几个月折腾慢查询日志,踩了不少坑,列几个最典型的:

坑1:log_queries_not_using_indexes一开,磁盘瞬间爆满

log_queries_not_using_indexes = ON配上long_query_time = 0.5,以为万无一失。结果第二天早上磁盘告警——2小时写了40GB日志。

原因:很多低区分度索引的查询(比如status字段只有两个值),优化器会全表扫,这些查询执行时间不长但被打进日志。当时有一个报表接口,每次跑批5万行全扫,直接刷爆。

解决办法:min_examined_row_limit = 100加上,扫描行数低于100的不记录。还有long_query_time先别设太低,从1秒开始。

坑2:mysqldumpslow的-t参数不是time

一开始用mysqldumpslow -t 10,以为取Top10耗时的SQL。实际效果:返回的是按次数排序的前10条,不是按时间排的。

看源码才知道,-t是"top n",只是取前n条,不指定-s时默认按count排序。正确姿势:

# 按总耗时排序取Top10
mysqldumpslow -s at -t 10 /var/log/mysql/mysql-slow.log

# 按平均耗时排序取Top10
mysqldumpslow -s ar -t 10 /var/log/mysql/mysql-slow.log

-s at是按平均查询时间排序,-s ar是按平均锁定时间排序。建议直接用pt-query-digest,它的排序逻辑更直观。

坑3:慢查询日志里的时间是完成时间,不是开始时间

有一次排查夜间慢SQL,日志里看到一条凌晨3点的慢查询。去看当时有没有Job在跑,发现没有。后来查binlog,发现那条SQL其实是晚上11点开始执行的,跑了4个小时才完成,写日志的时间是凌晨3点。

所以排查慢SQL时,要看Query_timeLock_time,结合binlog确认真实开始时间,别被日志的写入时间误导。

坑4:服务器时钟漂移导致日志时间对不上

K8s里pod和宿主机时间戳可能不一致。有一次从慢查询日志看到一个查询在12:00:00执行,但业务高峰期在11:58-11:59,数据库监控也显示11:59有IO波峰。

对不上,排查了半小时发现是时钟漂移——NTP没同步。mysqld_slow_query_log记录的是系统时间,而业务监控用的是另一台机器时间。上线前先检查所有数据库节点的时间同步。

坑5:本地开发环境配置了慢查询日志,把sleep也算进去了

本地MySQL设了long_query_time = 0.1,发现有条SQL执行了200ms:SELECT SLEEP(1)。这是同事手动跑测试的SQL,不是应用发的。

不解决也不影响,但每天看日志会麻痹。给本地和产线分开配置,产线严格0.5秒,本地随意。

坑6:小心索引失效的三种情况

加上复合索引后,开发同事随手一个WHERE DATE(created_at) = '2024-03-18'就把索引废了——函数包裹索引列会导致索引失效。还有WHERE status LIKE '%paid%'这种前缀模糊匹配,也走不了索引。

EXPLAIN里的type字段很直观:ALL是全表扫,ref是普通索引查询,const是主键等值查询。上线前拿生产真实SQL跑一遍EXPLAIN,看到ALL再改SQL。

坑7:pt-query-digest不会按天自动分割日志

慢查询日志文件一大了,pt-query-digest分析起来非常慢,一个2GB日志跑了20分钟没出结果。后来发现它可以把分析结果按天切割:

# 按天分割并分析
pt-query-digest --output slowlog /var/log/mysql/mysql-slow.log --since 24h > /tmp/slow-today.txt

但更推荐的做法:配合前面写的rotates脚本,每天归档一份,分析昨天的文件。

最后说一句

慢查询日志只是排查工具,真正解决问题的是理解索引和SQL的执行计划。每次看到慢SQL,别急着加索引,先EXPLAIN搞清楚它为什么慢——是全表扫、文件排序、还是临时表。搞清楚原因再动手,大部分慢查询一个复合索引就能解决。