
网站被拖库、后台被撞开,很多时候不是框架本身多脆弱,而是某段查询还在用字符串拼接。PHP 里最稳妥、也最该养成习惯的做法,是用 PDO 预处理语句(Prepared Statement)把「SQL 结构」和「用户数据」彻底分开。本文按日常业务写法说清楚:怎么写才对、哪些坑容易踩、动态排序和批量 IN 该怎么处理。
为什么拼接 SQL 一定会出事
下面这种写法在老项目里仍常见:
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = $id";
$result = $pdo->query($sql);
看起来只是查一条用户,但攻击者只要把 id 改成 1 OR 1=1,条件就被改写了。更狠一点还可以叠 UNION SELECT、注释符、多语句,把整张表摸出来。问题不在「有没有过滤引号」,而在:用户输入参与了 SQL 语法本身。
预处理的核心思路相反:先把带占位符的语句发给数据库解析、编译,再单独绑定参数。参数只当数据,不再被当成 SQL 关键字解析。这才是防注入的正道,比手写 addslashes、mysqli_real_escape_string 可靠得多。
最小可用写法
先建立连接,再准备语句、绑定、执行:
<?php
$dsn = 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4';
$pdo = new PDO($dsn, 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false, // 尽量用真正的服务端预处理
]);
$stmt = $pdo->prepare('SELECT id, name, email FROM users WHERE id = ?');
$stmt->execute([(int) $_GET['id']]);
$user = $stmt->fetch();
几点值得固定下来:
charset=utf8mb4写进 DSN,避免中文和 emoji 出乱码,也减少编码相关的边界问题。ERRMODE_EXCEPTION让失败直接抛异常,别靠默默返回false然后漏判。ATTR_EMULATE_PREPARES => false尽量关闭模拟预处理,让 MySQL 真正做服务端 prepare(个别驱动/版本有兼容差异,测一下再定)。
命名占位符 vs 问号占位符
业务里参数一多,命名占位符更可读:
$sql = 'SELECT * FROM orders
WHERE user_id = :uid AND status = :status
ORDER BY id DESC LIMIT :limit';
$stmt = $pdo->prepare($sql);
$stmt->bindValue(':uid', $userId, PDO::PARAM_INT);
$stmt->bindValue(':status', $status, PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
? 适合参数很少、顺序一眼能看清的场景;命名占位符适合条件多、还要复用同一语句的场景。两种都能防注入,别混着用同一语句里两种风格即可。
注意:LIMIT、OFFSET 这类数字,建议显式 PDO::PARAM_INT。开启模拟预处理时,字符串形式的 limit 有时会拼进 SQL 出奇怪语法错误;关模拟后按整数绑定更稳。
IN 查询怎么安全拼
「按一批 ID 查」没法直接 WHERE id IN (:ids) 绑一个数组,需要按数量生成占位符:
$ids = array_map('intval', $ids); // 先洗成整数更安心
if ($ids === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT id, title FROM posts WHERE id IN ($placeholders)";
$stmt = $pdo->prepare($sql);
$stmt->execute($ids);
return $stmt->fetchAll();
这里拼的是固定数量的 ?,不是用户字符串,所以仍然安全。切记不要把用户输入直接塞进 IN (...) 括号里。
动态排序、表名:预处理管不到的地方
预处理只保护值,保护不了标识符。下面这种写法依然危险:
// 错误:把用户输入当列名
$order = $_GET['sort'];
$sql = "SELECT * FROM products ORDER BY $order";
正确做法是白名单映射:
$allowed = [
'price' => 'price',
'sold' => 'sold_count',
'new' => 'created_at',
];
$key = $_GET['sort'] ?? 'new';
$column = $allowed[$key] ?? $allowed['new'];
$dir = (($_GET['dir'] ?? 'desc') === 'asc') ? 'ASC' : 'DESC';
$sql = "SELECT * FROM products ORDER BY {$column} {$dir} LIMIT ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([20]);
表名、列名、排序方向、运算符,一律自己的代码决定;用户只能在你给的选项里挑。这是和预处理配合使用的硬规矩。
写入与事务
插入、更新同样用预处理。涉及余额、库存这类「读-改-写」,再套上事务:
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'UPDATE accounts SET balance = balance - :amount WHERE id = :id AND balance >= :amount'
);
$stmt->execute([':amount' => $amount, ':id' => $fromId]);
if ($stmt->rowCount() !== 1) {
throw new RuntimeException('余额不足或账户不存在');
}
$stmt = $pdo->prepare(
'UPDATE accounts SET balance = balance + :amount WHERE id = :id'
);
$stmt->execute([':amount' => $amount, ':id' => $toId]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
防注入解决的是「语句被改写」;事务解决的是「半截成功」。两边都要,业务才站得住。
常见误区
「我转义过了,不用预处理。」 转义依赖字符集和调用时机,漏一次就翻车;预处理把职责交给数据库协议层,心智负担更小。
「用了 ORM 就绝对安全。」 多数 ORM 默认查询是安全的,但一旦写原生 SQL、字符串拼条件、动态表名,一样会中招。原则不变:值走绑定,标识符走白名单。
「query() 和 exec() 更快。」 没有用户输入的固定语句可以用;一旦有外部数据,优先 prepare + execute。性能瓶颈几乎从不在「少了一次 prepare」,而在索引和查询本身。
「关闭模拟预处理后某些语句报错。」 多半是驱动对 LIMIT 绑定、或存储过程返回多结果集的支持差异。先查官方文档和当前 PDO/MySQL 版本说明,必要时对那一类语句单独处理,而不是全局退回拼接。
落地检查清单
- 所有带外部输入的 SQL,是否都走了
prepare/ 绑定? - 排序字段、表名、运算符,是否用了白名单而不是直接拼接?
- 连接是否固定
utf8mb4,错误模式是否为异常? - 批量
IN是否按数量生成占位符,而不是把 CSV 字符串塞进去? - 资金、库存类更新是否还有事务与条件更新(如
balance >= :amount)?
写在最后
PHP 防 SQL 注入不是玄学:把「结构」和「数据」分开,再用白名单管住标识符,就能挡住绝大多数注入面。PDO 预处理是这套习惯里最省事的一层——新代码默认这么写,改旧代码时优先把拼接查询换成绑定。安全这件事,往往赢在不偷懒的那一次。