返回

Doctrine 查询性能优化:从74秒到0.4秒的提速技巧

php

为啥我的 Doctrine 查询慢得像蜗牛?74 秒 vs 0.4 秒大揭秘

写代码的时候,碰上性能问题真是头疼。就比如下面这个场景:用 Doctrine ORM 跑一个看起来挺简单的查询,结果竟然花了 74 秒!可要是直接把 SQL 扔数据库里执行,嗖的一下,0.4 秒就搞定了。这巨大的反差,到底是咋回事?

问题来了:简单的 Doctrine 查询为啥要 74 秒?

咱们先来看看这位“慢动作”选手。有这么一个 Service 类:

namespace App\Infrastructure\Services;

use Doctrine\ORM\EntityManagerInterface;
use App\Entity\MyModel;

class FetchResultFromDb 
{
    public function __construct(public EntityManagerInterface $entityManager)
    {
    }

    public function getEarliestResult(int $rowNum)
    {
        $queryBuilder = $this->entityManager->getRepository(MyModel::class)->createQueryBuilder('m');

        // 注意这里的写法,createQueryBuilder('m') 已经指定了别名 'm'
        // 下面的 from() 理论上可以省略,或者至少别名需要一致
        return $queryBuilder
            // ->from(MyModel::class, 'm') // 这行可以优化掉
            ->select('m.id')
            ->orderBy('m.sentTimestamp', 'ASC')
            ->setMaxResults($rowNum)
            ->getQuery()
            ->getResult(); // 问题可能出在这里或者生成的 SQL 上
    }  
}

对应的 Doctrine Entity 也挺常规:

namespace App\Entity;

use App\Infrastructure\Repository\MailLogRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
#[ORM\HasLifecycleCallbacks] // 注意:这里有生命周期回调,但当前查询逻辑似乎用不到
class MyModel
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;
 
    #[ORM\Column(name:'sent_timestamp', type: Types::DATETIME_IMMUTABLE)]
    private ?\DateTimeInterface $sentTimestamp = null;
    
    // 省略其他可能的属性和方法...
}

目标很明确,就是想从 my_model 表里,按 sent_timestamp 字段升序排序,取出最早的 $rowNum 条记录的 id。对应的原生 SQL 大概长这样:

SELECT id 
FROM my_model 
ORDER BY sent_timestamp ASC 
LIMIT :row_num; 

这个 SQL,直接在数据库客户端跑(比如查 5020 条记录),只要 0.4 秒。可到了 Doctrine 这里,通过 getEarliestResult(5020) 调用,却慢了将近 200 倍,要 74 秒!这性能差距也太离谱了。

慢在哪儿?揪出性能瓶颈

原生 SQL 快如闪电,ORM 查询慢如老牛,问题肯定出在 Doctrine 处理这事的某个环节上。咱们捋一捋 Doctrine ORM 查询数据的大致流程:

  1. 写 DQL (Doctrine Query Language) 或用 QueryBuilder: 你告诉 Doctrine 你想要啥。
  2. DQL 解析: Doctrine 把你的 DQL 或 QueryBuilder 指令翻译成它内部能理解的结构。
  3. SQL 生成: Doctrine 根据数据库方言(MySQL, PostgreSQL 等)生成对应的 SQL 语句。
  4. 数据库执行: 把生成的 SQL 发给数据库去干活。
  5. 结果处理与映射(Hydration): 数据库返回结果,Doctrine 再把这些行数据“转换”成 PHP 对象或数组。

那么,74 秒的耗时可能发生在哪个或哪些环节呢?

  • 数据库执行环节? 这个可能性最大。虽然原生 SQL 很快,但 Doctrine 生成的 SQL 可能和手写的略有不同,或者因为某些原因没用上合适的数据库索引,导致数据库执行计划很差。ORDER BY ... LIMIT 这类操作尤其依赖索引。
  • 结果处理与映射 (Hydration)? 你用了 getResult(),它默认会尝试把结果映射成 MyModel 对象(即使你只 select('m.id'))。虽然只选了 id,Doctrine 可能还是会做一些对象初始化的准备工作,或者进行对象跟踪。如果 $rowNum 很大,比如成千上万,这个过程累加起来也可能很耗时,但从 0.4 秒飙到 74 秒,光靠 Hydration 似乎有点夸张,除非还有其他因素。
  • SQL 生成或 DQL 解析? 这俩通常非常快,不太可能是性能瓶颈的主要原因,除非查询本身极其复杂,但眼下这个查询很简单。
  • ORM 本身的开销? ORM 框架本身肯定有抽象层带来的开销,但这通常是毫秒级别的,不太可能造成几十秒的延迟。

