CatchAdmin PHP 后台管理框架 Logo CatchAdmin

PDO、Doctrine DBAL、Eloquent 还是自己造——PHP 数据库层选型指南

大多数 PHP 数据库教程中都潜藏着一个令人困扰的默认假设,有必要在展开讨论之前先将其指明。

这个假设是:PDO、Doctrine DBAL、Eloquent 和自定义数据层都是"与数据库对话的方式",问题仅在于选择哪一个。大多数对比文章将它们并列排开,列出功能对照表,然后给出类似"用 Eloquent 快速开发,用 DBAL 处理复杂查询,用 PDO 获得完全控制"的结论。

这个框架是错的,至少具有误导性。这四个选项并非同一层次上的替代品——它们处于四个完全不同的抽象层级上,而"哪个最好"完全取决于你的应用实际需要哪个层次。逐项功能对比它们,就像对比一把锤子、一把钉枪、一块预制墙板和一间定制木工车间。是的,它们都能让你把东西固定在建筑上。不,它们不是同一类选择。

本文尝试提供一种真正有意义的对比:不是功能清单,而是诚实地评估每个层次给了你什么、从你那里拿走了什么,以及哪种项目适合选它。文中还将讨论最具争议的选项——"自己造"——因为一些经验丰富的开发者会告诉你(他们也并非全错),现有的现成方案都不符合他们的需求。

需要说明的是:本文观点来自在所有四种方案上实际交付应用的经验,每种方案在实际使用中也各有遗憾。以下内容来自实际项目中的经验教训。

每种方案分别处于哪个抽象层级

在进入具体分析之前,先来理清这层"抽象层级蛋糕"。

PDO 是驱动抽象层。它为开发者提供了一套与 MySQL、PostgreSQL、SQL Server、SQLite 等数据库交互的统一 PHP API。开发者手写 SQL。PDO 负责预处理语句、绑定参数、执行查询、获取行数据。它不理解 SQL 的含义,不知道什么是"用户"。它在 PHP 和数据库之间传输字节,在参数绑定上提供类型安全,并提供一些便捷的获取方法。

Doctrine DBAL 是查询构建器和可移植 SQL 抽象层。它构建在 PDO 之上。它依然不知道什么是"用户",但它知道如何为开发者编写可移植的 SQL——LIMIT vs ROWNUM、标识符引用、日期函数、模式自省。开发者通过流式 API 构建查询;DBAL 根据配置的数据库驱动生成正确的 SQL。在这一层,"编写的代码无需修改就能在 MySQL 和 PostgreSQL 上运行"才真正变为可行。

Eloquent 是 Active Record ORM。它构建在自有的查询构建器之上,而后者构建在 PDO 之上。它确实知道什么是"用户",因为开发者已经告诉它了:class User extends Model。Eloquent 为开发者提供代表行的对象、代表关联的关系定义,以及在幕后生成 SQL 的方法调用。开发者以对象的方式思考,框架以查询的方式思考。

自己造 则是一切由开发者决定。大多数"自定义"数据层实际上是 PDO + 一层薄约定 + 封装查询的按模型类 的组合。有些则野心更大——从头构建完整的 ORM,通常包含针对特定场景(如多租户查询或领域特定模式模式)的专用抽象。

核心洞察:往上走获得更多便利和更少控制,往下走获得更多控制和更少便利。世上没有免费的午餐。每一层都添加了一些有用的东西,也都拿走了一些东西。问题在于哪种权衡与你的项目的实际需求相匹配。

PDO:被低估的默认选项

PDO 是每个人最先学会、大多数人在发现"真正的"ORM 后就放弃的选项。有必要论证的是,这往往是一个错误。

