PHP PDO 与预处理语句
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
拼 SQL 字符串把用户输入塞进查询,是 PHP 项目最常见的漏洞来源。PDO 的预处理语句把「语句」和「数据」分开传,从机制上堵住注入。
环境准备
示例用 PHP 8.4 + PDO。MySQL 需 pdo_mysql 扩展,SQLite 需 pdo_sqlite:
php -m | findstr pdo
示例默认用 SQLite(文件即数据库),换 MySQL 只改 DSN。
建立连接
<?php
declare(strict_types=1);
$dsn = 'sqlite:' . __DIR__ . '/app.db';
$pdo = new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 出错抛异常
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 默认关联数组
PDO::ATTR_EMULATE_PREPARES => false, // 用数据库原生预处理
]);
MySQL 的 DSN 形式:
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=app;charset=utf8mb4';
$pdo = new PDO($dsn, $user, $password, $options);
三个选项都别省:
- 异常模式:默认是静默失败,
execute()返回false而你毫无察觉。 - 默认取值模式:避免每处都写
fetch(PDO::FETCH_ASSOC)。 - 关闭模拟预处理:模拟模式下驱动自己拼接 SQL,类型与转义行为由驱动决定,边界情况更容易出问题。
建表与插入
$pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
created TEXT NOT NULL
)
SQL);
$stmt = $pdo->prepare(
'INSERT INTO posts (title, body, status, created) VALUES (:title, :body, :status, :created)'
);
$stmt->execute([
':title' => 'PDO 入门',
':body' => '正文内容',
':status' => 'published',
':created' => date('c'),
]);
$id = (int) $pdo->lastInsertId();
命名参数可读性好,同名参数在关闭模拟预处理时不能重复使用(这是常见坑,见下文)。
查询
// 多行
$stmt = $pdo->prepare('SELECT id, title, status FROM posts WHERE status = :status ORDER BY id DESC');
$stmt->execute([':status' => 'published']);
$posts = $stmt->fetchAll();
// 单行或 false
$stmt = $pdo->prepare('SELECT * FROM posts WHERE id = :id');
$stmt->execute([':id' => $id]);
$post = $stmt->fetch();
if ($post === false) {
http_response_code(404);
exit('文章不存在');
}
// 单值
$stmt = $pdo->prepare('SELECT COUNT(*) FROM posts WHERE status = :status');
$stmt->execute([':status' => 'published']);
$count = (int) $stmt->fetchColumn();
fetch() 没数据返回 false,不是 null。用 if (!$post) 判断可以,但别把 0、空字符串混进来——fetch() 返回的一定是数组或 false。
位置参数
$stmt = $pdo->prepare('SELECT * FROM posts WHERE status = ? AND id > ?');
$stmt->execute(['published', 100]);
位置参数按顺序绑定,条件多了容易错位。一个查询里只用一种占位符,混用会出错。
绑定类型
execute() 传数组时,所有值按字符串处理。需要严格类型时用 bindValue:
$stmt = $pdo->prepare('SELECT * FROM posts WHERE id = :id AND created > :since');
$stmt->bindValue(':id', $id, PDO::PARAM_INT);
$stmt->bindValue(':since', $since, PDO::PARAM_STR);
$stmt->execute();
LIMIT / OFFSET 在部分驱动下必须按整数绑定,否则报语法错误——这是分页最常见的坑:
$stmt = $pdo->prepare('SELECT * FROM posts ORDER BY id LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
事务
try {
$pdo->beginTransaction();
$pdo->prepare('UPDATE accounts SET balance = balance - :amt WHERE id = :id')
->execute([':amt' => 100, ':id' => 1]);
$pdo->prepare('UPDATE accounts SET balance = balance + :amt WHERE id = :id')
->execute([':amt' => 100, ':id' => 2]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
现实里要检查「扣款影响行数是否为 1」,否则可能出现余额不足却成功的情况:
$stmt = $pdo->prepare('UPDATE accounts SET balance = balance - :amt WHERE id = :id AND balance >= :amt');
$stmt->execute([':amt' => 100, ':id' => 1]);
if ($stmt->rowCount() !== 1) {
throw new RuntimeException('余额不足');
}
rowCount() 对 UPDATE 返回受影响行数(MySQL 默认返回实际改变的行数,值没变就是 0)。
预处理语句的边界
占位符只能替代值,不能替代表名、列名、关键字:
// 错:会当成字符串 "created_at"
$sql = 'SELECT * FROM posts ORDER BY :column';
排序字段必须白名单:
$sortable = ['id', 'created', 'title'];
$sort = in_array($_GET['sort'] ?? 'id', $sortable, true) ? $_GET['sort'] : 'id';
$dir = ($_GET['dir'] ?? 'asc') === 'desc' ? 'DESC' : 'ASC';
$sql = "SELECT * FROM posts ORDER BY {$sort} {$dir} LIMIT 20";
$pdo->query($sql)->fetchAll();
LIKE 要自己加通配符,且转义用户输入里的 % 和 _:
$keyword = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $q);
$stmt = $pdo->prepare("SELECT * FROM posts WHERE title LIKE :kw ESCAPE '\\'");
$stmt->execute([':kw' => "%{$keyword}%"]);
IN 需要按元素个数动态生成占位符:
$ids = [1, 2, 3];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM posts WHERE id IN ($placeholders)");
$stmt->execute($ids);
常见错误与注意事项
1. 用字符串拼接 SQL
// 危险
$pdo->query("SELECT * FROM users WHERE name = '$name'");
占位符、bindValue 才是正解;quote() 只是退路。
2. 同一命名参数用两次
// 关闭模拟预处理后报 HY093
'SELECT * FROM posts WHERE title = :kw OR body = :kw'
改成两个参数名,或用位置参数。
3. 中文乱码
MySQL 的 DSN 必须带 charset=utf8mb4,建表也用 utf8mb4;只改 SET NAMES 不够稳。
4. 连接写在每个函数里
一次请求创建多个连接会耗尽连接池。在入口建一个 $pdo 传下去,或用简单的容器共享。
5. 把密码写进代码
用环境变量或配置文件(不进仓库)。生产库账号只给必要权限。
6. 忽略异常信息里的 SQL
PDOException 的 getMessage() 会带 SQL 片段,直接返回给用户等于泄露结构。对外只返回通用错误,细节写日志。
7. 长事务锁表
事务里不要做 HTTP 请求、发邮件、等用户输入。开始事务到提交之间越短越好。
小结
ERRMODE_EXCEPTION+ 关闭模拟预处理是默认配置。- 占位符只替代值;表名、列名、
ORDER BY、LIMIT要白名单或整数绑定。 - 命名参数不能重复(原生预处理下),位置参数要严格对齐。
- 事务里做行数校验,别只看「没抛异常」。
- 排序、
LIKE、IN是预处理语句之外必须手动处理的三个点。