综合来看,数据库索引没用上 或者 生成的 SQL 对数据库不够友好 ,是这次性能问题的头号嫌疑犯,其次可能是 结果映射(Hydration)过程比预期更重

对症下药:加速你的 Doctrine 查询

既然找到了可能的病根,咱们就来挨个试试怎么治。

1. 索引,索引,还是索引!

对于 ORDER BY sent_timestamp LIMIT :row_num 这样的查询,sent_timestamp 列上有没有索引,性能表现是天壤之别。

  • 没索引: 数据库需要扫描整个表(或者很大一部分),把所有行的 sent_timestamp 加载到内存里排序,然后只取前 $rowNum 条。表越大,越慢。74 秒很可能就是这种情况。
  • 有索引: 数据库可以直接利用 B-Tree 索引的有序性,快速定位到时间戳最早的记录,然后顺序读取 $rowNum 条即可,根本不需要全表扫描和内存排序。0.4 秒的速度符合有索引的情况。

原理与作用:

数据库索引就像书的目录,能让数据库根据特定列(比如 sent_timestamp)快速找到数据行,尤其是在排序(ORDER BY)和查找(WHERE)时。对 ORDER BY ... LIMIT 来说,排序列上的索引简直是救命稻草。

怎么做:

sent_timestamp 字段加上索引。推荐使用 Doctrine Migrations 来管理数据库结构变更。

  • 使用 Doctrine Migrations (推荐) :

    在你的 MyModel Entity 的 sentTimestamp 属性上添加 #[ORM\Index] 注解(如果你希望 Doctrine 自动检测并生成迁移),或者直接在 Migration 文件里手动添加。

    在 Entity 中添加索引提示 (可选,辅助生成迁移)

    namespace App\Entity;
    
    // ...其他 use 语句 ...
    use Doctrine\ORM\Mapping as ORM;
    use Doctrine\ORM\Mapping\Index; // 引入 Index
    
    #[ORM\Entity]
    #[ORM\Table(name: 'my_model', indexes: [ // 在 Table 注解里定义索引
        new Index(name: 'idx_sent_timestamp', columns: ['sent_timestamp']) 
    ])] 
    #[ORM\HasLifecycleCallbacks]
    class MyModel
    {
        // ... id 属性 ...
    
        #[ORM\Column(name:'sent_timestamp', type: Types::DATETIME_IMMUTABLE)]
        private ?\DateTimeInterface $sentTimestamp = null;
    
        // ... 其他 ...
    }
    

    然后生成迁移文件:

    php bin/console doctrine:migrations:diff
    

    检查生成的迁移文件,确认包含了类似 CREATE INDEX idx_sent_timestamp ON my_model (sent_timestamp); 的 SQL 语句。

    最后执行迁移:

    php bin/console doctrine:migrations:migrate
    
  • 直接执行 SQL (如果不用 Migrations 或需要立即生效) :

    连接到你的数据库,执行:

    CREATE INDEX idx_sent_timestamp ON my_model (sent_timestamp);
    

    注意: 索引名 idx_sent_timestamp 可以自定义,保持清晰易懂就好。

如何验证?

加上索引后,再次运行你的 PHP 代码,看看 getEarliestResult() 的执行时间是不是大幅缩短了。理论上,它应该接近原生 SQL 的 0.4 秒了。

如果还是慢,可以用数据库的 EXPLAIN (或 EXPLAIN ANALYZE,对 PostgreSQL 更佳) 命令来分析 Doctrine 生成的 SQL 语句,看看索引到底有没有被用上。

