Нетерпеливая загрузка может быть вредна

Источник: «Laravel - Eager loading can be bad!»
Да, вы всё правильно поняли. Нетерпеливая загрузка (Eager loading) может быть вредной, очень вредной. Однако мы часто прибегаем к ней, когда имеем дело со сценарием N+1, думая, что решили проблему, а на самом деле, возможно, сделали её ещё хуже. Каким образом? Давайте посмотрим.

Оглавление

Насколько всё плохо

В этой демонстрации мы собираем Laravel Forge. Как и (почти) в каждом приложении Laravel, у нас будет отношение "Один Ко Многим".

Мы стремимся регистрировать каждое действие на сервере. Лог может включать тип активности, пользователя, который её инициировал, и другую полезную информацию для последующего анализа.

<?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);
}
}

Теперь в приложении нам нужен список всех серверов. Поэтому мы можем сделать что-то вроде

<!-- Это воображаемая таблица, используйте своё воображение... -->

<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 запрос для получения всех серверов
select * from `servers`

-- N запросов к каждому из серверов
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 (я знаю, что вы так и сделали).

// В вашем контроллере
$servers = Server::query()
->with('logs')
->get();

// В вашем 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 запрос для получения всех серверов
select * from `servers`

-- 1 запрос для получения всех связанных с ним логов
select * from `logs` where `logs`.`server_id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

И казалось бы, мы решили проблему, верно?

Неверно! Мы рассматриваем только количество запросов. Давайте рассмотрим использование памяти и количество загруженных моделей — эти факторы не менее важны.

Похоже, мы ничего не исправили, а только усугубили. И имейте в виду, что это очень упрощённый пример. В реальном мире вы легко можете получить сотни или тысячи записей, что приведёт к загрузке миллионов моделей... Теперь заголовок статьи стал понятнее?

Как действительно решить эту проблему

В нашем случае нетерпеливая загрузка — это НЕТ НЕТ. Вместо этого мы можем использовать подзапросы и задействовать базу данных для выполнения задач, для которых она создана и оптимизирована.

$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']) // касты здесь
->get();

Используя метод withCasts(), вы можете обращаться с last_activity так, как если бы это была дата.

Как насчёт Laravel-способа

Reddit-сообщество никогда не разочаровывает! Они указали на ещё одно альтернативное решение, подход в духе Laravel; One Of Many.

Давайте определим новое отношение, чтобы всегда получать последний лог

// В Модели Server
public function latestLog(): HasOne
{
return $this->hasOne(Log::class)->latestOfMany();
}

Теперь мы можем использовать эти отношения следующим образом

// В Контроллере (или экшене..)
$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 следующим образом

// В представлении Blade
@foreach ($servers as $server)
{{$server->latestLog }}
@endforeach

Для сравнения этих двух методов:

Оба метода действительно хороши; какой из них использовать, зависит от вашего случая. Если вам абсолютно необходима гидратированная дочерняя модель и вы будете использовать все её поля, выбирайте latestOfMany(). Однако если нужно только несколько полей, то подзапрос будет работать лучше. Это связано с тем, что в подзапросе вы выбираете именно то, что нужно. Независимо от количества записей, потребление памяти будет практически одинаковым. Что касается второго метода, то использование памяти в значительной степени зависит от количества столбцов в таблице. В реальности таблица может легко содержать 50 столбцов, поэтому гидратация модели будет стоить дорого, даже если она будет только одна на родителя, это следует иметь в виду при выборе!

Заключение

Я видел, как некоторые разработчики по собственной воле выбирали нетерпеливую загрузку для всех моделей. Нельзя использовать его для всего подряд: и хотя, кажется, что вы решили проблему, на самом деле вы могли создать ещё худшую. Не всё является гвоздём, молоток может не сработать 🔨.

Дополнительные материалы

Предыдущая Статья

Функциональное программирование в JavaScript

Следующая Статья

Создание меню "вне холста" с <dialog> и веб-компонентами