「用 PDO 预处理就不会有注入」——这句话只对了一半。参数化能保护值,但保护不了标识符和结构。这篇把实际代码里反复出现的几个误区拆开讲。

一、先明确预处理的边界

PDO 预处理能安全处理的只有一种东西:值占位符。

// 安全:值是参数
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);

它不能处理这些:

  • 表名、列名
  • ORDER BY 的字段和方向
  • LIMIT / OFFSET(部分驱动下)
  • IN () 里的元素个数
  • SQL 关键字

原因是预处理的本质是「先把语句结构发给数据库解析,再单独传值」。结构一旦定了就不能再变。

二、误区一:拼接表名/字段名

// 危险
$sql = "SELECT * FROM {$table} WHERE {$column} = ?";
$pdo->prepare($sql)->execute([$value]);

$table 和 $column 是直接拼进 SQL 结构的,占位符救不了。

正确做法是白名单:

const ALLOWED_SORT = ['created_at', 'views', 'id'];
const ALLOWED_DIR  = ['asc', 'desc'];

$sort = in_array($_GET['sort'] ?? '', ALLOWED_SORT, true) ? $_GET['sort'] : 'created_at';
$dir  = strtolower($_GET['dir'] ?? 'desc') === 'asc' ? 'ASC' : 'DESC';

$sql = "SELECT * FROM posts ORDER BY `{$sort}` {$dir} LIMIT 20";

白名单比「过滤特殊字符」可靠得多——因为它的逻辑是「只允许已知安全的」,而不是「试图列出所有危险字符」。

三、误区二:LIMIT ? 绑定成字符串

这是最经典的一个坑。

$stmt = $pdo->prepare('SELECT * FROM posts LIMIT ?');
$stmt->execute([20]);   // 报错

当 PDO::ATTR_EMULATE_PREPARES 为 false(真·服务端预处理)时,MySQL 要求 LIMIT 的参数是整数类型,而 execute() 数组里的值默认按字符串发送,于是报:

SQLSTATE[42000]: Syntax error or access violation: 1064

解法是显式指定类型:

$stmt = $pdo->prepare('SELECT * FROM posts LIMIT :limit OFFSET :offset');
$stmt->bindValue(':limit',  (int) $limit,  PDO::PARAM_INT);
$stmt->bindValue(':offset', (int) $offset, PDO::PARAM_INT);
$stmt->execute();

注意不能用 execute([...]) 传数组,必须用 bindValue(),否则类型信息丢失。

四、误区三:IN 子句

// 错误:只绑了一个值,但 SQL 里有三个问号
$stmt = $pdo->prepare('SELECT * FROM posts WHERE id IN (?, ?, ?)');

占位符数量必须和实际元素数量一致,所以要动态生成:

$ids = array_values(array_unique(array_map('intval', $ids)));

if ($ids === []) {
    return [];   // 空数组要提前返回,否则 SQL 变成 IN () 语法错误
}

$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare("SELECT * FROM posts WHERE id IN ({$placeholders})");
$stmt->execute($ids);

这里 $placeholders 拼的是问号,不是用户输入,所以是安全的。

如果 $ids 可能上千个,MySQL 对单个语句的参数个数有上限(默认 max_prepared_stmt_count、以及 65535 个占位符的硬限制),建议分批:

foreach (array_chunk($ids, 500) as $chunk) {
    $ph = implode(',', array_fill(0, count($chunk), '?'));
    $stmt = $pdo->prepare("SELECT * FROM posts WHERE id IN ({$ph})");
    $stmt->execute($chunk);
    // 合并结果
}

五、误区四:LIKE 里的通配符

$stmt = $pdo->prepare('SELECT * FROM posts WHERE title LIKE ?');
$stmt->execute(['%' . $keyword . '%']);

这段代码没有注入风险,但有逻辑漏洞:用户输入里的 % 和 _ 会被当成通配符。

搜 50% 会匹配到所有以 50 开头的内容;搜 a_b 会匹配 axb。如果这是后台管理或者有性能敏感的表,还可能被构造出 %%%%%% 这种全表扫描。

要转义:

$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $keyword);
$stmt = $pdo->prepare("SELECT * FROM posts WHERE title LIKE ? ESCAPE '\\\\'");
$stmt->execute(['%' . $escaped . '%']);

注意 ESCAPE 后面的反斜杠在 PHP 字符串里要写成 '\\\\',实际发给 MySQL 的是 '\\'。

六、误区五:DSN 里漏了 charset

// 有隐患
$pdo = new PDO('mysql:host=127.0.0.1;dbname=test', $user, $pass);

如果连接字符集和服务端不一致,某些宽字节字符集(GBK 等)下存在经典的多字节注入问题。虽然现在基本都是 utf8mb4,但显式声明是零成本的保险:

$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=test;charset=utf8mb4',
    $user,
    $pass,
    [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,   // 出错抛异常,别静默
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,                     // 用服务端真预处理
    ]
);

ATTR_EMULATE_PREPARES => false 有两个好处:真的走服务端预处理,以及 LIMIT 报错能被及时发现(而不是在模拟模式下悄悄工作、换环境才炸)。

七、误区六:二次注入

这个最容易被忽略。数据进库时安全了,但从库里读出来又拼进 SQL:

// 第一步:入库,参数化,安全
$stmt = $pdo->prepare('INSERT INTO users (nickname) VALUES (?)');
$stmt->execute([$_POST['nickname']]);   // 用户填了:admin' --

// 第二步:从库里读出来,直接拼接
$row = $pdo->query("SELECT * FROM users WHERE id = 1")->fetch();
$sql = "UPDATE users SET intro = '{$row['nickname']}' WHERE id = 2";   // 这里被注入了

关键认知:数据库里的数据不代表可信。任何进入 SQL 的变量,不管来源是用户输入还是数据库,都要走参数化。

八、一个可以抄走的封装

final class Db
{
    public function __construct(private PDO $pdo) {}

    /** @param array $params */
    public function query(string $sql, array $params = []): PDOStatement
    {
        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt;
    }

    public function fetchAll(string $sql, array $params = []): array
    {
        return $this->query($sql, $params)->fetchAll();
    }

    public function fetchOne(string $sql, array $params = []): ?array
    {
        $row = $this->query($sql, $params)->fetch();
        return $row === false ? null : $row;
    }
}

配套两条规则:

  1. 所有 SQL 必须走 query(),禁止在业务代码里直接 $pdo->query() 拼字符串
  2. 只有白名单校验过的标识符才允许用 {} 插进 SQL,其余一律用占位符

小结

场景占位符能用吗正确做法
普通值✅? 或 :name
表名 / 列名❌白名单
ORDER BY❌白名单
LIMIT / OFFSET⚠️bindValue + PARAM_INT
IN ()⚠️动态生成占位符
LIKE 通配符✅(但要转义 % _)ESCAPE

记住一句话就够了:参数化保护值,白名单保护结构。