PHP 数据库访问深入:PDO、Eloquent 与 MySQL 性能优化

系统覆盖 PHP 数据库访问:PDO 预处理与占位符陷阱、Eloquent vs Doctrine ORM 对比、事务与隔离级别、连接管理、MySQL 索引/EXPLAIN/慢查询治理、分页与大数据量、Laravel 迁移。

引言

PHP 访问数据库有两条主线:PDO(原生、精准控制、必须懂)与 ORM(Eloquent/Doctrine,生产力高但隐藏查询成本)。本文先把 PDO 的预处理、占位符、事务这些「活命技能」讲透,再对比两套 ORM,最后落到 MySQL 的索引/EXPLAIN/慢查询——因为 90% 的 PHP 性能问题都出在数据库查询。

前置:/php8-modern-features/(类型系统)、/php-oop-design-patterns/(Repository 模式)。容器注入见 /php-laravel-internals/。


目录


1. 数据访问方案全景

方案抽象层级适用代表
PDO最底层精准 SQL、复杂查询、批量原生
Query Builder中层动态查询、可读性Illuminate\Database
ORM高层领域建模、CRUD 生产力Eloquent / Doctrine
专用客户端高层特定数据库能力php-mysql、MongoDB ext

选择原则:

  • 需要完全掌控 SQL → PDO
  • 常规 CRUD 追求效率 → ORM
  • 复杂报表/批量 → 原生 SQL + PDO

记忆:ORM 提速开发,PDO 保底性能——成熟项目两者共存:模型走 ORM,报表走 PDO。


2. PDO 基础与预处理语句

PDO(PHP Data Objects):统一接口访问多种数据库(MySQL/PG/SQLite)。

$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=app;charset=utf8mb4';
$pdo = new PDO($dsn, 'app', 'secret', [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,   // 用真预处理
]);

// 预处理:SQL 与参数分离,天然防注入
$stmt = $pdo->prepare(
    'SELECT id, name FROM users WHERE email = ? AND active = ?'
);
$stmt->execute([$email, $active]);
$users = $stmt->fetchAll();

为什么要预处理:

特性说明
防注入参数与 SQL 分离,不拼接
可复用同语句多次执行只编译一次
性能服务端预编译缓存

铁律:任何用户输入进 SQL 都必须走预处理占位符,绝不字符串拼接——这是防 SQL 注入的第一道闸(见 /php-security-hardening/)。


3. PDO 陷阱:占位符、错误模式与类型

占位符两种:位置 ? 与命名 :name:

// 命名占位符(可读性更好)
$stmt = $pdo->prepare('UPDATE users SET name = :name WHERE id = :id');
$stmt->execute([':name' => 'Alice', ':id' => 1]);
// 注意:execute 的数组键要么都带 :,要么都不带

// 绑定参数(精确控制类型)
$stmt->bindParam(':id', $id, PDO::PARAM_INT);

错误模式(必须在创建时设置):

// 推荐:抛异常,捕获后统一处理
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION

// 反例:静默返回 false,容易漏查
PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT

类型陷阱:

// execute 数组里的字符串会被当字符串绑定
// "0" 与 0 在不同 MySQL 模式下可能行为不同
$stmt = $pdo->prepare('SELECT * FROM t WHERE id = ?');
$stmt->execute([$id]);        // 若 $id 是 "1" 字符串,某些场景走索引可能异常
// 稳妥:bindParam 指定类型,或强制转换
$stmt->execute([(int) $id]);

4. Eloquent vs Doctrine:两套 ORM 哲学

Eloquent(Laravel 默认)——主动记录 + 魔法便捷:

$users = User::query()
    ->where('active', true)
    ->orderBy('id', 'desc')
    ->limit(20)
    ->get();

$user = User::find(1);
$user->name = 'Alice';
$user->save();

Doctrine(Symfony 默认)——数据映射 + 类型安全:

// 实体类 + 注解/属性映射
#[ORM\Entity]
#[ORM\Table(name: 'users')]
class User {
    #[ORM\Id, ORM\Column(type: 'integer')]
    private int $id;
    #[ORM\Column(type: 'string', length: 100)]
    private string $name;
}

// 通过 EntityManager 操作
$em->persist($user);
$em->flush();
维度EloquentDoctrine
风格主动记录(ActiveRecord)数据映射(Data Mapper)
便捷度高(魔法多)低(显式)
类型安全中高(实体约束)
复杂查询弱(要写原生)强(DQL/Criteria)
学习成本低中高
框架LaravelSymfony

选型:Laravel 项目用 Eloquent;多数据库/复杂领域模型用 Doctrine。


5. 事务与隔离级别

事务:一组操作要么全成要么全败:

