На групповом обеде за мексиканской едой и маргаритой кто-то спросил: «Какой код был самым впечатляющим, который вы когда-либо писали?».
Я не подумал об этом раньше и был удивлен, что у меня был готовый ответ. Хотя я не уверен, было ли влияние чистым положительным.
Настройка сцены
Я вернулся в GoCardless в 2016 году, когда в компании было 80 человек, 20 инженеров в 3 командах.
GoCardless — это платежная компания, предлагающая API, аналогичный Stripe, с упором на платежи между банками. Проще говоря, GC получает запросы на создание платежей, группирует их и отправляет в клиринговые палаты для обработки.
Я присоединился к команде Core Payments & Internal Tools (CPIT), основной обязанностью которой была защита процесса оплаты. Как это принято в стартапах, мы начали сталкиваться с проблемами масштабирования пакетных процессов, отправляемых в банки, и все были очень обеспокоены.
Пакетная обработка была ключевой для CPIT, поэтому наша команда активно обсуждала проблему масштабирования. Это был пик ажиотажа вокруг микросервисов, и было предположение, что наш монолит Ruby on Rails не способен к масштабированию и что нам нужен новый сервис, с которым мы будем общаться через брокера сообщений, с источниками событий и т. д.
Опоздав на разговор, я не до конца понял опасения по поводу монолита. Я работал в GC год назад во время стажировки и остался большим поклонником: я думал, что кодовая база надежна, мне нравилась простота одного сервиса, и я чувствовал, что дополнительная инфраструктура может ухудшить ситуацию, а не улучшить ее.
Однажды поздно вечером я начал копаться в плохом конвейере. Это было пакетное задание, которое начиналось в 16:00 (наше время завершения отправки платежей), затем находили и группировали все платежи для этой ежедневной отправки, в конечном итоге создавая несколько CSV-файлов, которые мы загружали в наши банковские провайдеры.
Причина нашего беспокойства заключалась в том, что в дни больших объемов (~ 200 тысяч платежей) этот процесс мог занять до 4 часов, а это означало, что мы рисковали пропустить крайний срок в 7 часов вечера, чтобы отправить файлы в этот день в банки.
Код
Из различных заданий в этом пайплайне процесс, который находил и переводил платежи, отвечал за большую часть времени выполнения. В псевдокоде это выглядело так:
def run
PaymentForSubmission.for(date: Date.today).
find(batch_size: 500).
each { |payment| payment.mark_as_submitted! }
end
Это работало часами, обрабатывая платежи по одному. Но подождите… мы делаем это по очереди? Мы хотим, чтобы это было быстро, но для этого мы используем один процесс Ruby и соединение с базой данных?
Хотя у нас были асинхронные рабочие процессы на основе библиотеки Ruby под названием Que, не было простой абстракции для упорядочивания заданий после их постановки в очередь. Учитывая, что каждая часть конвейера пакетной обработки (поиск, переход, пакетная обработка) должна быть завершена, прежде чем мы начнем следующую, простое добавление работы в очередь не сработает.
Итак, мы обнаружили проблему, за которой возникла проблема: наши конвейеры были однопоточными, потому что у нас не было простого механизма для их распараллеливания. Раньше я использовал такие инструменты, как OpenMP, и я подумал, что мы могли бы адаптировать аналогичную технику для каждого из асинхронных рабочих процессов Que, создав абстракцию, позволяющую легко преобразовать однопоточный код в многопоточную рабочую группу.
Я начал прикидывать, как это могло бы выглядеть, предполагая, что мы построили абстракцию поверх очереди заданий Que под названием QueCommit:
# The top-level job that finds and enqueues sub-jobs:
class MarkPaymentsAsSubmitted < Que::Job
def self.run
batch_job = MarkPaymentsAsSubmittedBatch
commit = QueCommit.new(
batch_job, payment_batches, parallelism: 3)
commit.wait do |remaining|
log(msg: "Polling for remaining...", remaining_batches: remaining)
end
end
def payment_batches
PaymentForSubmission.for(date: Date.today).
in_batches(batch_size: 500).
map { |batch| batch.pluck(:id) }
end
end
# This job processes a single batch, and is enqueued by QueCommit:
class MarkPaymentsAsSubmittedBatch < Que::Job
def run(payment_ids)
Payment.find(payment_ids).
each { |payment| payment.mark_as_submitted! }
end
end
Я подумал, что если вы разделите работу на одно задание для загрузки пакетов и координации, а другое — на передачу платежей, мы могли бы транзакционно поставить пакетные задания в очередь и заставить координатора (QueCommit) ждать их завершения.
Все, что нужно было сделать QueCommit, — это опросить очередь (задания хранятся в Postgres, поэтому простой select * from que_jobs where id = ?
), чтобы проверить, когда все невыполненные задания завершены, а затем вызвать исключение, если какое-либо из них не удалось.
Это сработало очень, очень хорошо. База данных была далеко не вместительной и могла поддерживать несколько одновременных дозаторов без насыщения, а это означало, что вы получали примерно Nx ускорений для любого количества рабочих, которые мы использовали: для нас это означало примерно 5-10-кратное улучшение.
Вот и все?
Да, это так. Создание QueCommit заняло вечер и следующий день, а постепенное изменение заданий пайплайна для его использования было чем-то, чем я занимался в свободное время в течение следующих нескольких недель.
Это может показаться не таким впечатляющим, но в краткосрочной перспективе это было огромно. Добавление параллелизма к пайплайну означало, что самые большие объемы дней будут достигать пика в 1,5 часа, задолго до того, как мы рискнули нарушить сроки банка.
Без давления «это не может масштабироваться, и наш критический конвейер рухнет!» многомесячный план по выделению банковского кода в отдельный сервис из соображений производительности сдулся и появился только через пару лет в другом обличье.
Это было краткосрочно, но долговечность QueCommit меня удивила.
Поскольку GC продолжал расти, QueCommit стал идеальным решением для масштабирования этих пакетных заданий. С тех пор я перешел в команду SRE и больше не работал над приложением, а наблюдал, как абстракция распространяется по кодовой базе. Люди даже обнаружили более тонкие части кода — такие оптимизации, как различная обработка отдельных пакетных рабочих нагрузок и использование эвристики для сортировки пакетов — и начали настраивать их для своего варианта использования.
К тому времени, как я ушел из GoCardless в 2021 году, QueCommit использовался более чем в тридцати пакетных процессах, большинство из которых были критически важны для работы GC. Несмотря на то, что ему было пять лет и он выдерживал в 20 раз большую нагрузку, для которой он был создан, реализация практически не изменилась.
Трудно количественно
Хотя я уверен, что QueCommit является одним из самых важных вкладов, которые я сделал, его влияние за этот период времени очень трудно оценить количественно.
Например, GoCardless продолжает управлять одним из крупнейших в мире монолитов Ruby on Rails. Я по-прежнему считаю, что QueCommit помог избежать преждевременного разделения этого монолита, и я считаю, что работа на монолите очень помогла GC масштабировать свой бизнес.
Но я мог бы привести аргументы и для другого случая. Поскольку GC так успешно масштабировала свой монолит, самые болезненные проблемы, с которыми они сталкиваются сейчас, заключаются в том, как разделить его, чтобы справиться со следующим 10-кратным ростом. Может быть, без QueCommit нам пришлось бы разойтись раньше, и, возможно, так было бы лучше?
Я не уверен, но в этом проблема таких сложных результатов: мы никогда не узнаем этого на самом деле.
В любом случае, наблюдение за тем, как QueCommit развивается, в то время как GC несколько раз перестраивается, стало для меня отличным уроком. Это одно из многих преимуществ, которые вы получаете, присоединяясь к компании на ранней стадии и оставаясь там, где вы можете увидеть, какие из ваших ставок окупятся. Я, конечно, благодарен за это.
Если вам понравился этот пост и вы хотите увидеть больше, подпишитесь на меня в @lawrjones.
Первоначально опубликовано на https://blog.lawrencejones.dev 19 марта 2022 г.