数据库操作,是Web开发中最核心的功能之一。用户数据、文章内容、订单信息、日志记录……几乎所有的Web应用都需要和数据库打交道。

PHP提供了多种数据库操作方式:

  • mysql扩展:最早的MySQL扩展,PHP 5.5已废弃,PHP 7已移除
  • mysqli扩展:MySQL的增强版扩展,支持面向对象和过程式两种风格
  • PDO(PHP Data Objects):数据库抽象层,支持多种数据库(MySQL、PostgreSQL、SQLite等),统一的API

推荐使用PDO,因为:

  1. 支持多种数据库,切换数据库不需要改太多代码
  2. 支持预处理语句(Prepared Statements),防止SQL注入
  3. 面向对象的API,代码更优雅
  4. 支持事务
  5. 性能不错

今天,我们来详细学习PDO的使用,从基础连接到增删改查,从预处理语句到事务,从错误处理到性能优化,帮你掌握PHP数据库操作的最佳实践。

PDO基础

连接数据库

用PDO连接数据库,需要创建PDO对象:

// MySQL连接
$dsn = 'mysql:host=localhost;dbname=test;charset=utf8mb4';
$user = 'root';
$pass = 'password';

try {
    $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,  // 禁用模拟预处理,使用真正的预处理
    ]);
    echo '连接成功';
} catch (PDOException $e) {
    echo '连接失败:' . $e->getMessage();
    exit;
}

DSN(Data Source Name)格式:

  • MySQL:mysql:host=localhost;dbname=test;charset=utf8mb4;port=3306
  • PostgreSQL:pgsql:host=localhost;dbname=test
  • SQLite:sqlite:/path/to/database.db
  • SQL Server:sqlsrv:Server=localhost;Database=test

重要选项:

  • PDO::ATTR_ERRMODE:错误模式

- PDO::ERRMODESILENT:静默模式(默认),不报错,需要手动检查 - PDO::ERRMODEWARNING:警告模式,发出EWARNING - PDO::ERRMODEEXCEPTION:异常模式(推荐),抛出PDOException

  • PDO::ATTRDEFAULTFETCH_MODE:默认获取模式

- PDO::FETCHASSOC:关联数组 - PDO::FETCHNUM:索引数组 - PDO::FETCHOBJ:对象 - PDO::FETCHBOTH:两者都有(默认)

  • PDO::ATTREMULATEPREPARES:是否模拟预处理

- false:使用数据库原生的预处理(推荐,更安全) - true:PDO模拟预处理(某些数据库不支持原生预处理时用)

关闭连接

PDO连接在脚本结束时会自动关闭,也可以手动关闭:

$pdo = null;  // 关闭连接

执行SQL语句

exec():执行无结果集的SQL

exec()用于执行INSERT、UPDATE、DELETE等没有结果集的SQL,返回受影响的行数。

// INSERT
$sql = "INSERT INTO users (name, email) VALUES ('张三', 'zhangsan@example.com')";
$affectedRows = $pdo->exec($sql);
echo '插入了' . $affectedRows . '行,新ID:' . $pdo->lastInsertId();

// UPDATE
$sql = "UPDATE users SET name = '李四' WHERE id = 1";
$affectedRows = $pdo->exec($sql);
echo '更新了' . $affectedRows . '行';

// DELETE
$sql = "DELETE FROM users WHERE id = 1";
$affectedRows = $pdo->exec($sql);
echo '删除了' . $affectedRows . '行';

注意:exec()不适合SELECT语句,因为它不返回结果集。SELECT用query()或预处理语句。

query():执行有结果集的SQL

query()用于执行SELECT等有结果集的SQL,返回PDOStatement对象。

$sql = "SELECT * FROM users";
$stmt = $pdo->query($sql);

// 获取所有行
$users = $stmt->fetchAll();
foreach ($users as $user) {
    echo $user['name'] . ' - ' . $user['email'] . "\n";
}

// 逐行获取
while ($user = $stmt->fetch()) {
    echo $user['name'] . ' - ' . $user['email'] . "\n";
}

注意:query()也不应该用于有用户输入的SQL,因为有SQL注入风险。有用户输入时,用预处理语句。

预处理语句(Prepared Statements)

预处理语句,是PDO最重要的功能之一,能有效防止SQL注入。

原理:先发送SQL模板(用占位符代替实际值),数据库编译并缓存,然后再发送参数,数据库执行。这样,参数值永远不会被当作SQL代码执行,从而防止SQL注入。

占位符

PDO支持两种占位符:

  1. 问号占位符(?):按顺序绑定
$sql = "INSERT INTO users (name, email) VALUES (?, ?)";
$stmt = $pdo->prepare($sql);
$stmt->execute(['张三', 'zhangsan@example.com']);
  1. 命名占位符(:name):按名称绑定
