PHP 数据库操作:PDO 与预处理语句
数据库是几乎所有 PHP 应用的核心组件。无论是用户管理、内容存储还是订单处理,都离不开对数据库的操作。然而,数据库操作也是安全风险的高发地带——SQL 注入攻击可以让攻击者窃取、篡改甚至删除你的全部数据。
本文将详细介绍如何使用 PHP 的 PDO(PHP Data Objects)扩展配合预处理语句,构建安全、可靠的数据库操作层。阅读完毕后,你将能够独立编写防注入的数据库代码,并掌握常见的最佳实践。
第一部分:认识 PDO
为什么选择 PDO?
在 PHP 历史上,数据库操作曾长期依赖 mysql_* 系列函数(如 mysql_query)。这些函数存在两个致命问题:功能受限且不支持预处理语句。PHP 官方早已废弃这些函数,转而推荐 PDO 和 MySQLi 两个替代方案。
PDO 的核心优势体现在三个方面。首先是跨数据库支持——同一套代码可以连接 MySQL、PostgreSQL、SQLite、SQL Server 等十余种数据库,只需切换数据源名称(DSN)。其次是预处理语句支持——这是防止 SQL 注入的核心机制。最后是统一的错误处理模式——所有数据库操作遵循相同的异常处理逻辑。
创建数据库连接
使用 PDO 连接数据库的第一步是实例化 PDO 类。连接时需要提供三个关键信息:数据源名称(DSN)、用户名和密码。
<?php
$host = 'localhost';
$dbname = 'myapp';
$username = 'root';
$password = 'your_password';
try {
$pdo = new PDO(
"mysql:host=$host;dbname=$dbname;charset=utf8mb4",
$username,
$password,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
echo "数据库连接成功";
} catch (PDOException $e) {
die("数据库连接失败: " . $e->getMessage());
}
```
上述代码中有几个配置项值得特别注意。`PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION` 让 PDO 抛出异常而非静默失败,这对调试和错误处理至关重要。`PDO::ATTR_EMULATE_PREPARES => false` **禁用模拟预处理**,强制数据库使用原生预处理语句,这是最安全的运行模式。`charset=utf8mb4` 确保正确存储表情符号等四字节字符。
---
## 第二部分:预处理语句详解
### 什么是预处理语句?
预处理语句的核心思想是将 SQL 结构与数据值分离。传统拼接方式下,SQL 语句和用户输入混在一起,攻击者可以通过构造特殊输入改变 SQL 的原有逻辑。预处理语句则先发送 SQL 结构给数据库编译,再单独发送数据值——数据库会将数据值当作纯文本处理,根本不可能解析为 SQL 代码。
用生活场景类比:传统拼接相当于把原材料(用户输入)直接扔进加工车间加工;预处理语句则先确定产品模板(SQL 结构),再把原材料送进仓库,后续加工时模板和原材料始终分离。
### 占位符的类型
PDO 支持两种占位符语法:**命名参数**和**问号参数**。命名参数使用冒号开头的占位符(如 `:name`),可读性更好,适合参数较多的场景。问号参数按位置索引(如 `?`),语法更简洁,适合参数较少的场景。
```php
// 命名参数示例
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email AND status = :status");
$stmt->execute([':email' => $email, ':status' => 1]);
// 问号参数示例
$stmt = $pdo->prepare("SELECT * FROM users WHERE email = ? AND status = ?");
$stmt->execute([$email, 1]);
```
两种方式可以混合使用,但同一语句中建议保持统一风格。命名参数的优势在于可以多次复用同一参数名,而问号参数必须按顺序提供所有值。
---
## 第三部分: CRUD 实战操作
### 创建数据(INSERT)
插入数据时使用预处理语句能够有效防止注入攻击。以下示例展示了如何安全地向 `users` 表插入新用户:
```php
function createUser(PDO $pdo, string $name, string $email, int $age): int
{
$sql = "INSERT INTO users (name, email, age, created_at) VALUES (:name, :email, :age, NOW())";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':name' => $name,
':email' => $email,
':age' => $age,
]);
return (int) $pdo->lastInsertId();
}
// 调用示例
$userId = createUser($pdo, "张三", "zhangsan@example.com", 28);
echo "新用户 ID: " . $userId;
```
`lastInsertId()` 方法返回最后插入记录的自动生成 ID。需要注意,插入操作执行后即可获取该值,无需额外查询。
### 读取数据(SELECT)
读取数据时,根据返回结果的数量选择不同的获取方法。`fetch()` 获取单条记录,`fetchAll()` 获取所有记录。
```php
function getUserById(PDO $pdo, int $id): ?array
{
$sql = "SELECT * FROM users WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => $id]);
$user = $stmt->fetch();
return $user ?: null;
}
function getActiveUsers(PDO $pdo): array
{
$sql = "SELECT id, name, email FROM users WHERE status = 1 ORDER BY created_at DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute();
return $stmt->fetchAll();
}
// 调用示例
$user = getUserById($pdo, 1);
if ($user) {
echo "找到用户: " . $user['name'];
}
```
`fetch()` 在找到记录时返回数组,找不到时返回 `false`,因此需要配合三元运算符处理「未找到」的情况。`fetchAll()` 始终返回数组,即使查询结果为空也会返回空数组 `[]`。
### 更新数据(UPDATE)
更新操作同样使用预处理语句,关键在于 WHERE 子句必须使用占位符,防止条件被篡改。
```php
function updateUserStatus(PDO $pdo, int $id, int $status): bool
{
$sql = "UPDATE users SET status = :status, updated_at = NOW() WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':status' => $status,
':id' => $id,
]);
return $stmt->rowCount() > 0;
}
// 调用示例
if (updateUserStatus($pdo, 5, 0)) {
echo "用户状态已更新";
} else {
echo "未找到该用户或数据未变化";
}
```
`rowCount()` 返回受影响的行数。如果更新时传入的值与数据库原有值相同,`rowCount()` 会返回 0——这是正常行为,不是错误。
### 删除数据(DELETE)
删除操作需要特别谨慎,建议先执行查询确认要删除的记录存在,再执行删除。
```php
function deleteUser(PDO $pdo, int $id): bool
{
// 先检查是否存在
$checkSql = "SELECT id FROM users WHERE id = :id";
$checkStmt = $pdo->prepare($checkSql);
$checkStmt->execute([':id' => $id]);
if (!$checkStmt->fetch()) {
return false; // 用户不存在
}
// 执行删除
$deleteSql = "DELETE FROM users WHERE id = :id";
$deleteStmt = $pdo->prepare($deleteSql);
$deleteStmt->execute([':id' => $id]);
return $deleteStmt->rowCount() > 0;
}
// 调用示例
if (deleteUser($pdo, 10)) {
echo "用户已删除";
} else {
echo "用户不存在";
}
第四部分:高级用法
事务处理
当一次操作涉及多条数据库写入时(如转账、批量插入),事务可以确保数据一致性——要么全部成功,要么全部回滚。
function transferPoints(PDO $pdo, int $fromUserId, int $toUserId, int $points): bool
{
try {
$pdo->beginTransaction();
// 扣除转出方积分
$sql1 = "UPDATE users SET points = points - :points WHERE id = :id AND points >= :points";
$stmt1 = $pdo->prepare($sql1);
$stmt1->execute([':points' => $points, ':id' => $fromUserId]);
if ($stmt1->rowCount() === 0) {
throw new Exception("转出用户积分不足或不存在");
}
// 增加转入方积分
$sql2 = "UPDATE users SET points = points + :points WHERE id = :id";
$stmt2 = $pdo->prepare($sql2);
$stmt2->execute([':points' => $points, ':id' => $toUserId]);
$pdo->commit();
return true;
} catch (Exception $e) {
$pdo->rollBack();
error_log("转账失败: " . $e->getMessage());
return false;
}
}
事务的关键步骤是:beginTransaction() 开启事务 -> 执行多条 SQL -> commit() 提交。如果任何环节出错,进入 catch 块执行 rollBack() 回滚所有更改。
批量插入
插入大量数据时,使用批量插入比循环单条插入效率高数十倍。核心技巧是构建单条 SQL 语句,循环复用占位符。
function batchInsertUsers(PDO $pdo, array $users): int
{
if (empty($users)) {
return 0;
}
$placeholders = [];
$values = [];
foreach ($users as $user) {
$placeholders[] = "(?, ?, ?)";
$values[] = $user['name'];
$values[] = $user['email'];
$values[] = $user['age'];
}
$sql = "INSERT INTO users (name, email, age) VALUES " . implode(", ", $placeholders);
$stmt = $pdo->prepare($sql);
$stmt->execute($values);
return $stmt->rowCount();
}
// 调用示例
$newUsers = [
['name' => '用户A', 'email' => 'a@example.com', 'age' => 25],
['name' => '用户B', 'email' => 'b@example.com', 'age' => 30],
['name' => '用户C', 'email' => 'c@example.com', 'age' => 35],
];
$inserted = batchInsertUsers($pdo, $newUsers);
echo "成功插入 $inserted 条记录";
```
这段代码将三条记录合并为一条 SQL 语句一次性插入。占位符数组 `$placeholders` 最终形如 `("(?, ?, ?)", "(?, ?, ?)", "(?, ?, ?)")`。
### 预处理语句的参数绑定
有时需要在执行前动态绑定参数,PDO 提供了 `bindParam()` 和 `bindValue()` 两个方法。`bindParam()` 绑定的是变量引用,`bindValue()` 绑定的是变量的当前值。
```php
$sql = "INSERT INTO logs (user_id, action, created_at) VALUES (:user_id, :action, NOW())";
$stmt = $pdo->prepare($sql);
// bindParam 示例——引用绑定
$userId = 1;
$action = "login";
$stmt->bindParam(':user_id', $userId, PDO::PARAM_INT);
$stmt->bindParam(':action', $action);
$userId = 2; // 修改原变量
$action = "logout";
$stmt->execute(); // 实际插入的是 user_id=2, action="logout"
// bindValue 示例——值绑定
$stmt = $pdo->prepare($sql);
$userId = 1;
$action = "login";
$stmt->bindValue(':user_id', $userId, PDO::PARAM_INT);
$stmt->bindValue(':action', $action);
$userId = 2; // 修改原变量
$action = "logout";
$stmt->execute(); // 实际插入的是 user_id=1, action="login"
```
实际开发中,多数情况下直接使用 `execute([])` 传参即可,`bindParam` 和 `bindValue` 主要用于需要指定参数类型或变量值会在执行前变化的场景。
---
## 第五部分:安全与最佳实践
### 防止 SQL 注入的核心原则
只要遵循以下规则,SQL 注入攻击将被彻底杜绝。**永远使用预处理语句处理用户输入**,这是最根本的防护手段。**永远不要拼接 SQL 字符串**,即使觉得输入来源可靠也不例外——代码审查时发现字符串拼接应立即报警。**严格验证数据类型**,整数使用 `(int)` 转换,布尔值使用 `(bool)` 转换。
```php
// 危险!禁止使用
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = $id"; // 直接拼接
$stmt = $pdo->query($sql);
// 安全写法
$id = (int) $_GET['id']; // 强制转换为整数
$sql = "SELECT * FROM users WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => $id]);
错误处理模式
PDO 提供三种错误处理模式,通过 PDO::ATTR_ERRMODE 设置。PDO::ERRMODE_SILENT 是默认模式,仅设置错误码,脚本继续执行,适合不希望中断但需要自定义错误处理的高级场景。PDO::ERRMODE_WARNING 在错误时触发 PHP 警告,适合开发调试。PDO::ERRMODE_EXCEPTION 抛出 PDOException 异常,这是最推荐的方式,便于统一捕获和处理。
// 配置为异常模式
$pdo = new PDO(...);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// 统一异常处理
try {
$stmt = $pdo->prepare("SELECT ...");
$stmt->execute();
} catch (PDOException $e) {
// 记录详细错误,日志中显示完整信息
error_log("数据库错误: " . $e->getMessage());
// 向用户显示通用错误信息,不暴露内部细节
die("系统繁忙,请稍后重试");
}
```
### 常见问题排查
遇到数据库操作失败时,按照以下步骤排查:首先检查异常信息,PDOException 的消息通常能直接指出问题所在;其次确认数据库服务是否正常运行;然后验证 SQL 语法是否正确,可以将 SQL 语句直接在数据库管理工具中测试;最后检查占位符名称是否与实际传入的键名完全匹配,包括冒号有无和拼写。
---
## 快速参考表
| 场景 | 推荐方法 | 关键代码 |
|------|----------|----------|
| 连接数据库 | 实例化 PDO | `$pdo = new PDO($dsn, $user, $pwd)` |
| 插入并获取 ID | `prepare` + `execute` + `lastInsertId` | `lastInsertId()` |
| 查询单条 | `fetch()` | `while ($row = $stmt->fetch())` |
| 查询全部 | `fetchAll()` | `$rows = $stmt->fetchAll()` |
| 更新/删除 | `rowCount()` 检查影响行数 | `rowCount()` |
| 批量操作 | 事务 | `beginTransaction()` / `commit()` |
| 参数绑定 | `execute([...])` 数组传参 | 最常用 |
---
## 结语
PDO 与预处理语句是 PHP 安全数据库操作的基础。掌握本文介绍的内容后,你将能够编写出既安全又高效的数据库代码。建议在后续开发中,将常用的数据库操作封装为模型类或 Repository 模式,进一步提升代码的可维护性和复用性。
暂无评论,快来抢沙发吧!