PDO 连 MySQL 默认是模拟预处理:prepare 不报 1146,LIMIT '10' 却报 1064
两个现象放在一起看很别扭:
$pdo->prepare('SELECT * FROM no_such_table'); // 不抛异常
$stmt = $pdo->prepare('SELECT id FROM orders LIMIT ?');
$stmt->execute([10]); // SQLSTATE[42000] 1064
第一条要到 execute() 才报 1146 Table doesn't exist,第二条干脆在语法层被 MySQL 拒掉。两件事的根源是同一个:PDO_MYSQL 默认没有把 SQL 交给服务器预处理。
相关手册与源码:ref.pdo-mysql、PDO::prepare、PDO 常量、php-src。
默认到底是谁在预处理
ref.pdo-mysql 的原话是 "PDO_MYSQL uses emulated prepares by default."。也就是说 PDO::prepare() 默认不创建服务端 prepared statement:PDO 在客户端把占位符替换成实际值,execute() 时发一条普通 SQL 给 MySQL。
控制这个行为的是 PDO::ATTR_EMULATE_PREPARES。手册写明它只在 OCI、Firebird、MySQL 驱动上可用,PDO::MYSQL_ATTR_DIRECT_QUERY 是它的别名。设为 true 时 PDO 总是模拟;设为 false 时尝试走原生预处理——注意后半句:驱动不能成功 prepare 时,PDO 会回退到模拟。
四个可复现的行为差异
校验时点
模拟模式下 prepare() 不与服务器通信,手册原话:"Emulated prepared statements do not communicate with the database server so PDO::prepare() does not check the statement."。原生模式在 prepare 阶段就把语句发给 MySQL 解析,表不存在当场抛 1146。
拿 prepare() 当启动自检 SQL 的手段,在默认配置下是无效的。
LIMIT 与类型
execute([10]) 里的值默认按 PARAM_STR 绑定,拼出来是 LIMIT '10',MySQL 报 1064。必须显式声明整型:
$stmt = $pdo->prepare('SELECT id FROM orders WHERE status = ? ORDER BY id LIMIT ?');
$stmt->bindValue(1, 'paid', PDO::PARAM_STR);
$stmt->bindValue(2, 20, PDO::PARAM_INT);
$stmt->execute();
原生模式同样不接受被字符串化的整型,区别只是报错发生在哪一步。
同名命名参数
PDO::prepare 手册原文:"You cannot use a named parameter marker of the same name more than once in a prepared statement, unless emulation mode is on."。
WHERE a = :id OR b = :id 这种写法只在模拟模式下能跑。关掉 emulate 后 PDO 不会替你复制占位符,直接报 HY093(Invalid parameter number)。改成两个不同名字,或者用 ? 手工绑两次。
N 前缀
PDO::PARAM_STR_CHAR / PDO::PARAM_STR_NATL 控制绑定时是否加 N'' 前缀。手册 pdo.constants 说明:PHP 7.3 起只有 dblib 和 mysql 支持,且 "for the mysql driver, the flags only affect parameter binding when PDO::ATTR_EMULATE_PREPARES is true (the default)"。
关掉 emulate 后这段 N'' 逻辑不再生效。utf8 与 utf8mb4 混用的历史库,需要单独验证。
怎么确认线上走的是哪条路
查状态计数器最直接:
SHOW GLOBAL STATUS WHERE Variable_name IN
('Com_stmt_prepare','Com_stmt_execute','Com_stmt_close','Com_query');
模拟模式只让 Com_query 增长,Com_stmt_* 基本不动;原生模式三个都会涨。Com_stmt_prepare 与 Com_stmt_close 的差值就是当前挂着没释放的 stmt,长期增长说明 prepare 没有 close,或者连接寿命过长。进一步可以查 performance_schema.prepared_statements_instances。
SHOW SESSION STATUS 看当前连接,SHOW GLOBAL STATUS 看实例,别混。
这里还牵出一个配额问题:只有原生模式消耗服务端 stmt 配额(max_prepared_stmt_count,默认 16382,超出报 1461)。模拟模式走普通查询,不会出现 1461。
读配置时有一个坑:getAttribute(PDO::ATTR_EMULATE_PREPARES) 返回的是 int 1/0 而不是 bool,所以
if ($pdo->getAttribute(PDO::ATTR_EMULATE_PREPARES) !== false) { ... }
永远为真。要写成 != false 或 !== 0。这条来自 php.net 用户注解,建议在目标 PHP 版本上 var_dump 确认一次。
什么时候关,代价是什么
关闭的方式:
$pdo = new PDO('mysql:host=127.0.0.1;dbname=app;charset=utf8mb4', $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
Laravel 的 options 会直接传给 PDO 构造器:
'mysql' => [
'options' => [
PDO::ATTR_EMULATE_PREPARES => false,
],
],
模拟模式一次往返:值拼进 SQL 直接 query。适合 SQL 文本高频变化、短连接、临时查询多的场景。关掉之后每条新 SQL 多一次 prepare 往返,收益来自重复执行同一模板时省下的解析和计划生成。
所以「关掉 emulate 更快」不是通用结论,只在热点 SQL 模板固定时成立。如果 SQL 文本变化频繁——IN 列表长度变化、动态排序字段——命中率会很低,只剩多一次往返的成本。
PHP 8.4 与 8.5 的两个变化
PHP 8.4 起,PDO 用驱动侧 SQL 解析器定位占位符,字符串字面量和注释里的 ? 或 :name 不再被误认成占位符。8.3 及更早,SELECT '?' 或带 ? 的注释会干扰占位符计数,报参数个数不符。
PHP 8.5 起 PDO::MYSQL_ATTR_* 这组常量标记为 deprecated,改用 Pdo\Mysql 命名空间下的对应常量。PDO::MYSQL_ATTR_DIRECT_QUERY 与 PDO::ATTR_EMULATE_PREPARES 是同一件事的两个名字,很容易在两处配置里互相覆盖;而 PDO::ATTR_EMULATE_PREPARES 本身是 PDO 通用常量,不属于 Pdo\Mysql。
改不改默认值,取决于你的 SQL 模板复用率和连接生命周期——这两项没有统一答案。