$sql = "INSERT INTO users (name, email) VALUES (:name, :email)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':name' => '张三',
    ':email' => 'zhangsan@example.com',
]);

推荐使用命名占位符,更清晰,不容易出错。

INSERT示例

$sql = "INSERT INTO users (name, email, age) VALUES (:name, :email, :age)";
$stmt = $pdo->prepare($sql);

$stmt->execute([
    ':name' => '张三',
    ':email' => 'zhangsan@example.com',
    ':age' => 25,
]);

echo '新用户ID:' . $pdo->lastInsertId();

批量INSERT

预处理语句可以多次执行,效率很高:

$sql = "INSERT INTO users (name, email) VALUES (:name, :email)";
$stmt = $pdo->prepare($sql);

$users = [
    ['name' => '张三', 'email' => 'zhangsan@example.com'],
    ['name' => '李四', 'email' => 'lisi@example.com'],
    ['name' => '王五', 'email' => 'wangwu@example.com'],
];

foreach ($users as $user) {
    $stmt->execute($user);
}

UPDATE示例

$sql = "UPDATE users SET name = :name, email = :email WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
    ':id' => 1,
    ':name' => '李四',
    ':email' => 'lisi@example.com',
]);
echo '更新了' . $stmt->rowCount() . '行';

DELETE示例

$sql = "DELETE FROM users WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => 1]);
echo '删除了' . $stmt->rowCount() . '行';

SELECT示例

// 查询单条
$sql = "SELECT * FROM users WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => 1]);
$user = $stmt->fetch();  // 获取一行
if ($user) {
    echo $user['name'] . ' - ' . $user['email'];
} else {
    echo '用户不存在';
}

// 查询多条
$sql = "SELECT * FROM users WHERE age > :age";
$stmt = $pdo->prepare($sql);
$stmt->execute([':age' => 18]);
$users = $stmt->fetchAll();  // 获取所有行
foreach ($users as $user) {
    echo $user['name'] . "\n";
}

// 查询单个值
$sql = "SELECT COUNT(*) FROM users";
$count = $pdo->query($sql)->fetchColumn();
echo '用户总数:' . $count;

fetch获取模式

fetch()和fetchAll()可以指定获取模式:

// 关联数组(推荐)
$user = $stmt->fetch(PDO::FETCH_ASSOC);

// 索引数组
$user = $stmt->fetch(PDO::FETCH_NUM);

// 对象
$user = $stmt->fetch(PDO::FETCH_OBJ);
echo $user->name;

// 两者都有
$user = $stmt->fetch(PDO::FETCH_BOTH);

// 绑定到指定类
class User {}
$user = $stmt->fetchObject('User');

// fetchAll获取某一列
$names = $stmt->fetchAll(PDO::FETCH_COLUMN, 0);  // 获取第0列

bindValue和bindParam

除了execute()传数组,还可以用bindValue()和bindParam()绑定参数:

$sql = "INSERT INTO users (name, email) VALUES (:name, :email)";
$stmt = $pdo->prepare($sql);

// bindValue:绑定值
$stmt->bindValue(':name', '张三', PDO::PARAM_STR);
$stmt->bindValue(':email', 'zhangsan@example.com', PDO::PARAM_STR);
$stmt->execute();

// bindParam:绑定变量引用(变量变化时,执行时用最新值)
$name = '张三';
$email = 'zhangsan@example.com';
$stmt->bindParam(':name', $name, PDO::PARAM_STR);
$stmt->bindParam(':email', $email, PDO::PARAM_STR);
$stmt->execute();

// 指定参数类型
$stmt->bindValue(':age', 25, PDO::PARAM_INT);
$stmt->bindValue(':active', true, PDO::PARAM_BOOL);
$stmt->bindValue(':null', null, PDO::PARAM_NULL);

参数类型:

  • PDO::PARAM_STR:字符串
  • PDO::PARAM_INT:整数
  • PDO::PARAM_BOOL:布尔
  • PDO::PARAM_NULL:NULL
  • PDO::PARAM_LOB:大对象(如文件)

推荐用execute()传数组,简单方便。bindParam适合需要绑定变量引用的场景。

事务(Transaction)

事务,是一组SQL操作,要么全部成功,要么全部失败。适合需要保证数据一致性的场景(如转账、下单)。

try {
    $pdo->beginTransaction();  // 开始事务
    
    // 操作1:A账户减钱
    $sql1 = "UPDATE accounts SET balance = balance - 100 WHERE id = 1";
    $pdo->exec($sql1);
    
    // 操作2:B账户加钱
    $sql2 = "UPDATE accounts SET balance = balance + 100 WHERE id = 2";
    $pdo->exec($sql2);
    
    $pdo->commit();  // 提交事务
    echo '转账成功';
} catch (Exception $e) {
    $pdo->rollBack();  // 回滚事务
    echo '转账失败:' . $e->getMessage();
}

