Один год с GraphQL: как технология работает на длинной дистанции?

42caf33700518b0c68e8f2d4abe3212e.png

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

В Сравни у нас много продуктов, включая сервисы подбора и покупки полиса ОСАГО, подбора кредита, образовательных курсов, онлайн-оформления ипотеки. Информация о пользователях хранится и обрабатывается в специальном центральном сервисе (Profile Service). На текущий момент сервис содержит миллионы объектов пользователей, а нагрузка в пиковые моменты — до 180 запросов в секунду.

Исходная реализация Profile Service была построена на микросервисах и, как казалось нам ранее, полностью покрывала наши потребности: в плане гибкости взаимодействия с продуктами и работы с существующей нагрузкой. Изменения назрели под конец прошлого года, когда продуктовые команды стали настойчиво обращаться в команду сервиса для решения срочных задач. Им регулярно требовалась помощь разработчиков — и команда Profile Service около месяца занималась только обработкой продуктовых запросов.

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

Привет, Хабр! Я Максим, разработчик команды Profile Service в Сравни. 

Эта история началась около двух лет назад, когда я пришел в компанию. Стал разбираться, как система устроена и как продукты, технически представляющие из себя независимые сервисы, взаимодействуют с Profile Service. Быстро пришло понимание, что работает всё неоднозначно. 

Исходная реализация была построена на микросервисах по типу конвейера: запрос в API обрабатывался одним сервисом, а далее переходил от сервиса к сервису через брокер сообщений, производя парсинг маппингов, обработку данных, выборку или запись в БД, и возвращался по цепочке обратно. Всего сервисов было семь, из них четыре напрямую участвовали в обработке запросов API, а три — косвенно, производя нотификацию, логирование и другие «закулисные» операции. При этом были большие потери на транспорте, низкая надежность выбранного брокера сообщений, низкая прозрачность происходящих операций. Не всегда было понятно, из-за какого именно сервиса в цепочке мы начинали задерживать ответ при росте нагрузки, хотя логировалось почти всё возможное. Пытались решить проблемы с транспортом, перейдя на Kafka, но как оказалось в итоге, это был не наш путь.

«Так сложилось исторически»

Из плюсов исходной реализации Profile Service стоит выделить гибкость в плане взаимодействия с различными продуктами. Например, интегрирующиеся продукты не обязаны были подстраиваться под формат наших полей и контракта данных. Продуктовые команды просто приходили и говорили, что работают с такими-то полями и им нужны данные в таком-то виде, а мы для них всё настраивали. 

Самым же заметным минусом был слишком сложный API (то есть то, с чем продукты сталкиваются в первую очередь). Вслед за двумя основными эндпойнтами — получением и созданием формы — в дело вступало сохранение данных из форм, и здесь начинались вопросы. Например, чем отличается создание формы от сохранения данных формы в профиль? А ведь у нас был ещё autoStore — эндпойнт, который мог создать и сразу же сохранить данные. (Причина его появления весьма прозаическая: предложил кто-то из продуктов).

3dd6d92f32051c03a16e94697d909c38.jpeg

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

Другая проблема исходной реализации Profile Service связана с маппингами. Маппинг — это объект (по сути, контракт), в котором описывается структура входящих данных, обработка и исходящий объект: как он записывается к нам в базу, либо в обратном направлении. Поскольку маппинги у нас были построены на JavaScript и мы могли выполнять в них некоторые функции, это давало нам необходимую гибкость. Допустим, продукты отправляли нам какой-то объект с непонятными полями; мы его сперва преобразовывали, прогоняя через маппинг, и уже на выходе получали аккуратный объект, который хранится у нас в базе данных. 

Вместе с тем наличие исполняемого кода в маппингах затрудняло их понимание

Мы не могли просто взять и отдать маппинги продуктам, чтобы они их собирали самостоятельно. Продуктовым командам требовалось понять, как маппинги работают, а нам — регулярно объяснять им, как подступиться к тем или иным нетривиальным моментам, например: обязательная конвертация дат в формат БД и обратно, конвертация значений словарей, нюансы с выборкой массивов данных по ссылкам и многое другое. Наличие различных помощников и вспомогательных функций не сильно облегчало процесс. 

04dd3c4b7bd201d6b5520989027b9bc7.jpeg

Также в нашем API не было валидации и типизации как таковых, что негативно сказывалось на качестве данных. Не единожды продакты приходили и говорили: «Ребята, а что у вас там в этом поле? Вообще-то там должны быть цифры, а у вас прилетают строки». 

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

Наконец, у нас не было адекватной возможности разграничить доступ к данным. Мы не могли отдать маппинги продуктам и все 200 маппингов поддерживала команда разработчиков профиль-сервиса, это создавало большие затраты на поддержку.

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

В поиске выхода

В улучшении нашего Profile Service глобально мы хотели достичь трёх целей:

  1. Предоставить продуктам инструмент для набора данных, чтобы они сами могли выбирать, какие данные хотят видеть (финальная цель: вообще избавиться от маппингов);  

  2. Ввести валидацию и типизацию; контролировать доступ к данным с целью повышения их качества;

  3. Не ухудшить показатели по скорости ответа и надежности сервиса.

Размышляя над подходящим решением, сразу отклонили вариант «доработать то, что уже имеется». Загвоздка очевидна: для обеспечения передачи ответственности пришлось бы разработать и поддерживать некий инструмент, соответственно, потратить много ресурсов на наём специалистов (как минимум разработчиков и UI-дизайнеров). Речь шла, по сути, о создании отдельного продукта, к чему мы не были готовы. 

Думали и над применением популярных вариантов в виде REST API и RPC. Но такой подход также предполагал разработку самописного UI, поскольку, например, Swagger не отражал сложность наших данных так, как это требовалось. 