$pdo->beginTransaction();
try {
    $pdo->exec('UPDATE accounts SET balance = balance - 100 WHERE id = 1');
    $pdo->exec('UPDATE accounts SET balance = balance + 100 WHERE id = 2');
    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

Eloquent 事务:

DB::transaction(function () {
    $order = Order::create([...]);
    Stock::where('product_id', $order->product_id)->decrement('qty', 1);
});

隔离级别(MySQL 默认 REPEATABLE READ):

级别脏读不可重复读幻读适用
READ UNCOMMITTED可能可能可能极少
READ COMMITTED无可能可能多数业务
REPEATABLE READ无无可能MySQL 默认
SERIALIZABLE无无无强一致

事务要点:短事务(别在事务里做 HTTP/长计算)、连接不够时事务排队、注意 InnoDB 死锁重试。


6. 连接管理与复用

PHP-FPM 模型:每个请求一个进程,连接用完即关——连接复用靠不了进程常驻:

// 短连接成本高,一个请求可能建多次连接
// 用静态/单例缓存连接(同进程内复用)
final class Db {
    private static ?PDO $pdo = null;
    public static function pdo(): PDO {
        return self::$pdo ??= new PDO($dsn, ...);
    }
}

常驻模式(Swoole/FrankenPHP):进程常驻 → 可以维护连接池:

// Swoole 协程连接池(示意)
$pool = new ConnectionPool(size: 10, factory: fn() => new PDO($dsn, ...));
$conn = $pool->get();
try {
    // 查询
} finally {
    $pool->put($conn);   // 归还复用
}
运行模式连接策略
PHP-FPM每进程单连接 + 静态缓存
Swoole/常驻协程连接池复用
高并发连接数 = 进程数,注意 MySQL max_connections

记忆:FPM 时代连接难复用,靠 OPcache 和查询优化省;常驻时代才有真连接池。


7. MySQL 性能:索引与 EXPLAIN

索引是数据库性能的根基——查询慢先看索引。

-- 常用索引
CREATE INDEX idx_email ON users(email);
CREATE INDEX idx_user_status ON users(user_id, status);   -- 复合索引
-- 覆盖索引:查询列都在索引里 → 免回表

EXPLAIN 解读:

EXPLAIN SELECT * FROM orders WHERE user_id = 1 AND created_at > '2026-01-01';
列重点看
typeconst/ref(好)vs ALL(全表扫,坏)
key是否命中索引(NULL = 没命中)
rows扫描行数(越小越好)
ExtraUsing filesort/Using temporary(警惕)

索引失效常见原因:

-- 函数包裹列 → 索引失效
WHERE YEAR(created_at) = 2026;            -- 坏
WHERE created_at >= '2026-01-01'
  AND created_at <  '2027-01-01';         -- 好

-- 前导通配符 → 索引失效
WHERE name LIKE '%abc';                    -- 坏
WHERE name LIKE 'abc%';                    -- 好

-- 类型不一致 → 隐式转换失效
WHERE mobile = 13800138000;                -- 若 mobile 是 VARCHAR,索引失效
WHERE mobile = '13800138000';              -- 好

8. 慢查询治理与 N+1

开启慢查询日志:

slow_query_log = ON
long_query_time = 1        # 超过 1 秒记录

N+1 问题(ORM 最容易踩):

// 坏:循环里每次查询(N+1)
$orders = Order::all();
foreach ($orders as $order) {
    echo $order->customer->name;   // 每次触发一条 SELECT
}

// 好:预加载(Eager Loading)
$orders = Order::with('customer')->get();   // 一条查询 + JOIN/IN

批量操作:

// 批量插入:一条语句多组值
$stmt = $pdo->prepare('INSERT INTO logs (msg, level) VALUES (?, ?)');
foreach ($logs as $log) {
    $stmt->execute([$log['msg'], $log['level']]);   // 复用预处理
}
// 或
INSERT INTO logs (msg, level) VALUES ('a', 1), ('b', 2), ('c', 3);

查询优化清单:

手段效果
只 select 所需列减 IO
覆盖索引免回表
分页用 keyset大数据量不退化
批量写入减往返
缓存热查询Redis(见 [[redis]])

9. 分页与大数据量

普通分页(小数据量 OK):

$page  = max(1, (int)($_GET['page'] ?? 1));
$size  = 20;
$stmt  = $pdo->prepare(
    'SELECT * FROM orders ORDER BY id DESC LIMIT ? OFFSET ?'
);
$stmt->bindValue(1, $size, PDO::PARAM_INT);
$stmt->bindValue(2, ($page - 1) * $size, PDO::PARAM_INT);

Keyset 分页(大数据集:OFFSET 越翻越慢,改游标):

// 用上次的 id 做游标,走索引无扫描
SELECT * FROM orders
WHERE id < :lastId          -- 上一页最后一条 id
ORDER BY id DESC
LIMIT 20;
分页方式适用问题
LIMIT/OFFSET< 10 万行深分页慢
Keyset(游标)大数据量需可排序唯一列
分页缓存高频数据一致性

10. 速查表

需求做法
防注入查询PDO 预处理 + 占位符
常规 CRUDEloquent / Query Builder
复杂报表原生 SQL + PDO
多操作原子性DB::transaction / beginTransaction
连接复用静态 PDO / 协程连接池
慢查询EXPLAIN + 索引 + 慢查询日志
N+1with() 预加载
大数据分页keyset pagination
批量插入复用预处理 / 多值语句
模型演进Laravel migrations

一句话记忆:PDO 保底防注入,ORM 提速省开发;慢查询先 EXPLAIN,索引命中靠前缀匹配;N+1 用预加载,深分页用游标。


延伸阅读

  • /php-security-hardening/ — SQL 注入防护全清单
  • /php-performance-tuning/ — 查询性能与缓存治理
  • /php-laravel-internals/ — Eloquent 的查询构建内核
  • /php-microservices-message-queue/ — 数据库在事件驱动中的角色
  • [[database]] — 存储引擎与索引底层

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. PHP 面向对象与设计模式:SOLID、常用模式与 Laravel 实践
  2. PHP 静态分析与代码质量:PHPStan、Psalm、Rector 与 CI 门禁
  3. PHP 部署运维实战:Nginx、PHP-FPM、Docker 与 CI/CD