Laravel Eloquent 如何在pivot关联中计算产品平均评分并规避N+1问题
原实现N+1问题根因
原代码的平均分访问器触发额外查询的核心原因是:$this->reviews()->average('rating') 属于数据库查询构造器调用,不会复用已经通过with('reviews')加载到模型内存中的关联评论数据,会针对每个产品单独执行一次聚合查询,最终查询数量为「1条产品表查询 + 1条关联评论查询 + N条单产品平均分查询」,和产品数量线性相关,数据量大时性能会快速下降。
无N+1的优化实现
方案1:基于已加载关联数据的本地计算(适配现有代码结构)
不修改原有模型关系定义,仅调整平均分访问器逻辑,从已经预加载到内存的评论集合中计算平均分,不触发任何额外SQL。
- 修改
Product模型的平均分访问器:
public function getProductRatingAttribute() { // 兜底:如果关联未加载则主动触发一次加载,避免非预加载场景下的异常 if (!$this->relationLoaded('reviews')) { $this->load('reviews'); } // 从内存中的评论集合提取所有评分值 $ratings = $this->reviews->pluck('pivot.rating'); // 无评论时返回null,匹配示例输出结构 if ($ratings->isEmpty()) { return null; } // 保留4位小数格式,匹配示例输出的字符串格式 return number_format($ratings->avg(), 4, '.', ''); }
- 简化查询逻辑,不需要额外调用
append方法:
// 仅需2条固定SQL:1条查所有产品、1条批量查所有关联评论,后续计算全部在内存完成 $productList = Product::with('reviews')->get(); return response()->json(['msg' => $productList]);
该方案在所有规模的产品列表下查询数固定为2,彻底消除N+1问题。
方案2:SQL层预加载聚合(适配超大数据量场景)
如果单产品的评论量极大,全量加载评论到内存会造成内存压力,可以使用Laravel内置的预加载聚合能力,直接在SQL层计算平均分,不需要加载全量评论数据。
- 不需要修改原有
reviews关联定义,仅在查询时通过withAvg将平均分作为产品的附属字段查询出来:
// 同样仅需2条固定SQL:1条查产品(含平均分子查询)、1条批量查关联评论 $productList = Product::with('reviews') ->withAvg('reviews as product_rating', 'rating') ->get(); // 如需匹配示例的4位小数格式,可在模型中补充简单的格式化访问器 // public function getProductRatingAttribute($value) // { // return is_null($value) ? null : number_format($value, 4, '.', ''); // } return response()->json(['msg' => $productList]);
该方案不需要将全量评论数据加载到内存,适合评论量达到十万级以上的高并发场景。
验证效果
两种优化方案的最终返回结构完全匹配示例JSON:
- 有评论的产品会输出4位小数格式的平均评分
- 无评论的产品评分返回null,关联评论为空数组
- 全程不会因为产品数量增长产生额外SQL查询,Telescope监控下的查询数会从原来的N+2降到固定2条。
内容的提问来源于stack exchange,提问作者Miracle Hades
相关产品推荐
相关产品推荐