Наконец, вариант, который мы взяли на вооружение — использование GraphQL. Это язык запросов, опубликованный в open source. Самое главное — в нём есть спецификация, а значит, гарантия, что все готовые API-клиенты плюс-минус одинаковы. То есть люди, которые имеют хоть какой-то опыт работы с GraphQL, смогут сразу начать работать с нашим API. К тому же многие языки программирования уже имеют API-клиент для GraphQL. И мы можем быть уверены, что они работают так, как нам надо. 

Из коробки мы имеем описанную схему данных (GQL Schema) — это и есть контракт взаимодействия между нашим API и сервисом. Она очень хорошо отражает нашу структуру данных в БД: древовидную структуру на двух коллекциях, objects и links.

Далее, в GrapQL есть встроенная типизация и валидация. Мы можем создать кастомный тип, заложить в него некоторую валидацию — на дату или что-то другое. Естественно, в GraphQL включены стандартные операции чтения и мутации (то есть создания, удаления и изменения). 

Всё это находится в рамках одного эндпоинта, то есть нам не требуется знать 100500 вещей. В принципе, если использовать API-клиент, можно один раз указать всё в настройках — и забыть. 

А главное — это много классных готовых UI. Их сравнение достойно отдельного поста. Мы выбрали для себя Apollo GraphQL Studio, в котором продукт может собирать запросы, смотреть схему данных, поля и значения — что доступно, что недоступно, что уже deprecated и так далее.

Таким образом переход на GraphQL в теории покрывал все наши потребности. Посмотрим, что получилось на практике. 

Как GraphQL работает у нас

Кратко рассмотрим специфику GraphQL на примере наших рабочих задач. 

Допустим, в базе есть объект недвижимости, с полями, которые имеют свои типы, и комментариями к полям. Есть также тип «человек». Здесь мы подходим к объяснению того, почему в названии GraphQL присутствует слово Graph. Дело в том, что при описании объектов (типов) в GraphQL можно указать в поле ссылку на другой тип. Таким образом мы можем построить древовидные связи между типами, что, как я отметил, выше, прекрасно укладывается в нашу текущую архитектуру: данные хранятся древовидно и связаны между собой при помощи ссылок.

8d67d9adf651ac20cc3fecbd9e03be3e.jpeg

Например, мы хотим взять информацию о владельце профиля: имя, фамилию, адрес. Соответственно, описываем это в запросе, выполняем и получаем ровно то, что запросили. Никаких лишних данных или операций. Единственно, могут прилететь сообщения об ошибке, если вы, к примеру, запросили какие-то неразрешенные поля или устаревшие данные. 

5c5ae15511f93710de2761692f09391f.jpg

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

1fea4ba8467fd2397ea34de4a25b8a78.jpg

К слову, для упрощения переезда продуктов на новый API GraphQL мы написали пару утилит, которые прошлись по 200+ маппингам, создав запросы и мутации. Этот шаг, наряду с хорошо описанной схемой данных и UI, четко отображающим нашу сложную структуру, помог избежать заминок при переезде. Помогло и наличие в UI песочницы: разработчики могли экспериментировать с выборкой данных, просто открыв страницу в браузере. 

Действительно ли GraphQL — медленный?

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

Вторая причина медленной работы GraphQL связана с проблемой N+1. Нас она, к счастью, не коснулась, поскольку реализация запроса от GraphQL в базу данных у нас сделана особым образом. Мы строим по схеме запрос напрямую в БД, без хождений в стороны. Поэтому один запрос в GraphQL — это запросы в одну БД в рамках одной сессии.

Для успокоения мы, конечно, сделали тестирование и проверили, что GraphQL точно не будет тормозить. 

8ef16c3e46465010cf2a70dc41098e15.jpeg

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

Как мы видим, где-то в районе 960 одновременных подключений разница уже двукратная. API, реализованные на маппингах, отвечали нам в течение двух секунд, и это при условии, что у нас были включены все кэширования. Тот же запрос с аналогичным объёмом данных на GraphQL дал всего одну секунду.

9755e26e089e4e949a8a64c00f789545.png

Также это видно в Summary, где маппинги успевали за отведенное время обработать 17 тысяч запросов, а GraphQL — 32 тысячи. В результате проблемы с медленной работой у нас не подтвердились. 

Год с GraphQL: к чему мы пришли

Рабочий MVP мы собрали за полтора месяца, получив валидацию и типизацию данных, отличный UI для погружения в API и тестирования, множество готовых API-клиентов под разные языки. Большинство продуктов, которые с нами интегрировались, просто писали «Apollo GraphQL Client такой-то язык» и получали готовый клиент почти под все языки. 

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

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

С GraphQL мы получили масштабируемое решение с протестированной нагрузкой, классный UI, которым могут пользоваться те, кто с нами интегрируются, и стандартизированный API. Есть конкретное понимание, как это всё работает, спецификации и так далее. 

Единственный минус, который мы для себя обнаружили: в реализации GraphQL от Apollo заложено, что любой ответ будет возвращаться с HTTP-статусом 200, вне зависимости от того, ошибка это или корректный ответ. Мы всё же стали отдавать ошибки 404, 400, 429 и другие, в критичных для нас местах, иначе не все продуктовые команды обрабатывали поле errors в ответе, из-за чего страдал сам продукт. 

***

Прошедший год показал, что переход нашего сервиса на GraphQL был правильным решением по всем направлениям. Мы без проблем держим возросшую нагрузку, многие продукты интегрируются, разбираясь во всём самостоятельно, мы контролируем запросы на запись, качество данных возросло многократно. Profile Service «повзрослел» и уже не требует к себе столько внимания от команды разработчиков.

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

© Habrahabr.ru