引言
PHP 访问数据库有两条主线:PDO(原生、精准控制、必须懂)与 ORM(Eloquent/Doctrine,生产力高但隐藏查询成本)。本文先把 PDO 的预处理、占位符、事务这些「活命技能」讲透,再对比两套 ORM,最后落到 MySQL 的索引/EXPLAIN/慢查询——因为 90% 的 PHP 性能问题都出在数据库查询。
前置:/php8-modern-features/(类型系统)、/php-oop-design-patterns/(Repository 模式)。容器注入见 /php-laravel-internals/。
目录
- 1. 数据访问方案全景
- 2. PDO 基础与预处理语句
- 3. PDO 陷阱:占位符、错误模式与类型
- 4. Eloquent vs Doctrine:两套 ORM 哲学
- 5. 事务与隔离级别
- 6. 连接管理与复用
- 7. MySQL 性能:索引与 EXPLAIN
- 8. 慢查询治理与 N+1
- 9. 分页与大数据量
- 10. 速查表
- 延伸阅读
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();
| 维度 | Eloquent | Doctrine |
|---|---|---|
| 风格 | 主动记录(ActiveRecord) | 数据映射(Data Mapper) |
| 便捷度 | 高(魔法多) | 低(显式) |
| 类型安全 | 中 | 高(实体约束) |
| 复杂查询 | 弱(要写原生) | 强(DQL/Criteria) |
| 学习成本 | 低 | 中高 |
| 框架 | Laravel | Symfony |
选型: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';
| 列 | 重点看 |
|---|---|
type | const/ref(好)vs ALL(全表扫,坏) |
key | 是否命中索引(NULL = 没命中) |
rows | 扫描行数(越小越好) |
Extra | Using 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 预处理 + 占位符 |
| 常规 CRUD | Eloquent / Query Builder |
| 复杂报表 | 原生 SQL + PDO |
| 多操作原子性 | DB::transaction / beginTransaction |
| 连接复用 | 静态 PDO / 协程连接池 |
| 慢查询 | EXPLAIN + 索引 + 慢查询日志 |
| N+1 | with() 预加载 |
| 大数据分页 | 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]] — 存储引擎与索引底层
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。