Нетерпеливая загрузка может быть вредна
Оглавление
Насколько всё плохо
В этой демонстрации мы собираем 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)
И казалось бы, мы решили проблему, верно?
Неверно! Мы рассматриваем только количество запросов. Давайте рассмотрим использование памяти и количество загруженных моделей — эти факторы не менее важны.
- До нетерпеливой загрузки
- 11 запросов: 1 для получения всех серверов и 10 запросов для каждого сервера.
- Всего загружено 20 моделей.
- Использование памяти: 2 МБ.
- Время выполнения: 38,19 мс.
- После нетерпеливой загрузки
- 2 запроса: 1 для получения всех серверов и 1 для получения всех логов.
- Всего загружено 10010 моделей 🤯.
- Использование памяти: 13 МБ (увеличение в 6,5 раз).
- Время выполнения: 66,5 мс (увеличение в 1,7 раза).
- Замедление времени вычислений из-за загрузки всех моделей 🐢.
Похоже, мы ничего не исправили, а только усугубили. И имейте в виду, что это очень упрощённый пример. В реальном мире вы легко можете получить сотни или тысячи записей, что приведёт к загрузке миллионов моделей... Теперь заголовок статьи стал понятнее?
Как действительно решить эту проблему
В нашем случае нетерпеливая загрузка — это НЕТ НЕТ. Вместо этого мы можем использовать подзапросы и задействовать базу данных для выполнения задач, для которых она создана и оптимизирована.
$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
Для сравнения этих двух методов:
- Использование подзапросов
- 1 запрос.
- Всего загружено 10 моделей.
- Использование памяти: 2 МБ.
- Время выполнения: 21,55 мс.
- Использование
latestOfMany()
- 2 запроса
- Всего загружено 20 моделей.
- Использование памяти: 2 МБ.
- Время выполнения: 20,63 мс
Оба метода действительно хороши; какой из них использовать, зависит от вашего случая. Если вам абсолютно необходима гидратированная дочерняя модель и вы будете использовать все её поля, выбирайте latestOfMany(). Однако если нужно только несколько полей, то подзапрос будет работать лучше. Это связано с тем, что в подзапросе вы выбираете именно то, что нужно. Независимо от количества записей, потребление памяти будет практически одинаковым. Что касается второго метода, то использование памяти в значительной степени зависит от количества столбцов в таблице. В реальности таблица может легко содержать 50 столбцов, поэтому гидратация модели будет стоить дорого, даже если она будет только одна на родителя, это следует иметь в виду при выборе!
Заключение
Я видел, как некоторые разработчики по собственной воле выбирали нетерпеливую загрузку для всех моделей. Нельзя использовать его для всего подряд: и хотя, кажется, что вы решили проблему, на самом деле вы могли создать ещё худшую. Не всё является гвоздём, молоток может не сработать 🔨.