多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PHP的PDO预处理语句与存储过程

PHP的PDO预处理语句与存储过程 前言预处理语句prepared statement是 PDO 最核心的一个能力。手册对它的定位是一份可以反复使用的、用参数定制的编译模板并列出两条好处解析只做一次。语句被 prepare 时数据库会分析、编译并优化执行计划之后用不同参数反复 execute 时这套分析编译过程不再重复。参数不需要引号。驱动会自动处理引号与转义。手册的原话是——如果应用完全使用预处理语句开发者可以确信不会发生 SQL 注入。第二句话里完全两个字是整个话题最容易出问题的地方后面会专门展开。与之配套的还有存储过程stored procedure。PDO 在这方面支持到什么程度取决于驱动输出参数、输入输出参数属于驱动支持才行的能力而且有一个硬性约束——你必须提前告诉 PDO 那个参数最大能有多长。本文按 PHP 8.1 讲清三件事两种占位符的用法与不能参数化的位置、存储过程三类参数的绑定写法、以及多结果集该怎么收尾。一、占位符两种写法与一条红线PDO 支持两种占位符风格写法绑定方式命名参数:name、:valuebindParam(:name, $v)或execute([:name $v])位置参数?bindParam(1, $v)从 1 开始计数或execute([$v1, $v2])两种方式都能防注入选哪个是口味问题。命名参数在参数多的时候更可读位置参数在动态拼装时更省事。?php // 适用于 PHP 8.0declare(strict_types1);// 命名参数反复插入先绑定后循环赋值$stmt $pdo-prepare(INSERT INTO registry (name, value) VALUES (:name, :value));$stmt-bindParam(:name, $name, PDO::PARAM_STR);$stmt-bindParam(:value, $value, PDO::PARAM_INT);$name one; $value 1; $stmt-execute();$name two; $value 2; $stmt-execute();注意这里的bindParam()是引用绑定$stmt-execute()读的是那一瞬间$name变量的值所以循环里反复改$name再执行是合法的。如果换成bindValue()值在绑定那一刻就被固定后面改变量不会影响已绑定的值。红线在这里占位符只能替代值不能替代标识符或语法结构。手册给了一个反面例子?php // 反例占位符写在字符串字面量里不会按预期工作$stmt $dbh-prepare(SELECT * FROM REGISTRY where name LIKE %?%);$stmt-execute([$_GET[name]]);这里的?被单引号包住对数据库来说它就是一个普通字符不是占位符。正确的写法是把通配符拼进参数值里?php // 正确写法通配符属于值的一部分$stmt $pdo-prepare(SELECT id, name FROM registry WHERE name LIKE ?);$stmt-execute([% . $keyword . %]);同理下面这些位置都不能参数化必须靠白名单校验表名、列名ORDER BY ?不会按列名排序只会按一个常量排序LIMIT ?在PDO::ATTR_EMULATE_PREPARES true时可能碰巧能用但原生预处理下 MySQL 不接受跨驱动不可移植SQL 关键字片段ASC/DESC、AND/OR。对这类位置标准做法是值不落进 SQL 字符串这条底线之上的第二层防御——把用户输入映射到固定选项?php // 适用于 PHP 8.0白名单式排序$allowSort [created_at, id, score];$sort in_array($_GET[sort] ?? , $allowSort, true) ? $_GET[sort] : id;$allowDir [asc, desc];$dir strtolower((string) ($_GET[dir] ?? desc));$dir in_array($dir, $allowDir, true) ? $dir : desc;// 到这里 $sort 和 $dir 一定来自常量集合不是用户原文$sql SELECT id, score FROM users ORDER BY {$sort} {$dir} LIMIT 20;$rows $pdo-query($sql)-fetchAll();in_array()的第三个参数true是必需的。不传它就会走松散比较历史上出过太多看起来校验了、其实被绕过的案例。二、存储过程三类参数与长度约束PDO 调用存储过程的方式和普通语句一样用prepare()execute()差别在参数绑定的类型上。手册把参数分成三类参数类型绑定常量典型用途输入参数PDO::PARAM_STR/PDO::PARAM_INT等给过程传值输出参数PDO::PARAM_STR等 长度取过程的返回值输入输出参数PDO::PARAM_STR按位或PDO::PARAM_INPUT_OUTPUT既传入又接收输出参数有一个硬约束绑定的时候必须给出长度。手册解释得很清楚——开发者需要知道给定参数可能有多大如果实际值比声明的长度大就会报错。?php // 适用于 PHP 8.0驱动需支持输出参数// 输出参数第 4 个参数 4000 是最大长度不能省$stmt $pdo-prepare(CALL sp_returns_string(?));$stmt-bindParam(1, $returnValue, PDO::PARAM_STR, 4000);$stmt-execute();echo 过程返回: {$returnValue}\n;// 输入输出参数用按位或把 INOUT 标志叠加到基础类型上$stmt $pdo-prepare(CALL sp_takes_string_returns_string(?));$value hello;$stmt-bindParam(1, $value, PDO::PARAM_STR | PDO::PARAM_INPUT_OUTPUT, 4000);$stmt-execute();echo 过程改写为: {$value}\n;PDO::PARAM_INPUT_OUTPUT的官方说明是它表示该参数是存储过程的 INOUT 参数必须用按位或与一个明确的PDO::PARAM_*数据类型组合使用。只写PDO::PARAM_INPUT_OUTPUT而不叠加基础类型是错的。两个变量都必须用bindParam()引用而不是bindValue()否则过程写回的值你拿不到。这里必须强调驱动支持才行。手册在讲输出参数时用的措辞是If the database driver supports it。输出参数属于驱动可选能力PDO_MySQL、PDO_SQLSRV 这类驱动支持得比较完整但并不是所有驱动都一样。落到项目里之前请在目标驱动上实测不要凭教程照抄。三、多结果集nextRowset()该收的尾存储过程经常不止返回一个结果集中间步骤可能先SELECT一下做日志最后再返回真正的主结果。这类情况用PDOStatement::nextRowset(): bool逐个推进。?php // 适用于 PHP 8.0$stmt $pdo-prepare(CALL sp_report(?));$stmt-execute([2026]);do {// rowCount() 对返回结果集的语句不一定有意义这里只做示例$rows $stmt-fetchAll(PDO::FETCH_ASSOC);if ($rows ! []) {print_r($rows);}} while ($stmt-nextRowset());nextRowset()返回bool没有下一个结果集时返回false循环自然结束。为什么必须把结果集读干净因为连接上残留着未消费的结果后续在同一条连接上发起的查询可能拿到错位的数据或直接报错。稳妥的做法是把nextRowset()循环到底或者在不关心后续结果时调用PDOStatement::closeCursor(): bool把游标释放掉。顺带说一个相关的排查技巧。PDOStatement::debugDumpParams(): ?bool可以把已绑定参数的当前状态打到输出里用来确认参数到底绑上没有、类型对不对在预处理行为不符合预期时非常省事。注意它会把参数值也打出来生产环境不要留着。四、EMULATE_PREPARES真预处理还是假预处理PDO::ATTR_EMULATE_PREPARES这个属性决定了预处理到底是数据库在做还是 PDO 在 PHP 侧模拟。取值行为结果trueMySQL 驱动默认PDO 在 PHP 侧把参数拼进 SQL再当普通语句发给服务器兼容性好参数类型可能被转成字符串false交给数据库做原生预处理类型更准确能拿到更真实的错误码LIMIT ?等场景行为一致?php // 适用于 PHP 8.0MySQL 场景推荐关闭模拟预处理$pdo new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION,PDO::ATTR_EMULATE_PREPARES false,]);手册在PDOStatement::bindParam()的说明里还提示过一点以PDO::PARAM_INT绑定的参数如果用bindValue()传入值会被转成字符串。这正是模拟预处理下常见的类型困惑来源之一。要传整数就用bindValue($n, $v, PDO::PARAM_INT)显式声明类型或者用原生预处理。另外手册在 LOB 相关内容里提到PDO::PARAM_STR_NATL与PDO::PARAM_STR_CHARPHP 7.2 起提供这两个标志时专门注明对 MySQL 驱动而言这两个标志只在PDO::ATTR_EMULATE_PREPARES为true时才影响参数绑定。这又一次说明这个属性会改变参数的处理路径改动它要整体评估。常见坑点以为用了预处理就绝对安全❌ 参数化查询 拼接未过滤的ORDER BY字段以为万事大吉 ✅ 手册原话是完全使用预处理才安全标识符位置用白名单校验不能参数化把占位符写进引号里❌prepare(SELECT * FROM t WHERE name LIKE %?%)✅prepare(SELECT * FROM t WHERE name LIKE ?)并在参数值里拼%位置参数从 0 开始编号❌$stmt-bindParam(0, $a);✅ PDO 的位置参数从1开始计数重复使用同一个命名占位符❌prepare(SELECT * FROM t WHERE a :x OR b :x)然后只绑一次 ✅ 该写法在原生预处理下不被支持改成:x1/:x2两个占位符输出参数忘了给长度❌$stmt-bindParam(1, $out, PDO::PARAM_STR);✅$stmt-bindParam(1, $out, PDO::PARAM_STR, 4000);实际值超过声明长度会报错PDO::PARAM_INPUT_OUTPUT单独使用❌bindParam(1, $v, PDO::PARAM_INPUT_OUTPUT)✅bindParam(1, $v, PDO::PARAM_STR | PDO::PARAM_INPUT_OUTPUT, 4000)必须按位或叠加基础类型用bindValue()接输出参数❌$stmt-bindValue(1, $out, PDO::PARAM_STR);✅ 输出/输入输出参数要用bindParam()靠引用把值带回来存储过程的多结果集不读干净❌ 只取第一个结果集就走后面的结果留在连接上 ✅do { ... } while ($stmt-nextRowset());或用closeCursor()释放总结项目说明两条好处解析编译只做一次参数无需手工加引号与转义占位符命名:name与位置?位置从 1 计数不可参数化表名、列名、LIMIT、排序方向等标识符与关键字参数绑定bindParam()为引用绑定bindValue()为值绑定输出参数必须声明最大长度且依赖驱动支持INOUT 参数PDO::PARAM_STR与PDO::PARAM_INPUT_OUTPUT按位或必须叠加基础类型多结果集PDOStatement::nextRowset(): bool逐个推进或closeCursor()释放原生 vs 模拟PDO::ATTR_EMULATE_PREPARESMySQL 下默认开启模拟预处理语句把值这条通道彻底封死了剩下能出问题的只有结构——表名、列名、排序方向、LIMIT 数量。把这部分交给白名单把值交给占位符SQL 注入就没有落脚点。存储过程那边则要记住两件事输出参数要声明长度、要用bindParam()引用绑定而且这些能力取决于驱动不能想当然。
返回列表