进阶技巧:部分索引 (Partial Index)

如果你的 sent_timestamp 列允许为 NULL,并且你只关心非 NULL 的时间戳排序,在某些数据库(如 PostgreSQL)可以创建部分索引,只索引非空值,可能更高效:

CREATE INDEX idx_sent_timestamp_not_null ON my_model (sent_timestamp) WHERE sent_timestamp IS NOT NULL;

但你需要确保你的查询逻辑也是匹配的(比如 DQL 里也加上 WHERE m.sentTimestamp IS NOT NULL)。对于当前场景,简单索引通常就够了。

2. 告别完全体:精简查询结果 (Hydration Optimization)

getResult() 默认使用 HYDRATE_OBJECT 模式,它会创建完整的 Entity 对象。即使你只 select('m.id'),Doctrine 为了对象一致性和跟踪,可能还是做了不少幕后工作。对于只需要少量字段的场景,这有点浪费。

原理与作用:

Doctrine 提供了不同的结果获取模式(Hydration Modes),可以选择只获取纯数组或标量值,跳过昂贵的、内存消耗大的对象创建和填充过程。

怎么做:

尝试换成更轻量的结果获取方法:

  • 获取纯数组 getArrayResult():

    public function getEarliestResultIdsAsArray(int $rowNum)
    {
        $queryBuilder = $this->entityManager->getRepository(MyModel::class)->createQueryBuilder('m');
    
        return $queryBuilder
            ->select('m.id')
            ->orderBy('m.sentTimestamp', 'ASC')
            ->setMaxResults($rowNum)
            ->getQuery()
            ->getArrayResult(); // 改用 getArrayResult
    }  
    

    这将返回一个类似 [['id' => 1], ['id' => 2], ...] 的数组结构。处理速度通常比 getResult() 快,内存占用也更少。

  • 获取标量结果 getScalarResult()getResult(Query::HYDRATE_SCALAR):

    如果你只需要 id 这一个值,连数组的 ['id' => ...] 结构都嫌多余,可以用标量结果。

    use Doctrine\ORM\AbstractQuery; // 需要引入
    
    public function getEarliestResultIdsAsScalar(int $rowNum)
    {
        $queryBuilder = $this->entityManager->getRepository(MyModel::class)->createQueryBuilder('m');
    
        $results = $queryBuilder
            ->select('m.id') // 只选择 id
            ->orderBy('m.sentTimestamp', 'ASC')
            ->setMaxResults($rowNum)
            ->getQuery()
            // ->getScalarResult(); // 返回 [['m_id' => 1], ['m_id' => 2], ...]
            // 或者更明确地指定 Hydration Mode
             ->getResult(AbstractQuery::HYDRATE_SCALAR); // 效果同 getScalarResult
    
        // 如果只要纯 id 列表 [1, 2, 3...]
        return array_column($results, 'm_id'); // 从标量结果中提取 id 列 (注意 DQL select 'm.id' 生成的列名可能是 m_id)
    }
    

    getScalarResult() 会返回一个包含标量值的嵌套数组,例如 [['m_id' => 1], ['m_id' => 5], ...]. m_id 这个键名是 Doctrine 根据 DQL 的 m.id 推断的。之后通常需要 array_column 之类的函数再处理一下。

    或者,如果只需要 id 列表,还可以考虑迭代获取单个标量:

    public function getEarliestResultIdsAsPureList(int $rowNum)
    {
        $queryBuilder = $this->entityManager->getRepository(MyModel::class)->createQueryBuilder('m');
    
        $query = $queryBuilder
            ->select('m.id') 
            ->orderBy('m.sentTimestamp', 'ASC')
            ->setMaxResults($rowNum)
            ->getQuery();
    
        $ids = [];
        foreach ($query->toIterable([], AbstractQuery::HYDRATE_SCALAR) as $row) {
            // 注意这里访问的方式,它可能是 ['m_id' => value]
            $ids[] = $row['m_id']; 
        }
        return $ids;
    }
    

    toIterable 配合 HYDRATE_SCALAR 可以逐条处理,对于超大数据集可能更省内存。

