第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![Simple tech illustration expla](https://blog.xxyye.com/wp-content/uploads/2026/06/1b777f9145438dc.png)\n\n![AI comic creation scene, creat](https://blog.xxyye.com/wp-content/uploads/2026/06/a03e23fd0f7acdb.png)\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 的两种写法……」

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。