Динамические отношения с использованием подзапросов

Это мощная техника, который позволит вам перенести большой кусок работы на слой БД. Она может оказать огромное влияние на производительность, позволяя значительно сократить количество запросов и общий объем используемой памяти.

При создании веб-приложений, использующих базу данных, я всегда преследую две цели:

  1. Свести к минимуму запросы к базе данных.
  2. Свести к минимуму использование памяти.

Так как от этого сильно зависит производительность приложения.

Разработчики, как правило, успешно достигают первой цели. Мы знаем о проблемах типа N+1 и используем жадную загрузку для уменьшения количества запросов к БД. А вот со второй целью  — уменьшение потребления памяти, мы справляемся не всегда. Более того, пытаясь сократить количество запросов, мы иногда приносим больше вреда — за счет увеличения потребления памяти.

Сейчас расскажу, как это происходит и что можно сделать для успешного достижения обеих наших целей.

Задача

Допустим, у нас есть страница с пользователями, которая показывает о них какую-то информацию, в то числе дату их последнего входа в систему. Казалось бы, простая страница, но в ней есть интересный подвох.

Динамически отношения в Laravel

Мы отслеживаем входы через таблицу logins, соответственно мы можем генерировать из неё статистические отчеты. Вот как выглядит схема база данных:

Schema::create('users', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->string('email');
    $table->timestamps();
});

Schema::create('logins', function (Blueprint $table) {
    $table->increments('id');
    $table->integer('user_id');
    $table->string('ip_address');
    $table->timestamp('created_at');
});

А вот модели и их отношения:

class User extends Model
{
    public function logins()
    {
        return $this->hasMany(Login::class);
    }
}

class Login extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

Итак, как же нам создать показанную выше страницу пользователей? И как мы можем получить дату последнего входа в систему? Простой ответ может быть таким:

$users = User::all();

@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($lastLogin = $user->logins()->latest()->first())
                {{ $lastLogin->created_at->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

Но, если мы хорошие программисты (а мы такие и есть), то заметим здесь проблему N+1. Для каждого пользователя, которого мы здесь показываем, запускается дополнительный запрос, для получения последнего входа. И, если на нашей странице 50 пользователей, то выполнится 51 запрос.

select * from "users";
select * from "logins" where "logins"."user_id" = 1 and "logins"."user_id" is not null order by "created_at" desc limit 1;
select * from "logins" where "logins"."user_id" = 2 and "logins"."user_id" is not null order by "created_at" desc limit 1;
// ...
select * from "logins" where "logins"."user_id" = 49 and "logins"."user_id" is not null order by "created_at" desc limit 1;
select * from "logins" where "logins"."user_id" = 50 and "logins"."user_id" is not null order by "created_at" desc limit 1;

Давайте сделаем лучше — используем «жадную загрузку» (eager-load):

$users = User::with('logins')->get();

@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($user->logins->isNotEmpty())
                {{ $user->logins->sortByDesc('created_at')->first()->created_at->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

Это решение потребует всего лишь двух запросов к базе данных. Один для пользователей, а другой для соответствующих записей входа. Отлично!

Ну, не совсем. Вот тут и появляются проблемы с памятью. Конечно, мы избежали проблемы N+1, но создали гораздо большую проблему с памятью:

Динамически отношения в Laravel

Мы загрузили 12 500 логинов, только для того, чтобы показать дату последнего входа для каждого пользователя. Это не только жрёт память, но и требует дополнительных вычислений, ведь каждая запись должна быть инициализирована как Eloquent-модель. И это еще довольно скромный пример. Вы можете легко столкнуться с подобными ситуациями, которые приведут к загрузке миллионов записей.

Кэширование

Сейчас вы можете подумать: «Ничего страшного, я просто закеширую last_login_id в таблице пользователей». Например так:

Schema::create('users', function (Blueprint $table) {
   $table->integer('last_login_id');
});

Теперь, когда пользователь входит в систему, мы создаем новую запись логина и обновляем last_login_id в таблице пользователей. Затем мы создадим отношения lastLogin в модели пользователя и используем на них жадную загрузку.

$users = User::with('lastLogin')->get();

И это абсолютно верное решение. Но имейте в виду, что не всегда кэширование это так просто. Да, есть ситуации, когда денормализация уместна, но мы можем сделать лучше.

Подзапросы

Есть еще один способ решить эту проблему — подзапросы. Они позволяют нам выбирать дополнительные столбцы (атрибуты) прямо в нашем запросе к базе данных (запрос пользователей в нашем примере). Давайте посмотрим, как мы можем это сделать.

$users = User::query()
    ->addSelect(['last_login_at' => Login::select('created_at')
        ->whereColumn('user_id', 'users.id')
        ->latest()
        ->take(1)
    ])
    ->withCasts(['last_login_at' => 'datetime'])
    ->get();

@foreach ($users as $user)
    <tr>
        <td>{{ $user->name }}</td>
        <td>{{ $user->email }}</td>
        <td>
            @if ($user->last_login_at)
                {{ $user->last_login_at->format('M j, Y \a\t g:i a') }}
            @else
                Never
            @endif
        </td>
    </tr>
@endforeach

В этом примере мы не загружаем динамические отношения, а используем подзапрос, чтобы получить дату последнего входа каждого пользователя в качестве атрибута. Мы также пользуемся преимуществами приведения времени запроса (Query Time Casting) для преобразования last_login_at в экземпляр Carbon.

Давайте посмотрим на итоговый запрос к базе данных:

select
    "users".*,
    (
        select "created_at" from "logins"
        where "user_id" = "users"."id"
        order by "created_at" desc
        limit 1
    ) as "last_login_at"
from "users"

Использование подзапроса позволяет нам получить всю необходимую информацию одним запросом. Этот метод обеспечивает огромный выигрыш в производительности, поскольку мы сводим к минимуму как запросы к базе данных, так и использование памяти, плюс мы избегаем необходимость кэширования.

Скоуп

Перед тем, как перейти к следующему шагу, давайте переместим наш подзапрос в скоуп на модели User:

class User extends Model
{
    public function scopeWithLastLoginDate($query)
    {
        $query->addSelect(['last_login_at' => Login::select('created_at')
            ->whereColumn('user_id', 'users.id')
            ->latest()
            ->take(1)
        ])->withCasts(['last_login_at' => 'datetime']);
    }
}

$users = User::withLastLoginDate()->get();

Мне нравится прятать код конструктора запросов в моделях. Это не только упрощает код контроллеров, но и позволяет повторно использовать эти запросы. Кроме того, это поможет нам в следующем шаге — загрузке динамических отношений через подзапросы.

Динамические отношения через подзапросы

Использование подзапроса для получения последней даты входа — это замечательно, но что если нам понадобится дополнительная информация о входе? Например, IP-адрес. Как это сделать?

Один из вариантов — просто создать второй скоуп с подзапросом:

$users = User::withLastLoginDate()
    ->withLastLoginIpAddress()
    ->get();

{{ $user->last_login_at->format('M j, Y \a\t g:i a') }}
({{ $user->last_login_ip_address }})

И это, безусловно, будет работать. Но процесс станет утомительным, если будет много атрибутов. Разве не лучше было бы работать с реальным экземпляром модели Login? Особенно, если у модели есть дополнительный функционал, например методы доступа или отношения. Что-то вроде этого:

$users = User::withLastLogin()->get();

{{ $user->lastLogin->created_at->format('M j, Y \a\t g:i a') }}
({{ $user->lastLogin->ip_address }})

Введем динамические отношения.

Начнем с определения belongsTo-отношений для lastLogin. Для такого типа отношений обычно нужна колонка для внешнего ключа. В нашем примере, это будет last_login_id в таблице users. Но, так как мы пытаемся избежать денормализации и хранения этих данных в таблице пользователей, то вместо этого мы будем использовать подзапрос для выбора внешнего ключа. Eloquent понятия не имеет, что это не настоящая колонка, поэтому всё работает, как будто это так. Смотрим код:

class User extends Model
{
    public function lastLogin()
    {
        return $this->belongsTo(Login::class);
    }

    public function scopeWithLastLogin($query)
    {
        $query->addSelect(['last_login_id' => Login::select('id')
            ->whereColumn('user_id', 'users.id')
            ->latest()
            ->take(1)
        ])->with('lastLogin');
    }
}

$users = User::withLastLogin()->get();

<table>
    <tr>
        <th>Name</th>
        <th>Email</th>
        <th>Last Login</th>
    </tr>
    @foreach ($users as $user)
        <tr>
            <td>{{ $user->name }}</td>
            <td>{{ $user->email }}</td>
            <td>
                @if ($user->lastLogin)
                    {{ $user->lastLogin->created_at->format('M j, Y \a\t g:i a') }}
                @else
                    Never
                @endif
            </td>
        </tr>
    @endforeach
</table>

Это все, что нужно сделать! В итоге у нас два запроса к базе данных. Давайте посмотрим на них:

select
    "users".*,
    (
        select "id" from "logins"
        where "user_id" = "users"."id"
        order by "created_at" desc
        limit 1
    ) as "last_login_id"
from "users"

Этот запрос похож на предыдущий, за исключением того, что вместо выбора даты последнего входа в систему мы выбираем последний идентификатор входа. По сути, мы получили колонку last_login_id, которую мы бы добавили, если бы кэшировали значение.

Давайте посмотрим на второй запрос. Его автоматически запускает Laravel, когда мы используем жадную загрузку через with('lastLogin').

select * from "logins" where "logins"."id" in (1, 3, 5, 13, 20 ... 676, 686)

Наш подзапрос позволил нам выбрать только последние логины для каждого пользователя. Кроме того, поскольку мы используем стандартные Laravel-отношения для lastLogin, то получаем результат в виде правильных Eloquent-моделей Login. И нам больше не нужно приведение времени, так как модель автоматически обрабатывает его в атрибуте created_at. Просто супер!

Ленивая загрузка динамических отношений

Помните одну вещь: не получится использовать ленивую загрузку динамических отношений из коробки. Так как нашего скоупа по умолчанию нет.

$lastLogin = User::first()->lastLogin; // вернёт null

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

class User extends Model
{
    protected static function booted()
    {
        static::addGlobalScope('with_last_login', function ($query) {
            $query->withLastLogin();
        });
    }
}

Я обычно этого не делаю, так как предпочитаю загружать свои динамические отношения явно, когда они нужны.

Можно ли это сделать с помощью has-one?

И последнее. Вы можете задаться вопросом, могли бы мы избежать этой работы, просто используя отношения Один к Одному (has-one). Короткий ответ: нет. Давайте посмотрим почему.

Первый способ, о которым вы можете подумать, это отсортировать запрос has-one:

class User extends Model
{
    public function lastLogin()
    {
        return $this->hasOne(Login::class)->latest();
    }
}

$lastLogin = User::first()->lastLogin;

И, на первый взгляд, это действительно дает нужный результат. Отношение lastLogin предоставляет корректный последний экземпляр Login. Однако, если мы посмотрим на сгенерированный запрос, мы увидим проблему:

select * from "logins"
where "logins"."user_id" in (1, 2, 3...99, 100)
order by "created_at" desc

Это жадная загрузка логинов по user_id, без всяких лимитов и фильтров. Это означает, что загрузится не только последний логин, а каждая запись для всех пользователей. И мы снова вернулись к проблеме с 12 500 записями, которая была ранее.

Но, наш дух не сломлен! Мы добавляем лимитирование:

public function lastLogin()
{
    return $this->hasOne(Login::class)->latest()->take(1);
}

Вроде это должно работать, но давайте посмотрим на сгенерированный запрос:

select * from "logins"
where "logins"."user_id" in (1, 2, 3...99, 100)
order by "created_at" desc
limit 1

Laravel жадно загружает отношения одним запросом к БД, а теперь мы добавили лимит в одну запись. Это означает, что мы получим только одну запись для всех пользователей. И это будет дата входа последнего пользователя, вошедшего в систему. Для всех остальных пользователей отношения lastLogin вернут null.

Автор: Jonathan Reinink
Перевод: Алексей Широков

Наш Телеграм-канал — следите за новостями о Laravel.