PHP数据对象PDO操作技巧小结 前言PDOPHP Data ObjectsPHP 数据对象是 PHP 访问数据库的统一抽象层。它的价值不在于能连数据库——mysqli也能——而在于同一套 API 可以换驱动并且它从设计上就鼓励预处理语句。但 PDO 有几个默认行为是反直觉的不弄清楚就会写出能跑但很危险的代码第一PDO 默认的错误模式是静默。PDO::ATTR_ERRMODE的默认值是PDO::ERRMODE_SILENT这意味着 SQL 写错、表不存在、字段名打错时PDO不会抛异常、也不会警告只是让execute()返回false。如果你不检查返回值程序会带着错误继续往下跑最后在一个完全无关的地方崩溃。这是 PDO 最常见的坑必须显式把错误模式设成PDO::ERRMODE_EXCEPTION。第二prepare()未必是真的预处理。MySQL 驱动的PDO::ATTR_EMULATE_PREPARES默认是true此时预处理是 PDO 在客户端用字符串拼接和转义模拟出来的SQL 并没有以二进制协议发给 MySQL 服务端。这不代表它不安全PDO 的转义在字符集正确时是可靠的但它不等于服务端预处理且带来一堆类型和字符集上的差异。第三防注入只能靠参数化查询 绑定参数不能靠字符串拼接加转义。本文只讲防御侧写法不涉及任何注入构造过程。一、连接与错误模式?php// 适用于 PHP 8.0PDO 自 PHP 5.1 起内置$dsn mysql:host127.0.0.1;port3306;dbnamedemo;charsetutf8mb4;$options [// 关键一出错抛 PDOException而不是静默返回 falsePDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION,// 默认取回关联数组PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC,// 关键二关闭客户端模拟交给服务端做真正的预处理PDO::ATTR_EMULATE_PREPARES false,// 让整型/浮点列按原生类型返回而不是一律返回字符串PDO::ATTR_STRINGIFY_FETCHES false,];try {$pdo new PDO($dsn, app_user, app_password, $options);} catch (PDOException $e) {// 生产环境不要把 getMessage() 回显给用户——那会泄漏连接信息与库名error_log(数据库连接失败: . $e-getMessage());exit(服务暂时不可用);}关于 DSN 有三个必须记住的细节charsetutf8mb4一定要写在 DSN 里。不带这个参数时连接字符集取决于服务端配置可能与 PDO 用于转义的字符集不一致在模拟预处理模式下这种不一致是有实际风险的。写死utf8mb4是最省心的做法。host127.0.0.1和hostlocalhost不是一回事。前者走 TCP后者在 MySQL 客户端库里常被解析为 Unix socketWindows 上则是命名管道。排查连接问题时这两种写法要分别试。port写错或服务端不监听时异常信息里会有明确提示这就是把ERRMODE设成EXCEPTION带来的第一个好处。连接建立后还可以按需设置?php// 适用于 PHP 5.1$pdo-setAttribute(PDO::ATTR_TIMEOUT, 5); // 连接/读超时秒驱动相关$pdo-setAttribute(PDO::ATTR_EMULATE_PREPARES, false);$pdo-exec(SET SESSION time_zone 08:00); // 会话级设置PDO::ATTR_TIMEOUT的具体行为由驱动决定不要把它当成跨驱动的精确超时保证。二、预处理与绑定参数防注入的唯一正解核心思路是SQL 语句的结构和数据分开送。先送带占位符的语句骨架再送参数值。数据库永远知道哪些部分是 SQL、哪些部分是数据因此数据里包含什么都无法改变语句结构。?php// 适用于 PHP 8.0// 方式一位置占位符?按出现顺序绑定$stmt $pdo-prepare(SELECT id, name, email FROM users WHERE status ? AND age ?);$stmt-execute([1, 18]);$rows $stmt-fetchAll();// 方式二命名占位符:name可读性更好也不怕顺序写错$email userexample.com;$stmt $pdo-prepare(SELECT id, name FROM users WHERE email :email);$stmt-execute([:email $email]);$user $stmt-fetch();execute()的数组参数非常方便但有两个限制要知道所有参数都会被当作字符串发送除非用bindValue()显式指定类型。大多数情况下 MySQL 会做隐式转换但在LIMIT、OFFSET这类位置会出问题。在关闭模拟预处理服务端真预处理时同一个命名占位符不能在一个语句里出现两次。这是 PDO 手册明确写出的限制模拟模式下则可以重复使用。需要复用同一个值时用两个不同的占位符或者打开模拟模式。需要精确控制类型时用bindValue()或bindParam()?php// 适用于 PHP 5.1// bindValue绑定的是值立即求值$stmt $pdo-prepare(INSERT INTO users (name, age) VALUES (:name, :age));$stmt-bindValue(:name, $name, PDO::PARAM_STR);$stmt-bindValue(:age, $age, PDO::PARAM_INT);$stmt-execute();// bindParam绑定的是变量引用execute() 时才去读变量的当前值$stmt $pdo-prepare(INSERT INTO users (name, age) VALUES (:name, :age));$stmt-bindParam(:name, $name, PDO::PARAM_STR);$stmt-bindParam(:age, $age, PDO::PARAM_INT);$name Ada;$age 36;$stmt-execute(); // 这一刻才去读取 $name / $agebindParam是引用绑定这是它与bindValue最本质的区别。它有两个后果一是在循环里可以反复改同一个变量再execute()做到一条语句执行多次效率更高二是如果变量在execute()之前被改掉绑定进去的就是新值——不小心就会写错。NULL的处理也值得单说bindValue(:x, null)默认会绑定成PDO::PARAM_STR得到的是空字符串而不是NULL。要真正插入 SQLNULL需要PDO::PARAM_NULL?php// 适用于 PHP 5.1$stmt-bindValue(:note, $note, $note null ? PDO::PARAM_NULL : PDO::PARAM_STR);分页是另一个经典问题。在服务端预处理模式下LIMIT :limit必须绑定为PDO::PARAM_INT绑成字符串会导致语法错误?php// 适用于 PHP 8.0$page max(1, (int) ($_GET[page] ?? 1));$size 20;$offset ($page - 1) * $size;$stmt $pdo-prepare(SELECT id, title FROM posts ORDER BY id DESC LIMIT :limit OFFSET :offset);$stmt-bindValue(:limit, $size, PDO::PARAM_INT);$stmt-bindValue(:offset, $offset, PDO::PARAM_INT);$stmt-execute();如果驱动对这个写法仍然不配合退路是在 PHP 里用(int)强制转换后再拼进 SQL。整数强转之后的字符串是不可能携带注入内容的这是唯一可以接受字符串拼接的位置。三、模拟预处理与持久连接PDO::ATTR_EMULATE_PREPARES是最容易被误解的选项。把两种模式摊开对比对比项模拟预处理默认true服务端预处理false预处理在哪里做PDO 在客户端拼字符串 转义MySQL 用二进制协议真正 prepareSQL 语句何时到达服务端每次execute()都发完整语句prepare 时发一次execute 只发参数命名占位符可否重复可以不可以返回值的类型容易被统一成字符串更接近列的真实类型LIMIT ?绑定字符串也能用必须绑PDO::PARAM_INT安全前提连接字符集必须与转义字符集一致语句与数据天然分离注意安全前提那一行模拟模式本身不是不安全的PDO 会根据连接的字符集做转义。真正的风险来自字符集不一致——这就是为什么 DSN 里必须显式写charsetutf8mb4。关闭模拟还有一个实际收益一条语句只发送一次。在循环里反复execute()上千次时服务端预处理省掉的网络传输是可观的这里只讲机制不给具体数字——不同驱动、不同网络下的差异很大你可以自己压测对比。PDO::ATTR_PERSISTENT控制的是持久连接长连接?php// 适用于 PHP 5.1$pdo new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION,PDO::ATTR_PERSISTENT true, // 请求结束时不断开留给下一个请求复用PDO::ATTR_EMULATE_PREPARES false,]);它不是连接池只是这个 PHP 进程不关闭这条连接下次复用。以下几点必须清楚复用范围是同进程。PHP-FPM 下每个 worker 进程各自持有一条进程数就是连接数上限。会话状态会跨请求残留。未提交的事务、没用完的临时表、SET SESSION改过的变量、还没释放的行锁都可能被下一个请求继承。如果一次请求里开了事务又异常退出下一个请求可能带着一个开着的事务跑。用完必须显式收尾提交或回滚事务、closeCursor()释放结果集、恢复会话变量。不适合需要严格会话隔离的场景比如依赖LAST_INSERT_ID()、临时表、会话变量的业务。如果你的框架没有统一管理连接用持久连接前请先确认异常路径下一定会回滚事务否则这个选项弊大于利。四、事务、批量操作与几个常用技巧事务是保证多条语句要么全成功、要么全失败的手段?php// 适用于 PHP 8.0$pdo-beginTransaction();try {$pdo-exec(UPDATE accounts SET balance balance - 100 WHERE id 1);$pdo-exec(UPDATE accounts SET balance balance 100 WHERE id 2);$pdo-commit();} catch (Throwable $e) {// inTransaction() 判断是必要的DDL 语句会隐式提交导致这里回滚失败if ($pdo-inTransaction()) {$pdo-rollBack();}throw $e;}注意 MySQL 里CREATE TABLE这类 DDL 会隐式提交当前事务。在事务中混入 DDLcommit()/rollBack()的行为会变得难以预测——不要在事务里做结构变更。批量插入的正确做法是一次 prepare 事务里循环 execute?php// 适用于 PHP 5.1$pdo-beginTransaction();$stmt $pdo-prepare(INSERT INTO logs (level, message, created_at) VALUES (?, ?, ?));foreach ($records as $r) {$stmt-execute([$r[level], $r[message], $r[time]]);}$pdo-commit();prepare()只做一次execute()复用同一份预处理语句。比起在循环里$pdo-exec()拼 SQL这既快省去反复解析又安全。其他几个高频 API$pdo-lastInsertId()返回最后插入行的自增 ID。注意它在事务未提交时也有效但如果一次execute()插入了多行如INSERT ... VALUES (...), (...)返回值通常只是第一行的 ID。$stmt-rowCount()返回受影响行数。对UPDATE/DELETE可靠对SELECT手册明确说明并非所有数据库都保证返回结果集行数——不要把它当作跨驱动可用的结果条数需要计数就用SELECT COUNT(*)。$stmt-closeCursor()释放结果集让连接可以执行下一条语句。在使用不缓冲查询unbuffered query时不调用它会直接报命令不同步。$pdo-quote($string)给字符串加引号并转义。它不能用于表名、列名也不处理数值类型。表名/列名如果必须动态拼接唯一安全的做法是白名单?php// 适用于 PHP 5.1$allowed [id, title, created_at];$orderBy $_GET[sort] ?? id;if (!in_array($orderBy, $allowed, true)) { // 注意第三个参数 true严格比较$orderBy id;}$sql SELECT id, title FROM posts ORDER BY {$orderBy} DESC;常见坑点❌ 用默认配置new PDO($dsn, $user, $pass)就开始写业务代码✅ 必须显式设置PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION。默认的ERRMODE_SILENT会让 SQL 错误悄无声息地过去只在execute()的返回值里留下一个false。❌$pdo-query(SELECT * FROM users WHERE name $name)✅ 用prepare() 占位符 execute()。拼接字符串是注入的根源参数化查询让数据库自己区分语句与数据。❌ 用$pdo-quote()或addslashes()替代参数化查询✅ 参数化查询是唯一可靠的手段。quote()只处理字符串字面量不处理标识符也不处理类型转义函数在任何字符集变化下都可能失效。❌ 把表名、列名也用占位符绑定prepare(SELECT * FROM ?)✅ 占位符只能出现在值的位置。表名/列名无法参数化动态场景必须用白名单映射in_array($col, $allowed, true)。❌ 认为ATTR_EMULATE_PREPARES默认是false✅ MySQL 驱动的默认值是true也就是客户端模拟。要服务端预处理必须显式设false并接受命名占位符不能重复出现等限制。❌ 在foreach里用bindParam却每次都用同一个变量名且忘了execute()✅bindParam绑定的是引用。要么在循环体中先execute()再改值要么改用bindValue()消除引用语义。❌ 开了PDO::ATTR_PERSISTENT就不管事务✅ 持久连接会被同进程的下一个请求复用未回滚的事务、会话变量、临时表都会残留。异常路径必须inTransaction()检查后回滚。❌ 用rowCount()统计SELECT的结果条数✅ 手册明确说明并非所有驱动都保证这个行为。要计数就用SELECT COUNT(*)要取行就用fetchAll()后count()。总结要点正确做法错误模式显式设PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION防注入prepare() 占位符 execute()绝不拼接用户输入标识符表名/列名不能参数化必须白名单in_array(..., true)DSN 字符集显式写charsetutf8mb4预处理模式ATTR_EMULATE_PREPARES默认true客户端模拟设false才是服务端预处理绑定类型数值位置LIMIT/OFFSET用PDO::PARAM_INTnull用PDO::PARAM_NULL绑定方式bindValue值传递bindParam引用传递execute()时才求值事务beginTransaction()try/catchinTransaction()判断后rollBack()持久连接复用范围为同进程必须处理会话状态残留计数用SELECT COUNT(*)不要依赖SELECT的rowCount()结论PDO 的正确用法可以浓缩成三句话——错误模式设成异常、所有数据走预处理绑定、所有标识符走白名单。这三条做到绝大多数数据层的安全和稳定性问题都被挡在门外了。剩下的EMULATE_PREPARES、PERSISTENT这些选项属于知道它在做什么再开的调优项而不是可以照抄的默认配置。