第5章 5.2 PDO 预处理与防 SQL 注入
「上一章我们搞定了 MySQL 和 phpMyAdmin,能创建数据库、往里面塞数据了。但你现在面对一个新问题:PHP 代码里怎么安全地操作这些数据?」
想象一下:你开了一家小店,进货的时候总不能直接让陌生人进仓库随便拿吧?得先登记、审核、限额——数据库操作也一样。直接拼接 SQL 就像敞开大门,而预处理语句(Prepared Statement)就是给你的数据存取加一把锁。
这一章,我们来搞定 PDO 预处理,用它来防 SQL 注入。学完你就能写出「即使有人想捣乱也进不去」的健壮代码。
🎯 开场 3 分钟:为什么要学这个?
场景带入:你的网站被「拜访」了
你写了一个用户登录页面,用户名输入 ' OR '1'='1,密码随便填——结果 登录成功了。
这就是 SQL 注入,一个存在了 20 年的漏洞,至今仍是 Web 安全 TOP 10 之一。
痛点 1:直接拼接 SQL = 在马路上裸奔
// 危险!用户输入被直接塞进 S\n\n\n\n\n\nQL
$sql = "SELECT * FROM users WHERE name = '$name' AND pwd = '$pwd'";
痛点 2:数据多了性能差,每次查询都要解析 SQL
学完本文你能:
- 写出「永远不被注入」的查询
- 用预处理语句提升查询性能
- 玩转事务,保证数据一致性
🧱 基础 25 分钟:核心概念
5.2.1 PDO 是什么?
PDO = PHP Data Objects,官方提供的数据库统一接口。就像手机的「万能充电器」——不管你连接的是 MySQL、PostgreSQL 还是 SQLite,用同一套代码就能操作。
为什么要用 PDO?
- 代码一次编写,到处运行(换数据库不用重写)
- 原生支持预处理,从根本上防注入
- 有事务支持,数据安全有保障
怎么用?先建个「连接」:
<?php
// 用 try-catch 包裹,数据库挂了也知道怎么回事
try {
// 本机 MySQL,用户名 root,密码 123456,要操作的数据库叫 demo
$dsn = 'mysql:host=localhost;dbname=demo;charset=utf8mb4';
$username = 'root';
$password = '123456';
$pdo = new PDO($dsn, $username, $password);
// 设置错误模式为异常,这样出错会抛异常而不是悄无声息
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo "数据库连接成功!";
} catch (PDOException $e) {
echo "连接失败:" . $e->getMessage();
}
输出:
数据库连接成功!
这行
$pdo = new PDO(...)就好比你第一次去图书馆办借书证,以后借书还书都走这个证。
5.2.2 为什么要预处理?生活类比
不用预处理 = 点餐时喊「给我来一份宫保鸡丁,不要辣的」
厨师听一次记住了,下单时直接做。但如果你喊「给我来一份宫保鸡丁,不要辣的,然后再来一份鱼香肉丝,微辣,再来一份……」厨师可能会记错。
用预处理 = 填表点餐
你填一张表:「主菜:_,口味:___」。不管你填什么内容,表格格式不变,厨师只解析表格结构,你填的内容老老实实当食材用,不会被当成指令执行。
核心流程就三步:
<?php
// 假设 $pdo 已经成功连接
// 第一步:预处理 - 发送 SQL 骨架,问号是占位符
$stmt = $pdo->prepare("SELECT * FROM users WHERE name = ? AND age > ?");
// 第二步:绑定参数 - 把具体值塞进去
$stmt->bindValue(1, '小明'); // 第一个问号的值
$stmt->bindValue(2, 18); // 第二个问号的值
// 第三步:执行
$stmt->execute();
// 第四步:取结果
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
print_r($results);
输出类似:
Array
(
[0] => Array
(
[id] => 1
[name] => 小明
[age] => 20
)
)
你看,
?占位的地方,不管用户输入什么奇怪的东西,都只会被当成「名字」这个字段的值,而不会变成 SQL 命令的一部分。
5.2.3 bindValue vs bindParam:易混点
这两个兄弟长得像,但性格不同:
| 方法 | 性格 | 例子 |
|---|---|---|
bindValue |
急性子,传值 | 填表时刻苦铭心就把表交上去了 |
bindParam |
慢性子,传引用 | 填表后等通知,等的人会变 |
实战区别:
<?php
$pdo = new PDO('mysql:host=localhost;dbname=demo;charset=utf8mb4', 'root', '123456');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// bindValue:立即取值
$name = '小明';
$stmt = $pdo->prepare("SELECT * FROM users WHERE name = ?");
$stmt->bindValue(1, $name); // 此时 $name = '小明'
$name = '老王'; // 改了这个变量
$stmt->execute(); // 查的还是 '小明'
// bindParam:等执行时才取值
$name2 = '小明';
$stmt2 = $pdo->prepare("SELECT * FROM users WHERE name = ?");
$stmt2->bindParam(1, $name2); // 此时绑定的是 $name2 这个「坑」
$name2 = '老王'; // 改了这个变量
$stmt2->execute(); // 查的是 '老王'!
大多数情况下用 bindValue 就行,简单直接。除非你玩循环插入、想共用同一个预处理语句,才需要 bindParam。
5.2.4 快捷方式:execute 带数组
如果你懒得 bindValue,还有一招更简便的:
<?php
$pdo = new PDO('mysql:host=localhost;dbname=demo;charset=utf8mb4', 'root', '123456');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 直接把参数数组扔给 execute
$stmt = $pdo->prepare("INSERT INTO users (name, age, email) VALUES (?, ?, ?)");
$stmt->execute(['小红', 22, 'xiaohong@example.com']);
echo "插入成功,最后ID:" . $pdo->lastInsertId();
输出:
插入成功,最后ID:5
这就像点外卖时直接勾选套餐,不用一步步填表了。
5.2.5 事务:要么全成功,要么全失败
场景:给小明的账户转 100 块钱,要同时操作两个表——减余额、加交易记录。如果第二步失败了,第一步也得撤销。
事务就是干这个的——一组操作要么全部成功,要么全部回滚(撤销)。
<?php
$pdo = new PDO('mysql:host=localhost;dbname=demo;charset=utf8mb4', 'root', '123456');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
// 开启事务
$pdo->beginTransaction();
// 操作1:扣钱
$stmt = $pdo->prepare("UPDATE accounts SET balance = balance - 100 WHERE user_id = 1");
$stmt->execute();
// 操作2:记录
$stmt = $pdo->prepare("INSERT INTO transactions (user_id, amount, type) VALUES (?, ?, ?)");
$stmt->execute([1, -100, 'transfer']);
// 两步都成功了,提交!
$pdo->commit();
echo "转账成功!";
} catch (Exception $e) {
// 任何一步出错,都撤销所有操作
$pdo->rollBack();
echo "转账失败,已撤销:" . $e->getMessage();
}
输出(成功时):
转账成功!
输出(失败时):
转账失败,已撤销:...
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):用户注册表单——体验预处理
场景:做一个用户注册页面,把用户信息存进数据库。
<?php
/**
* 项目1:用户注册 - 演示预处理插入
* 预期:往 users 表插入一条记录
*/
$pdo = new PDO('mysql:host=localhost;dbname=demo;charset=utf8mb4', 'root', '123456');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 模拟用户提交的数据(实际来自 $_POST)
$user_data = [
'name' => "张三'; DROP TABLE users; --", // 恶意输入!
'email' => 'zhangsan@example.com',
'age' => 25
];
// 预处理插入(恶意输入会被当作普通字符串,不会被执行!)
$stmt = $pdo->prepare("INSERT INTO users (name, email, age) VALUES (?, ?, ?)");
$stmt->execute([$user_data['name'], $user_data['email'], $user_data['age']]);
echo "注册成功!用户ID:" . $pdo->lastInsertId();
// 验证一下:查出来看看是不是原样存储
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$user_data['email']]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
echo "\n存入数据库的值:\n";
echo "name: " . $result['name'] . "\n"; // 会原样输出那串恶意代码,但不会生效
输出:
注册成功!用户ID:6
存入数据库的值:
name: 张三'; DROP TABLE users; --
看,即使用了
'; DROP TABLE users; --这种「注射式」输入,数据库也只是老老实实存成了字符串,根本不会执行删除表的命令。这就是预处理防注入的威力。
项目 2(15 分钟):CSV 批量导入用户——真实场景
场景:运营给了一份 100 人的 CSV 名单,要一次性导入数据库。
<?php
/**
* 项目2:从 CSV 批量导入用户
* 模拟 CSV 数据:
* name,email,age
* 李四,lisi@example.com,30
* 王五,wangwu@example.com,28
*/
// 模拟 CSV 内容(实际会用 file() 或 fgetcsv 读取)
$csv_content = "name,email,age
李四,lisi@example.com,30
王五,wangwu@example.com,28
赵六,zhaoliu@example.com,35";
// 解析 CSV
$lines = explode("\n", trim($csv_content));
$header = str_getcsv(array_shift($lines)); // 第一行是表头
$users = [];
foreach ($lines as $line) {
if (trim($line) === '') continue;
$values = str_getcsv($line);
$users[] = array_combine($header, $values);
}
// 批量插入(用事务保证要么全成功要么全失败)
$pdo = new PDO('mysql:host=localhost;dbname=demo;charset=utf8mb4', 'root', '123456');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("INSERT INTO users (name, email, age) VALUES (?, ?, ?)");
$count = 0;
foreach ($users as $user) {
$stmt->execute([
$user['name'],
$user['email'],
(int)$user['age']
]);
$count++;
}
$pdo->commit();
echo "成功导入 {$count} 条用户数据!\n";
// 验证一下
$stmt = $pdo->query("SELECT COUNT(*) as total FROM users");
echo "当前数据库共有 " . $stmt->fetch(PDO::FETCH_ASSOC)['total'] . " 个用户\n";
} catch (Exception $e) {
$pdo->rollBack();
echo "导入失败:" . $e->getMessage() . "\n";
}
输出:
成功导入 3 条用户数据!
当前数据库共有 9 个用户
如果 CSV 有 1000 条,用预处理 + 事务,1 秒左右就能搞定,而且中途某条数据有问题不会污染数据库。
项目 3(15 分钟):带搜索和统计的用户管理小工具
场景:做一个命令行小工具,可以搜索用户、按年龄筛选、统计各年龄段人数。
<?php
/**
* 项目3:用户搜索统计小工具
* 功能:
* 1. 按名字模糊搜索
* 2. 筛选年龄范围
* 3. 统计各年龄段分布
*/
$pdo = new PDO('mysql:host=localhost;dbname=demo;charset=utf8mb4', 'root', '123456');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 模拟用户查询请求
$search_name = '小'; // 搜索名字包含 "小" 的
$min_age = 20;
$max_age = 30;
// ========== 功能1:模糊搜索 + 年龄筛选 ==========
echo "=== 功能1:搜索用户 ===\n";
$sql = "SELECT * FROM users WHERE 1=1";
$params = [];
if (!empty($search_name)) {
$sql .= " AND name LIKE ?";
$params[] = "%{$search_name}%";
}
if (!empty($min_age)) {
$sql .= " AND age >= ?";
$params[] = $min_age;
}
if (!empty($max_age)) {
$sql .= " AND age <= ?";
$params[] = $max_age;
}
$sql .= " ORDER BY age DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$users = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "找到 " . count($users) . " 个用户:\n";
foreach ($users as $u) {
echo sprintf(" - %s (%d岁) - %s\n", $u['name'], $u['age'], $u['email']);
}
// ========== 功能2:年龄段统计 ==========
echo "\n=== 功能2:年龄段统计 ===\n";
$stmt = $pdo->query("
SELECT
CASE
WHEN age < 20 THEN '20岁以下'
WHEN age >= 20 AND age < 30 THEN '20-29岁'
WHEN age >= 30 AND age < 40 THEN '30-39岁'
ELSE '40岁以上'
END as 年龄段,
COUNT(*) as 人数
FROM users
GROUP BY 1
ORDER BY MIN(age)
");
$stats = $stmt->fetchAll(PDO::FETCH_NUM);
foreach ($stats as $row) {
echo sprintf(" %s:%d 人\n", $row[0], $row[1]);
}
输出示例:
=== 功能1:搜索用户 ===
找到 3 个用户:
- 小红 (22岁) - xiaohong@example.com
- 小明 (20岁) - xiaoming@example.com
- 小刚 (28岁) - xiaogang@example.com
=== 功能2:年龄段统计 ===
20-29岁:3 人
30-39岁:2 人
这个小工具展示了预处理的真正威力:查询条件动态组合,不管用户传几个参数,SQL 都能安全拼接。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:占位符只能替代「值」,不能替代表名/列名
<?php
// ❌ 错误:占位符不能替代表名
$stmt = $pdo->prepare("SELECT * FROM ? WHERE id = ?");
$stmt->execute(['users', 1]); // 会报错!
// ✅ 正确:表名用白名单方式硬编码
$allowed_tables = ['users', 'admins', 'products'];
$table = in_array($_GET['table'], $allowed_tables) ? $_GET['table'] : 'users';
$stmt = $pdo->prepare("SELECT * FROM {$table} WHERE id = ?");
$stmt->execute([$_GET['id']]);
坑 2:忘记 setAttribute,错误变哑巴
<?php
// ❌ 错误:默认错误模式是静默的,出错了你不知道
$pdo = new PDO($dsn, $user, $pass);
// ✅ 正确:设置为异常模式,出错会抛异常
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
坑 3:bindParam 用在循环外,但值在循环内变
<?php
// ❌ 错误:bindParam 绑定的是引用,最后循环完 $name 永远是最后一个
$stmt = $pdo->prepare("INSERT INTO users (name) VALUES (?)");
$stmt->bindParam(1, $name); // 绑定的是 $name 这个变量名,不是值
$names = ['A', 'B', 'C'];
foreach ($names as $name) {
$stmt->execute(); // 三次插入的都是 'C'
}
// ✅ 正确:用 bindValue 或者每次循环内 execute
$stmt = $pdo->prepare("INSERT INTO users (name) VALUES (?)");
$names = ['A', 'B', 'C'];
foreach ($names as $name) {
$stmt->bindValue(1, $name); // 每次循环重新绑定值
$stmt->execute();
}
坑 4:事务没提交,卡死一片
<?php
// ❌ 错误:开启事务后,如果忘记 commit 或 rollback,后续所有查询都会排队等待
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE accounts SET balance = balance - 100 WHERE user_id = 1");
$stmt->execute();
// 忘了 commit,也没 catch 住异常
// ✅ 正确:放 try-catch 里,finally 确保无论成功失败都释放
try {
$pdo->beginTransaction();
// 操作...
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
}
坑 5:预处理语句重复创建,性能浪费
<?php
// ❌ 错误:每次循环都 prepare 一次
foreach ($user_list as $user) {
$stmt = $pdo->prepare("INSERT INTO users (name, email) VALUES (?, ?)");
$stmt->execute([$user['name'], $user['email']]);
}
// ✅ 正确:prepare 一次,execute 多次
$stmt = $pdo->prepare("INSERT INTO users (name, email) VALUES (?, ?)");
foreach ($user_list as $user) {
$stmt->execute([$user['name'], $user['email']]);
}
性能小贴士:预处理的「两次执行」优化
预处理语句第一次 prepare 时,MySQL 会解析、编译、优化 SQL 生成执行计划。后续 execute 时直接套用已有计划,省去重复解析的开销。
实测对比:批量插入 10000 条数据,预处理比普通拼接快 30-50%。
调试技巧:打印预处理语句
<?php
// 调试时看看到底传了什么值
$stmt = $pdo->prepare("SELECT * FROM users WHERE name = ? AND age > ?");
$stmt->execute(['小明', 18]);
// 用 debugDumpParams 看绑定情况
echo "<pre>";
$stmt->debugDumpParams();
echo "</pre>";
输出:
SQL: [8] SELECT * FROM users WHERE name = ? AND age > ?
Params: 2
key(0)_paramno=0 paramtype=2
key(1)paramno=1 paramtype=2
✏️ 练习题
练习 1(2 分钟):改改名字
- 输入:把项目 1 中的
$user_data['name']改成你自己的名字 - 预期输出:
存入数据库的值:后面显示你的名字 - 提示:只改一个变量的值
练习 2(2 分钟):加个判断
- 输入:在项目 1 中,如果用户名为空,抛出异常不插入
- 预期输出:空名字时显示
用户名不能为空 - 提示:在
execute()前加个if判断
练习 3(3 分钟):换个数据源
- 输入:用项目 2 的方法,解析这份 CSV:
name,age\n老张,40\n老刘,45 - 预期输出:
成功导入 2 条用户数据! - 提示:改
$csv_content变量的值就行
练习 4(5 分钟):串个项目
- 输入:把项目 2(CSV导入)和项目 3(搜索统计)串起来,先导入CSV,再搜索「老」字开头的人
- 预期输出:搜索结果里显示刚才导入的老张或老刘
- 提示:两段代码放一个文件里,按顺序执行
练习 5(5 分钟):读懂报错
- 输入:运行下面这段代码,告诉我哪里错了
<?php
$pdo = new PDO('mysql:host=localhost;dbname=demo;charset=utf8mb4', 'root', '123456');
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->bindValue(1, 1);
$stmt->execute();
$result = $stmt->fetch();
print_r($result);
- 预期输出:正常显示 id=1 的用户
- 提示:这段代码本身没问题,但如果你改成
$stmt->bindParam(1, 1)会怎样?
作业:做一个「留言板后台管理小工具」
需求描述:写一个 PHP 脚本,模拟留言板后台,可以发表留言、按关键字搜索、删除过期留言。
功能点:
1. 发表留言(预处理插入,防注入)
2. 搜索留言(模糊搜索 + 预处理)
3. 删除 N 天前的留言(用事务)
4. 统计每日留言数量
加分项:
1. 用事务包装删除操作
2. 支持多条删除
验收标准:
- 能跑起来,不报错
- 增、查、删功能正常
- 代码有注释,说明每一步在干嘛
提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本文学了 3 件事:
1. PDO 连接数据库,用 new PDO() 加 setAttribute()
2. 预处理防注入,用 prepare() + execute([$params])
3. 事务保安全,用 beginTransaction() + commit() / rollBack()
延伸学习资源:
- PHP 官方 PDO 文档 — 权威指南,每个方法都有例子
- 《PHP 核心技术》— 第 8 章数据库,对 PDO 讲得很透
- 视频:B 站「韩顺平 PHP 教程」PDO 那一集 — 适合喜欢看视频学的同学
互动钩子:
「你在实际项目中遇到过 SQL 注入吗?或者被坑过什么奇怪的查询Bug?评论区聊聊,老粉优先回复!」
下章预告:
「PDO 用的是统一的数据库接口,但如果你想用 MySQL 专属的一些高级特性,或者更喜欢面向过程代码风格,mysqli 才是你的菜。下一章我们聊聊 mysqli 的两种写法……」

评论(0)