
本教程详细讲解如何优化 Laravel Eloquent 查询以高效生成基于关联记录计数的排行榜。通过识别并消除冗余的 whereHas 子句,并巧妙利用 withCount 的条件闭包,我们能显著提升查询性能,大幅缩短数据获取时间,从而改善用户体验并降低数据库负载。
在 laravel 应用开发中,eloquent orm 极大地简化了数据库操作。然而,当处理涉及关联模型和复杂聚合查询的场景时,如果不注意查询的编写方式,很容易导致性能瓶颈。一个典型的例子是构建用户排行榜,根据用户发布的图片数量(或其他关联记录数量)进行排名,并按不同时间维度(如本周、上周、总计)展示。本文将深入探讨如何优化这类 eloquent 查询,以显著提升数据获取效率。
原始查询的性能瓶颈分析
考虑以下 Eloquent 查询代码,其目标是获取在当前周、上周以及总计发布图片数量最多的前10名用户:
public function show()
{
$currentWeek = User::whereHas('pictures')
->whereHas('pictures', fn ($q) => $q->whereBetween('created_at', [Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek()]))
->withCount(['pictures' => fn ($q) => $q->whereBetween('created_at', [Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek()])])
->orderBy('pictures_count', 'DESC')
->limit(10)
->get();
$lastWeek = User::whereHas('pictures')
->whereHas('pictures', fn ($q) => $q->whereBetween('created_at', [Carbon::now()->startOfWeek()->subWeek(), Carbon::now()->endOfWeek()->subWeek()]))
->withCount(['pictures' => fn ($q) => $q->whereBetween('created_at', [Carbon::now()->startOfWeek()->subWeek(), Carbon::now()->endOfWeek()->subWeek()])])
->orderBy('pictures_count', 'DESC')
->limit(10)
->get();
$overall = User::whereHas('pictures')
->whereHas('pictures') // 再次冗余
->withCount('pictures')
->orderBy('pictures_count', 'DESC')
->limit(10)
->get();
return view('users.leaderboard', [
'currentWeek' => $currentWeek,
'lastWeek' => $lastWeek,
'overall' => $overall,
]);
}
上述代码在实际运行中可能耗时较长(例如1.5秒),主要原因在于其存在以下问题:
- 冗余的 whereHas 调用: 对于 currentWeek 和 lastWeek 的查询,whereHas(‘pictures’) 后面紧跟着一个带有条件闭包的 whereHas(‘pictures’, fn ($q) => $q->whereBetween(…))。前一个 whereHas 仅仅检查用户是否有任何图片,而后一个 whereHas 则检查用户是否有在特定日期范围内的图片。如果用户有在特定日期范围内的图片,那么他必然有图片。因此,第一个无条件的 whereHas 是多余的。
- whereHas 与 withCount 的重复逻辑: withCount 方法本身就可以接受一个条件闭包来计算符合条件的关联记录数量。如果某个用户在指定日期范围内没有图片,withCount 会将其 pictures_count 设置为 0。由于最终结果是按 pictures_count 降序排列,计数为 0 的用户自然会被排到末尾。这意味着,whereHas 的作用(过滤掉没有符合条件图片的用户)在大多数情况下可以通过 withCount 的结果和 orderBy 隐式实现,从而避免额外的 EXISTS 子查询。
优化步骤一:消除冗余的 whereHas 子句
首先,针对 currentWeek 和 lastWeek 的查询,我们可以移除重复且无条件的 whereHas(‘pictures’)。
优化前 SQL 示例(针对 currentWeek):
select `users`.*, (
select count(*) from `pictures` where `users`.`id` = `pictures`.`user_id` and `created_at` between ? and ? and `pictures`.`deleted_at` is null
) as `pictures_count`
from `users`
where exists (select * from `pictures` where `users`.`id` = `pictures`.`user_id` and `pictures`.`deleted_at` is null) -- 冗余的 EXISTS
and exists (select * from `pictures` where `users`.`id` = `pictures`.`user_id` and `created_at` between ? and ? and `pictures`.`deleted_at` is null)
and `users`.`deleted_at` is null
order by `pictures_count` desc
limit 10
优化后的 Eloquent 代码片段:
$currentWeek = User::whereHas('pictures', fn ($q) => $q->whereBetween('created_at', [Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek()]))
->withCount(['pictures' => fn ($q) => $q->whereBetween('created_at', [Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek()])])
->orderBy('pictures_count', 'DESC')
->limit(10)
->get();
优化后 SQL 示例:
select `users`.*, (
select count(*) from `pictures` where `users`.`id` = `pictures`.`user_id` and `created_at` between ? and ? and `pictures`.`deleted_at` is null
) as `pictures_count`
from `users`
where exists (select * from `pictures` where `users`.`id` = `pictures`.`user_id` and `created_at` between ? and ? and `pictures`.`deleted_at` is null)
-- 移除了冗余的 EXISTS 子句
and `users`.`deleted_at` is null
order by `pictures_count` desc
limit 10
通过这一步,我们减少了一个不必要的 EXISTS 子查询,使得 SQL 查询语句更为简洁。
优化步骤二:利用 withCount 实现过滤与计数一体化
这是性能优化的关键一步。由于 withCount 已经能够通过闭包对关联记录进行计数,并且会在没有匹配记录时返回 0,那么我们完全可以移除 whereHas。因为排行榜是根据 pictures_count 降序排列的,计数为 0 的用户自然会被排到列表末尾。如果排行榜要求不显示计数为 0 的用户,可以在获取结果后进行过滤。
最终优化后的 Eloquent 代码片段:
$currentWeek = User::withCount(['pictures' => fn ($q) => $q->whereBetween('created_at', [Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek()])])
->orderBy('pictures_count', 'DESC')
->limit(10)
->get();
最终优化后 SQL 示例:
select `users`.*, (
select count(*) from `pictures` where `users`.`id` = `pictures`.`user_id` and `created_at` between ? and ? and `pictures`.`deleted_at` is null
) as `pictures_count`
from `users`
where `users`.`deleted_at` is null
-- 彻底移除了所有的 where exists 子句
order by `pictures_count` desc
limit 10
可以看到,SQL 查询中不再包含任何 EXISTS 子句,这极大地简化了数据库的执行计划,从而显著提升查询速度。
重要提示: 这种优化方式可能导致结果集中包含 pictures_count 为 0 的用户(如果他们被 limit 包含在内)。如果您的排行榜不希望显示这些用户,可以在 get() 之后使用 filter() 方法进行过滤:
$currentWeek = User::withCount(['pictures' => fn ($q) => $q->whereBetween('created_at', [Carbon::now()->startOfWeek(), Carbon::now()->endOfWeek()])])
->orderBy('pictures_count', 'DESC')
->limit(10)
->get()
->filter(fn ($user) => $user->pictures_count > 0); // 过滤掉计数为0的用户
完整的优化方案示例
将上述优化应用到所有三个查询中,并对 Carbon 日期计算进行变量提取,避免重复计算,最终的 show 方法如下:
use Carbon/Carbon;
use Illuminate/Support/Facades/Cache; // 如果需要缓存
public function show()
{
// 提取日期计算,避免重复调用
$startOfCurrentWeek = Carbon::now()->startOfWeek();
$endOfCurrentWeek = Carbon::now()->endOfWeek();
$startOfLastWeek = Carbon::now()->startOfWeek()->subWeek();
$endOfLastWeek = Carbon::now()->endOfWeek()->subWeek();
// 当前周排行榜
$currentWeek = User::withCount(['pictures' => fn ($q) => $q->whereBetween('created_at', [$startOfCurrentWeek, $endOfCurrentWeek])])
->orderBy('pictures_count', 'DESC')
->limit(10)
->get();
// 上周排行榜
$lastWeek = User::withCount(['pictures' => fn ($q) => $q->whereBetween('created_at', [$startOfLastWeek, $endOfLastWeek])])
->orderBy('pictures_count', 'DESC')
->limit(10)
->get();
// 总排行榜 (无需日期过滤)
$overall = User::withCount('pictures')
->orderBy('pictures_count', 'DESC')
->limit(10)
->get();
return view('users.leaderboard', [
'currentWeek' => $currentWeek,
'lastWeek' => $lastWeek,
'overall' => $overall,
]);
}
重要考量与建议
为了进一步提升性能和应用健壮性,以下几点至关重要:
-
数据库索引:
确保 pictures 表的 user_id 和 created_at 字段上都有合适的索引。这是任何关联查询性能优化的基石。一个复合索引 (user_id, created_at) 通常是理想的选择,它可以加速按用户ID过滤并按创建时间范围查找的操作。ALTER TABLE pictures ADD INDEX idx_user_id_created_at (user_id, created_at);
登录后复制 -
缓存策略:
对于排行榜这类数据,其内容在短时间内不会频繁变动,但访问量可能非常大。强烈建议使用 Laravel 的缓存系统将查询结果缓存起来。例如,可以缓存几分钟或几小时,以减少对数据库的重复查询压力。// 示例:缓存 currentWeek 数据,缓存时间为5分钟 $currentWeek = Cache::remember('leaderboard_current_week', 60 * 5, function () use ($startOfCurrentWeek, $endOfCurrentWeek) { return User::withCount(['pictures' => fn ($q) => $q->whereBetween('created_at', [$startOfCurrentWeek, $endOfCurrentWeek])]) ->orderBy('pictures_count', 'DESC') ->limit(10) ->get(); });登录后复制 -
软删除 (Soft Deletes):
如果 User 或 Picture 模型使用了软删除(即存在 deleted_at 字段),Eloquent 会自动在查询中加入 where deleted_at is null 条件。这通常是期望的行为,但也要注意其对索引和查询的影响。确保 deleted_at 字段也有索引可以进一步优化带软删除的查询。
总结
通过本文介绍的两个关键优化步骤——消除冗余的 whereHas 子句以及充分利用 withCount 的条件闭包,我们能够显著提升 Laravel Eloquent 关联查询的效率。这种优化策略减少了不必要的数据库子查询,从而大幅缩短了数据获取时间。结合适当的数据库索引和缓存策略,可以确保您的应用程序在处理复杂数据聚合和排行榜功能时,保持高性能和良好的响应速度。
以上就是优化 Laravel Eloquent 查询:高效构建用户排行榜数据的详细内容,更多请关注php中文网其它相关文章!