Doctrine 2.1带多关联查询的分页性能问题,求高效解决方案
针对你遇到的Doctrine 2.1分页+复杂LEFT JOIN/WHERE IN的性能问题,我有几个实战验证过的优化方案,比你当前的全量查询后切片的临时方案高效很多:
方案1:拆分查询,先分页获ID再查完整数据
这个方案解决了setMaxResults在fetch join场景下结果条数不准的问题,同时避免了全量查询的性能开销:
第一步:分页查询主实体ID
单独查询主实体的ID列表,不包含关联集合的fetch join,这样分页逻辑会准确生效,不会因为关联数据的重复导致结果条数不足:$idQuery = $entityManager->createQueryBuilder() ->select('e.id') ->from('YourMainEntity', 'e') ->leftJoin('e.RelationA', 'ra') ->leftJoin('e.RelationB', 'rb') ->where('e.status = :status') // 保留你的WHERE/WHERE IN条件 ->andWhere('e.category IN (:categories)') ->setParameters([ 'status' => 'active', 'categories' => [1,3,5] ]) ->setMaxResults(25) ->setFirstResult(10) ->getQuery(); $idResults = $idQuery->getResult(); $entityIds = array_column($idResults, 'id');第二步:根据ID列表查询完整实体数据
用获取到的ID列表作为条件,查询包含关联集合的完整数据,这里可以安全使用fetch join,结果不会重复:$dataQuery = $entityManager->createQueryBuilder() ->select('e, ra, rb') ->from('YourMainEntity', 'e') ->leftJoin('e.RelationA', 'ra') ->leftJoin('e.RelationB', 'rb') ->where('e.id IN (:ids)') ->setParameter('ids', $entityIds) ->getQuery(); $data = $dataQuery->getResult();第三步:高效获取总条数
单独写计数查询,针对主实体ID做COUNT(DISTINCT)(避免JOIN带来的重复计数),不要查询全量数据再count:$countQuery = $entityManager->createQueryBuilder() ->select('COUNT(DISTINCT e.id)') ->from('YourMainEntity', 'e') ->leftJoin('e.RelationA', 'ra') ->leftJoin('e.RelationB', 'rb') ->where('e.status = :status') ->andWhere('e.category IN (:categories)') ->setParameters([ 'status' => 'active', 'categories' => [1,3,5] ]) ->getQuery(); $totalCount = $countQuery->getSingleScalarResult();
这个方案的核心是把分页逻辑和关联数据查询解耦,既保证了分页条数准确,又避免了全量查询的性能浪费,比你当前的临时方案内存占用和查询时间都低很多。
方案2:使用原生SQL直接控制查询逻辑
如果Doctrine ORM的限制让你束手束脚,直接用原生SQL是最直接的性能优化方式,完全掌控查询的每一步:
// 1. 获取总条数,用DISTINCT避免JOIN导致的重复计数 $countSql = <<<SQL SELECT COUNT(DISTINCT e.id) FROM your_main_entity e LEFT JOIN relation_a ra ON e.id = ra.entity_id LEFT JOIN relation_b rb ON e.id = rb.entity_id WHERE e.status = ? AND e.category IN (?, ?, ?) SQL; $connection = $entityManager->getConnection(); $totalCount = $connection->fetchOne($countSql, ['active', 1,3,5]); // 2. 获取分页数据,直接使用MySQL的LIMIT/OFFSET $dataSql = <<<SQL SELECT e.*, ra.*, rb.* FROM your_main_entity e LEFT JOIN relation_a ra ON e.id = ra.entity_id LEFT JOIN relation_b rb ON e.id = rb.entity_id WHERE e.status = ? AND e.category IN (?, ?, ?) LIMIT ?, ? SQL; $stmt = $connection->prepare($dataSql); $stmt->execute(['active', 1,3,5, 10, 25]); $data = $stmt->fetchAll(\PDO::FETCH_ASSOC); return [ 'data' => $data, 'count' => $totalCount ];
原生SQL的优势是没有ORM层的额外开销,对于复杂JOIN场景性能提升非常明显,而且可以直接使用MySQL的LIMIT语法,完全不用担心Doctrine的分页兼容性问题。
方案3:封装轻量分页工具类(可选)
如果需要多次使用这类分页逻辑,可以把方案1的逻辑封装成一个通用工具类,比如:
class Doctrine21Paginator { public static function paginate(EntityManager $em, string $entityClass, array $conditions, int $limit, int $offset, array $joins = []): array { // 构建ID查询 $qb = $em->createQueryBuilder()->select('e.id')->from($entityClass, 'e'); foreach ($joins as $join) { $qb->leftJoin("e.{$join['alias']}", $join['name']); } // 添加条件、参数绑定... // 获取ID列表 $idResults = $qb->setMaxResults($limit)->setFirstResult($offset)->getQuery()->getResult(); $entityIds = array_column($idResults, 'id'); // 获取完整数据 $dataQb = $em->createQueryBuilder()->select('e')->from($entityClass, 'e'); foreach ($joins as $join) { $dataQb->leftJoin("e.{$join['alias']}", $join['name']); } $data = $dataQb->where('e.id IN (:ids)')->setParameter('ids', $entityIds)->getQuery()->getResult(); // 获取总条数 $countQb = $em->createQueryBuilder()->select('COUNT(DISTINCT e.id)')->from($entityClass, 'e'); foreach ($joins as $join) { $countQb->leftJoin("e.{$join['alias']}", $join['name']); } // 复用条件参数 foreach ($conditions as $key => $value) { $countQb->andWhere($key)->setParameter(substr($key, 2), $value); } $totalCount = $countQb->getQuery()->getSingleScalarResult(); return ['data' => $data, 'count' => $totalCount]; } }
这样后续使用时只需传入参数即可,减少重复代码。
为什么这些方案比你的临时方案更好?
你的临时方案是全量查询所有数据后用array_slice切片,当数据量达到几千甚至几万条时,会占用大量内存,查询时间也会急剧增加。而上面的方案都是只查询需要的分页数据,计数也是高效的聚合查询,性能和内存占用都能得到极大优化。
内容的提问来源于stack exchange,提问作者Hevyweb

