Привет! Сегодня мы поговорим с вами о том, как эволюционировала архитектура Яндекс.Афиши, а именно — как и почему мы перешли от REST на GraphQL к Node.js + Python, а потом в целях оптимизации избавились от Node.js + Python и переписали весь GraphQL на Java. Это история борьбы за производительность, скорость и удобство работы Афиши и некоторых других сервисов, которая может быть полезна тем, кто планирует оптимизировать ресурсы на своём проекте, ускорить работу запросов в БД, создать быстрый и поддерживаемый API Gateway с удобным языком запросов или разделить устаревший монолит на микросервисы.



Меня зовут Александр Поляков, я больше семи лет работаю в Медиасервисах Яндекса: руководил командой программистов в Афише, а сейчас руковожу разработкой в Яндекс Плюсе. Соавтор этой статьи — мой коллега Михаил Сурин, который руководит разработчиками в Яндекс.Афише сейчас. Теперь, когда наша роль в этой долгой и непростой истории ясна, начнём!

История изменений в сервисе


Прежде чем перейти к рассказу об архитектуре, я хочу напомнить, что такое Яндекс.Афиша и какие задачи она решает. Афиша — это сервис для покупки электронных билетов в кино, театры и на концерты. Он также работает на Кинопоиске и других партнёрских сайтах — через Яндекс.Афишу можно приобрести билеты на сеансы в более чем 100 городах России: от Москвы до Южно-Сахалинска.

Но так было не всегда. Сервис был запущен больше десяти лет назад и первоначально лишь помогал пользователям выбрать, куда стоит сходить. По сути он представлял собой каталог событий с расписанием. С точки зрения архитектуры всё это выглядело следующим образом: бэкенд на Python, база — очень популярная тогда MongoDB, фронтенд — Node.js.

Какие проблемы у нас возникли



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

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

  4. Мы устали писать всё новые и новые методы под каждое требование клиентов. Систему стало сложно поддерживать и развивать. На картинке видно, что только для сущности «Место» мы реализовали шесть методов. А для сущности «Событие» — семь.

  5. Ответы REST часто содержали либо слишком много данных, либо недостаточно, из-за чего появлялась необходимость в дополнительных запросах. Всё это создавало лишний трафик, который мог быть критичным для устройств, подключенных, например, по 3G, и увеличивало нагрузку на наши сервера.


Мы изучили рынок, посмотрели, какие технологии и подходы используются: REST, SOAP, RPC, Swagger (Open APi), GraphQL. Лучше остальных всем нашим требованиям отвечал GraphQL. Технологию разработали в Facebook в 2012 году, а в 2015-м выложили в открытый доступ.

GraphQL — язык, который описывает, как запрашивать данные. В основном он используется клиентом для загрузки данных с сервера. Назовём три его основные характеристики:

  • Позволяет клиенту точно указать, какие данные ему нужны.

    {
      user(name: "John Smith") {
        friends {
          name
        }
        city {
          name
          population
        }
      } 
    }
    
  • Облегчает агрегацию данных из нескольких источников.

  • Использует систему типов для описания данных.


Как же всё это работает на практике? Владимир Цукур прекрасно рассказал, что такое GraphQL, какие задачи он решает и как с ним работать в своём докладе GraphQL — APIs The New Way. Посмотреть доклад можно ниже, я лишь обращу внимание на основные моменты.

Доклад целиком

Прежде всего, GraphQL — не база данных. GraphQL — это язык запросов для API и среда, в которой они выполняются.

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



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

{
   user(name: "John Smith") {
      friends {
         name
      }
      city {
         name
         population
      } 
   }
}

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

{
   "data": { 
      "user": {
         "friends": [
            { "name": "Janis" }, 
            { "name": "Anna" },
      ]
      "city" {
         "name" : "Anna" 
         "population”: 641423
      }
   }
}

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

Первый переезд: GraphQL vs REST


Если клиент работает с REST, ему нужно самому знать, к каким методам обращаться и как собирать ответ. В случае с GraphQL клиент ходит в один API Gateway, который инкапсулирует логику получения данных. По сути клиент пишет запросы декларативно (как, например, в SQL), и с их помощью получает данные от сервера GraphQL.



Вернёмся к Афише. Мы убедились, что GraphQL подходит для решения наших задач, и для начала реализовали GraphQL API gateway на Node.js. Теперь через API к нам стали обращаться: сайт Афиши, приложение, КиноПоиск. Сам GraphQL API посылал запросы в бэкенды Билетов и Афиши.



Слияние сервисов


Параллельно в 2014 году был запущен ещё один сервис — Яндекс.Билеты. Это агрегатор билетных операторов, который позволял не только посмотреть расписание событий, но и купить билеты. Бэкенд был написан на Java, БД — снова MongoDB, фронтенд виджета покупки — Node.js.

В 2016 году мы поняли, что на самом деле Афиша и Билеты отлично дополняют друг друга и решают схожие проблемы, поэтому решили объединить сервисы.

В процессе слияния мы столкнулись со следующими проблемами:

  • Дублирование. Многие компоненты систем, написанные разными командами в разное время, повторяли друг друга, например: импорты событий, расписаний, проклейка, админки.
  • «Зоопарк» технологий, инструментов и решений: Python и Java на бэкенде, БЭМ и React на фронтенде.
  • Хранение данных. Две разные базы данных, синхронизация и переимпорты отнимают много времени.

Как мы решали эти проблемы? Сначала отказались от Python-бэкенда, так как экспертности в Java было больше — три монолита свели в один.



Переписали Node.js GraphQL API на Java, потому что в какой-то момент у нас стали сильно снижаться метрики производительности, а в Java, опять же, было много экспертности.



Решили использовать GraphQL всеми внешними фронтендами (в том числе билетным виджетом) и сервисами.



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

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

Второй переезд: оптимизация и рефакторинг


В 2018 году, в рамках оптимизации расходов на поддержание многочисленных технологий API Афиши, мы решили заменить JS GraphQL API фронтенда и REST Python API бэкенда единым API, написанным на Java. Он должен был полностью соответствовать схеме JS GraphQL API и работать оптимальнее, чем REST Python API.



Перед началом рефакторинга необходимо было выбрать метрики, по которым мы определим успешность проекта. Важнейшее условие — полностью воспроизвести функциональность текущего API (и ничего не сломать на стороне клиентов). Для этого мы получили существующую GraphQL-схему через запрос интроспекции и написали новое приложение в соответствии с этой схемой. Для оценки производительности нового API мы выбрали запрос, возвращающий информацию для главной страницы Афиши. Исходное время работы запроса составляло 500 миллисекунд.

Первая сложность, с которой мы столкнулись ещё на подготовительном этапе, до того, как начали переписывать API, заключалась в том, что JS GraphQL API был подключен как middleware на сервере, осуществляющем рендеринг страниц. Чтобы иметь возможность заменить JS на Java, сначала пришлось заменить внутренние JS-вызовы на HTTP-запросы. Это сразу же замедлило работу запроса главной страницы на 300 миллисекунд.

Проблема медленного старта


В феврале 2019-го начались основные работы по написанию API на Java. Главная цель — сделать всё быстро, сохранив скорость работы главной страницы по HTTP такой же, какой она была на JS, и при этом полностью воспроизвести функциональность старого API.

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

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



На самом деле медленный старт — нормальное поведение для Java-приложений, связанное с работой JIT-компилятора. Мы заменили компилятор по умолчанию на экспериментальный GraalVM, и в результате сократили время прогрева с двух минут до 30 секунд:



Лучше, но всё ещё недостаточно для работы в продакшене. Следующим шагом стало добавление Python-скрипта для прогрева компилятора: в течение минуты мы отправляли запросы, похожие на реальные, и только после этого делали приложение доступным для внешнего мира. Такое решение полностью сняло все проблемы старта:



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

GraphQL для оптимизации запросов


Оптимизация списочных запросов


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

Посмотреть, как это работает на практике, можно в проекте по ссылке. Это приложение, позволяющее получить информацию по топ-50 фильмам Кинопоиска.

Например, мы можем взять стандартный REST-подобный запрос списка элементов. Хотим получить 10 фильмов, нам возвращается результат и информация для пагинации — общее количество фильмов в базе. Если мы посмотрим на логи, то увидим два запроса: список элементов (counts) и отдельно запрос общего количества элементов (total).





GraphQL позволяет не запрашивать total. Однако если мы его уберём, запросов в базу всё равно останется два. Это происходит, потому что total для ответа всегда вычисляется явным образом.



Перепишем код так, чтобы total вычислялся только по прямому запросу пользователя.



Добавим в ответ метод total, который вызывается, только если поле total запрошено в query. Итог — один запрос в базу.



На графике видно, что таким нехитрым способом мы сократили количество запросов для подсчёта размера коллекции в БД в четыре раза:



Оптимизация получения объектов по ID


Для GraphQL всегда актуальна проблема N+1 запросов, и нас она тоже не обошла стороной. Мы занялись оптимизацией запросов по идентификаторам — это когда мы получаем из базы список элементов по ID, и хотим по тем же ID получить более полные данные.

В коде это выглядит примерно так: видим два отдельных запроса на два фильма по ID 326 и 435. Если мы посмотрим на них, то увидим, что ID 326 — это «Побег из Шоушенка», а ID 435 — «Зелёная миля».

Для оптимизации в подобных ситуациях придуман подход с даталоадерами — это специальные классы, позволяющие объединять запросы объектов по ID и отправлять их в источники данных батчами. Схема работы даталоадера проста: сначала сбор списка идентификаторов, затем получение по списку ID самих элементов и составление словаря. Всё это делается в асинхронной манере. По результатам работы исполняемый код может получить искомый элемент из словаря и вернуть его пользователю.

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

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

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



Заключение


Результатом нашей работы стал быстрый и поддерживаемый API Gateway с удобным для клиентов языком запросов. Если к вам регулярно обращаются с просьбой реализовать новый эндпоинт в API, аналогичный уже существующему, но немного лучше — задумайтесь, возможно, GraphQL — ваш выбор.

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

Благодаря даталоадерам всё общение с источниками данных в Афише сводится к двум типам запросов: список ID объектов по фильтру и запрос за объектами по списку ID. Это прекрасное подспорье на пути создания микросервисов, поставляющих данные. Опять же кажется, что это хороший паттерн проектирования, позволяющий ускорить переезд.

Надеемся, что у вас получилось вынести что-то ценное для себя из многолетнего опыта Яндекс.Афиши и ждём ваши вопросы в комментариях — с радостью на них ответим.