事务的特性(ACID):

  • 原子性(Atomicity):要么全部成功,要么全部失败
  • 一致性(Consistency):事务前后数据保持一致
  • 隔离性(Isolation):并发事务之间互不干扰
  • 持久性(Durability):事务提交后,数据永久保存

注意:

  • MySQL的MyISAM引擎不支持事务,InnoDB支持
  • 事务中不要有DDL语句(CREATE、ALTER等),因为DDL会隐式提交
  • 长时间的事务会锁表,影响性能,尽量短

错误处理

异常模式(推荐)

设置PDO::ATTRERRMODE => PDO::ERRMODEEXCEPTION后,错误会抛出异常:

try {
    $pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
    $pdo->exec("INSERT INTO users (name) VALUES ('张三')");
} catch (PDOException $e) {
    echo '错误:' . $e->getMessage();
    echo '错误码:' . $e->getCode();
}

静默模式

默认的静默模式,需要手动检查错误:

$pdo->exec($sql);
if ($pdo->errorCode() !== '00000') {
    $error = $pdo->errorInfo();
    echo '错误:' . $error[2];
}

// PDOStatement的错误
$stmt->execute();
if ($stmt->errorCode() !== '00000') {
    $error = $stmt->errorInfo();
    echo '错误:' . $error[2];
}

errorInfo()返回数组:

  • [0]:SQLSTATE错误码(5位)
  • [1]:驱动错误码
  • [2]:错误信息

推荐用异常模式,代码更简洁,不容易遗漏错误。

常用操作封装

查询单条

function findOne(PDO $pdo, $table, $id) {
    $sql = "SELECT * FROM {$table} WHERE id = :id";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([':id' => $id]);
    return $stmt->fetch();
}

$user = findOne($pdo, 'users', 1);

查询全部

function findAll(PDO $pdo, $table, $where = [], $limit = null) {
    $sql = "SELECT * FROM {$table}";
    $params = [];
    
    if (!empty($where)) {
        $conditions = [];
        foreach ($where as $key => $value) {
            $conditions[] = "{$key} = :{$key}";
            $params[":{$key}"] = $value;
        }
        $sql .= " WHERE " . implode(' AND ', $conditions);
    }
    
    if ($limit) {
        $sql .= " LIMIT " . intval($limit);
    }
    
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    return $stmt->fetchAll();
}

$users = findAll($pdo, 'users', ['age' => 25], 10);

插入

function insert(PDO $pdo, $table, $data) {
    $fields = array_keys($data);
    $placeholders = array_map(function($f) { return ":{$f}"; }, $fields);
    
    $sql = "INSERT INTO {$table} (" . implode(', ', $fields) . ") VALUES (" . implode(', ', $placeholders) . ")";
    $stmt = $pdo->prepare($sql);
    $stmt->execute($data);
    
    return $pdo->lastInsertId();
}

$id = insert($pdo, 'users', [
    'name' => '张三',
    'email' => 'zhangsan@example.com',
    'age' => 25,
]);

更新

function update(PDO $pdo, $table, $id, $data) {
    $sets = [];
    $params = [':id' => $id];
    
    foreach ($data as $key => $value) {
        $sets[] = "{$key} = :{$key}";
        $params[":{$key}"] = $value;
    }
    
    $sql = "UPDATE {$table} SET " . implode(', ', $sets) . " WHERE id = :id";
    $stmt = $pdo->prepare($sql);
    $stmt->execute($params);
    
    return $stmt->rowCount();
}

update($pdo, 'users', 1, ['name' => '李四', 'age' => 26]);

删除

function delete(PDO $pdo, $table, $id) {
    $sql = "DELETE FROM {$table} WHERE id = :id";
    $stmt = $pdo->prepare($sql);
    $stmt->execute([':id' => $id]);
    return $stmt->rowCount();
}

delete($pdo, 'users', 1);

分页查询

function paginate(PDO $pdo, $table, $page = 1, $pageSize = 10) {
    $offset = ($page - 1) * $pageSize;
    
    // 查询总数
    $countSql = "SELECT COUNT(*) FROM {$table}";
    $total = $pdo->query($countSql)->fetchColumn();
    
    // 查询当前页数据
    $sql = "SELECT * FROM {$table} ORDER BY id DESC LIMIT :offset, :pageSize";
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->bindValue(':pageSize', $pageSize, PDO::PARAM_INT);
    $stmt->execute();
    $data = $stmt->fetchAll();
    
    return [
        'data' => $data,
        'total' => $total,
        'page' => $page,
        'pageSize' => $pageSize,
        'totalPages' => ceil($total / $pageSize),
    ];
}