好处: 减少了 PHP 端的内存消耗和 CPU 时间,对于返回大量记录或字段很少的查询,效果可能比较明显。

3. 审视生成的 SQL:知己知彼

有时候,Doctrine QueryBuilder 生成的 SQL 可能和你手写的不太一样,或者包含了一些数据库不喜欢的“小动作”(比如不必要的类型转换),导致无法有效利用索引。

原理与作用:

了解 Doctrine 最终交给数据库执行的 SQL 是什么样子,是诊断性能问题的关键一步。你可以拿到这个 SQL,然后直接在数据库客户端用 EXPLAINEXPLAIN ANALYZE 分析它的执行计划。

怎么做:

在执行查询前,先获取生成的 SQL 和参数:

public function getEarliestResult(int $rowNum)
{
    $queryBuilder = $this->entityManager->getRepository(MyModel::class)->createQueryBuilder('m');

    $query = $queryBuilder
        // ->from(MyModel::class, 'm') // 依然建议去掉这行冗余代码
        ->select('m.id')
        ->orderBy('m.sentTimestamp', 'ASC')
        ->setMaxResults($rowNum)
        ->getQuery();

    // 获取生成的 SQL 语句
    $sql = $query->getSQL();
    // 获取绑定的参数
    $parameters = $query->getParameters(); // 返回 Parameter Bag 对象

    // 打印出来看看 (实际应用中用日志记录)
    dump($sql);
    dump($parameters->toArray()); // 转成数组方便看

    // 然后才执行
    return $query->getResult(); 
}  

拿到 SQL 语句(比如它可能是 SELECT m0_.id AS id_0 FROM my_model m0_ ORDER BY m0_.sent_timestamp ASC LIMIT ?)和参数值(比如 ? 对应的值是 5020),去数据库管理工具里执行:

-- 示例 (PostgreSQL)
EXPLAIN ANALYZE SELECT m0_.id AS id_0 FROM my_model m0_ ORDER BY m0_.sent_timestamp ASC LIMIT 5020; 

EXPLAIN ANALYZE 的输出:

  • 关注 Seq Scan (顺序扫描) vs Index Scan (索引扫描) or Index Only Scan: 如果 ORDER BY 引发了 Seq Scan 或者需要在扫描后进行额外的 Sort 操作,那八九不离十就是索引没用对。理想情况下,你应该看到基于 sent_timestamp 索引的 Index Scan
  • 关注 Sort Method: 如果有 Sort 步骤,看看是 external merge Disk (极慢,数据量大到内存装不下要用磁盘) 还是 quicksorttop-N heapsort (内存排序,相对快但还是不如直接用索引)。
  • 关注 rowscost: 这些估算值能帮你判断数据库认为这个查询计划的开销有多大。

如果发现生成的 SQL 执行计划很糟糕,你可能需要:

  • 回去检查索引是否正确创建并生效 (第一步)。
  • 考虑调整 QueryBuilder 的写法,看是否能引导 Doctrine 生成更优的 SQL (虽然对于这个简单查询来说,调整空间不大)。
  • 如果实在不行,考虑下面的终极手段。

4. 终极武器?原生 SQL 查询 (Native Query)

当 ORM 的抽象确实带来了无法接受的性能损耗,或者你需要利用特定数据库的高级特性而 Doctrine DQL 不支持时,可以直接在 Doctrine 里执行原生 SQL。

原理与作用:

绕过 DQL 解析和大部分 ORM 映射开销,直接把 SQL 语句发送给数据库执行,获取原始结果。性能最接近直接在数据库客户端执行。

怎么做:

使用 EntityManagergetConnection() 方法获取底层的 DBAL 连接对象来执行 SQL。

use Doctrine\DBAL\ParameterType; // 需要引入

