Сделайте свое приложение масштабируемым, оптимизировав производительность ORM

Автор оригинала: Valerio Barbera
  • Перевод
Перевод статьи подготовлен в преддверии старта курса «Backend-разработчик на PHP».





Привет! Я Валерио, разработчик из Италии и технический директор платформы Inspector.dev.

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

Уверен, каждому из нас приходилось жаловаться, что сервер или приложение работает медленно (а то и вовсе не работает), и коротать время у кофемашины в ожидании результатов длительного запроса.

Как это исправить?
Давайте узнаем!

База данных — это общий ресурс


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

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

Поэтому не забывайте о возможных последствиях, думая: «Ничего страшного, что этот фрагмент кода не оптимизирован!» Одно медленное обращение к базе данных может привести к ее перегрузке, а это, в свою очередь, может негативно сказаться на работе пользователей.

Проблема N+1 запроса к базе данных


В чем состоит проблема N+1?

Это типичная проблема, возникающая при использовании ORM для взаимодействия с базой данных. Она не связана с написанием кода на SQL.

При использовании системы ORM, такой как Eloquent, не всегда очевидно, какие запросы будут выполняться и когда. В контексте этой конкретной проблемы давайте поговорим об отношениях и безотложной загрузке (eager loading).

Любая система ORM позволяет объявлять отношения между сущностями и предоставляет отличный API для навигации по структуре базы данных.
Ниже приведен хороший пример для сущностей «Статья» (Article) и «Автор» (Author).

/*
 * Each Article belongs to an Author
 */
$article = Article::find("1");
echo $article->author->name; 
/*
 * Each Author has many Articles
 */
foreach (Article::all() as $article)
{
    echo $article->title;
}

Однако при использовании отношений внутри цикла нужно писать код осторожно.

Взгляните на приведенный ниже пример.

Мы хотим добавить имя автора рядом с названием статьи. Благодаря ORM можно получить имя автора, используя отношение типа «один-к-одному» между статьей и автором.

Кажется, все просто:

// Initial query to grab all articles
$articles = Article::all();
foreach ($articles as $article)
{
    // Get the author to print the name.
    echo $article->title . ' by ' . $article->author->name;
}

Но тут-то мы и попали в ловушку!

Этот цикл генерирует один начальный запрос для получения всех статей:

SELECT * FROM articles;

и еще N запросов, чтобы получить автора каждой статьи и вывести значение поля «имя» (name), даже если автор всегда один и тот же.

SELECT * FROM author WHERE id = [articles.author_id]

Получаем ровно N+1 запрос.

Это может показаться не такой уж важной проблемой. Ну, сделаем пятнадцать-двадцать лишних запросов — не страшно. Однако давайте вернемся к первой части этой статьи:

  • База данных — это ресурс, совместно используемый всеми процессами.
  • Сервер базы данных имеет ограниченные ресурсы, а если используется управляемый сервис, то более высокая нагрузка на базу данных может привести к более высоким денежным расходам.
  • Если база данных размещена на отдельном физическом сервере, все данные будут передаваться с дополнительной сетевой задержкой.

Решение: использовать безотложную загрузку


Согласно документации Laravel существует немалая вероятность столкнуться с проблемой N+1 запроса, потому что при обращении к отношениям Eloquent как к свойствам ($article->author) происходит «ленивая загрузка» (lazy loading) данных отношений.

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

Однако, воспользовавшись простым методом, мы можем загрузить все данные отношений сразу. Тогда при обращении к отношению Eloquent как к свойству ORM-система не будет выполнять новый запрос, потому что данные уже были загружены.

Такая тактика называется «безотложной загрузкой» и поддерживается всеми ORM.

// Eager load authors using "with".
$articles = Article::with('author')->get();
foreach ($articles as $article)
{
    // Author will not run a query on each iteration.
    echo $article->author->name;
}

Eloquent предлагает метод with() для безотложной загрузки отношений.

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

SELECT * FROM articles;

Второй будет выполнен методом with() и извлечет всех авторов:

SELECT * FROM authors WHERE id IN (1, 2, 3, 4, ...);

Внутренний механизм Eloquent сопоставит данные, и к ним можно будет обращаться обычным способом:

$article->author->name;

Оптимизируйте операторы select


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

Кроме того, жесткое задание списка полей в конкретном операторе select усложняет дальнейшую поддержку такого фрагмента кода.

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

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

Laravel Eloquent предоставляет метод select, позволяющий ограничить запрос только теми столбцами, которые нам нужны:

$articles = Article::query()
    ->select('id', 'title', 'content') // The fields you need
    ->latest()
    ->get();

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

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

