На групповом обеде за мексиканской едой и маргаритой кто-то спросил: «Какой код был самым впечатляющим, который вы когда-либо писали?».

Я не подумал об этом раньше и был удивлен, что у меня был готовый ответ. Хотя я не уверен, было ли влияние чистым положительным.

Настройка сцены

Я вернулся в 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 г.