PHP PDO 与预处理语句

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

PHP 8.4

拼 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 是预处理语句之外必须手动处理的三个点。

下一步