Используйте представления в MySQL


Представления (view) — это SELECT-запросы, построенные на основе других таблиц и хранящиеся в базе данных.

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

Представление — это предварительно скомпилированный оператор SELECT, при обработке которого MySQL немедленно выполняет лежащий в основе представления внутренний запрос.

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

Если вы хотите подробнее изучить возможности MySQL для разработки приложений, интенсивно использующих базу данных, ознакомьтесь вот с этим замечательным сайтом: www.mysqltutorial.org

Свяжите модель Eloquent с представлением


Представления также называют «виртуальными таблицами». С точки зрения ORM они выглядят как обычные таблицы.

Поэтому можно создать модель Eloquent для запроса данных, находящихся в представлении.

class ArticleStats extends Model
{
    /**
     * The name of the view is the table name.
     */
    protected $table = "article_stats_view";
    /**
     * If the resultset of the View include the "author_id"
     * we can use it to retrieve the author as normal relation.
     */
    public function author()
    {
        return $this->belongsTo(Author::class);
    }
}

Отношения работают как обычно, равно как и приведение типов, разбиение на страницы и т. д. И при этом не страдает производительность.

Заключение


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

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

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

Большое спасибо, что прочитали статью до конца. Если хотите узнать больше про Inspector, приглашаю на наш сайт www.inspector.dev. Не стесняйтесь писать в чат, если будут вопросы!

Ранее опубликовано здесь: www.inspector.dev/make-your-application-scalable-optimizing-the-orm-performance


Читать ещё:


OTUS. Онлайн-образование
Цифровые навыки от ведущих экспертов

Комментарии 6

    +3
    и еще N запросов, чтобы получить автора каждой статьи и вывести значение поля «имя» (name), даже если автор всегда один и тот же.


    что говорит о том, что перед нами плохо спроектированная ORM. доктрина например сделает только 1 доп. запрос если у всех статей один автор, а все остальные пойдут через identity cache. не может быть такого в системе с ORM, что в памяти находятся два различных объекта отражающих одну и ту же entity, это сразу выстрел себе в ногу, в частности как раз во всяких отношениях, т.к. корректно траверсить графы объектов становится невозможнным.

    Это конечно все не отменяет основной тезис о том, что в циклах с ленивой загрузкой надо быть аккуратным

    SELECT * FROM articles;


    SELECT * FROM authors WHERE id IN (1, 2, 3, 4, ...);


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

      public static function getBuilder()
      {
          return \DB::table((new static)->getTable());
      }
      

      В случаях когда не нужны связи и функциональность моделей, лучше кверибилдером пользоваться, элоквентовское оборачивание записей в модели занимает очень много времени и памяти на больших выборках. Сделал так чтобы в коде имена таблицы не дублировать при создании кверибилдера каждый раз
        0
        А, разве Model::query()->toBase() не тоже самое?
          0
          Оно! Не знал
        0
        Знаете меня реально достала эта странная вырожденная идея с 3ей нормальной формой которую суют везде. В результате чего получаются монстры кеда чтоб вывести авторов статей нужно сделать N запросов или «хитро»-стандартный JOIN 2х таблиц по ключу. Вся эта сраная магия которая согласуется с реальностью от слова никак.

        НЕ нужно хранить связанные данные отдельно. Храните себе данные домена — ВМЕСТЕ.
        Данные об авторе рядом со статьей, теги тоже как часть статьи. А для сбора авторов — отдельно заботясь при сохранении в Repository. Или вообще отдельным процессом раз в день по крону. Ну не появится ваш супер пупер автор в туже секунду и что? Мир схлопнется — нет же. Никто его и не заметит. Тоже самое +1 тег для статьи влияет на общую картину тегов — от слова НИКАК. Потому что 1 тег << 100500 других тегов в 1000+ статей
          +1
          Данные об авторе рядом со статьей, теги тоже как часть статьи

          Как в этом случае отличить двух одинаковых авторов (условные тезки) от одного и того же?
          Как в этом случае исправить опечатку в фамилии автора не нагнув всю базу обновлением всех документов?
          Что происходит с автором, если удаляется последняя его статься? данные о нем канут в небытие?

          Гораздо проще из нормальной формы сделать денормализованную через любого рода кэши или документные хранилища (типа эластика, например, если нужен поиск), чем наоборот, если вам нужны оптимизации, когда база разрастется. Т.е есть мастер данные, нормализованные и есть денормализованные рид-онли слепки с этих данных, с которыми работает сайт, если есть недостаток производительности реляционной модели.

          У вас же на ровном месте на старте проекта есть какие-то кроны и обновления, даже если у вас всего 5 записей в БД.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое