Создание высокопроизводительного бэкенда с использованием GraphQL и DataLoader
Независимо от того, создаете ли вы приложение на React, Vue или Angular, GraphQL - это естественный выбор для создания современного API. Однако любой, кто знаком с этой технологией, знает проблемы оптимизации, возникающие при написании бэкэнда GraphQL. Для некоторых время отклика и загрузка базы данных могут быть некритичными. Приложения, которые не собирают много данных, могут работать нормально без какой-либо конкретной оптимизации. К сожалению, это не наш случай. Наш продукт, платформа управления капиталом под названием Equify, извлекает огромные объемы данных каждый раз, когда загружается новый экран, и базе данных приходилось обрабатывать сотни запросов на каждый запрос. Нам нужно было быстро найти решения, чтобы улучшить наш сервер GraphQL и сократить время отклика.
Подводные камни GraphQL 🤒
Корень проблемы достаточно прост для понимания: поля GraphQL автономны и отвечают за выборку собственных данных. При наивном подходе одни и те же данные могут быть получены несколько раз.
Рассмотрим следующий сценарий: вы хотите показать пользователю его первых 5 друзей и лучшего друга каждого из них:
Здесь R, T, E, N и P - все мои друзья. Я лучший друг R и P, W - лучший друг E и N, и так далее. Запрос, который извлекает данные, довольно прост:
Теперь, если каждое из полей friends
и bestFriend
должно вызывать базу данных для разрешения, мы можем получить всего 7 вызовов. Поначалу это может показаться разумным. Однако, если присмотреться, то понимаешь, что ситуация далека от оптимальной ...
Как видите, бэкэнд в конечном итоге получал одних и тех же пользователей несколько раз:
- A (я) был получен 3 раза, один раз в
me
преобразователе и дважды вbestFriend
преобразователе - R был получен 2 раза: один раз в
friends
преобразователе и один раз вbestFriends
преобразователе. - W также был получен 2 раза, оба в преобразователе
bestFriend
Быстро возникает менее чем оптимальная ситуация, в которой ваш бэкэнд перегружен, делая несколько вызовов для получения одних и тех же данных снова и снова. Что еще хуже, нагрузка на серверную часть приводит к тому, что ваши пользователи получают неудовлетворительный опыт.
Идеальная ситуация - получить каждого пользователя только один раз. По сути, нам нужно кэшировать вызовы базы данных таким образом, чтобы они не зависели от логики наших преобразователей:
Но кеширования мало. При работе с массивом ресурсов, например списком наших друзей, каждый экземпляр будет извлекать свои собственные данные. Решатель bestFriend
генерирует 5 вызовов, тогда как одного могло быть достаточно. Эта проблема усугубляется по мере увеличения размера ваших коллекций.
С самого начала мы интуитивно чувствовали, что вопрос заключается в том, чтобы пакетировать запросы и объединять аналогичные вызовы в базу данных:
Это понимание определенно неочевидно: почему один большой запрос может быть более эффективным, чем несколько маленьких? Давайте пролим свет на этот вопрос:
- Системы баз данных оптимизированы для обработки больших объемов данных.
- Накладные расходы сети действительно случаются только один раз (они могут быть весьма значительными)
- Ядро базы данных анализирует и оптимизирует запрос только один раз.
В идеале мы хотим, чтобы механизм пакетной обработки был полностью прозрачен для нас, то есть он должен быть отделен от нашей логики, и мы по-прежнему должны получать отдельные объекты:
Теперь, когда мы определили проблему, давайте перейдем к ее решению.
Серебряная пуля: DataLoader ✨
Причина, по которой мы обратились к DataLoader, заключается в том, что он решает две проблемы, которые нам нужно было решить в первую очередь, чтобы сделать наше решение возможным: пакетирование и кеширование. Это не специфично для GraphQL, но идеально подходит.
DataLoader - это просто тонкая оболочка вокруг функции, которая принимает набор идентификаторов и возвращает набор значений в обещании - рендеринг чистого API, в котором вы можете передать один идентификатор и получить взамен одно значение. Все, что вам нужно сделать, это написать функцию пакетной обработки, а DataLoader сделает все остальное.
Базовая реализация:
DataLoader обрабатывает пакетную обработку довольно умно, он помещает все запросы в очередь и отправляет идентификаторы функции выборки в конце цикла событий. Все вызовы, происходящие в течение одного тика цикла событий, автоматически группируются.
Этот подход особенно хорошо работает с GraphQL, потому что дерево преобразователя вызывается слой за слоем. Другими словами: все преобразователи изображения профиля пользователя всех сообщений вызываются в одном и том же тике цикла событий, и DataLoader может группировать вызовы, как и ожидалось.
С другой стороны, кэширование намного проще. С DataLoader пакетная функция не вызывается второй раз, если вы запрашиваете идентификатор, который уже был загружен, гарантируя, что каждый идентификатор загружается только один раз. При запросе одного и того же идентификатора несколько раз DataLoader каждый раз возвращает одно и то же обещание:
Легко, правда? Давайте реализуем это.
Настоящая сделка 🧐
Задача номер один - создание кеша для каждого запроса, это предотвращает проблемы с разрешениями или недействительностью кеша. Это также наиболее часто используемые шаблоны. Чтобы воспроизвести его, вам просто нужно будет создавать новые загрузчики каждый раз при обработке запроса. Используя Express, вы можете легко добавить промежуточное ПО в свое приложение:
Чтобы сделать загрузчики доступными для каждого преобразователя, передайте его в контекст:
Прелесть DataLoader заключается в том, что с помощью нескольких строк кода вы можете извлечь выгоду из кэширования и пакетной обработки и значительно повысить производительность. В производственной среде мы снизили на 95% количество обращений к базе данных и увеличили время отклика в 4 раза. И это нас радует!
Сложная часть 🙃
Последнее, что нам нужно рассмотреть, - это отношения многие-2-многие и один-2-многие. Может возникнуть соблазн создать загрузчик для каждого отношения для пакетной обработки, к сожалению, это нарушит кеширование. Вы можете получить одни и те же данные несколько раз, по одному разу для каждого отношения, в котором они появляются. Рекомендуемый способ для этого сценария - создать загрузчик для отношений, который извлекает только идентификаторы. Затем уже созданные загрузчики используются для фактического извлечения данных из идентификаторов.
Для этого мы можем передать объект методу load
вместо простой строки идентификатора. Мы будем называть эти объекты запросами.
В Equify мы решили сформировать наш запрос так:
- modelName: имя модели, которую нужно запросить, чтобы найти список идентификаторов.
- foreignKey: имя столбца, содержащего внешний ключ.
- id: значение внешнего ключа
- foreignIdentifier: имя столбца, из которого будет считываться идентификатор.
Давайте прямо сейчас посмотрим на пример. Чтобы запросить список сообщений пользователя, мы должны сделать следующее:
Это должно вызвать 3 запроса:
Попробуйте сопоставить параметры запроса со вторым SQL-запросом, теперь у вас должен быть момент ха-ха 🤩
Отношения «многие-2-многие» работают точно так же. Чтобы получить список сообщений, понравившихся пользователю, мы просто делаем следующее:
Это приведет к примерно таким же запросам. Как видите, one-2-many и many-2-many абсолютно одинаковы с точки зрения реализации и вызовов базы данных. Поскольку нам нужны только идентификаторы, мы можем напрямую запрашивать таблицу соединения для отношений «многие-2-многие» без необходимости использовать соединение в запросе.
Давайте теперь посмотрим на функцию пакетной обработки, помните, что нам нужно принимать массив запросов вместо массива идентификаторов:
Чтобы помочь DataLoader кэшировать ваши запросы, вы должны указать функцию, которая создает ключ кеширования следующим образом:
Я не буду вдаваться в подробности о том, как реализовать пакетную функцию findRelationIds, но все же хочу дать вам представление о том, как мы ее реализовали:
- Группируйте запросы по modelName.
- Сделайте один вызов базы данных для каждой модели.
- Сопоставьте каждый запрос со списком идентификаторов, используя результаты.
У вас должны быть все необходимые знания, чтобы адаптировать его к вашему приложению, поздравляем! 👏
Заключение
Учитывая простоту реализации и огромные преимущества, которые может дать DataLoader, я надеюсь, что убедил вас использовать его для улучшения вашего бэкэнда. Расскажите в комментариях, как вы это реализовали и каковы масштабы ваших улучшений!
Не стесняйтесь поделиться этой статьей со всеми, кто может быть заинтересован в производительности на бэкэнде GraphQL, обмен - это забота 🙌
Если вам понравилась эта статья, не забудьте хлопать в ладоши и если у вас возникнут вопросы, не стесняйтесь обращаться ко мне 💬