
本文详解如何使用 doctrine querybuilder 对自关联实体的子记录进行聚合求和(如 sum(s.executed)),精准筛选出已完全售出或部分售出的交易记录,并规避浮点精度问题与 n+1 查询风险。
本文详解如何使用 doctrine querybuilder 对自关联实体的子记录进行聚合求和(如 sum(s.executed)),精准筛选出已完全售出或部分售出的交易记录,并规避浮点精度问题与 n+1 查询风险。
在构建金融类交易系统时,常见一种“父子交易”模型:买入交易(parent)作为根节点,后续多次卖出交易(children)通过 parent 关联回原始买入记录。此时业务常需识别两类关键状态:
- ✅ 完全售出:所有子交易 executed(实际成交数量)之和等于父交易的 executed;
- ⚠️ 部分售出:存在子交易,但其 SUM(executed) < parent.executed;
- ❌ 未售出:无子交易(即 parent IS NULL,已由 findAllBuyTrades() 覆盖)。
要高效实现该逻辑,必须避免 PHP 层循环 + 多次查询(N+1),而应将聚合计算下推至数据库层。Doctrine 提供了完整的 DQL/QueryBuilder 支持,但需注意几个关键实践要点:
✅ 正确使用 INNER JOIN 与 HAVING 实现聚合过滤
原答案中的方法接近正确,但存在一个典型误区:->innerJoin(Trade::class, 's') 缺少 ON 条件,Doctrine 默认会尝试基于映射关系自动推导,但在自关联场景下极易失效或生成错误 SQL。必须显式指定连接条件:
public function findFullySoldTrades(): array
{
$qb = $this->createQueryBuilder('t')
->innerJoin(Trade::class, 's', 'WITH', 's.parent = t') // ✅ 显式 WITH 条件,语义清晰
->groupBy('t.id')
->having('SUM(s.executed) = t.executed')
->orderBy('t.created', 'ASC');
return $qb->getQuery()->execute();
}? 注:'s.parent = t' 利用了 Doctrine 的对象引用语法(等价于 s.parent_id = t.id),比硬编码字段更安全、可维护。
✅ 区分“完全售出”与“部分售出”,复用同一查询结构
只需调整 HAVING 子句即可复用逻辑:
// 部分售出:有子记录且总和小于父记录
public function findPartiallySoldTrades(): array
{
$qb = $this->createQueryBuilder('t')
->innerJoin(Trade::class, 's', 'WITH', 's.parent = t')
->groupBy('t.id')
->having('SUM(s.executed) > 0 AND SUM(s.executed) < t.executed')
->orderBy('t.created', 'ASC');
return $qb->getQuery()->execute();
}
// 合并查询:返回所有已售出(全+半)交易
public function findAllSoldTrades(): array
{
$qb = $this->createQueryBuilder('t')
->innerJoin(Trade::class, 's', 'WITH', 's.parent = t')
->groupBy('t.id')
->having('SUM(s.executed) > 0') // 只要存在子交易即视为“已售出”
->orderBy('t.created', 'ASC');
return $qb->getQuery()->execute();
}⚠️ 关键注意事项
-
数据类型必须为 DECIMAL:原文已指出,float 类型在数据库中会导致精度丢失(如 0.1 + 0.2 ≠ 0.3)。务必在实体中修正:
#[ORM\Column(type: 'decimal', precision: 15, scale: 6)] private string|float $executed; // 推荐用 string 存储,PHP 中转 float 运算
- 避免 fetch: 'EAGER' 在生产环境滥用:虽然 @ORM\OneToMany(..., fetch: 'EAGER') 能预加载子集,但会引发严重性能问题(尤其当子记录数庞大时)。聚合查询应完全依赖 SQL JOIN + GROUP BY,而非加载全部子对象。
-
索引优化必不可少:确保数据库表 trade.parent_id 字段已建立索引,否则 JOIN 性能将急剧下降:
CREATE INDEX idx_trade_parent_id ON trade(parent_id);
✅ 最佳实践总结
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 筛选“已售出”交易(全/半) | INNER JOIN + GROUP BY + HAVING | 数据库层聚合,单次查询,高性能 |
| 获取某笔交易的子记录详情 | findBy(['parent' => $trade]) 或懒加载 | 按需加载,避免冗余数据 |
| 高并发统计报表 | 考虑物化视图或定时汇总表 | 规避实时聚合开销 |
通过以上方式,你不仅能精准满足业务需求,还能写出符合 Doctrine 最佳实践、可扩展、易维护的仓储层代码。记住:聚合逻辑永远优先交给数据库,而非 PHP 循环。

















