先说个自己的真实经历。有段时间线上接口隔三差五就出现一次“慢请求”,页面转圈好几秒才出来,查了Nginx日志、PHP-FPM状态,都没看出明显异常。后来我给所有DB和Redis操作加了自动耗时记录,一个下午就抓到了罪魁祸首:一条查订单明细的SQL,在数据量涨到百万级之后,走了全表扫描,耗时稳定在1.8秒左右。更坑的是,这条SQL藏在一个被频繁调用的公共方法里,平时数据量小根本发现不了。
后来我养成了习惯:只要经手的PHP项目,必须有一套“自动记录DB/Redis操作耗时”的机制。这里说的不是手动在每个查询前后加microtime,那是体力活,漏一个就白搭。我要做的是在原生PHP环境里,用AOP切面的思路,把耗时统计这件事从业务代码里彻底剥离出去。
今天就把这套方案完整拆开讲,包括原理、代码、坑点,以及在原生PHP里怎么实现“切面”这件事的思路。
1. 为什么是AOP,而不是手动埋点
1.1 AOP的核心思想拆解
AOP(Aspect Oriented Programming,面向切面编程)听起来很高大上,其实核心就一句话:把横跨多个业务模块的公共逻辑抽出来,在不修改原业务代码的前提下,统一织入到指定位置。
和它相对的是OOP(面向对象编程)。OOP解决的是“对象”维度的代码组织问题,比如User类、Order类、PaymentService类,每个类负责自己的职责。但有一类逻辑不属于任何一个业务类,却又出现在所有业务类里,例如:
- 操作耗时统计
- 日志记录
- 权限校验
- 事务管理
- 缓存刷新
这些逻辑横切在业务代码的各个层级,如果手动在每个方法里写一遍,代码会变得非常啰嗦,而且极难维护。比如你要给100个方法加耗时统计,手动改就是100处代码改动,遗漏一个就是监控盲区。用AOP来做,你只需要写一次切面逻辑,然后声明“哪些地方需要织入”,剩下的事交给切面引擎自动完成。
很多同学一听到AOP就想到Spring,想到Java里的@Component、@Aspect、@Around注解。其实AOP本身是方法论,不是某个框架的专利。PHP生态里也有AOP扩展(如PHP-AOP扩展),Laravel、ThinkPHP等框架也内置了类似机制。但问题是,很多老项目、外包项目、自研框架并没有引入这些依赖,强制引入反而增加维护成本。
所以我在这里讲的是“原生PHP”的实现方案:不依赖框架、不依赖扩展、不改变现有架构,只通过一层巧妙的代理或包装,把耗时统计这个横切逻辑织入到DB和Redis操作上。
1.2 原生PHP环境下的现实约束
先说清楚,什么是“原生PHP”。这里指的是:
- 没有使用Laravel、Symfony、ThinkPHP等现代框架
- 数据库操作多数直接使用PDO、mysqli,或者自己封装的DB类
- Redis操作直接使用phpredis扩展,可能自己包了一层RedisService
- 项目结构可能是传统MVC,甚至是面向过程的脚本
在这种项目里,你没法用框架自带的中间件、事件系统、容器AOP功能。但项目又确实需要监控,怎么办?
最直观的思路是“偷梁换柱”:不让业务代码直接使用PDO或Redis类,而是使用我们封装的代理类。业务代码对外接口和原来一模一样,内部却多了一层“切面逻辑”。一旦代理类被真正使用,所有DB/Redis操作都会自动经过耗时统计逻辑,这就是不侵入业务的AOP。
有一个点需要提醒:这个方案确实需要在现有代码里替换类的实例化方式,但替换完之后,调用方基本不用改。比如原来代码是 $pdo = new PDO(...),改成 $pdo = new MonitoredPDO(...),后面所有 $pdo->query()、$pdo->prepare() 调用的参数和返回值保持不变。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体方案设计与核心技术选型
2.1 拦截DB操作:代理PDO而不是改业务代码
我的第一版方案天真地想直接在业务代码里加函数:写一个 dbQuery($sql) 全局函数,里面先记时间再执行再记时间,然后把项目里所有 query、exec 调用全部替换成 dbQuery。改完发现两个问题:
- 项目里几百处调用,替换工作量巨大,而且总有漏网之鱼
- 大量代码用了
prepare+execute分开调用,单靠替换query函数覆盖不了
后来我意识到,更优雅的办法是做一个PDO代理类。PHP的PDO和PDOStatement都是可继承的类,我们可以继承它们,覆写耗时敏感的方法,在方法内部执行耗时统计。业务代码拿到的是代理类对象,但它完全不知道发生了什么。
核心逻辑分两层:
MonitoredPDO extends PDO,覆写query()、exec()、prepare()、beginTransaction()、commit()等MonitoredPDOStatement extends PDOStatement,覆写execute(),因为真实执行SQL的时机在execute()
这个方案的好处是:
- 业务代码改动极小,甚至不改
- 覆盖范围广,所有通过这个PDO连接执行的查询都在监控范围内
- 切面逻辑和业务逻辑物理隔离,一个文件搞定
2.2 Redis拦截:装饰器模式包装原生Redis
处理Redis时会遇到一个实际问题:phpredis的 Redis 类虽然历史版本允许继承,但Redis类内部魔法方法多、命令方法多,直接继承覆写所有方法不现实。比如 get、set、hGetAll、zAdd 方法几十上百个,每个都覆写一遍太痛苦。
更实际的方案是装饰器模式:重新写一个类,内部持有原生Redis对象,对外提供相同的命令方法,每个方法内部先计时再调用原生Redis,最后记录耗时。
php复制class MonitoredRedis
{
private Redis $redis;
private array $timer = [];
public function __construct(Redis $redis)
{
$this->redis = $redis;
}
public function get(string $key): mixed
{
$start = microtime(true);
$result = $this->redis->get($key);
$this->record('get', $key, $start);
return $result;
}
public function set(string $key, mixed $value, array $options = []): bool
{
$start = microtime(true);
$result = $this->redis->set($key, $value, $options);
$this->record('set', $key, $start);
return $result;
}
// 其他命令方法同理
}
这个方案的代价是需要手动编写常用命令的包装方法,但覆盖日常高频命令(get、set、hGet、hSet、del、expire、zAdd、lPush等)就够用。低频命令如果有遗漏,后续业务用到时再加,维护成本可控。
还有一种是“魔法方法兜底”:利用 __call 捕获所有未显式定义的方法调用,统一转发给原生Redis。这样即使有遗漏的Redis命令也会被监控到,而且不会报方法不存在。
php复制public function __call(string $name, array $arguments): mixed
{
$start = microtime(true);
$result = $this->redis->{$name}(...$arguments);
$key = $arguments[0] ?? '';
$this->record($name, $key, $start);
return $result;
}
我建议两种方式结合:高频命令显式包装,保证性能、方便读取参数;其余命令走 __call 兜底,防止遗漏。实际跑下来效果很好。
2.3 切面总体架构与开关设计
整个切面系统分四层:
- 代理层:MonitoredPDO、MonitoredPDOStatement、MonitoredRedis,负责拦截操作、计时、收集数据
- 采集层:把耗时数据格式化,判断是否超过慢查询阈值
- 存储层:将慢查询记录写入日志文件、日志表或外部日志系统
- 开关层:总开关和分级阈值配置,比如开发环境记录所有操作,生产环境只记录超过阈值的操作
分层设计的好处是解耦。比如存储层今天写文件,明天换Kafka或者ES,只改存储层实现,代理层和采集层完全不用动。
还需要考虑性能开销。如果每个Redis get都执行一次time计算、一次阈值判断、以及可能的日志写入,高频接口会明显变慢。这里我做了一个关键取舍:只有耗时超过阈值,才执行完整的日志记录;耗时正常的操作,只做一次时间差计算就够了。microtime(true)本身的性能损耗可以忽略,但如果要写日志、格式化参数、做debug_backtrace,开销就上来了,必须放在阈值判断之后。
3. 核心代码实现:DB切面、Redis切面与日志落地
3.1 实现MonitoredPDO:拦截query、exec、prepare+execute
先看代码。
php复制<?php
class MonitoredPDO extends PDO
{
private float $slowQueryThreshold = 0.5; // 秒,超过该值判定为慢查询
private bool $enabled = true;
public function __construct(
string $dsn,
?string $username = null,
?string $password = null,
?array $options = null
) {
parent::__construct($dsn, $username, $password, $options);
$this->setAttribute(PDO::ATTR_STATEMENT_CLASS, [MonitoredPDOStatement::class, [$this]]);
}
public function query(string $sql, ?int $fetchMode = null, mixed ...$fetchModeArgs): PDOStatement|false
{
if (!$this->enabled) {
return parent::query($sql, $fetchMode, ...$fetchModeArgs);
}
$start = microtime(true);
try {
return parent::query($sql, $fetchMode, ...$fetchModeArgs);
} finally {
$elapsed = microtime(true) - $start;
$this->report($sql, [], $elapsed);
}
}
public function exec(string $sql): int|false
{
if (!$this->enabled) {
return parent::exec($sql);
}
$start = microtime(true);
try {
return parent::exec($sql);
} finally {
$elapsed = microtime(true) - $start;
$this->report($sql, [], $elapsed);
}
}
public function prepare(string $query, array $options = []): PDOStatement|false
{
$stmt = parent::prepare($query, $options);
if ($stmt instanceof MonitoredPDOStatement) {
$stmt->setThreshold($this->slowQueryThreshold);
}
return $stmt;
}
private function report(string $sql, array $params, float $elapsed): void
{
if ($elapsed >= $this->slowQueryThreshold) {
SlowQueryLogger::log('mysql', $sql, $params, $elapsed);
}
}
}
细节分析:
setAttribute(PDO::ATTR_STATEMENT_CLASS, [MonitoredPDOStatement::class, [$this]])这行很重要。它告诉PDO,执行prepare()时返回的不是原生PDOStatement,而是我们自定义的MonitoredPDOStatement实例。这样execute()方法就会被我们的代理逻辑接管。PDO::query()在某些场景下也会有慢查询,比如直接执行原生SQL,也要覆写。- 使用
finally块确保即使SQL执行抛出异常,也能记录耗时。这对排查慢SQL + 异常很有价值。
3.2 实现MonitoredPDOStatement:真正的SQL执行点
php复制<?php
class MonitoredPDOStatement extends PDOStatement
{
private float $slowQueryThreshold = 0.5;
private array $boundParams = [];
protected function __construct()
{
// PDOStatement 的构造函数是 protected,这里不需要做什么
}
public function setThreshold(float $threshold): void
{
$this->slowQueryThreshold = $threshold;
}
public function bindValue(int|string $param, mixed $value, int $type = PDO::PARAM_STR): bool
{
$this->boundParams[$param] = $value;
return parent::bindValue($param, $value, $type);
}
public function execute(?array $params = null): bool
{
$start = microtime(true);
try {
return parent::execute($params);
} finally {
$elapsed = microtime(true) - $start;
$sql = $this->queryString;
$execParams = $params ?? $this->boundParams;
if ($elapsed >= $this->slowQueryThreshold) {
SlowQueryLogger::log('mysql', $sql, $execParams, $elapsed);
}
}
}
}
这里的核心在于:PDOStatement::execute() 才是SQL真正执行的位置。你在 prepare() 阶段拿到的是预编译语句,还没执行,无法统计耗时。所以耗时监控必须挂在 execute() 上。
同时我观察到很多慢查询定位困难的原因是SQL带参数但日志只有SQL模板。比如 SELECT * FROM orders WHERE user_id = ? 和实际参数 [100023] 分离,导致DBA没法直接拿去执行分析。所以在 bindValue 和 execute 中我都尝试收集参数,最终把 SQL模板 + 实际参数 一起记录到日志中。
3.3 实现MonitoredRedis:命令级耗时统计
php复制<?php
class MonitoredRedis
{
private Redis $redis;
private float $slowCommandThreshold = 0.2;
private bool $enabled = true;
// 高频方法显式写出,便于清晰记录key
public function get(string $key): mixed
{
$start = microtime(true);
try {
return $this->redis->get($key);
} finally {
$elapsed = microtime(true) - $start;
$this->report('GET', $key, [], $elapsed);
}
}
public function set(string $key, mixed $value, array $options = []): bool
{
$start = microtime(true);
try {
return $this->redis->set($key, $value, $options);
} finally {
$elapsed = microtime(true) - $start;
$this->report('SET', $key, [], $elapsed);
}
}
public function hGetAll(string $key): array
{
$start = microtime(true);
try {
return $this->redis->hGetAll($key);
} finally {
$elapsed = microtime(true) - $start;
$this->report('HGETALL', $key, [], $elapsed);
}
}
// 其他高频命令...
public function __call(string $name, array $arguments): mixed
{
$start = microtime(true);
try {
return $this->redis->{$name}(...$arguments);
} finally {
$elapsed = microtime(true) - $start;
$key = $arguments[0] ?? '';
$this->report(strtoupper($name), $key, $arguments, $elapsed);
}
}
private function report(string $command, string $key, array $args, float $elapsed): void
{
if ($this->enabled && $elapsed >= $this->slowCommandThreshold) {
SlowQueryLogger::log('redis', $command . ' ' . $key, $args, $elapsed);
}
}
}
代码思路很直白:每个命令执行前取 microtime(true),执行后计算差值,超过阈值就上报。__call 兜底方案保证了新增的Redis命令无需手动维护也能被监控到。
有一个必须注意的坑:Redis调用方需要访问原生Redis对象的 connect、auth、select 等连接方法,这些方法不应该记入慢命令。可以通过 $this->redis->isConnected() 判断,或者在 __call 里用命令白名单过滤。我建议单独封装一个工厂方法,负责创建 MonitoredRedis 实例并完成连接认证,连接过程不走监控逻辑。
3.4 慢查询日志存储与报警格式设计
慢查询日志直接进入业务日志显然不合理,因为日志系统通常会按天切割、容量有限。我单独定义了一个 SlowQueryLogger 类,输出成独立文件。
php复制<?php
class SlowQueryLogger
{
private static string $logDir = '/data/logs/slow-query/';
private static ?string $lastReport = null;
public static function log(string $type, string $statement, array $params, float $elapsed): void
{
$date = date('Y-m-d H:i:s');
$trace = self::extractCaller();
// 单条日志格式保持一行,方便grep
$line = sprintf(
"[%s] [%s] [%.3fs] %s | params=%s | caller=%s:%d\n",
$date,
$type,
$elapsed,
$statement,
json_encode($params, JSON_UNESCAPED_UNICODE),
$trace['file'],
$trace['line']
);
$file = self::$logDir . date('Y-m-d') . '_' . $type . '.log';
if (!is_dir(self::$logDir)) {
mkdir(self::$logDir, 0755, true);
}
file_put_contents($file, $line, FILE_APPEND);
}
private static function extractCaller(): array
{
// 默认记录log()方法的调用者,这里要往前推几层
$trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 6);
foreach ($trace as $frame) {
$file = $frame['file'] ?? '';
$line = $frame['line'] ?? 0;
// 跳过框架自身文件
if (strpos($file, 'Monitored') === false && strpos($file, 'SlowQueryLogger') === false) {
return ['file' => $file, 'line' => $line];
}
}
return ['file' => 'unknown', 'line' => 0];
}
}
日志格式是一行一条,不用多行JSON,方便用命令快速过滤:
bash复制grep '\[redis\]' /data/logs/slow-query/2025-06-01_redis.log | sort -t'[' -k3 -rn | head -20
加 caller=%s:%d 字段很有价值。这是定位慢查询的“最后一公里”,直接告诉你这条SQL是从哪个文件的哪一行发出的,不用再去全局搜索SQL字符串。
有一点现实中的教训:debug_backtrace 在高频调用下性能开销不小,所以我只在耗时超过阈值后才调用它。也就是说,正常操作的开销只是 microtime 加一次减法,阈值判断失败就直接返回,不会走到 debug_backtrace 和文件写入。
4. 慢查询的发现、定位与优化实战思路
4.1 从日志到SQL性能瓶颈的定位方法
日志拿到之后,第一件事是看耗时。0.5秒的阈值是我在普通业务系统里的常用值,但如果系统本身数据量大、硬件配置一般,可以把SQL阈值调到1秒。原则是:慢查询日志不能太吵,否则你根本不想看;也不能太少,否则覆盖不到问题。上线第一周可以先调低阈值,观察一整天的数据规模和分布,再逐步放宽。
拿到慢SQL后排查思路如下:
- 先在日志里找到耗时最高的SQL,把SQL模板和params拼成完整SQL
- 用
EXPLAIN这条SQL,重点看type列和key列。type为ALL说明全表扫描,为index说明索引全扫描,为range或ref说明走了索引,效果通常可以接受 - 如果发现没走索引,优先看
WHERE条件里的字段是否有索引,如果组合查询还要注意索引顺序是否符合最左前缀原则 - 如果走了索引还是慢,看是不是数据量大导致回表多,可以尝试覆盖索引或改写SQL
- 还有一种常见情况:SQL本身不慢,但在高并发下被阻塞,实际耗时飙高。需要配合数据库的
SHOW PROCESSLIST或者information_schema.innodb_trx来排查锁等待
实际项目里我遇到最多的是两种情况:一是业务前期数据量小,建表时没建索引,数据量大了之后查询慢;二是ORM或老代码里用了 SELECT *,返回了大量不需要的字段,导致IO开销大。这两种问题在慢查询日志里都能清楚看到。
4.2 从Redis慢命令到热点Key的定位
Redis慢命令和DB慢查询不同,Redis本身是单线程的,一个慢命令会阻塞后面所有命令,危害比慢SQL更大。常见的Redis慢命令:
KEYS *,全量扫描键名,绝对禁止在生产使用,应该用SCAN替代HGETALL一个大hash,如果hash里有几万条字段,耗时可能几十毫秒ZRANGE一个超大有序集合,返回所有成员SORT排序一个很大的list或setDEL删除一个大key(如一个包含大量元素的set/list/zset),在新版本Redis中UNLINK是异步删除,会更友好
慢Redis命令日志还有一个独特价值:发现热点Key。如果某个key频繁出现且每次耗时都不低,说明这个key是热点,比如缓存中一个超大的配置项。定位到热点Key之后,优化手段包括:
- 把大key拆分为多个小key,比如Hash用字段拆分,分散热点
- 为热点数据加短期本地缓存,比如进程内缓存,防止大量请求直接穿透到Redis
- 对读多写少的数据,用多级缓存:本地缓存 + Redis + DB
4.3 定期统计慢查询趋势,提前预防
慢查询日志不能只是出了故障才看,更推荐做一个简单的统计任务,每天跑一次,输出前N条耗时最长的SQL/Redis命令、命中次数、平均耗时。我通常用crontab加一个PHP脚本,解析当天的日志文件,生成报表格式的输出。
bash复制0 2 * * * /usr/bin/php /data/script/analyze_slow_query.php >> /data/logs/slow-query/analysis.log 2>&1
统计脚本里关键指标是:
- Top 10耗时SQL(去重SQL模板,聚合最大耗时和总次数)
- 按小时分布的慢查询次数,定位业务高峰
- 首次出现的新慢SQL,识别隐患
这个分析脚本写起来不复杂,但是对预防性维护特别有用。我就是靠它提前发现了某一个统计接口的SQL在数据量增长后开始变慢,在用户投诉之前就优化掉了。
5. 我的坑点与建议
5.1 常见问题速查表
把之前拆解过程中遇到的各种问题整理成表格,方便查阅。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| MonitoredPDO::prepare返回的是原生PDOStatement | 构造函数里没有设置ATTR_STATEMENT_CLASS | 在构造函数里加上setAttribute(PDO::ATTR_STATEMENT_CLASS, [MonitoredPDOStatement::class, [$this]]) |
| Redis命令完全没记录 | 业务代码里使用的是原生Redis类而非MonitoredRedis | 修改连接工厂,统一返回MonitoredRedis实例 |
| 日志文件为空 | 阈值设置过高,所有操作都未超过阈值 | 临时调低阈值观察;或输出一条“示例”日志验证链路 |
| 日志文件无限膨胀 | 某个接口存在大量慢查询 | 设置日志文件自动切割(按大小切割或按天切割),并对高IOPS的日志key做降级采样 |
| debug_backtrace开销过大 | 慢查询太多,每次都做完整调用栈分析 | 将debug_backtrace放入阈值判断之后,并在高频慢接口上跳过堆栈记录 |
| 慢SQL日志里有大量重复SQL | 某个公共方法的SQL被循环调用 | 在采集层增加“同一SQL模板每分钟最多记录N次”的限制 |
5.2 上线这套监控的注意事项
第一,生产环境上线前,先在测试环境完整跑一遍,确认日志格式正确、文件路径可写、阈值配置符合预期。我见过很多次“日志文件为空”不是因为没抓到慢查询,而是因为目录没有写权限,PHP进程静默失败。
第二,连接层替换要彻底。项目里如果有多个地方直接new PDO或者new Redis,需要统一改造到工厂方法。我建议做一个简单的Container或ServiceProvider,统一管理连接对象的构造。
第三,要注意事务场景。如果一个长事务里包含多条SQL,整体耗时可能在汇总时被放大。我的做法是PDO层单独记录每条SQL的耗时,同时事务的beginTransaction/commit也记录下来,这样既能定位慢SQL,也能发现长事务问题。
5.3 这套方案后续还能怎么扩展
目前这套切面系统解决了“慢查询发现”问题,但还有两个改进方向:
- 接入告警通知。当慢查询数量在短时间内突增,或者某条SQL首次超过阈值,可以推送通知到企业微信/钉钉/邮件。这需要在采集层增加一个简单的实时统计器,看每分钟慢查询数量是否超过基线值。
- 接入全量链路追踪。如果项目里已经用了请求ID(request_id),可以在切面日志里一并记录request_id,这样多个慢查询就能串联到同一个用户请求上,回放慢请求时能看出是先慢在Redis还是先慢在MySQL。
我在实际项目中的体会是,不要为了AOP而AOP。原生PHP里做AOP,最难的不是技术,而是取舍:哪些逻辑值得切出来,哪些逻辑放业务里更合适。耗时统计是AOP的典型场景,因为它横切面广、逻辑独立、讲究零侵入。当这套系统上线后,遇到慢请求你不再需要猜,翻日志就行,排查效率提升非常明显。
最后再分享一个小技巧:慢查询日志中记录的SQL参数,如果包含敏感信息(如手机号、身份证号),落地前要做脱敏处理。日志文件权限也要严格设置,最好只允许应用账号和运维账号读取,防止数据泄露。