PDO 真正用好了,能给你带来什么:

  • 直接 SQL。开发者写出数据库将执行的精确查询。可以直接阅读、分析、优化和推理,无需在脑海中将对象方法调用翻译回 SQL。
  • 可预测的性能。没有隐藏的连接,没有 N+1 的意外,没有触发预期外查询的延迟加载。每次操作的成本就是开发者所写的成本。
  • 对后端团队的最低学习曲线。每位数据库工程师都能读懂 SQL。他们未必都能读懂 Eloquent 的流式调用链。
  • 极小的依赖体量。PDO 在 PHP 内核中。没有需要更新的包,没有 3.x → 4.x 升级中的破坏性变更,三年后也不需要迁移到社区 fork。
  • 易于 mock 测试,或切换到内存 SQLite。当数据层只是 SQL 的一层薄封装时,测试隔离非常简单。

PDO 的成本是什么:

  • 样板代码。每次都要写 prepare-bind-execute-fetch。对于拥有几十张表的 CRUD 重度应用,这会很快变得重复繁琐。
  • 跨数据库不可移植。PDO 统一了 API,但没有统一 SQL 方言。为 MySQL 编写的查询如果使用了 MySQL 特定语法,不修改就无法在 PostgreSQL 上运行。
  • 手动处理关系。没有 with(),没有预加载辅助方法,没有关联对象的自动水合。如果想要"用户及其帖子",需要编写两个查询并手动组装结果。
  • 没有模式内省或迁移工具。PDO 只做查询,不管理数据库模式。需要自己带迁移方案(Phinx、原始 SQL 文件,或其他)。

谁应该直接使用 PDO

当应用与数据库的关系是操作性的而非模型化的,PDO 是合适的工具。以下是一些典型场景:

  • 一个运行 30 条特定复杂查询并返回格式化输出的报表服务。没必要为 30 条查询搭建一个 ORM。
  • 一个读取 CSV 并插入行的数据导入脚本。不需要领域模型;行进,行出。
  • 一个只有两张表且 API 接口清晰的微服务。ORM 的开销在两张表的规模上收不回成本。
  • 任何 SQL 本身就是核心价值、不希望框架决定发送什么查询的应用。

PDO 也适合那些处于应用内部但层级足够低、ORM 反而碍事的代码:批量导入操作、数据迁移、经过性能分析确认 ORM 开销过高的关键热路径。

大多数团队在 PDO 上犯的错误不是使用它——而是用得不好。用原始字符串拼接、到处复制粘贴 prepare/bind 代码块、没有统一的获取模式、没有错误处理策略的 PDO 代码,确实比使用 ORM 更糟。反之,精心组织的 PDO 代码——配合按表的薄封装类和一致的约定——是 PHP 中最干净的数据层之一。

Doctrine DBAL:PHP 中被最严重低估的一层

如果 PDO 是人们过早放弃的选项,那么 DBAL 就是人们根本没意识到它存在的选项。它是 Doctrine 生态体系的一部分,但完全可以独立于 Doctrine ORM 使用。它提供查询构建器、模式管理、类型系统以及跨数据库的可移植性——而不需要完整 ORM 映射的重型机制。

在这一层,开发者编写的是这样的代码:

php
$query = $connection->createQueryBuilder()
    ->select('u.id', 'u.email', 'COUNT(o.id) as order_count')
    ->from('users', 'u')
    ->leftJoin('u', 'orders', 'o', 'o.user_id = u.id')
    ->where('u.created_at > :date')
    ->groupBy('u.id', 'u.email')
    ->setParameter('date', $cutoffDate);
$results = $query->executeQuery()->fetchAllAssociative();

开发者的思考仍然在 SQL 层面——有 SELECT,有 JOIN,有 GROUP BY——但不再做字符串拼接,且生成的查询会根据配置的数据库自动生成方言正确的版本。

DBAL 比 PDO 多给了什么

  • 一个生成可移植 SQL 的查询构建器。将数据库从 MySQL 切换到 PostgreSQL 只需改一个配置值,大多数查询无需修改即可继续工作。
  • 参数绑定的类型系统。字符串、整数、日期、布尔值、JSON——DBAL 负责将值转换为目标数据库期望的格式。
  • 模式管理。通过编程方式构建、检查和对比数据库模式。适用于迁移工具、每租户一个数据库的多租户架构,或任何需要在运行时感知模式结构的场景。
  • 事务抽象。嵌套保存点、重试辅助方法、隔离级别控制——跨驱动一致地处理。

