你好 👋
是的,你没有看错。预加载(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列,因此填充模型将会很昂贵,即使每个父模型只填充一个,这也是你选择时需要记住的!
结论
我见过一些开发者出于设计选择为所有模型强制执行懒加载。你不能把它用在一切上,尽管它看起来像你已经解决了问题,但你实际上可能已经创造了更糟糕的问题。不是所有东西都是钉子;锤子可能不适用 🔨
用户 driesvints、sajibadhi、speed、antoniputra 赞同了这篇文章