// ... 在你的 Service 类里 ...
public function getEarliestResultIdsWithNativeSql(int $rowNum)
{
    $connection = $this->entityManager->getConnection();
    $sql = 'SELECT id FROM my_model ORDER BY sent_timestamp ASC LIMIT :limit';
    
    // 使用 prepare + execute (推荐,安全防注入)
    $statement = $connection->prepare($sql);
    $statement->bindValue('limit', $rowNum, ParameterType::INTEGER); // 绑定参数,并指定类型
    $resultSet = $statement->executeQuery();

    // 获取所有 id 列的值为一个简单数组 [1, 5, 10, ...]
    return $resultSet->fetchFirstColumn(); 
    
    /* 或者,如果需要关联数组 [['id' => 1], ['id' => 5], ...]
    return $resultSet->fetchAllAssociative(); 
    */
}

重要安全建议:

永远不要直接拼接字符串来构建 SQL 语句! 必须使用参数绑定 (bindValue, bindParamexecuteQuery 的第二个参数) 来传入变量 $rowNum。这能有效防止 SQL 注入攻击。上面例子中的 bindValue(':limit', $rowNum, ParameterType::INTEGER) 就是在做这个。

权衡利弊:

  • 优点: 性能极致,可以写任何数据库支持的 SQL。
  • 缺点:
    • 失去了 ORM 的对象映射、关系加载、生命周期回调等便利性。结果是原始数据,需要手动处理。
    • SQL 语句与特定数据库耦合,如果换数据库(比如从 PostgreSQL 换到 MySQL),LIMIT 子句的语法可能不同(虽然这个例子里 LIMIT 比较通用),或者用了特定函数就得改。
    • 维护成本可能稍高,因为 SQL 散落在代码中,不像 Entity 和 Repository 那样集中。

什么时候用?

  • 性能要求极高、ORM 优化后仍不达标的场景。
  • 需要执行非常复杂、用 DQL 难以表达或效率低下的 SQL 逻辑。
  • 只需要原始数据,不需要 ORM 对象模型的场景。

5. 小调整,大不同?优化 QueryBuilder 写法

回头看看原始代码:

$queryBuilder = $this->entityManager->getRepository(MyModel::class)->createQueryBuilder('m');
// ...
return $queryBuilder
    ->from(MyModel::class, 'm') // <- 这行是多余的
    // ...

->getRepository(MyModel::class)->createQueryBuilder('m') 已经隐式地指定了查询的主体是 MyModel 并且别名为 m。后面再加一个 ->from(MyModel::class, 'm') 是重复的。虽然 Doctrine 可能足够智能会忽略它,或者对性能影响微乎其微,但保持代码简洁清晰总没错。

优化后的写法:

public function getEarliestResult(int $rowNum)
{
    $queryBuilder = $this->entityManager->getRepository(MyModel::class)->createQueryBuilder('m'); // 'm' 是 MyModel 的别名

    return $queryBuilder
        // 不需要再写 from() 了
        ->select('m.id')
        ->orderBy('m.sentTimestamp', 'ASC')
        ->setMaxResults($rowNum)
        ->getQuery()
        ->getResult(); // 或者 getArrayResult() / getScalarResult() 等
}  

这更像是代码风格优化,不太可能解决 74 秒的性能问题,但值得顺手改掉。

总结一下排查思路

遇到 Doctrine 查询缓慢,特别是与原生 SQL 性能差异巨大时:

  1. 查索引: ORDER BYWHERE 条件涉及的列,尤其是大表,必须要有索引。这是性能优化的第一步,也是最可能见效的一步。
  2. 看 Hydration: 如果查询返回大量数据或你只需要部分字段,尝试用 getArrayResult(), getScalarResult()toIterable() 替换 getResult(),减少对象映射开销。
  3. 验 SQL: 获取 Doctrine 生成的 SQL,用 EXPLAIN ANALYZE 分析其数据库执行计划,确认索引是否被使用,找出瓶颈。
  4. 上原生: 如果 ORM 优化到头了还是慢,或者有特殊 SQL 需求,考虑使用 Doctrine DBAL 执行原生 SQL,但务必做好参数绑定防注入。
  5. 小优化: 保持 QueryBuilder 写法干净、符合最佳实践。

对于这个 74 秒 vs 0.4 秒的问题,我打赌 sent_timestamp 列加上索引 (方案 1) 就能解决绝大部分问题。然后再考虑 优化结果获取方式 (方案 2) 来进一步榨干性能。动手试试吧!