DBAL 的成本是什么

  • 一个依赖。它是一个 Composer 包,有自己的发布周期和主版本间的破坏性变更。
  • 查询构建器的学习曲线。不算难,但也不为零,需要花一些时间查阅不常用操作的语法。
  • 没有领域建模。和 PDO 一样,DBAL 不知道什么是"用户"。如果需要这一层,仍然要自己构建。

谁应该使用 DBAL

DBAL 是需要查询构建和跨数据库可移植性、但不需要对象水合的应用的最佳平衡点。几个典型例子:

  • 需要对接多个客户数据库(有些是 MySQL,有些是 PostgreSQL)的报表和分析工具。
  • 需要复杂查询(聚合、窗口函数、CTE)但将结果视为原始数据而非领域对象的后端服务。
  • 每个租户可能使用不同数据库引擎的多租户应用。
  • 从 PDO 起步、样板代码逐渐变得痛苦、但引入完整 ORM 又感觉用力过猛的代码库。

DBAL 也非常适合作为自定义数据层的基础。如果想"自己造"数据层(详见下文),从 DBAL 起步可以免费获得可移植性和类型处理能力,自定义层只需专注于应用独有的约定和领域逻辑。

Eloquent:大多数 Laravel 开发者不假思索的选择

Eloquent 是 Laravel 应用的事实默认 ORM,而且也确实设计得相当出色——易于学习、语法表达力强、生态丰富、文档完善。对于绝大多数的 CRUD 型应用,Eloquent 就是正确答案,否认这一点是不现实的。

但随着应用的增长,Eloquent 有一些成本讨论得不够充分,需要诚实地面对它们。

Eloquent 给了你什么

  • Active Record 的便利。User::find(1) 返回一个完整水合的用户对象。$user->posts 返回他们的帖子。数据库行与 PHP 对象之间的映射完全透明。
  • 关系作为一等公民。hasManybelongsTomorphTohasManyThrough——声明式、表达力强、功能强大。
  • 预加载。User::with('posts')->get() 用两条查询(而非 N+1 条)解决了常见场景的 N+1 问题。
  • 庞大的生态。作用域、观察者、事件、修改器、访问器、类型转换、工厂、种子数据——几乎每一种常见需求都有对应工具。
  • 与 Laravel 其余部分的深度集成。验证、表单请求、API 资源、广播——Eloquent 模型在框架中自然流转。

Eloquent 的成本是什么

  • 规模化时的性能问题,其表现并非从语法上显而易见。相关规律另有专文讨论;简而言之,with('posts') 无论父记录是 100 条还是 100,000 条看起来都一样,而这两种情况之间的差距就是"快速"和"生产环境内存溢出"之间的差距。
  • 一个隐藏了 SQL 的心智模型。先学 Eloquent 的开发者通常更难推理查询性能,因为抽象层太平滑,底层查询变得不可见。Eloquent 并非恶意隐藏 SQL,但对开发者心智模型的影响是真实的。
  • 沉重的模型对象。每个 Eloquent 模型都包含属性类型转换、修改器缓存、关联加载机制、脏数据追踪、事件钩子。对于数万条记录的批量操作,这种开销累积得很快。
  • 必须接受的约定。created_at/updated_at、复数表名、整数主键、软删除——这些都是 Eloquent 强烈鼓励的约定。偏离这些约定是可能的,但会损失生态收益。
  • 对 Laravel 的锁定。Eloquent 不经过大量改造无法在 Laravel 之外使用。如果想从 Laravel 应用中提取一个服务独立运行,将需要重写数据层。

谁应该使用 Eloquent

Eloquent 是典型 Laravel CRUD 应用的正确答案——这是一个巨大的类别。拥有数十个关联模型的 Web 应用、涉及加载实体及其关系的面向用户功能、管理面板、内容管理等等。对于这些场景,Eloquent 的生产力在 PHP 中无出其右,上述成本真实存在但可以接受。

