支持 Laravel.io 的持续发展 →

Laravel - 预加载数据可能会导致问题!

29 Feb, 2024 7 min 读取

你好 👋

是的,你没有看错。预加载(Eager Loading)可能非常糟糕。然而,当我们处理 N+1 场景时,我们往往会求助于它,以为我们已经解决了问题,但事实上我们可能使情况变得更糟。怎么?让我们看看。

糟糕到什么程度

对于这个演示,我们将构建 Laravel Forge。像(几乎)所有的 Laravel 应用程序一样,我们将有一个 一对一(One To Many)关系

我们的目标是记录服务器的每一项活动。日志可以包括活动类型、启动用户和其他有用的信息,供以后分析。

<?php

namespace App\Models;
 
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
 
class Server extends Model
{
    // ...

    public function logs(): HasMany
    {
        return $this->hasMany(Log::class);
    }
}

现在,在应用程序中,我们想要列出所有服务器。所以,我们可能这样做

<!-- It's a fancy table, use your imagination... -->

<table>
    <tr>
        <th>Name</th>
    </tr>
    @foreach ($servers as $server)
    <tr>
        <td>{{ $server->name }}</td>
    </tr>
    @endforeach
</table>

接下来,我们有 10 个服务器,每个服务器都有 1000 条日志。

到目前为止,一切顺利。现在,我们想要显示服务器上最后活动发生的时间

<table>
    <tr>
        <th>Name</th>
        <th>Last Activity</th>
    </tr>
    @foreach ($servers as $server)
    <tr>
        <td>{{ $server->name }}</td>
        <td>
            {{ $server->logs()->latest()->first()->created_at->diffForHumans() }}
        </td>
    </tr>
    @endforeach
</table>

基本操作,我们访问 logs() 关系,对它进行排序以获取最新的记录,获取 created_at 列,并用 diffForHumans() 格式化它以提高可读性。后者生成类似“1周前”的内容。

但这很糟糕,我们引入了一个 N+1 问题

如果你不知道什么是 N+1,我们在运行以下查询

-- 1 query to get all the servers
select * from `servers`

-- N queries for each of servers
select * from `logs` where `logs`.`server_id` = 1 and `logs`.`server_id` is not null order by `created_at` desc limit 1
select * from `logs` where `logs`.`server_id` = 2 and `logs`.`server_id` is not null order by `created_at` desc limit 1
-- ...
select * from `logs` where `logs`.`server_id` = 10 and `logs`.`server_id` is not null order by `created_at` desc limit 1

为了解决这个问题,我们通常会求助于 预加载(Eager Loading)(我知道你做到过)。

// In your controller
$servers = Server::query()
    ->with('logs')
    ->get();

// In your blade
<table>
    <tr>
        <th>Name</th>
        <th>Last Activity</th>
    </tr>
    @foreach ($servers as $server)
    <tr>
        <td>{{ $server->name }}</td>
        <td>
            {{ $server->logs->sortByDesc('created_at')->first()->created_at->diffForHumans() }}
        </td>
    </tr>
    @endforeach
</table>

经过这次更新,我们将其减少到只有 2 个查询

-- 1 query to get all the servers
select * from `servers`

-- 1 query to get all the related logs
select * from `logs` where `logs`.`server_id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

而且看起来我们已经解决了问题,对吧?

错误!我们只考虑了查询次数。让我们看一下内存使用量和加载的模型数量;这些因素同样重要。

  • 在懒加载之前
    • 11次查询:1次用于检索所有服务器,每个服务器10次。
    • 共加载了20个模型。
    • 内存使用量:2MB。
    • 执行时间:38.19毫秒。

  • 在懒加载之后
    • 2次查询:1次获取所有服务器,1次获取所有日志。
    • 总共加载了10010个模型 🤯。
    • 内存使用量:13MB(增加了6.5倍)。
    • 执行时间:66.5毫秒(增加了1.7倍)。
    • 由于加载了所有模型,计算时间变慢了 🐢。

图中的工具是 Debugbar

看起来我们并没有解决问题;事实上,我们使情况变得更糟。而且记住,这仅仅是一个非常简化的例子。在现实场景中,你可能很容易就有成百上千的记录,从而导致数百万个模型的加载。标题现在有意义了吗?

我们如何才能真正解决这个问题?

在我们的情况下,懒加载是大忌。相反,我们可以使用子查询并利用数据库来执行其主要构建和优化的任务。

$servers = Server::query()
    ->addSelect([
        'last_activity' => Log::select('created_at')
            ->whereColumn('server_id', 'servers.id')
            ->latest()
            ->take(1)
    ])
    ->get();

这将导致单个查询

select `servers`.*, (
        select `created_at`
        from `logs`
        where
            `server_id` = `servers`.`id`
        order by `created_at` desc
        limit 1
    ) as `last_activity`
from `servers`

由于我们现在需要从关系中获取的列已在子查询中进行计算,因此我们得到了两全其美的结果:只加载了10个模型,内存使用量最小。

你可能认为这种方法有一个缺点:现在last_activity列是一个普通的字符串。所以,如果你想使用diffForHumans()方法,你会遇到Call to a member function diffForHumans() on string错误。但别担心,你并没有失去类型转换;这只需要加一行代码。

$servers = Server::query()
    ->addSelect([
        'last_activity' => Log::select('created_at')
            ->whereColumn('server_id', 'servers.id')
            ->latest()
            ->take(1)
    ])
    ->withCasts(['last_activity' => 'datetime']) // casts here
    ->get();

通过链式调用withCasts()方法,你现在可以将last_activity当作日期一样处理。

那么Laravel的方法呢?

Reddit社区从不让人失望!他们指出另一种可能的解决方案,一个Laravel-ish的方法;One Of Many

让我们定义一个新的关系,以始终检索最新的日志

// In the Server Model
public function latestLog(): HasOne
{
    return $this->hasOne(Log::class)->latestOfMany();
}

现在我们可以像这样使用这个关系

// In the Controller (or action..)
$servers = Server::query()
    ->with('latestLog')
    ->get();

这将导致以下查询

select * from `servers`

select `logs`.*
from
    `logs`
    inner join (
        select MAX(`logs`.`id`) as `id_aggregate`, `logs`.`server_id`
        from `logs`
        where
            `logs`.`server_id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
        group by
            `logs`.`server_id`
    ) as `latestOfMany` 
    on `latestOfMany`.`id_aggregate` = `logs`.`id`
    and `latestOfMany`.`server_id` = `logs`.`server_id`

并且可以在Blade中使用如下

// In the Blade view
@foreach ($servers as $server)
    {{$server->latestLog }}
@endforeach

对比两种方法的区别

  • 使用子查询
    • 1次查询。
    • 共加载了10个模型。
    • 内存使用量:2MB。
    • 执行时间:21.55毫秒。

  • 使用了latestOfMany()
    • 2次查询
    • 共加载了20个模型。
    • 内存使用量:2MB。
    • 执行时间:20.63毫秒

两种方法都非常不错;哪种方法取决于你的情况。如果你绝对需要子模型被填充,并且会使用其所有字段,请使用latestOfMany()。然而,如果你只需要少数几个字段,那么子查询将表现得更好。这是因为,在子查询中,你选择了你需要的所有内容。无论你有多少记录,内存使用量都将几乎是相同的。现在,对于第二种方法,内存使用量在很大程度上取决于你的表有多少列。实际上,一个表可以轻松地有50列,因此填充模型将会很昂贵,即使每个父模型只填充一个,这也是你选择时需要记住的!

结论

我见过一些开发者出于设计选择为所有模型强制执行懒加载。你不能把它用在一切上,尽管它看起来像你已经解决了问题,但你实际上可能已经创造了更糟糕的问题。不是所有东西都是钉子;锤子可能不适用 🔨

上次更新5个月前。

用户 driesvints、sajibadhi、speed、antoniputra 赞同了这篇文章

4
喜欢这篇文章吗?让作者知道并给予他们一个赞!
oussamamater (Oussama Mater) 我是一位软件工程师和CTF玩家。我使用Laravel和Vue.js将想法转化为应用程序🚀

你可能喜欢其他文章

2024年3月11日

如何使用Larastan将你的Laravel应用程序从0到9进行改进

在Laravel应用程序执行之前发现错误是可能的,多亏了Larastan,它...

阅读文章
2024年7月19日

无需特性即可标准化API响应

我发现大多数用于API响应的库都是使用特性实现的,并且...

阅读文章
2024年7月17日

在Laravel项目中通过Discord通知收集反馈

如何在Laravel项目中创建一个反馈模块并在发表评论时接收Discord通知

阅读文章

我们想感谢这些令人惊叹的公司 对我们的支持

您的标志在这里?

Laravel.io

Laravel的解决问题、知识分享和社区建设的门户网站。

© 2024 Laravel.io - 版权所有。