优化 Laravel Eloquent 查询:高效构建用户排行榜数据

优化 Laravel Eloquent 查询:高效构建用户排行榜数据

本教程详细讲解如何优化 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秒),主要原因在于其存在以下问题:

  1. 冗余的 whereHas 调用: 对于 currentWeek 和 lastWeek 的查询,whereHas(‘pictures’) 后面紧跟着一个带有条件闭包的 whereHas(‘pictures’, fn ($q) => $q->whereBetween(…))。前一个 whereHas 仅仅检查用户是否有任何图片,而后一个 whereHas 则检查用户是否有在特定日期范围内的图片。如果用户有在特定日期范围内的图片,那么他必然有图片。因此,第一个无条件的 whereHas 是多余的。
  2. 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,
    ]);
}
登录后复制

重要考量与建议

为了进一步提升性能和应用健壮性,以下几点至关重要:

  1. 数据库索引:
    确保 pictures 表的 user_id 和 created_at 字段上都有合适的索引。这是任何关联查询性能优化的基石。一个复合索引 (user_id, created_at) 通常是理想的选择,它可以加速按用户ID过滤并按创建时间范围查找的操作。

    ALTER TABLE pictures ADD INDEX idx_user_id_created_at (user_id, created_at);
    登录后复制
  2. 缓存策略:
    对于排行榜这类数据,其内容在短时间内不会频繁变动,但访问量可能非常大。强烈建议使用 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();
    });
    登录后复制
  3. 软删除 (Soft Deletes):
    如果 User 或 Picture 模型使用了软删除(即存在 deleted_at 字段),Eloquent 会自动在查询中加入 where deleted_at is null 条件。这通常是期望的行为,但也要注意其对索引和查询的影响。确保 deleted_at 字段也有索引可以进一步优化带软删除的查询。

总结

通过本文介绍的两个关键优化步骤——消除冗余的 whereHas 子句以及充分利用 withCount 的条件闭包,我们能够显著提升 Laravel Eloquent 关联查询的效率。这种优化策略减少了不必要的数据库子查询,从而大幅缩短了数据获取时间。结合适当的数据库索引和缓存策略,可以确保您的应用程序在处理复杂数据聚合和排行榜功能时,保持高性能和良好的响应速度。

以上就是优化 Laravel Eloquent 查询:高效构建用户排行榜数据的详细内容,更多请关注php中文网其它相关文章!

https://www.php.cn/faq/1430288.html

发表回复

Your email address will not be published. Required fields are marked *