以下情况 Eloquent 不再是正确答案:

  • 对数百万条记录进行批量操作,每条记录的开销不可忽略。
  • 需要查询构建器都难以驾驭的复杂查询(窗口函数、递归 CTE、PostgreSQL 高级特性)。
  • 应用的查询本身就是系统的核心价值,而非附带部分。
  • 构建的是 Laravel 之外的东西,无法承担框架依赖。

在实践中沉淀出的模式是:在便利性重要的应用代码中使用 Eloquent,在热路径、报表和批量操作中下沉到 DBAL 或原始 PDO。Laravel 使这种混合方法极易实现——DB::table()DB::raw() 都是一等公民——但这是一种需要主动选择的纪律。默认的"一切用 Eloquent"是阻力最小的路径,也是规模化后最容易反噬的路径。

自己造:可辩护的"异端"

有必要认真对待"自己造"这个选项,因为 PHP 社区有一种倾向,将自定义数据层要么视为过度工程化,要么视为 NIH 综合症,而这种否定有时是错的。

一些经验丰富的 PHP 开发者构建了自己的数据层,并在生产环境中维护了多年——有时甚至数十年——至今仍然真心满意。与足够多这类开发者交流后,可以确认这一立场并非疯狂。其中最有条理的表述大致如下:

"现成的 ORM 是为通用场景设计的。但并非所有应用都是通用场景。Eloquent 强制要求的约定、Doctrine ORM 强加的抽象、两者做出的权衡——都不一定匹配实际需求。在 PDO(或 DBAL)之上构建一个薄薄的、了解领域约定的数据层,结果是几百行代码就精确地做了需要做的事,没有任何意外。"

这话有道理。自定义数据层做得好时,确实有真正的优势:

  • 精确匹配领域。没有方钉入圆孔。
  • 零版本升级风险。Eloquent 12 版本的行为变更不会破坏应用,因为根本不依赖 Eloquent。
  • 性能尽在掌控。没有隐藏成本,没有无法分析的抽象开销。
  • 帮助团队学习领域。新人学的不是 Eloquent 或 Doctrine;他们学的是应用本身,而这才是他们真正需要知道的东西。

但"自己造"的成本同样真实,而且比第一天看起来要大得多:

  • 从此永久进入数据层维护行业。每个 bug、每个功能、每个数据库驱动的特性差异都由自己处理。
  • 新开发者在能高效产出之前必须先学会自定义约定。"打开文档看看"不再是一条可行路径。
  • 工具不认识自定义层。IDE 自动补全、静态分析、调试工具、性能分析辅助——没有大量配置,它们都无法理解自定义抽象。
  • 成本随时间复利增长。第一年是一个干净的 200 行抽象,五年后是一个 4,000 行的自有框架,充满了没人记得的边界情况和没人更新的测试。

谁应该"自己造"

坦白说,非常少的项目。以下情况确实是正确的选择:

  • 长期单团队维护的应用,团队有纪律和时间线来维护数据层,且领域足够特殊,改造现有方案的成本会超过从头构建。
  • 教学性质的项目,目标是理解 ORM 的工作原理,实现本身就是目的。
  • 高度专业化的领域——有审计要求的金融系统、有隔离保证的多租户系统、有自定义数据类型的科学应用——在这些领域中,现有方案无法在不做不可接受妥协的前提下满足需求。

以下情况虽然听起来诱人,但通常不是正确选择:

  • "Eloquent 太臃肿"——有时候的确如此,但臃肿可以通过纪律来修正(在热路径上使用 DB::table())。替换整个数据层比约束使用方式要高得多的承诺。
  • "不信任依赖"——合理的顾虑,但依赖 PDO 和依赖 Eloquent 都是真实的依赖,而自定义层的维护成本也是真实存在的。
  • "想学习"——很好的出发点,但把自建实现作为学习项目,而不是生产应用的数据层。

一个诚实的检验标准:如果未来五年不打算每年投入 10% 的工程时间维护数据层,就不要自己造。如果乐意且是有意识的权衡,那完全没问题。这个立场是可辩护的,只是成本很高。

