Извлечение биллинга: рассказ о внутренней оптимизации GraphQL API
Опубликовано: 2022-03-11Одним из основных приоритетов для команды инженеров Toptal является переход на сервисную архитектуру. Важнейшим элементом инициативы было извлечение биллинга , проект, в котором мы изолировали функции биллинга от платформы Toptal, чтобы развернуть их как отдельный сервис.
За последние несколько месяцев мы извлекли первую часть функционала. Для интеграции биллинга с другими сервисами мы использовали как асинхронный API (на основе Kafka), так и синхронный API (на основе HTTP).
Эта статья представляет собой отчет о наших усилиях по оптимизации и стабилизации синхронного API.
Инкрементальный подход
Это был первый этап нашей инициативы. На пути к полному извлечению счетов мы стремимся работать поэтапно, внося небольшие и безопасные изменения в производственную среду. (См. слайды из отличного доклада о другом аспекте этого проекта: постепенное извлечение движка из приложения Rails.)
Отправной точкой стала платформа Toptal, монолитное приложение Ruby on Rails. Мы начали с выявления стыков между биллингом и платформой Toptal на уровне данных. Первый подход заключался в замене отношений Active Record (AR) обычными вызовами методов. Затем нам нужно было реализовать REST-вызов биллинговой службы для получения данных, возвращаемых методом.
Мы развернули небольшой биллинговый сервис, обращающийся к той же базе данных, что и платформа. Мы могли запросить биллинг либо с помощью HTTP API, либо с прямыми обращениями к базе данных. Такой подход позволил нам реализовать безопасный запасной вариант; в случае сбоя HTTP-запроса по какой-либо причине (неправильная реализация, проблемы с производительностью, проблемы с развертыванием) мы использовали прямой вызов и возвращали правильный результат вызывающей стороне.
Чтобы сделать переходы безопасными и плавными, мы использовали флаг функции для переключения между HTTP и прямыми вызовами. К сожалению, первая попытка, реализованная с помощью REST, оказалась неприемлемо медленной. Простая замена отношений AR удаленными запросами вызывала сбои при включенном HTTP. Несмотря на то, что мы включили его только для относительно небольшого процента вызовов, проблема осталась.
Мы знали, что нам нужен совершенно другой подход.
Внутренний API для выставления счетов (также известный как B2B)
Мы решили заменить REST на GraphQL (GQL), чтобы получить больше гибкости на стороне клиента. Мы хотели принимать решения на основе данных во время этого перехода, чтобы на этот раз иметь возможность прогнозировать результаты.
Для этого мы инструментировали каждый запрос от платформы Toptal (monolith) к биллингу и записывали по ним подробную информацию: время ответа, параметры, ошибки и даже трассировку стека (чтобы понять, какие части платформы используют биллинг). Это позволило нам обнаружить горячие точки — места в коде, которые отправляют много запросов или вызывают медленные ответы. Затем с помощью трассировки стека и параметров мы могли бы воспроизводить проблемы локально и иметь короткий цикл обратной связи для многих исправлений.
Чтобы избежать неприятных сюрпризов в производстве, мы добавили еще один уровень флагов функций. У нас был один флаг для каждого метода в API для перехода с REST на GraphQL. Мы включали HTTP постепенно и смотрели, не появляется ли в логах «что-то нехорошее».
В большинстве случаев «что-то плохое» было либо большим (многосекундным) временем отклика, либо 429 Too Many Requests , либо 502 Bad Gateway . Мы использовали несколько шаблонов для решения этих проблем: предварительная загрузка и кэширование данных, ограничение данных, получаемых с сервера, добавление джиттера и ограничение скорости.
Предварительная загрузка и кэширование
Первой проблемой, которую мы заметили, был поток запросов, отправленных из одного класса/представления, подобно проблеме N+1 в SQL.
Предварительная загрузка Active Record не работала за границей службы, и в результате у нас была одна страница, отправляющая ~ 1000 запросов на выставление счетов при каждой перезагрузке. Тысяча запросов с одной страницы! Ситуация с некоторыми фоновыми работами была ненамного лучше. Мы предпочли делать десятки запросов, а не тысячи.
Одним из фоновых заданий было получение данных о задании (назовем эту модель Product ) и проверка того, должен ли продукт быть помечен как неактивный на основе данных выставления счетов (в этом примере мы назовем модель BillingRecord ). Несмотря на то, что продукты извлекались партиями, платежные данные запрашивались каждый раз, когда они были необходимы. Каждый продукт нуждался в биллинговых записях, поэтому обработка каждого отдельного продукта вызывала запрос к биллинговой службе для их извлечения. Это означало один запрос на продукт и приводило к тому, что за одно выполнение задания было отправлено около 1000 запросов.
Чтобы исправить это, мы добавили пакетную предварительную загрузку платежных записей. Для каждой партии продуктов, извлеченных из базы данных, мы запросили платежные записи один раз, а затем присвоили их соответствующим продуктам:
# fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end endС пакетами по 100 и одним запросом в службу выставления счетов на пакет мы перешли с ~ 1000 запросов на задание до ~ 10.
Соединения на стороне клиента
Пакетные запросы и кеширование биллинговых записей хорошо работали, когда у нас была коллекция продуктов и нам нужны были их биллинговые записи. А как насчет обратного: если мы извлекаем платежные записи, а затем пытаемся использовать соответствующие продукты, полученные из базы данных платформы?
Как и ожидалось, это вызвало еще одну проблему N+1, на этот раз на стороне платформы. Когда мы использовали продукты для сбора N платежных записей, мы выполняли N запросов к базе данных.
Решение состояло в том, чтобы получить все необходимые продукты сразу, сохранить их в виде хэша, проиндексированного по идентификатору, а затем назначить их соответствующим записям о выставлении счетов. Упрощенная реализация:
def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end endЕсли вы думаете, что это похоже на хэш-соединение, вы не одиноки.
Фильтрация на стороне сервера и неполная выборка
Мы боролись с сильнейшими всплесками запросов и проблемами N+1 на стороне платформы. Однако у нас по-прежнему были медленные ответы. Мы определили, что они были вызваны загрузкой слишком большого количества данных на платформу и их фильтрацией там (фильтрация на стороне клиента). Загрузка данных в память, их сериализация, отправка по сети и десериализация только для того, чтобы отбросить большую часть данных, были колоссальными потерями. Это было удобно во время реализации, потому что у нас были общие и многократно используемые конечные точки. В ходе эксплуатации оказался непригодным. Нам нужно было что-то более конкретное.
Мы решили эту проблему, добавив аргументы фильтрации в GraphQL. Наш подход был похож на известную оптимизацию, состоящую в переносе фильтрации с уровня приложения на запрос БД ( find_all vs. where в Rails). В мире баз данных этот подход очевиден и доступен как WHERE в запросе SELECT . В данном случае потребовалось реализовать обработку запросов самостоятельно (в биллинге).
Мы развернули фильтры и стали ждать улучшения производительности. Вместо этого мы увидели 502 ошибки на платформе (и наши пользователи тоже их видели). Нехорошо. Совсем не хорошо!
Почему это произошло? Это изменение должно улучшить время отклика, а не нарушить работу службы. Мы непреднамеренно внесли небольшую ошибку. Мы сохранили обе версии API (GQL и REST) на стороне клиента. Мы постепенно переключались с помощью флага функции. Первая неудачная версия, которую мы развернули, представила регрессию в устаревшей ветке REST. Мы сосредоточили наше тестирование на ветке GQL, поэтому упустили проблему с производительностью в REST. Извлеченный урок: если параметры поиска отсутствуют, верните пустую коллекцию, а не все, что есть в вашей базе данных.
Взгляните на данные NewRelic для выставления счетов. Мы развернули изменения с фильтрацией на стороне сервера во время затишья в трафике (мы отключили биллинг трафика после того, как столкнулись с проблемами платформы). Вы можете видеть, что после развертывания ответы стали более быстрыми и более предсказуемыми.
Добавить фильтры в схему GQL было несложно. Ситуации, в которых GraphQL действительно проявил себя, были случаи, когда мы извлекали слишком много полей, а не слишком много объектов. С REST мы отправляли все данные, которые могли быть необходимы. Создание универсальной конечной точки заставило нас упаковать в нее все данные и ассоциации, используемые на платформе.
С GQL мы смогли выбирать поля. Вместо выборки более 20 полей, которые требовали загрузки нескольких таблиц базы данных, мы выбрали только три-пять необходимых полей. Это позволило нам устранить внезапные всплески использования биллинга во время развертывания платформы, поскольку некоторые из этих запросов использовались заданиями переиндексации эластичного поиска, запускаемыми во время развертывания. Положительным побочным эффектом стало ускорение и надежность развертывания.

