PHP PDO是什么?核心特性与为什么弃用mysqli
搞后端开发的老哥们肯定都跟数据库打过交道,PHP 里操作数据库的工具不少,早年用 mysql_* 函数的人多,后来 mysqli 成了官方推荐,现在我要说,是时候把 mysqli 放一边,拥抱 PDO 了。
PDO 全称是 PHP Data Objects,其实, PHP 官方提供的一个数据库抽象层。它不是一个独立的库,而是跟着 PHP 核心一起发布的。拿现在最新的 PHP 8.3.8(2024年6月刚发)来说,PDO 就是内置核心扩展,装 PHP 的时候默认就带上了,不用你单独折腾安装。
为啥说它是“抽象层”?这就得说它的核心特性了。PDO 不是只盯着 MySQL 一个数据库,它提供了一套统一的接口,能支持 MySQL、PostgreSQL、SQLite、Oracle 等 20 多种数据库。这意味着啥?你写一套代码,只要改改连接字符串,就能从 MySQL 切到 PostgreSQL,不用重写底层的查询逻辑。这点在社区里讨论 PDO 和 mysqli 选型的时候,也是 PDO 最大的加分项——mysqli 是 MySQL 专属的,换个数据库?重写吧兄弟。
除了跨数据库兼容,PDO 还有几个硬核功能:
- 预处理语句(Prepared Statements):这是防 SQL 注入的杀手锏,后面会细讲,原理就是把 SQL 逻辑和参数彻底分开。
- 事务支持:通过
beginTransaction()、commit()、rollBack() 这几个方法,轻松实现 ACID 特性,做电商订单或者金融操作的时候离不开它。
- 异常模式:可以把错误模式设为
ERRMODE_EXCEPTION,数据库报错直接抛异常,配合 try-catch 写起来贼清爽。
- 灵活的结果集获取:
fetch() 拿单条,fetchAll() 拿全部,还能指定返回数组、对象,甚至直接映射到你定义的类实例里。
说到“弃用 mysqli”,其实官方没说 mysqli 不能用了,但在 2024 年的今天,除非你这个项目这辈子都不可能换数据库,而且开发组里全是只会 mysqli 的老古董,否则真没理由选它。社区里关于 PDO 和 mysqli 的争论,结论基本一边倒:PDO 的 API 更面向对象,功能更全,安全性设计更现代。mysqli 虽然也能用预处理,但那套接口又臭又长,面向对象和过程化混着来,看着就头大。
📌 要点提醒:如果你接手的老项目还在用 mysql_* 函数,赶紧跑路……哦不,赶紧重构。如果是新项目,直接上 PDO,别犹豫。哪怕现在只做 MySQL,保不齐哪天老板脑子一热让你兼容 SQLite 做本地缓存呢?PDO 能让你少加几天班。
PDO 支持的数据库示例
// 这只是为了展示DSN格式,不需要运行
$dsnList = [
'mysql' => 'mysql:host=localhost;dbname=test;charset=utf8mb4',
'pgsql' => 'pgsql:host=localhost;port=5432;dbname=test;user=root;password=secret',
'sqlite' => 'sqlite:/path/to/database.db',
'oci' => 'oci:dbname=//localhost:1521/mydb'
];
---
PHP PDO连接数据库与ERRMODE_EXCEPTION配置
连接数据库是第一步,也是最容易出幺蛾子的地方。PDO 连接主要靠 new PDO() 搞定,但里面有几个属性配置,新手很容易避雷经验。
先说连接代码。以现在最主流的 MySQL 为例,我们要用到 PHP 8.3+ 推荐的一些写法。
<?php
$host = '127.0.0.1';
$dbname = 'test_db';
$username = 'root';
$password = 'root_password';
$charset = 'utf8mb4';
// 数据源名称 DSN
$dsn = "mysql:host=$host;dbname=$dbname;charset=$charset";
// 连接选项
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 注意,错误模式设为异常
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 默认返回关联数组
PDO::ATTR_EMULATE_PREPARES => false, // 真正的预处理,防注入更稳
PDO::ATTR_PERSISTENT => false, // 持久化连接,新手先关掉
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
echo "数据库连接成功!当前 PHP 版本: " . PHP_VERSION;
} catch (PDOException $e) {
// 生产环境千万别直接 echo $e->getMessage(),这里只是演示
die("数据库连接失败: " . $e->getMessage());
}
这段代码里,有几个地方得拎出来说说。
第一个是 DSN(数据源名称)。它是个字符串,mysql: 后面跟参数。这里有个细节,charset=utf8mb4 一定要加上。很多新手用 utf8,结果存个 Emoji 表情就乱码了。MySQL 里的 utf8 其实是阉割版,只支持 3 字节,而 utf8mb4 才是完整的 4 字节 UTF-8。
第二个是 ATTR_ERRMODE。这是 PDO 的错误报告模式。默认是 ERRMODE_SILENT,也就是出了错也不吭声,你得自己检查。这在社区里被吐槽得不行,因为很多新手写完代码发现没数据,查半天原来是 SQL 写错了,但 PDO 没报错。强烈建议设为 ERRMODE_EXCEPTION。这样只要 SQL 有问题或者连接失败,PDO 就会抛出一个 PDOException,你用 try-catch 一抓就出来了,调试起来不要太爽。
第三个是 ATTR_EMULATE_PREPARES。这个属性默认是 true,意思是“模拟预处理”。打个比方, PDO 在本地把参数拼进 SQL 发给 MySQL,而不是让 MySQL 服务端去做预处理。这在老版本 PHP 里是为了兼容,但在 PHP 8.3 这种现代环境里,建议设为 false,让数据库原生支持预处理,安全性更高。
还有个面试常考的点:ATTR_PERSISTENT。设为 true 就是持久化连接。意思是脚本结束后,这个数据库连接不马上断开,而是留给下一个脚本用。这能减少连接开销,提升性能。但是!避雷经验警告:持久化连接如果没用好,容易导致连接数爆满,或者在单例模式里出现状态污染。新手阶段,直接设为 false(默认也是 false),等项目大到需要优化性能了再研究这个。
📌 要点提醒:把数据库连接封装成一个函数或者一个类。别在每个文件里都写一遍 new PDO。这样以后改密码或者换数据库,改一个地方就行。
<?php
// 这是一个简单的获取数据库连接的例子
function getPDO(): PDO {
static $pdo;
if ($pdo === null) {
$pdo = new PDO(
'mysql:host=localhost;dbname=test_db;charset=utf8mb4',
'root',
'password',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
]
);
}
return $pdo;
}
---
PDO预处理语句防SQL注入原理与增删改查实战
终于到了最核心的部分——防 SQL 注入。很多新手以为只要把参数用引号包起来就安全了,大错特错。
先讲原理。SQL 注入是咋发生的?是因为你把用户输入的数据直接拼到了 SQL 语句里。
比如这段代码:
$username = $_GET['user']; // 假设用户输入是:' OR '1'='1
$sql = "SELECT * FROM users WHERE username = '$username'";
// 最终SQL变成了:SELECT * FROM users WHERE username = '' OR '1'='1'
// 这就把全表数据查出来了,如果是DELETE语句,整个表都没了
预处理语句(Prepared Statements) 怎么解决这个问题的?它把 SQL 语句和数据分开了。它分两步走:
- 准备阶段:你先发给数据库一条带占位符(比如
? 或者 :name)的 SQL。数据库会先编译这条 SQL 的逻辑,但不执行。
- 执行阶段:你再把具体的参数值发给数据库。数据库只是把这个值当成一个纯粹的数据塞进之前编译好的逻辑里。
其实,数据库在第一步已经知道你要干嘛了(比如“我要查 users 表 where username = 某个值”),到了第二步,你传进来的无论是什么,哪怕是 ' OR '1'='1,它也只会被当成“值”,而不是“SQL 指令”。这就从根源上防住了注入。
PDO 里有两种占位符,一种是问号 ?,一种是命名占位符 :name。我个人更喜欢命名占位符,看着清楚。
下面来一套完整的增删改查(CRUD)实战代码,都是基于 PHP 8.3 环境可直接运行的。
<?php
// 假设已经连好数据库,$pdo 是上面那个连接对象
$pdo = new PDO('mysql:host=localhost;dbname=test_db;charset=utf8mb4', 'root', 'password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false
]);
// 1. 增 (INSERT)
echo "--- 新增数据 ---\n";
$sqlInsert = "INSERT INTO users (name, email, age) VALUES (:name, :email, :age)";
$stmt = $pdo->prepare($sqlInsert);
// 绑定参数并执行
$stmt->execute([
':name' => '张三',
':email' => 'zhangsan@example.com',
':age' => 25
]);
// 或者用 bindValue (推荐新手用 execute 传数组,更简洁)
$lastId = $pdo->lastInsertId();
echo "新增成功,ID: $lastId\n";
// 2. 查 (SELECT)
echo "\n--- 查询数据 ---\n";
$sqlSelect = "SELECT * FROM users WHERE age > :age LIMIT :limit";
$stmt = $pdo->prepare($sqlSelect);
// 注意:如果你在 SQL 里用了 LIMIT,且数据库驱动不支持在预处理中绑定整数(比如某些老版本的 SQLite),
// 可能需要强制类型转换,但在 MySQL + PDO 中通常没问题。
$minAge = 18;
$limit = 10;
$stmt->bindParam(':age', $minAge, PDO::PARAM_INT); // 明确指定类型
$stmt->bindParam(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
// 获取结果
$users = $stmt->fetchAll();
foreach ($users as $user) {
echo "用户: {$user['name']}, 邮箱: {$user['email']}\n";
}
// 3. 改 (UPDATE)
echo "\n--- 更新数据 ---\n";
$sqlUpdate = "UPDATE users SET age = :age WHERE name = :name";
$stmt = $pdo->prepare($sqlUpdate);
$stmt->execute([':age' => 26, ':name' => '张三']);
$affectedRows = $stmt->rowCount(); // 获取受影响的行数
echo "更新了 $affectedRows 条记录\n";
// 4. 删 (DELETE)
echo "\n--- 删除数据 ---\n";
$sqlDelete = "DELETE FROM users WHERE id = :id";
$stmt = $pdo->prepare($sqlDelete);
$stmt->execute([':id' => $lastId]);
echo "删除了 {$stmt->rowCount()} 条记录\n";
这里有个社区里讨论很火的话题:预处理是不是万能的?
并不是。有个典型的边缘场景是 ORDER BY。你没法这么写:ORDER BY :column,因为列名不是数据,数据库不允许把列名参数化。
如果用户输入排序字段,你得这样处理:
$allowedColumns = ['name', 'age', 'created_at'];
$sortBy = $_GET['sort'] ?? 'name';
// 白名单校验,这是防注入的第二道防线
if (!in_array($sortBy, $allowedColumns)) {
$sortBy = 'name';
}
$sql = "SELECT * FROM users ORDER BY $sortBy"; // 这里直接拼了,但因为是白名单过滤过的,安全
$stmt = $pdo->query($sql); // 没有参数的话,直接 query 也行
📌 要点提醒:永远不要信任用户输入。预处理是防注入的主力,但对于表名、列名这种没法参数化的地方,一定要用白名单过滤。另外,虽然 PDO 的 bindParam 和 bindValue 有区别(bindParam 是引用绑定,bindValue 是值绑定),但在 execute 里传数组其实内部是调用了 bindValue,对于大多数场景来说,直接传数组是最省事的写法。
随着 PHP 8.4+ 的发展,类型系统会更严格,PDO 的参数绑定说不定也会支持更细粒度的标量类型校验,到时候写代码会更安全。现在的趋势就是能原生预处理就原生预处理,别偷懒用模拟模式。
4. bindParam与bindValue区别及事务处理进阶技巧
很多新手刚开始用 PDO 预处理的时候,都会被 bindParam 和 bindValue 搞懵,觉得这俩不都是传参数吗,有啥区别?其实,这俩的区别要是没搞懂,很容易写出逻辑不对的代码,甚至经验之谈踩到怀疑人生。咱们先拿最直观的例子说,先回忆下预处理语句的基本用法:PDO 的预处理是把 SQL 逻辑和参数分开,参数用占位符(比如 ? 或者 :name)代替,之后再绑定值。那 bindValue 就是直接把你当时传的那个值“焊死”在占位符上,bindParam 呢,是绑定的一个变量的引用,这个变量后面要是变了,执行的时候用的就是变之后的值。
我给你写个完整的代码例子,你跑一遍就懂了。咱们假设用的是 PHP 8.3.8 环境,连的是 MySQL 数据库,先建个测试表:
CREATE TABLE IF NOT EXISTS `test_users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(50) NOT NULL,
`age` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
然后咱们写 PHP 代码测试俩方法的区别:
<?php
// 数据库连接配置,这里用 MySQL,注意 PHP 8.3+ 的 DSN 格式没啥大变化,但建议指定字符集为 utf8mb4
$dsn = 'mysql:host=localhost;dbname=test_db;charset=utf8mb4';
$username = 'root';
$password = '123456';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 开启异常模式,出错直接抛异常,方便调试
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
// 先清空测试表,避免重复数据
$pdo->exec('TRUNCATE TABLE test_users');
### 测试 bindValue 的行为
$sql = 'INSERT INTO test_users (username, age) VALUES (:username, :age)';
$stmt = $pdo->prepare($sql);
$name = '张三';
$age = 20;
// 绑定值,这时候传的是当前 $name 和 $age 的值
$stmt->bindValue(':username', $name);
$stmt->bindValue(':age', $age);
// 改原来的变量,看看会不会影响执行结果
$name = '李四';
$age = 25;
// 执行,这时候插入的应该是张三和20,因为 bindValue 绑的是当时的值
$stmt->execute();
echo "bindValue 插入后的数据:\n";
$res = $pdo->query('SELECT * FROM test_users WHERE id = 1')->fetch();
print_r($res); // 输出应该是 Array ( [id] => 1 [username] => 张三 [age] => 20 )
### 测试 bindParam 的行为
$sql2 = 'INSERT INTO test_users (username, age) VALUES (:username, :age)';
$stmt2 = $pdo->prepare($sql2);
$name2 = '王五';
$age2 = 30;
// 绑定参数,注意这里传的是变量的引用,不是值
$stmt2->bindParam(':username', $name2);
$stmt2->bindParam(':age', $age2);
// 改原来的变量,这时候再执行,用的就是新的值
$name2 = '赵六';
$age2 = 35;
$stmt2->execute();
echo "\nbindParam 插入后的数据:\n";
$res2 = $pdo->query('SELECT * FROM test_users WHERE id = 2')->fetch();
print_r($res2); // 输出应该是 Array ( [id] => 2 [username] => 赵六 [age] => 35 )
} catch (PDOException $e) {
die('数据库操作失败:' . $e->getMessage());
}
?>
你跑完这个代码就明白了,bindValue 绑的时候就把值抄过来了,后面改原变量没用;bindParam 是绑的变量地址,执行的时候才去取变量当前的值。那啥时候用哪个?比如你要循环插入数据,用 bindParam 就不用每次都重新绑定,改下变量值再执行就行,省点代码;要是你要绑定的值是临时计算的,之后不会再变,用 bindValue 更稳妥,避免后面不小心改了变量导致数据不对。
说完这俩,再聊聊事务处理。很多人知道 PDO 支持事务,但是进阶技巧就没那么清楚了。事务的核心是保证一组操作要么全成要么全败,比如电商下单,要扣库存、加订单、加订单详情,这三个操作必须一起成功,要是扣了库存订单没生成,那库存就白扣了。PDO 的事务用法很简单,beginTransaction() 开启,commit() 提交,rollBack() 回滚,但是有几个坑你要注意。
比如嵌套事务的问题,MySQL 本身是不支持嵌套事务的,你调用两次 beginTransaction(),第二次不会真的开新事务,但是 commit() 却会直接提交所有操作,这时候要是后面的操作出错了,你回滚已经来不及了。还有,要是你在事务里执行了 DDL 语句(比如 CREATE TABLE、ALTER TABLE),MySQL 会隐式提交事务,这时候你再回滚就没用了。我给你写个完整的事务示例,包括异常回滚的情况:
<?php
$dsn = 'mysql:host=localhost;dbname=test_db;charset=utf8mb4';
$username = 'root';
$password = '123456';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
// 开启事务
$pdo->beginTransaction();
// 操作1:插入用户
$stmt = $pdo->prepare('INSERT INTO test_users (username, age) VALUES (?, ?)');
$stmt->execute(['测试用户', 20]);
$userId = $pdo->lastInsertId(); // 获取刚插入的用户ID
// 操作2:模拟插入订单,这里故意写个错的表名,触发异常
$stmt2 = $pdo->prepare('INSERT INTO test_orders (user_id, amount) VALUES (?, ?)');
// 假设 test_orders 表不存在,执行这行会抛异常
// $stmt2->execute([$userId, 100.00]);
// 要是上面没报错,就提交事务
$pdo->commit();
echo "事务提交成功\n";
} catch (PDOException $e) {
// 出错了就回滚,所有操作都撤销
if ($pdo->inTransaction()) { // 值得留意的是,先判断是不是在事务里,避免不在事务里调用 rollBack 报错
$pdo->rollBack();
}
echo "事务回滚,错误原因:" . $e->getMessage() . "\n";
}
?>
这里有个⚡ 效率提示:每次回滚之前一定要用 inTransaction() 判断下当前是不是还在事务上下文中,不然要是事务已经自动提交了(比如执行了 DDL),你再调用 rollBack() 会直接抛异常,反而雪上加霜。另外,事务不要开太长,要是你事务里执行了耗时的操作(比如请求第三方接口),会长时间占用数据库连接,高并发的时候容易把连接池打满,要是真有这种场景,尽量把耗时的操作放到事务外面,事务里只做数据库操作。
5. PDO常见安全坑与ORDER BY注入解决方案
很多人觉得用了 PDO 预处理就万事大吉,绝对不会被 SQL 注入了,其实这是大错特错,预处理不是万能的,用不对照样有安全漏洞。我这几年见过太多新手踩这种坑,甚至有些写了两三年 PHP 的人都没搞清楚。先给你说几个最常见的 PDO 安全坑:
第一个坑:直接把用户输入拼到 SQL 里,还觉得用了 PDO 就没事。比如有人写 $sql = "SELECT * FROM users WHERE id = " . $_GET['id'];,然后 $pdo->query($sql),这跟不用 PDO 没区别,预处理根本没起作用,用户输入啥就拼啥,注入轻轻松松。
第二个坑:用 bindParam 或者 bindValue 的时候,占位符用了 ? 但是参数顺序搞混了,或者用了命名占位符但是名字写错,虽然不会注入,但是会导致 SQL 执行错误,要是你没开异常模式,错误直接暴露给用户,反而给了攻击者信息。
第三个坑,也是最难搞的:ORDER BY 后面的字段没法直接用预处理参数化。为啥?因为预处理的参数是作为字符串值处理的,比如你写 ORDER BY ?,然后绑定 'id',实际执行的 SQL 会变成 ORDER BY 'id',带引号的,MySQL 会把 'id' 当成字符串常量,而不是字段名,排序就失效了。这时候很多人就直接把用户输入拼到 ORDER BY 后面,比如 $order = $_GET['order']; $sql = "SELECT * FROM users ORDER BY $order";,这就给了注入空间,攻击者可以传 id, (SELECT 1 FROM (SELECT count(*),concat(version(),floor(rand(0)*2))x FROM information_schema.tables group by x)a) 这种 payload,直接能拿到数据库版本甚至数据。
那 ORDER BY 注入怎么解决?总不能不用排序吧?我给你说几个靠谱的方案,都是实际项目里能用的。
第一个方案:白名单校验。其实,你能接受的排序字段就那几个,比如只允许 id、age、create_time,用户输入的如果是这几个里面的,就直接用,不是就给个默认值。这个是最稳妥的,几乎没有风险。我给你写个完整例子:
<?php
$dsn = 'mysql:host=localhost;dbname=test_db;charset=utf8mb4';
$username = 'root';
$password = '123456';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
// 接收用户输入的排序字段和排序方向
$userOrderField = $_GET['order_field'] ?? 'id';
$userOrderDir = $_GET['order_dir'] ?? 'DESC';
// 白名单:允许的排序字段
$allowOrderFields = ['id', 'age', 'create_time'];
// 白名单:允许的排序方向
$allowOrderDirs = ['ASC', 'DESC'];
// 校验排序字段,不在白名单就用默认的 id
if (!in_array($userOrderField, $allowOrderFields)) {
$userOrderField = 'id';
}
// 校验排序方向,不在白名单就用默认的 DESC
if (!in_array($userOrderDir, $allowOrderDirs)) {
$userOrderDir = 'DESC';
}
// 直接用校验后的字段名拼 SQL,因为已经过了白名单,不会有注入风险
$sql = "SELECT * FROM test_users ORDER BY $userOrderField $userOrderDir";
$stmt = $pdo->query($sql);
$users = $stmt->fetchAll();
echo "排序后的用户数据:\n";
print_r($users);
} catch (PDOException $e) {
die('查询失败:' . $e->getMessage());
}
?>
这个方案的好处是简单有效,不管用户输入啥,只要不在白名单里就直接过滤,根本不给注入的机会。要是你的排序字段是动态的,比如从数据库里读出来的,那也可以把允许的字段存到数组里,每次查一下有没有,没有就用默认。
第二个方案:要是你实在不想用白名单,比如排序字段是动态的,你可以用字段映射,比如用户传 1 代表按 id 排序,2 代表按 age 排序,你后端做个映射,这样用户传的永远都是数字,也不会有注入风险。
还有个坑要注意:很多人用 LIKE 查询的时候,直接把 % 拼到参数里,比如 $keyword = '%' . $_GET['keyword'] . '%'; $stmt->bindValue(':keyword', $keyword);,这个其实没问题,但是要注意如果你的参数是用户直接传的,里面可能有 _ 或者 % 这种通配符,会匹配到额外的内容,要是你不想让用户用通配符,最好提前把这两个字符转义,用 addcslashes($keyword, '%_') 就行。
📖 学习建议:不管你用啥方案处理 ORDER BY,永远不要直接把用户输入拼到 SQL 里,白名单校验是最稳妥的,哪怕多写几行代码,也比留个安全漏洞强,真出了注入事故,背锅的还是你。
6. PHP 8.3+ PDO性能优化与未来异步趋势展望
现在 PHP 最新稳定版是 2024 年 6 月发布的 PHP 8.3.8,PDO 作为核心扩展跟着同步更新,虽然没有独立版本号,但是这几年 PHP 版本迭代很快,PDO 的性能和功能也在慢慢优化。很多开发者觉得 PDO 性能不如 mysqli,换个角度看大部分时候是用法不对,不是 PDO 本身的问题,优化好了性能差不了多少,还能享受多数据库支持的红利。
先说几个 PHP 8.3 下 PDO 的实用性能优化技巧。第一个:合理选择 fetch 模式。很多人查数据不管多少条都用 fetchAll(),要是数据量大的话,fetchAll() 会把所有结果都放到内存里,很容易内存溢出。比如你要导出十万条数据,用 fetchAll() 可能直接把内存吃满,这时候用 fetch() 循环取单条就好,用完一条释放一条,内存占用会小很多。我给你写个对比的例子:
<?php
$dsn = 'mysql:host=localhost;dbname=test_db;charset=utf8mb4';
$username = 'root';
$password = '123456';
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
];
try {
$pdo = new PDO($dsn, $username, $password, $options);
// 先插入10万条测试数据,方便测试性能
$pdo->beginTransaction();
$stmt = $pdo->prepare('INSERT INTO test_users (username, age) VALUES (?, ?)');
for ($i = 0; $i < 100000; $i++) {
$stmt->execute(["user_$i", rand(18, 60)]);
}
$pdo->commit();
echo "测试数据插入完成\n";
### 用 fetchAll 查询,内存占用高
echo "使用 fetchAll 查询:\n";
$startMem = memory_get_usage();
$stmt2 = $pdo->query('SELECT * FROM test_users');
$allData = $stmt2->fetchAll();
$endMem = memory_get_usage();
echo "内存占用:" . round(($endMem - $startMem) / 1024 / 1024, 2) . "MB\n"; // 可能占用几十MB甚至更多
unset($allData); // 释放内存
### 用 fetch 循环查询,内存占用低
echo "\n使用 fetch 循环查询:\n";
$startMem2 = memory_get_usage();
$stmt3 = $pdo->query('SELECT * FROM test_users');
while ($row = $stmt3->fetch()) {
// 这里可以做处理,比如写入文件,不用存到数组里
// 比如 file_put_contents('users.txt', implode(',', $row) . PHP_EOL, FILE_APPEND);
}
$endMem2 = memory_get_usage();
echo "内存占用:" . round(($endMem2 - $startMem2) / 1024 / 1024, 2) . "MB\n"; // 几乎没怎么涨
} catch (PDOException $e) {
if (isset($pdo) && $pdo->inTransaction()) {
$pdo->rollBack();
}
die('操作失败:' . $e->getMessage());
}
?>
第二个优化技巧:合理使用持久连接 ATTR_PERSISTENT。默认情况下,PDO 每次实例化都会新建一个数据库连接,用完就关,高并发的时候频繁的建连关连会很耗性能。要是你设置了 ATTR_PERSISTENT => true,连接会被放到连接池里,下次请求复用,能减少建连开销。但是要注意,持久连接不是越多越好,要是你设置的最大连接数不够,会导致连接排队,反而变慢,而且持久连接不会在脚本结束后自动关闭,要是有事务没提交,或者临时表没删,会影响到下一个请求。一般中小型应用用持久连接没问题,但是你要是用的共享主机或者连接数有限的环境,最好别开。
第三个技巧:预处理语句尽量复用。比如你要循环执行同一个 SQL,不要每次都 prepare,prepare 一次,然后循环绑定参数执行就行,prepare 本身是有开销的,复用能省不少性能。
再说说未来的趋势,根据现在 PHP 社区的讨论和官方路线图,2024-2026 年 PDO 会有几个明显的变化。第一个是和 PHP 类型系统深度整合,PHP 8.4+ 会强化属性类型声明,PDO 的参数绑定可能会支持更严格的标量类型校验,比如你绑定一个 int 类型的参数,传了字符串就会直接报错,不用等到执行 SQL 的时候才发现,能提前避免很多错误。
第二个是异步 PDO 的探索。现在 PHP 的异步生态越来越成熟,Swoole、Fiber 这些技术用得越来越多,但是传统的 PDO 是同步阻塞的,执行查询的时候整个进程都等着,浪费资源。现在社区已经在讨论给 PDO 加异步支持,或者出专门的异步 PDO 驱动,配合 PHP 的 Fiber 实现非阻塞数据库操作,高并发场景下的性能会提升很多。比如你同时查三个表的数据,现在要一个个等,以后可以三个查询同时发,等所有结果返回再处理,省时间。
第三个是安全增强,以后 PDO 可能会默认启用更严格的预处理模式,比如不允许直接拼接 SQL,减少开发者误配置导致的安全漏洞,甚至可能会对 ORDER BY 这种边缘场景做内置的安全处理,不用开发者自己写白名单了。还有 MySQL 8.0+ 的很多新特性,比如并行查询,PDO 的 MySQL 驱动也会慢慢适配,大数据量查询的时候效率会更高。
⚡ 效率提示:要是你现在用的是 PHP 8.2 及以下版本,可以考虑升级到 PHP 8.3.8 最新稳定版,PDO 的稳定性和性能都有提升,而且能提前适配未来的类型校验特性。要是你做高并发项目,现在可以关注下 Swoole 的异步 MySQL 客户端,虽然还不是标准 PDO,但是能解决现在的异步数据库操作需求,等以后官方 PDO 支持异步了再迁移过去也方便。