一条实用的决策路径

尝试用一串问题将这个决策具体化。为项目逐一走过这些步骤。

问题 1:这个应用的主要价值在于领域模型,还是在于它运行的查询?

如果价值在于领域模型——用户、帖子、订单、它们之间的关系、以对象方法表达的业务规则——那应该进入 ORM 领域。跳到问题 2。

如果价值在于查询——报表、分析、ETL、数据处理——那应该进入 PDO 或 DBAL 领域。跳到问题 3。

问题 2:正在使用 Laravel 吗,或者可以引入 Laravel 吗?

如果是:使用 Eloquent。生态收益真实且显著。不要对抗框架。将 Eloquent 成本高于收益的特定热路径下沉到 DBAL 或 PDO。

如果不是:考虑 Doctrine ORM(本文未详细展开,但对于非 Laravel 应用,它是显而易见的"Eloquent 等价物"),或评估在 DBAL 之上构建薄封装层是否足够。

问题 3:代码需要在多个数据库引擎上运行吗?

如果是:选 DBAL。跨数据库可移植性值得引入这个依赖。

如果不是:PDO 可能就足够了。零依赖选项是一个真正的优势,尤其是对于库和被其他代码消费的服务。

问题 4:是否因为特定、可明确说明的理由排除了以上三种现成方案?

如果是且能不用含糊其辞地用三句话说出这些理由:自己造。

如果不是——如果理由归结为"不喜欢框架的风格"或"觉得自己能做得更好"——从以上三种中选一个,用两年再重新评估。大多数情况下,第一天感觉不合适的现成方案,在第六十天已经内化了其约定之后,感觉就不错了。

没人告诉你的真相

大多数同类文章都遗漏了这一点:对大多数非平凡应用而言,正确的答案不是四选一——而是有意识的组合。

一个架构良好的 PHP 应用通常会使用:

  • ORM(Eloquent 或 Doctrine)用于应用的主领域模型——那些流经控制器、验证和视图的实体。
  • 查询构建器(DBAL 或 ORM 的底层构建器)用于 ORM 难以驾驭的复杂查询。
  • 通过 PDO 执行原始 SQL 用于批量操作、热路径和报表,这些场景中 ORM 的逐条记录开销不可接受。
  • 约定——通常是以上所有之上的薄封装——将领域中反复使用的特定模式固化下来。

这不是"犹豫不决"。这是让每个工具在其能收回成本的层次上发挥作用。试图在一个层次上完成所有事情,要么产生过度工程化的 ORM 花式操作,要么产生无法维护的原始 SQL 面条代码。真正的纪律是知道何时切换层次,并对此坦然接受。

如果代码库中有 50 个 Eloquent 模型,外加一个手工精心编写的 PDO 函数来处理复杂的月度报表,这不是不一致的标志。这恰恰说明有人在两个地方都做了深思熟虑的权衡。

结语

本文始终在推动的一个框架是:PDO、DBAL、Eloquent 和自定义数据层不是对同一个问题的竞争性答案。它们是针对不同问题的答案。在它们之间做选择,不是关于哪个"最好"——而是关于你的应用实际上在问哪个问题。

  • PDO 回答的是:如何以最小的机制与数据库对话?
  • DBAL 回答的是:如何在没有领域模型的情况下可移植地构建查询?
  • Eloquent 回答的是:如何将领域表达为对象,让框架负责 SQL?
  • 自己造回答的是:如果以上都不匹配实际需求怎么办?

对于大多数应用,答案是某种组合——Eloquent(用于大部分领域代码)、DBAL(用于 Eloquent 无法优雅处理的查询)、PDO(用于开销真正重要的操作)。组合胜过教条地使用任何单一层次。

如果构建过一个成功挑战这个框架的应用,或者见证过一个以启发性的方式失败的应用,非常期待在评论区听到具体的实践经验。这些模式不是教条;它们是对有效实践的描述。每一次有人分享实战故事,它们就会变得更加锐利。

本作品采用《CC 协议》,转载必须注明作者和本文链接