PDO::prepare()和execute()不能分开计时,因prepare可能触发服务端编译或无实际耗时,而execute才包含完整执行周期,且事务中语句复用计划导致时间不可比。

直接在 PDO 封装类里记录执行时间,是最轻量、最可控的慢 SQL 监控方式,但必须绕过 prepare/execute 分离导致的时间断点问题,且不能依赖异常捕获来兜底。
为什么 PDO::prepare() 和 execute() 不能分开计时
很多开发者习惯在 prepare() 前记一次时间、execute() 后再记一次,误以为差值就是 SQL 执行耗时。实际上:
– prepare() 在 MySQL 中可能触发预处理语句注册(尤其启用了 ATTR_EMULATE_PREPARES = false),这部分耗时属于服务端编译开销,和后续执行无关;
– 如果用了模拟预处理(ATTR_EMULATE_PREPARES = true),prepare() 几乎不耗时,真正执行压在 execute(),但此时你漏掉了网络往返和参数绑定开销;
– 更关键的是:事务中多条语句共用一个 PDOStatement,execute() 调用可能复用已编译计划,时间不可比。
正确做法是只对 execute() 全流程计时,并确保它包含「发送请求 + 等待响应 + 取回结果集首行」的完整周期。
- 在封装类的
execute()方法入口处调用microtime(true) - 执行原生
$stmt->execute($params) - 紧接着调用
$stmt->fetch()或$stmt->columnCount()(哪怕只取一行或检查列数),避免懒加载延迟暴露耗时 - 再记一次时间,差值才是真实执行窗口
PDOStatement 子类化比装饰器更可靠
用装饰器包装 PDOStatement 对象看似灵活,但在实际项目中容易被框架(如 Laravel 的 DB Facade、ThinkPHP 的 Query)绕过或替换,导致监控失效。子类化则能强制所有语句实例走同一路径。
示例:
class TrackedPDOStatement extends PDOStatement
{
private $sql;
private $startTime;
<pre class="brush:php;toolbar:false;">protected function __construct($sql) {
$this->sql = $sql;
$this->startTime = microtime(true);
}
public function execute($params = null): bool
{
$result = parent::execute($params);
$duration = microtime(true) - $this->startTime;
if ($duration >= config('database.slow_sql_threshold', 500) / 1000) {
$this->logSlowQuery($duration, $params);
}
return $result;
}
private function logSlowQuery($duration, $params) {
$log = sprintf(
"[%s] %.3fs | %s | params: %s\n",
date('Y-m-d H:i:s'),
$duration,
$this->sql,
json_encode($params)
);
error_log($log, 3, runtime_path() . 'log/slow_pdo.log');
}}
注意:
– 必须重写 execute(),不能只重写 __construct() 或 bindXXX();
– config() 需从项目配置读取阈值,别硬编码;
– error_log(..., 3, ...) 是 PHP 原生异步写入,比 file_put_contents(..., FILE_APPEND) 更安全,但仍需确保目录可写且磁盘不爆满。
事务内多语句的耗时归属要手动对齐
PDO::beginTransaction() 到 commit() 之间,每条 execute() 的耗时是独立的,但人肉排查时容易混淆「哪条语句拖慢了整个事务」。这时不能只看单条是否超阈值,而要叠加统计。
- 在事务开始时,用静态变量或上下文容器记录
microtime(true) - 每次
execute()结束后,把当前耗时累加进该事务 ID 的计数器 - 到
commit()时,如果总耗时超标,再遍历本次事务所有语句日志,标出贡献最大的几条 - 避免在
rollback()后丢弃日志——失败事务往往更值得分析
这个逻辑没法靠 PDO 自动完成,必须在你封装的 PDO 类(不是 PDOStatement)里补全。
生产环境必须关掉 ATTR_EMULATE_PREPARES
开启模拟预处理(PDO::ATTR_EMULATE_PREPARES = true)会让 prepare() 返回假句柄,所有 SQL 拼接发生在 PHP 层,execute() 实际发的是完整字符串。这会导致:
– 日志里的 $sql 是拼接后的最终语句,无法还原原始占位符结构;
– 参数绑定信息丢失,$params 日志变成空或错位;
– 更严重的是:同一条 SQL 因参数不同生成无数变体,索引优化和慢日志聚合完全失效。
务必在 PDO 构造时显式关闭:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
MySQL 5.1+ 默认支持原生预处理,只要驱动版本够新(php-pdo-mysql ≥ 7.4),就没兼容性问题。这是慢 SQL 可分析的前提。
真正难的不是记录时间,而是让每条日志都带上下文:请求 URL、用户 ID、事务 ID、是否在 CLI 模式下运行。这些字段漏掉任意一个,排查时就得来回翻 N 个日志文件。
php免费学习视频:立即使用
踏上前端学习之旅,开启通往精通之路!从前端基础到项目实战,循序渐进,一步一个脚印,迈向巅峰!