Самый быстрый запрос — это тот, который вы не делаете
Мы ограничили количество извлекаемых объектов и объем данных, упакованных в каждый объект. Что еще мы могли сделать? Может вообще не получать данные?
Мы заметили еще одну область, которую можно улучшить: мы часто использовали дату создания последней платежной записи на платформе и каждый раз звонили в биллинг, чтобы получить ее. Мы решили, что вместо того, чтобы получать его синхронно каждый раз, когда это необходимо, мы можем кэшировать его на основе событий, отправленных из биллинга.
Мы планировали заранее, готовили задачи (четыре-пять штук) и начинали работать над тем, чтобы сделать это как можно быстрее, так как эти запросы создавали значительную нагрузку. Впереди у нас было две недели работы.
К счастью, вскоре после того, как мы начали, мы еще раз взглянули на проблему и поняли, что можем использовать данные, которые уже были на платформе, но в другой форме. Вместо добавления новых таблиц в кеш данных из Kafka мы потратили пару дней на сравнение данных из биллинга и платформы. Мы также проконсультировались с экспертами в предметной области относительно того, можем ли мы использовать данные платформы.
Наконец, мы заменили удаленный вызов запросом к БД. Это была огромная победа как с точки зрения производительности, так и с точки зрения рабочей нагрузки. Мы также сэкономили больше недели времени на разработку.
Распределение нагрузки
Мы реализовывали и развертывали эти оптимизации одну за другой, но все еще были случаи, когда биллинг отвечал 429 Too Many Requests . Мы могли бы увеличить лимит запросов на Nginx, но хотели лучше понять проблему, так как это был намек на то, что связь работает не так, как ожидалось. Как вы, возможно, помните, мы могли позволить себе эти ошибки в рабочей среде, поскольку они не были видны конечным пользователям (из-за отката к прямому вызову).
Ошибка возникала каждое воскресенье, когда платформа планировала напоминания членам сети талантов о просроченных табелях учета рабочего времени. Чтобы отправить напоминания, задание извлекает платежные данные для соответствующих продуктов, которые включают тысячи записей. Первое, что мы сделали для его оптимизации, — это пакетная обработка и предварительная загрузка платежных данных, а также выборка только необходимых полей. Оба являются хорошо известными приемами, поэтому мы не будем здесь вдаваться в подробности.
Мы развернулись и стали ждать следующего воскресенья. Мы были уверены, что исправили проблему. Однако в воскресенье ошибка повторилась.
Служба выставления счетов вызывалась не только во время планирования, но и при отправке напоминания члену сети. Напоминания отправляются отдельными фоновыми заданиями (с помощью Sidekiq), поэтому о предварительной загрузке не могло быть и речи. Изначально мы предполагали, что это не будет проблемой, потому что не каждому продукту нужно напоминание, и потому что все напоминания отправляются одновременно. Напоминания запланированы на 17:00 по часовому поясу участника сети. Однако мы упустили важную деталь: наши участники неравномерно распределены по часовым поясам.
Мы планировали напоминания тысячам участников сети, около 25% из которых живут в одном часовом поясе. Около 15% живут во втором по численности населения часовом поясе. Поскольку часы в этих часовых поясах показывали 17:00, нам приходилось отправлять сотни напоминаний одновременно. Это означало всплеск сотен запросов к биллинговой службе, что было больше, чем служба могла обработать.
Не удалось предварительно загрузить платежные данные, поскольку напоминания планируются в независимых заданиях. Мы не могли получить меньшее количество полей из биллинга, так как мы уже оптимизировали это число. О перемещении членов сети в менее населенные часовые пояса также не могло быть и речи. Так что мы сделали? Мы немного переместили напоминания.
Мы добавили дрожание во время, когда напоминания были запланированы, чтобы избежать ситуации, когда все напоминания будут отправлены в одно и то же время. Вместо того, чтобы планировать ровно в 17:00, мы запланировали их в диапазоне двух минут, между 17:59 и 18:01.
Мы развернули сервис и дождались следующего воскресенья, уверенные, что наконец исправили проблему. К сожалению, в воскресенье ошибка снова появилась.
Мы были озадачены. Согласно нашим расчетам, запросы должны были распределяться в течение двухминутного периода, что означало, что у нас будет максимум два запроса в секунду. Это было не то, с чем служба не могла справиться. Мы проанализировали журналы и тайминги запросов на выставление счетов и поняли, что наша реализация джиттера не работает, поэтому запросы по-прежнему появлялись плотной группой.
Что вызвало такое поведение? Именно так Sidekiq реализует планирование. Он опрашивает redis каждые 10–15 секунд, и из-за этого не может обеспечить односекундное разрешение. Чтобы добиться равномерного распределения запросов, мы использовали Sidekiq::Limiter — класс, предоставляемый Sidekiq Enterprise. Мы использовали ограничитель окна, который допускал восемь запросов для движущегося односекундного окна. Мы выбрали это значение, потому что у нас было ограничение Nginx в 10 запросов в секунду при выставлении счетов. Мы сохранили код джиттера, потому что он обеспечивал грубую дисперсию запросов: он распределял задания Sidekiq в течение двух минут. Затем был использован Sidekiq Limiter, чтобы гарантировать, что каждая группа заданий обрабатывается без превышения заданного порога.
Мы снова развернули его и дождались воскресенья. Мы были уверены, что наконец решили проблему — и мы это сделали. Ошибка исчезла.
Оптимизация API: Nihil Novi Sub Sole
Я полагаю, вы не были удивлены решениями, которые мы использовали. Пакетная обработка, фильтрация на стороне сервера, отправка только обязательных полей и ограничение скорости не являются новыми методами. Опытные разработчики программного обеспечения, несомненно, использовали их в различных контекстах.
Предварительная загрузка, чтобы избежать N+1? У нас есть это в каждом ORM. Хэш присоединяется? Теперь они есть даже у MySQL. Недостаточно? SELECT * против SELECT field — известный трюк. Распределение нагрузки? Это тоже не новая концепция.
Так зачем я написал эту статью? Почему мы не сделали это правильно с самого начала ? Как обычно, контекст играет ключевую роль. Многие из этих методов выглядели знакомыми только после того, как мы их реализовали, или только когда мы заметили производственную проблему, которую нужно было решить, а не тогда, когда мы смотрели на код.
Тому было несколько возможных объяснений. Большую часть времени мы пытались сделать самое простое, что могло сработать , чтобы избежать чрезмерной инженерии. Мы начали со скучного REST-решения и только потом перешли на GQL. Мы развернули изменения за флагом функции, отслеживали, как все ведет себя с долей трафика, и применяли улучшения на основе реальных данных.
Одним из наших открытий было то, что ухудшение производительности легко не заметить при рефакторинге (и извлечение можно рассматривать как значительный рефакторинг). Добавление строгой границы означало, что мы обрезали связи, которые были добавлены для оптимизации кода. Однако это не было очевидно, пока мы не измерили производительность. Наконец, в некоторых случаях мы не могли воспроизвести рабочий трафик в среде разработки.
Мы стремились иметь небольшую поверхность универсального HTTP API биллингового сервиса. В результате мы получили кучу универсальных эндпоинтов/запросов, несущих данные, необходимые в разных вариантах использования. А это означало, что во многих случаях использования большая часть данных была бесполезна. Это своего рода компромисс между DRY и YAGNI: с DRY у нас есть только одна конечная точка/запрос, возвращающий платежные записи, в то время как с YAGNI мы получаем неиспользуемые данные в конечной точке, что только вредит производительности.
Мы также заметили еще один компромисс при обсуждении джиттера с командой по выставлению счетов. С точки зрения клиента (платформы) каждый запрос должен получать ответ, когда он нужен платформе. Проблемы производительности и перегрузка сервера должны быть скрыты за абстракцией биллинговой службы. С точки зрения биллинговой службы нам нужно найти способы информировать клиентов о характеристиках производительности сервера, чтобы выдерживать нагрузку.
Опять же, здесь нет ничего нового или революционного. Речь идет об определении известных шаблонов в различных контекстах и понимании компромиссов, связанных с изменениями. Мы усвоили это на собственном горьком опыте и надеемся, что уберегли вас от повторения наших ошибок. Вместо того, чтобы повторять наши ошибки, вы, несомненно, будете делать свои собственные ошибки и учиться на них.
Особая благодарность моим коллегам и товарищам по команде, которые участвовали в наших усилиях:
- Макар Ермохин
- Габриэле Ренци
- Самуэль Вега Кабальеро
- Лука Гуиди
