Привет! Я Валерио, разработчик из Италии и технический директор платформы
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
Читать ещё:
pbatanov
что говорит о том, что перед нами плохо спроектированная ORM. доктрина например сделает только 1 доп. запрос если у всех статей один автор, а все остальные пойдут через identity cache. не может быть такого в системе с ORM, что в памяти находятся два различных объекта отражающих одну и ту же entity, это сразу выстрел себе в ногу, в частности как раз во всяких отношениях, т.к. корректно траверсить графы объектов становится невозможнным.
Это конечно все не отменяет основной тезис о том, что в циклах с ленивой загрузкой надо быть аккуратным
Опять же, доктрина вместо двух запросов при EAGER загрузке сгенерирует вообще один запрос с джоином, а не два. Особенно весело, полагаю, будет выглядеть такая пара запросов если в таблице тысячи записей