[Перевод] Решение проблемы N+1 запроса без увеличения потребления памяти в Laravel

Одна из основных проблем разработчиков, когда они создают приложение с ORM — это N+1 запрос в их приложениях. Проблема N+1 запроса — это не эффективный способ обращения к базе данных, когда приложение генерирует запрос на каждый вызов объекта. Эта проблема обычно возникает, когда мы получаем список данных из базы данных без использования ленивой или жадной загрузки (lazy load, eager load). К счастью, Laravel с его ORM Eloquent предоставляет инструменты, для удобной работы, но они имеют некоторые недостатки.
В этой статье рассмотрим проблему N+1, способы ее решения и оптимизации потребления памяти.

Давайте рассмотрим простой пример, как использовать eager loading в Laravel. Допустим, у нас есть простое веб-приложение, которое показывает список заголовков первых статей пользователей приложения. Тогда связь между нашими моделями может быть вроде такой:

Class User extends Authenticatable
{
    public function articles()
    {
        return $this->hasMany(Aricle::class, 'writter_id');
    }

}

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

Route::get('/test', function() {
    $users = User::get();
    return view('test', compact('users'));
});

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

@extends('layouts.app')

@section('content')
    @foreach($users as $user)
  • Name: {{ $user->name}}
  • First Article: {{ $user->articles()->oldest()->first()->title }}
  • @endforeach
@endsection

И когда мы откроем нашу тестовую страницу в браузере, мы увидим нечто подобное:

image

Я использую debugbar (https://github.com/barryvdh/laravel-debugbar), чтобы показать, как выполняется наша тестовая страница. Для отображения этой страницы вызывается 11 запросов в БД. Один запрос для получения всей информации о пользователях и 10 запросов, чтобы показать заголовок их первой статьи. Видно, что 10 пользователей создают 10 запросов в базу данных к таблице статей. Это называется проблемой N+1 запроса.


Решение проблемы N+1 запроса с жадной загрузкой

Вам может показаться, что это не проблема производительности вашего приложения в целом. Но что, если мы хотим показать больше чем 10 элементов? И часто, нам также приходится иметь дело с более сложной логикой, состоящего из более чем одного N+1 запроса на странице. Это условие может привести к более чем 11 запросам или даже к экспоненциально растущему количеству запросов.

Итак, как мы это решаем? Есть один общий ответ на это:


Eager load

Eager load (жадная загрузка) — это процесс, при котором запрос для одного типа объекта также загружает связанные объекты в рамках одного запроса к базе данных. В Laravel мы можем загружать данные связанных моделей используя метод with (). В нашем примере мы должны изменить код следующим образом:

Route::get('/test', function() {
    $users = User::with('articles')->get();
    return view('test', compact('users'));
});
@extends('layouts.app')

@section('content')
    @foreach($users as $user)
  • Name: {{ $user->name}}
  • First Article: {{ $user->articles()->sortBy('created_at')->first()->title }}
  • @endforeach
@endsection

И, наконец, уменьшить количество наших запросов до двух:

image

Также мы можем создать связь hasOne, с соответствующим запросом для получения первой статьи пользователя:

    public function first_article()
    {
        return $this->hasOne(Aricle::class, 'writter_id')->orderBy('created_at', 'asc');
    }

Теперь мы можем загрузить ее вместе с пользователями:

Route::get('/test', function() {
    $users = User::with('first_article')->get();
    return view('test', compact('users'));
});

Результат теперь выглядит следующим образом:

image

Итого, мы можем уменьшить количество наших запросов и решить проблему N+1 запроса. Но хорошо ли мы улучшили нашу производительность? Ответом может быть «нет»! Это правда, что мы уменьшили количество запросов и решили проблемы N+1 запроса, но на самое деле мы добавили новую неприятную проблему. Как вы видите, мы уменьшили количество запросов с 11 до 2, но мы также увеличили количество загружаемых моделей с 20 до 10010. Это означает, чтобы показать 10 пользователей и 10 заголовков статей мы загружаем 10010 объектов Eloquent в память. Если у вас не ограничена память, то это не проблема. Иначе вы можете положить ваше приложение.


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

Должно быть 2 цели при разработке приложения:


  1. Мы должны сохранять минимальное количество запросов в БД
  2. Мы должны сохранять минимальное потребление памяти

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

Для реализации динамических отношений, мы будем напрямую использовать primary key вместо его foreign key. Мы также должны использовать подзапрос в связанной таблице, чтобы получить соответствующий идентификатор. Подзапрос будет размещен в select на основе отфильтрованных данных связанной таблицы.

Пример получения пользователей и id их первых статей через подзапрос:

select 
    "users".*,
    (
        select "id" 
        from "article"
        where "writter_id" = "users"."id"
        limit 1
    ) as "first_article_id"
from "users"

Мы можем получить такой запрос, если добавим select в подзапрос в нашем query builder. С использованием Eloquent это можно написать следующим образом:

User::addSelect(['first_article_id' => Article::select('id')
                ->whereColumn('writter_id', 'users.id')
                ->orderBy('created_at', 'asc')
                ->take(1)
    ])->get()

Этот код генерирует такой же sql запрос, что и в примере выше. После этого мы сможем использовать связь «first_article_id» для получения первых статей пользователя. Чтобы сделать наш код чище, мы можем использовать query scope Eloquent, чтобы упаковать наш код и выполнить жадную загрузку для получения первой статьи. Таким образом, мы должны добавить следующий код в класс модели User:

Class User extends Authenticatable
{
    public function articles()
    {
        return $this->hasMany(Aricle::class, 'writter_id');
    }

    public function first_article()
    {
        return $this->hasOne(Aricle::class, 'writter_id')->orderBy('created_at', 'asc');
    }

    public function scopeWithFirstArticle($query)
    {
        $query->addSelect(['first_article_id' => Article::select('id')
                ->whereColumn('writter_id', 'users.id')
                ->orderBy('created_at', 'asc')
                ->take(1)
        ])->with('first_article')
    }

}

И наконец, давайте изменим наш контроллер и шаблон. Мы должны использовать scope в нашем контроллере для жадной загрузки первой статьи. И мы можем напрямую обращаться к переменной first_article в нашем шаблоне:

Route::get('/test', function() {
    $users = User::withFirstArticle()->get();
    return view('test', compact('users'));
});
@extends('layouts.app')

@section('content')
    @foreach($users as $user)
  • Name: {{ $user->name}}
  • First Article: {{ $user->first_article->title }}
  • @endforeach
@endsection

Ниже результат производительности страницы после внесения этих изменений:

image

Теперь наша страница содержит всего 2 запроса и загружает 20 моделей. Мы достигли обеих целей оптимизации количества запросов к БД и минимизации потребления памяти.


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

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

Для этого нам нужно добавить небольшой хинт в наш ход, добавив accessor для свойства первой статьи:

public function getFirstArticleAtribute()
{
    if(!array_key_exists('first_article', $this->relations)) {
        $this->setRelation('first_article', $this->articles()->oldest()->first());
    }

    return $this->getRelation('first_article');
}

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


Динамические связи в Laravel 5.X

К сожалению, наше решение применимо только к Laravel 6 и выше. Laravel 6 и предыдущие версии используется разная реализация addSelect. Для использования в более старых версиях фреймворка мы должны изменить наш код. Мы должны использовать selectSub для выполнения подзапроса:

public function scopeWithFirstArticle($query)
{
    if(is_null($query->toBase()->columns)) {
        $query->select([$query->toBase()->from . '.*']);
    }

    $query->selectSub(
                Article::select('id')
                ->whereColumn('writter_id', 'users.id')
                ->orderBy('created_at', 'asc')
                ->take(1)
                ->toSql(), 
            'first_article_id'
            )->with('first_article');
}

© Habrahabr.ru