Привет! На связи Миша Меркушин. Я тимлид команды Ruby Platform, ответственной за разработку внутренних библиотек и улучшение архитектуры микросервисов на Ruby в СберМаркете. Эта статья про архитектурный паттерн Outbox и инструмент, который мы создали, итерационно развивали внутри команды и лишь недавно «выпустили» его в мир. Он решает проблему обеспечения надежной и согласованной отправки сообщений из приложения, гарантируя, что они будут отправлены после успешного завершения транзакции базы данных.
Как мы шли в Outbox: Rake–таска, Sidekiq + Schked и наслаивание задач
Перевод заказов из async–http на Kafka и масштабирование решения
В поисках надежной доставки
У нас была классическая архитектура, состоящая из монолита, выступающего в роли витрины для покупателей, и отдельного микросервиса для бэк-офиса, обслуживающего сборщиков заказов. Покупатели через витрину собирали товары в корзину и оформляли заказы, которые затем отправлялись в бэк-офис через REST API с использованием асинхронных Sidekiq задач.
Несмотря на отказ от синхронных распределенных транзакций, мы столкнулись с проблемой потери заказов, что неизбежно вело к ухудшению клиентского опыта. Очевидно, что в Sidekiq есть функция ретраев, позволяющая повторить попытку выполнения задачи в случае возникновения сетевых сбоев, но это не решает основную проблему.
Проблема заключается в том, как именно поставлена фоновая задача. Если задача ставится до фиксации транзакции в базе данных, существует риск отправки «сырых» данных в бэк-офис, поскольку нет гарантии, что сама транзакция будет успешно завершена. С другой стороны, если мы ставим задачу сразу после фиксации транзакции, может возникнуть ситуация, когда процесс приложения неожиданно завершается до момента постановки задачи, например, из-за превышения лимита потребления памяти со следствием в виде «убийства» процесса. В результате заказ теряется, а клиент остается ни с чем.
Используем паттерн Outbox
Transactional outbox pattern — это архитектурный паттерн, используемый в распределенных системах для обеспечения надежной доставки сообщений. Он обеспечивает сохранение сообщений в хранилище данных (обычно в таблице outbox в базе данных) перед их окончательной отправкой в брокер сообщений. Этот подход гарантирует, что данные не будут потеряны, так как либо будет зафиксировано всё, либо при возникновении ошибки произойдет полный откат.
Когда приложение получает запрос на создание заказа, оно открывает транзакцию в базе данных, в которую сохраняется заказ. Внутри транзакции создается сообщение для другого сервиса, оно сохраняются в базу данных в специальную таблицу outbox. После этого транзакция фиксируется.
Далее в отдельном процессе происходит периодический опрос новых записей из этой таблицы.
Затем этот процесс забирает сообщения из таблицы outbox и начинает их обработку, в нашем случае отправку сообщений в брокер сообщений.
Таблица outbox обычно содержит поля:
primary key
payload (тело сообщения)
и статусы, которые помогают понять, что с сообщением сейчас происходит.
В случае проблем с сетью или недоступности брокера выполняется повторная попытка, в результате чего мы не потеряем сообщение.
Паттерн позволяет гарантировать at-least-once доставку сообщений — это означает, что они будут доставлены хотя бы один раз, но могут быть доставлены более одного раза, что может привести к дубликатам. Этот подход обеспечивает надежность в условиях нестабильной сетевой среды, где сообщения могут теряться.
Упрощенно в коде это выглядит так:
Order.transaction do
order.complete!
OrderOutboxItem.create(order)
end
Когда использовать Outbox
Представьте большую распределенную систему из сотни сервисов. Её элементы обмениваются друг с другом сообщениями, получая события об изменениях для реализации собственной бизнес-логики. При этом требования к надежности доставки сообщений между сервисами во многом обусловлены бизнес-логикой. Рассмотрим на паре примеров.
Курьер везет заказ. Система отслеживает его координаты и отправляет клиенту, чтобы сформировать корректные временные ожидания. Поток данных очень большой, поскольку положение курьера в пространстве постоянно меняется. Но потеря нескольких сообщений ни на что не повлияет.
В противовес первому другой пример:
Прием заказа на доставку пиццы из ресторана. Чтобы доставить пиццу — ее первым делом нужно начать готовить. Чем раньше мы получим этот сигнал тем лучше:
Если новый заказ придет с опозданием — клиент не получит пиццу вовремя.
Если сообщение потеряется — клиент вообще не получит пиццу. Компания потеряет и клиента и деньги.
В статье мы сфокусируемся на втором кейсе, когда требования к надежности передачи данных высоки, а потеря сообщений приводит к серьезному нарушению работы бизнес–логики.
Как мы шли в Outbox
В СберМаркете у нас есть ряд сервисов, написанных на Ruby. В ходе работы над проектом мы столкнулись с потребностью создать простое в использовании и одновременно масштабируемое решение для обеспечения бесперебойной передачи данных между сервисами. Мы прошли несколько этапов развития, каждый раз сталкиваясь с новыми задачами и преодолевая возникающие трудности. Мы начали с базовых подходов и постепенно усложняли наше решение. Обсуждение этих этапов поможет вам глубже понять концепцию паттерна outbox и поможет избежать некоторых ловушек, в которые мы попадали в процессе разработки.
Стриминг настроек магазинов
Магазин — ключевая сущность любого маркетплейса. СберМаркет — сервис доставки, мы работаем с самыми разными магазинами, каждый из которых имеет ряд параметров. Напаример:
магазин может быть доступен или нет;
магазин может работать в определенные часы.
Переводя в техническую плоскость, это означает, что изменения настроек магазинов из одного микросервиса отправляются в десятки других. В системе СберМаркета тысячи магазинов, настройки по ним регулярно изменяются и уточняются. Трафик небольшой, но постоянный. Важно, что изменения настроек должны дойти до потребителей без потерь. При этом задержка до 10 минут допустима. Именно поэтому мы решили использовать паттерн Outbox для решения этой задачи, обеспечивая таким образом надежную и безопасную передачу данных.
Rake–таска
Для того чтобы проверить нашу концепцию в действии, мы решили пойти по пути наименьшего сопротивления и задействовать Rake-таску, которая будет активироваться по Cron-расписанию. Идея заключается в том, чтобы каждую минуту эта таска собирала данные о недавно оформленных заказах, а также о тех заказах, которые по каким-то причинам не были отправлены ранее (например, из-за ошибок в сети), и передавала их в очередь брокера сообщений.
task :publish_store do
StoreOutboxItem.pending.find_each(&:publish!)
end
Для PoC схема рабочая, но:
загрузка приложения занимает продолжительное время;
cхема не масштабируется, так как всё работает в 1 поток.
Sidekiq + Schked
Запускать каждый раз новый Ruby–процесс долго. Лучше воспользуемся планировщиком задач Schked — будем ставить регулярную задачу на отправку сообщений в брокер 1 раз в минуту. Задача будет обрабатываться Sidekiq’ом.
every "1m" do
StoreOutboxItemsPublishJob.enqueue
end
class StoreOutboxItemsPublishJob < ApplicationJob
def perform
StoreOutboxItem.pending.find_each(&:publish!)
end
end
В результате мы добились того, что задачи стартуют моментально, без задержек, связанных с инициализацией окружения. Но даже с этим улучшением масштабирование системы остается сложной задачей. Есть риск, что одна задача начнется, пока предыдущая еще не закончилаcm, что может нарушить порядок обработки заказов.
Чтобы избежать хаоса, мы запускаем Sidekiq в режиме одного потока, чтобы задачи выполнялись строго одна за другой. Это решает проблему параллельного выполнения, но в то же время ограничивает нас в использовании всей мощи Sidekiq для обработки задач массово и быстро.
Так как проблему скейлинга мы не решили, то самое время обратиться к концепции Kafka. Топик в Kafka — это аналог очереди сообщений. Сам топик делится на партиции, за счет которых достигается распараллеливание очереди сообщений и масштабирование пропускной способности. При реализации логики отправки сообщений мы в праве решить, в какую партицию его положим, то есть можем распределять события, связанные с одним заказом в одну партицию. За счет потребления одним процессом сообщений из одной партиции достигается параллельная обработка с соблюдением порядка.
Чтобы за 20 минут погрузиться в суть Kafka, рекомендую статью моего коллеги Глеба Гончарова «Кафка за 20 минут».
Идея в том, чтобы распараллелить отправку событий по разным партициям и осуществлять ее в разных потоках. Для этого нужно заранее высчитывать партицию при сохранении outbox item.
Соответственно, Schked ставит в Sidekiq задачу на отправку сообщений в определенную партицию.
every "1m" do
PARTITIONS_COUNT.times do |partition|
StoreOutboxItemsPublishJob.enqueue(partition)
end
end
Таким образом достигаем распараллеливания отправки по разным партициям.
Наслаивание задач
Как мы уже обсуждали, наша текущая схема работы с задачами имеет недостаток, связанный с риском накопления и перекрытия задач. Это может привести к ситуации, когда система просто не успевает обработать все события в отведенное время. Чтобы предотвратить это и обеспечить более гладкую и надежную обработку задач, мы можем воспользоваться популярным инструментом — гемом sidekiq-uniq-jobs
. Этот гем блокирует запуск новой задачи, если предыдущая задача с таким же уникальным идентификатором еще находится в процессе выполнения.
class StoreOutboxItemsPublishJob < ApplicationJob
sidekiq_options lock: :until_executed
def perform(partition)
StoreOutboxItem.publish!
end
end
Схема хороша:
Опираемся на стандартные инструменты, что упрощает поддержку и развертывание.
Хорошая наблюдаемость (observability), так как все метрики есть из коробки.
Хорошо масштабируется. Если нужно больше параллельности, добавляем больше партиций в топик Кафка, увеличиваем concurrency очереди Sidekiq.
Однако, несмотря на эти плюсы, система остается сложной, с множеством потенциальных точек отказа.
Все работало более-ли-менее, пока в один прекрасный момент не сломалось... Опытный рубист без труда укажет на самое слабое звено ?. В один прекрасный день залип sidekiq-uniq-jobs. Перед нами встал новый вызов — уменьшить количество точек отказа, тем самым дать путь для решения более масштабных задач.
Перевод заказов из async–http на Kafka
Итак, в нашем распоряжении две системы — витрина и бэк-офис, которые активно обмениваются информацией о заказах с интенсивностью около 100 сообщений в секунду.
Исторически сложилось, что для обмена данными между системами использовалась комбинация Sidekiq и HTTP. Этот подход справлялся с задачами, пока объем данных оставался умеренным. Однако с увеличением нагрузки стали очевидны проблемы с соблюдением порядка сообщений, сложностью масштабирования системы и потерей сообщений. Кроме того, система была не расширяемой, то есть добавление новых потребителей требовало изменений в монолите.
Команда Ruby-платформы приняла решение перевести механизм синхронизации заказов на Kafka, учитывая, что задержки в обмене информацией о заказах были неприемлемы. Стало ясно, что дальнейшее использование схемы на базе Schked/Sidekiq/Uniq-jobs не сможет обеспечить необходимую надежность и производительность.
Стало понятно, что на решении Schked/Sidekiq/Uniq-jobs мы далеко не уедем. Было принято решение реализовать свой outbox–демон, снизив количество точек отказа до одной, при этом демоны должны запускаться независимо, быть реплицируемыми и взаимозаменяемыми. Чтобы выход из строя одного не влиял на работу других.
Концепция нового демона была проста и эффективна: запуск отдельного процесса, который в многопоточном режиме обрабатывает сообщения для всех партиций Kafka последовательно. Синхронизация работы процессов осуществлялась через Redis с использованием алгоритма Red Lock, обеспечивая блокировку конкретной партиции и избегание конфликтов при обработке данных.
В рамках новой архитектуры, один Ruby-процесс обслуживает всю систему, что упрощает ее поддержку и управление. Масштабирование же осуществляется путем увеличения количества процессов и потоков, что обеспечивает гибкость и способность системы адаптироваться к возрастающей нагрузке.
Безопасная миграция на Kafka
Исходная задача заключалась в замене транспортного механизма с Sidekiq+HTTP на Kafka, при этом акцент делался на обеспечении максимальной стабильности перехода. Мы приняли решение параллельно запустить Kafka-транспорт и постепенно мигрировать с одного решения на другое. Для этого нам было необходимо обеспечить поддержку нескольких транспортов в рамках outbox-паттерна.
План действий был следующим:
Сначала мы незначительно модифицировали существующую реализацию на базе Sidekiq+HTTP для взаимодействия с Outbox.
После начали дублировать отправку заказов через Kafka.
Используя сравнительные метрики производительности (например, определяя, какой из транспортов обрабатывает заказы быстрее), мы смогли убедиться в превосходстве нового решения. После этого старая система синхронизации была отключена, оставив в работе только новый транспорт на базе Kafka.
Такой подход позволил нам построить и отладить новый процесс синхронизации без риска для уже работающей системы. Эволюционная миграция с минимальными рисками и без сбоев в работе — это стратегия, которая может быть применена не только для замены транспортных протоколов, но и для разделения монолитных приложений на более мелкие и управляемые сервисы. Этот опыт подтверждает, что постепенный и контролируемый переход — эффективный способ внедрения изменений в критически важные системы.
Observability & Tracing
В предыдущей версии обработки outbox-сообщений, основанной на Sidekiq, мы имели преимущество в виде встроенных метрик, таких как yabeda-schked
и yabeda-sidekiq
, которые обеспечивали хорошую наблюдаемость системы без дополнительных усилий. Однако, разрабатывая свой демон для обработки outbox-сообщений с нуля, мы столкнулись с необходимостью самостоятельно реализовать все метрики наблюдаемости, включая метрики и распределенные трейсинги.
После запуска нашего нового outbox-демона стало ясно, что его функциональность не ограничивается только отправкой сообщений. Для обеспечения аналогичного уровня наблюдаемости, который мы имели с Sidekiq, нам требуется интегрировать систему сбора метрик и трейсинга, чтобы мы могли отслеживать производительность, выявлять узкие места и быстро реагировать на возникающие проблемы.
В процессе разработки и запуска нового демона мы должны были уделить особое внимание следующим аспектам:
Метрики производительности. Необходимо было настроить сбор данных о количестве обработанных сообщений, времени обработки, ошибках и прочих ключевых показателях, которые помогают оценить эффективность системы.
Распределенные трейсинги. Для понимания потока выполнения и взаимодействия между различными компонентами системы, а также для упрощения отладки и улучшения производительности, мы интегрировали распределенный трейсинг. Это позволило нам прослеживать каждый запрос через все микросервисы.
Таким образом, хотя наш новый демон обеспечил более высокий уровень стабильности и масштабируемости, он также потребовал дополнительной работы по обеспечению наблюдаемости, которая была необходима для поддержания высокого качества обслуживания.
Inbox Pattern
Рассмотрим ситуацию с точки зрения получателя сообщений, который сталкивается с аналогичными вызовами. Например, при работе с Kafka нет встроенных гарантий соблюдения семантики доставки сообщений exactly-once. Для решения этой проблемы мы расширяем концепцию «Transactional Outbox» до «Transactional Inbox», где механизм работы зеркален и применяется на стороне потребителя сообщений.
Чтобы узнать больше про exactly-once в Kafka, рекомендую посмотреть доклад нашего коллеги Артема Кулешова.
По приходе сообщения оно сохраняется в специальную inbox-таблицу базы данных. В случае использования Kafka, этот процесс выполняется inbox-kafka-consumer, который в рамках транзакции фиксирует offset обработанных сообщений.
Отдельный inbox-процесс забирает события из базы данных и инициирует выполнение бизнес-логики для каждого из них. Этот процесс аналогичен outbox-процессу, но вместо отправки сообщения в брокер, он занимается обработкой бизнес-логики пришедшего сообщения.
Эта схема по сути является зеркальным отображением outbox-паттерна, но в ней инициатором создания записей в базе данных является консюмер. Механизм полингa событий из базы данных остается неизменным по сравнению с outbox-паттерном. Однако в процессе обработки вместо отправки сообщения в брокер, как это было в outbox, запускается бизнес-логика, предназначенная для обработки полученного сообщения.
Благодаря такому подходу удается обеспечить семантику доставки сообщений exactly-once. Это достигается за счет создания inbox-записей с тем же уникальным идентификатором (UUID), который использовался для outbox-записей, и использования уникального индекса по этой колонке в базе данных.
Стоит отметить, что такая схема оправдана и полезна в случаях, когда требуются дополнительные гарантии того, что сообщение будет корректно обработано потребителем.
Масштабирование
После реализации inbox-паттерна мы столкнулись с новой задачей — обеспечением его масштабируемости. Важно отметить, что в нашей архитектуре получение (потребление) сообщений концептуально отделено от их фактической обработки.
Процесс потребления сообщений из Kafka достаточно быстрый, так как он сводится к сохранению полученных данных в базу данных. В то же время, обработка сообщений, которая включает в себя запуск бизнес-логики, занимает значительно больше времени.
Это различие в скорости работы позволяет масштабировать потребление и обработку сообщений независимо друг от друга. Например, если пропускной способности консюмера достаточно для записи всех поступающих сообщений, но обработка бизнес-логики не успевает за этим потоком, мы можем масштабировать обработку отдельно, увеличивая количество потоков, отвечающих за запуск бизнес-логики.
Увеличение числа потоков обработки сверх количества партиций не имеет смысла, так как мы можем обрабатывать сообщения только из одной партиции одновременно. Остальные потоки будут простаивать в ожидании доступа к данным. Это связано с тем, что масштабирование в нашем случае основано на параллельной обработке сообщений из разных партиций.
Таким образом, мы сталкиваемся с ограничением: масштабируемость обработки сообщений ограничена количеством партиций в топике Kafka. Это может стать проблемой, если бизнес-логика обработки сообщений достаточно ресурсоемкая, а увеличение количества партиций в Kafka по каким-либо причинам затруднено.
Виртуальные партиции
Ранее, чтобы масштабировать outbox/inbox-демон, нам приходилось выполнять ряд неудобных действий: сначала увеличивать количество партиций в Kafka, перезапускать producer сообщений, а затем перезапускать сам outbox-демон. Такой подход был громоздким и требовал лишних манипуляций.
Для решения этой проблемы мы ввели концепцию «бакета». Теперь при создании записи в outbox/inbox вместо номера партиции указывается номер бакета. Количество бакетов ограничено и вычисляется аналогично номеру партиции — как остаток от деления ключа события. Однако, на одну партицию Kafka может быть смапплено несколько бакетов.
Такой подход позволил нам создать большое количество бакетов изначально, а количество партиций Kafka регулировать динамически, без необходимости перезапуска всей системы. Теперь мы можем масштабировать outbox/inbox-демон, просто изменяя конфигурацию, что значительно упрощает эксплуатацию системы.
В результате проделанной работы, мы решили задачу стриминга завершенных заказов с высокой надежностью и минимальными задержками. Были реализованы следующие улучшения:
Разработан собственный outbox/inbox-демон: это позволило нам получить полный контроль над процессом обработки сообщений и реализовать необходимые оптимизации.
Масштабирование outbox по количеству партиций Kafka: мы можем гибко регулировать пропускную способность системы, изменяя количество партиций.
Реализация надежного inbox-паттерна: это гарантирует обработку каждого сообщения ровно один раз, что критично для бизнес-процессов, чувствительных к потере данных.
Независимое масштабирование с помощью виртуальных партиций (бакетов): это позволяет масштабировать систему без необходимости изменения конфигурации Kafka и перезапуска producer'ов. В итоге мы получили гибкую, масштабируемую и отказоустойчивую систему обработки сообщений, способную справиться с большими нагрузками и обеспечить надежную доставку важных данных.
Тиражирование решения
Глобальной целью Ruby-платформы было создание и стандартизация универсального решения для обработки сообщений, которое могли бы использовать все Ruby-сервисы. Хорошо спроектированное решение позволило бы продуктовым командам не тратить время на решение технических задач и сосредоточиться на развитии продукта и реализации бизнес-логики.
Наш инструментарий должен был быть совместим с различными Ruby-сервисами, адаптироваться к разным нагрузкам и иметь полный набор инструментов для мониторинга. С этой точки зрения, наше решение на базе outbox достигло определенной зрелости:
Оно было успешно внедрено в нескольких Ruby-сервисах.
Демонстрировало стабильную работу.
-
Обладал хорошей наблюдаемостью и возможностями масштабирования. Однако при массовом использовании мы столкнулись с некоторыми сложностями. Несмотря на независимое масштабирование, для точной настройки демона под конкретную нагрузку требовалось рассчитывать соотношение нескольких параметров:
Количество партиций Kafka.
Количество бакетов.
Количество процессов демона.
Количество потоков в каждом процессе.
Такая конфигурация оказалась слишком сложной для пользователей, вынуждая их разбираться в технических деталях реализации. Нередко возникали ситуации, когда пользователи указывали слишком мало партиций и слишком много процессов демона, что приводило к снижению производительности. Для массового использования требовалось упростить конфигурацию и сделать процесс масштабирования более интуитивно понятным и простым.
Новая архитектура
Напомним принцип работы нашего демона: каждый поток последовательно опрашивает (polling) базу данных на предмет новых сообщений в привязанных к нему бакетах. После получения сообщения происходит его обработка (processing), включающая выполнение бизнес-логики. Polling выполняется достаточно быстро. В то же время, обработка сообщений может занимать продолжительное время.
Изначально, единицей масштабирования бизнес-логики в демоне был поток, который выполнял как polling, так и processing сообщений. Это приводило к тому, что при масштабировании увеличивалась интенсивность обеих операций, даже если это было не нужно. Например, если бизнес-логика начинала «тормозить», мы увеличивали количество потоков, что в свою очередь увеличивало частоту опроса базы данных, даже если bottleneck был связан именно с обработкой, а не с получением сообщений. Возникал эффект «перемасштабирования».
Увеличивая количество потоков, мы пропорционально увеличивали количество запросов к базе данных. Мы постоянно опрашивали базу данных на наличие новых сообщений, так как не могли позволить себе снизить частоту опроса, чтобы не замедлять обработку. Все эти проблемы были решены благодаря новой архитектуре, которая предполагает разделение демона на два пула потоков: polling pool и processing pool.
Разделяй и властвуй: polling и processing в разных пулах
Новая архитектура основана на идее разделения ответственности за polling и processing между разными пулами потоков, используя Redis в качестве промежуточной очереди сообщений.
Polling pool:
Потоки в этом пуле отвечают за выборку новых сообщений из базы данных для каждой партиции.
При обнаружении новых сообщений, они помещаются в очередь Redis с помощью команды
LPUSH
.Для обеспечения согласованности данных и сохранения порядка обработки, на время polling'а партиции устанавливается блокировка.
Processing pool:
Потоки в этом пуле получают сообщения из очереди Redis с помощью команды
BRPOP
.Каждый поток обрабатывает сообщения из определенного бакета, устанавливая блокировку на время обработки, чтобы предотвратить конкурентный доступ и сохранить порядок сообщений внутри бакета. Теперь каждый демон состоит из двух пулов потоков — polling pool и processing pool. По умолчанию на каждый поток polling'а приходится два потока processing'а, но это соотношение можно гибко настраивать.
Преимущества новой архитектуры:
Упрощение масштабирования: Так как polling — легковесная операция, количество потоков в polling pool может быть значительно меньше, чем в processing pool. Масштабирование системы теперь сводится к увеличению количества реплик демона, каждая из которых будет иметь собственные polling и processing пулы.
Оптимизация производительности: Разделение polling'а и processing'а позволяет более эффективно использовать ресурсы системы. Потоки polling'а не блокируются на время обработки сообщений, а потоки processing'а не простаивают в ожидании новых данных.
Гибкая настройка: Соотношение количества потоков в polling и processing пулах можно легко изменять в зависимости от профиля нагрузки и требований к производительности.
Автоматизация масштабирования с Kubernetes HPA: Использование Redis в качестве буфера между polling и processing пулами позволяет легко настроить автомасштабирование (HPA) в Kubernetes. Размер очереди Redis служит точным индикатором нагрузки на processing pool. Если очередь растет, значит processing не справляется, и HPA может автоматически увеличить количество реплик демона. И наоборот, если очередь пуста, HPA может уменьшить количество реплик, оптимизируя использование ресурсов. В результате мы получаем более гибкую, масштабируемую и простую в настройке систему обработки сообщений.
От идеи до open-source решения
Эта история — наглядный пример эволюционного развития инструмента, каждый этап которого был обусловлен новыми вызовами и растущими требованиями к производительности и надежности.
Главный вывод: на Ruby можно реализовать практически любую задачу. Многие open-source проекты проходят путь от решения конкретной бизнес-задачи до универсального инструмента, закаленного в боях на множестве production-сервисов.
В этой статье мы рассмотрели три этапа развития инструмента Inbox/Outbox, разработанного командой Ruby-платформы для решения задач обработки сообщений. Каждый этап был направлен на повышение надежности, масштабируемости и удобства использования инструмента.
Итоговое решение, которое мы получили, прошло обкатку на реальных проектах, как в крупных монолитных приложениях, так и в распределенных системах из десятков сервисов. Мы уверены в его стабильности и эффективности, поэтому приняли решение поделиться им с сообществом, опубликовав его в open-source.
sbmt-outbox — Outbox/Inbox Ruby гем
sbmt-kafka_producer — Outbox Kafka transport
sbmt-kafka_consumer — Inbox Kafka transport
example applications — пример реализации микросервисов
Tech-команда СберМаркета ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.
Комментарии (8)
Format-X22
07.06.2024 18:12Последние дни изучал как что Ruby в текущем году, заодно проверил количество вакансий на Rails на HH. Выглядит грустновато. А суть вопроса в том - имеет ли смысл смотреть в сторону Ruby и рельс в частности спустя 15 лет в JS/TS, из которых 8 в NodeJS? Не совсем с нуля - когда-то в 2017 целиком прочел Путь Ruby в бумажной версии на 1000 страниц и потом ещё парочку торговых роботов и аналитики на чистом Ruby без рельс было. И Ruby это кайфово. Но нужно ли оно миру или это будет лишь выбор сердца, но с деградацией по грейдам и финансам? На сколько это безумно?
Надеюсь не очень оффтоп, но не каждый день на хабре про Ruby пишут и чтобы в топ дня попало.
Arlekcangp
07.06.2024 18:12Не очень понятно, а на кой черт в этой схеме kafka? Если я правильно понял, у вас две таблицы в базе - outbox и inbox. Что мешает напрямую брать бэкэнд-воркеру сообщения из той же базы? Прямо из оутбокс, и помечать их статусом, что они "в обработке" Другие воркеры их уже не возьмут. По итогу тот, который взял, либо успешно завершит их обработку, либо поставит статус "ошибка", "повтор" или любой какой вам нужен. Скорость выше - т к таблица одна, транзакции короткие - только смена статусов. Надёжность та же - и у вас, и без Кафки всё упирается в то, есть ли коннект к базе (или у вас база на той же роде где и приложение?)
Ну допустим, по какой-то неведомой мне причине, воркеру база приложения недоступна. Но тогда можно, как уже подсказали, читать из wal-log или реплики... По сути в статье описан древний как мир подход "очередь через таблицу". Это работает и даже из коробки это есть, как верно вы написали, в редис. Спрашивается, а кафка зачем?
pkokoshnikov
@bibendi нужно отметить один важный момент - это производительность, БД становится узким местом при таком подходе. Думаю стоит про это упомянуть. И да, понятно, что мы платим эту цену за надёжность и непротиворечивость доставки.
bibendi Автор
Да, полностью согласен. Мы с этим столкнулись, хотя, к сожалению, я об этом не написал. Гем поддерживает полинг из реплики БД, что снимает значительную часть проблем, хотя может привнести новые, такие как лаг репликации.
pkokoshnikov
На мой взгляд тут 2 варианта.
Горизонтально масштабируемые транзакционные бд, которых к сожалению не так много.
Шардирование имеющихся бд. Скорее всего наиболее часто используемый вариант на данный момент. Но тут конечно минус, вся кодовая база должна поддерживать шардирование.
Soupbreak
Запись в условно 2 таблицы(ентити и ивент) в рамках 1й транзакции не оч увеличит нагрузку
А чтение ивентов с WAL лога, а не таблицы ивентов, снимет нагрузку с мастера/реплики
pkokoshnikov
Чтение с wal лога хороший вариант согласен. Единственное бывает не всегда доступен в инфраструктуре компании.
bibendi Автор
Если очень хочется читать именно с wal, то для этого можно использовать Debezium. Я в тексте оставлял ссылку на митап, где рассказывается о минусах такого подхода