$result = paginate($pdo, 'users', 1, 10);

注意:LIMIT的参数必须用bindValue绑定为INT类型,不能用execute传数组(数组默认是STR类型,MySQL的LIMIT不接受字符串)。

性能优化

1. 使用预处理语句

预处理语句,SQL只编译一次,可以多次执行,效率高。而且能防止SQL注入。

2. 只查询需要的字段

不要用SELECT *,只查询需要的字段,减少数据传输和内存占用。

// 不好
$sql = "SELECT * FROM users";

// 好
$sql = "SELECT id, name, email FROM users";

3. 使用LIMIT限制结果集

查询大量数据时,用LIMIT限制返回行数,避免内存溢出。

4. 合理使用索引

在WHERE、ORDER BY、JOIN的字段上建立索引,能大大提高查询速度。

CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_name_age ON users(name, age);

5. 避免在循环中查询

不要在循环中执行SQL,会导致大量数据库连接和查询。尽量用一次查询获取所有数据。

// 不好:N+1查询
$users = $pdo->query("SELECT * FROM users")->fetchAll();
foreach ($users as $user) {
    $posts = $pdo->query("SELECT * FROM posts WHERE user_id = {$user['id']}")->fetchAll();
}

// 好:一次查询
$users = $pdo->query("SELECT * FROM users")->fetchAll();
$userIds = array_column($users, 'id');
$in = implode(',', array_fill(0, count($userIds), '?'));
$stmt = $pdo->prepare("SELECT * FROM posts WHERE user_id IN ($in)");
$stmt->execute($userIds);
$posts = $stmt->fetchAll();

6. 使用事务批量插入

大量插入时,用事务包裹,能大大提高速度(因为事务只提交一次)。

$pdo->beginTransaction();
$stmt = $pdo->prepare("INSERT INTO users (name) VALUES (:name)");
for ($i = 0; $i < 1000; $i++) {
    $stmt->execute([':name' => "用户{$i}"]);
}
$pdo->commit();

7. 关闭游标

获取完结果后,关闭游标,释放资源:

$stmt->closeCursor();

8. 使用持久连接(谨慎)

PDO支持持久连接(连接复用,避免重复连接开销),但要谨慎使用,可能导致连接数过多。

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_PERSISTENT => true,
]);

安全注意事项

1. 永远用预处理语句,不要拼接SQL

// 危险:SQL注入
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = {$id}";  // 危险!
$pdo->query($sql);

// 安全:预处理语句
$id = $_GET['id'];
$sql = "SELECT * FROM users WHERE id = :id";
$stmt = $pdo->prepare($sql);
$stmt->execute([':id' => $id]);

2. 表名和字段名不能用占位符

预处理语句的占位符只能用于值,不能用于表名、字段名、SQL关键字。如果需要动态表名/字段名,要用白名单验证。

// 表名用白名单
$allowedTables = ['users', 'posts', 'comments'];
$table = $_GET['table'];
if (!in_array($table, $allowedTables)) {
    die('非法表名');
}
$sql = "SELECT * FROM {$table}";

3. 数据库账号权限最小化

不要用root账号连接应用,创建专用账号,只授予必要的权限(如SELECT、INSERT、UPDATE、DELETE,不要GRANT、DROP、ALTER等)。

4. 错误信息不要暴露给用户

生产环境不要显示数据库错误信息,可能泄露表结构、字段名等敏感信息。记录日志,给用户友好的提示。

5. 敏感数据加密存储

密码、身份证号等敏感数据,不要明文存储。密码用password_hash()哈希,其他敏感数据用加密。

总结

PDO是PHP数据库操作的最佳实践,功能强大,安全可靠。

核心要点:

  1. 连接:创建PDO对象,设置错误模式为异常、默认获取模式为关联数组、禁用模拟预处理
  2. 执行SQL:exec()(无结果集)、query()(有结果集,无用户输入)
  3. 预处理语句:prepare() + execute(),用命名占位符,防止SQL注入,支持批量执行
  4. 获取结果:fetch()(单行)、fetchAll()(多行)、fetchColumn()(单个值)、fetchObject()(对象)
  5. 事务:beginTransaction() + commit() + rollBack(),保证数据一致性
  6. 错误处理:异常模式(推荐),try-catch捕获PDOException
  7. 常用封装:findOne、findAll、insert、update、delete、paginate
  8. 性能优化:预处理、只查需要的字段、LIMIT、索引、避免循环查询、事务批量插入、关闭游标
  9. 安全:永远用预处理、表名/字段名用白名单、账号权限最小化、不暴露错误信息、敏感数据加密

PDO是PHP开发者必须掌握的技能。掌握PDO,能让你的数据库操作更安全、更高效、更优雅。希望这篇文章能帮你更好地使用PDO。