$10.000 за хвилину даунтайму: архітектура, черги та стрімінг у фінтех
Привіт, мене звати Макс Багінський, я Head of Engineering у Solidgate — українській продуктовій фінтех-компанії, що працює в ніші пейментів і допомагає розбудовувати платіжну інфраструктуру для інтернет-бізнесів.
Кожного місяця інфраструктура Solidgate процесить понад 18 мільйонів транзакцій. Відтак $10.000 за хвилину даунтайму — це не просто цифра для маркетингової презентації. Це — реальна вартість, яку можуть втратити наші клієнти за усього лише хвилину падіння Solidgate.
Робота нашої платіжної інфраструктури здається кінцевим споживачам доволі простою: юзер платить за таксі, доставку тощо у застосунку, кошти потрапляють на рахунок компанії, юзеру отримує повідомлення про успішний платіж та послугу. Саме тому, коли ми лише планували розробку нашого нового проєкту Taxer, його структура виглядала також просто:
Водночас коли занурюєшся у роботу інфраструктури, стається багато «якщо»:
- Що, якщо під час замовлення таксі у клієнта не запроцеситься оплата?
Він запізниться на бізнес-зустріч та отримає зіпсований день. - Що, якщо між системами втратиться транзакція?
Драйвер або кур’єр не отримає потрібні виплати й втратить зароблені кошти.
Таких «що, якщо» можна придумати безліч. А відтак стабільність роботи системи — ключове для платіжної інфраструктури.
RabbitMQ
Ми давно використовуємо Rabbit у своїх системах, маємо з ним найбільше досвіду і планували використати його в цьому проєкті. І хоч Durability, найкритичніший показник для фінтеху, у Rabbit не ідеальний, ми все ж спробували його в роботі з декількох причин:
📌 Erlang. Мова програмування, створена телеком-компанією Ericsson. Вона базується на тому, що обладнання ніколи не перезавантажується — це дозволяє системам працювати постійно і підтримувати роботу 24/7.
📌 Proof of fail-safety. Десятки років Erlang може працювати без жодного падіння — це емпіричний досвід роботи девайсів цією мовою впродовж такого періоду. Приклад: ATM AXD301, Uptime 99,9999999% тощо.
📌 Mnesia. Це розподілена система керування базами даних для Erlang. Недоліки — вона не підтримує відновлення у випадку зі Split brain або іншими типами падінь.
Durability RabbitMQ має різні механізми. Серед них publisher confirms, зберігання даних на диску, різні типи черг і стрімінг. Але чому взагалі важливо про це думати?
По-перше, якщо паблішити меседжі в Rabbit без publisher confirms, вони можуть втрачатись у сотнях місць — на це впливає нетворк, нестача місця для збереження даних на диску, або ж банальна відсутність черги.
Проігнорувати publisher confirms можна. Але в такому випадку RabbitMQ не гарантує:
- доставлення до черги після Exchange;
- запис на диск для Durable-черг;
- confirmation всіма репліками в випадку Quorum черг;
- місце в черзі або на диску та збереження меседжу;
- коректну обробку спайків за навантаженням, через що консьюмери не встигають обробити дані та паблішер забиває чергу або ж зовсім їх скіпає.
Кворумні черги в Rabbit — доволі новий механізм. Вони вміють реплікувати меседжі по різних нодах і зберігати дані навіть у випадку падіння однієї з нод RabbitMQ. Але в таких чергах є і недоліки — по-перше, вони все ще не вміють розсилати повідомлення різним клієнтам. По-друге, вони не здатні добре масштабуватися — оскільки у нас ноди можуть мати понад мільйон меседжів, падіння і відновлення однієї ноди може займати години. По-третє, кворумні черги все ще не підходять під декількох консьюмерів. Вони не зберігають порядок повідомлень, а нам це дуже критично для проведення платежів.
Split Brain
Попри всі переваги, які має RabbitMQ завдяки fail-safe мові Erlang, інциденти стаються — наприклад, у нас був випадок Split Brain. Split Brain — це стан кластера серверів, де вузли розходяться один з одним і мають IO-конфлікти. Фактично паблішер пише в один кластер, а читач читає з іншого. При цьому паблішер та читач навіть не підозрюють, що не отримують меседжі один одного.
Чи є в RabbitMQ стратегії вирішення Split Brain? Так, проте вони точно не пасують до вимогливої фінтех-індустрії. Чому?
Ignore. Стратегія, в якій система нічого не робить у випадку Split Brain. Infrastructure-інженер вручну рестартить певні ноди, які він вважає неосновними. Чому це проблема — не відбудеться автоматичного відновлення зв‘язку, а інженери самостійно обиратимуть мастер-ноду і будуть перезапускати інші ноди. Якщо черги не кворумні, то welcome to втрата даних. Якщо ж черги існували довгий час в такому стані, то втрата даних гарантована, оскільки повідомлення ніяк не можуть потрапити на обидва кластери.
Pause_minority. Цей інструмент автоматично відстежує, коли щось стається із нетворком, і пропонує рестартнути певні ноди. Проте як і в першому випадку, він не вміє відновлювати меседжі, а значить ми знову ризикуємо втратити важливі дані, і навіть більшу їх кількість.
Autoheal. Автоматичний рестарт нод. Проблема — система може обрати не ту ноду, на яку опублікували найбільше меседжів, і перезапустити ноди, які не мають даних або мають їх менше за інші. Хоча в такому варіанті рестарт нод відбудеться швидше, все ж це не розв’язує суть проблеми. Autoheal більше підходить для відновлення роботи кластеру замість консистентності даних.
Як результат — жодної гарантії збереження даних і коректного відновлення роботи нод.
Ось приклад логів при виникненні Split brain в RabbitMQ.
Streams
Так, в RabbitMQ є стріми. Вони вийшли 2 роки тому — у 2022. Здавалося б, це ідеальний інструмент — у нас є експертиза роботи з Rabbit і потреба реалізації нового проєкту, а стріми можуть забезпечити порядок доставлення та дати можливість партішінінгу. Але після детального аналізу ми їх відкинули. Що ж з ними не так?
- Сирий функціонал, який досі мало протестований.
- Відсутність великої кількості компаній, які регулярно працюють зі стрімами.
- Напівготовий гошний клієнт (а насправді — зовсім не готовий, навіть у
2024-му). - Складний сапорт, потреба в оновленні Erlang і лише потім сам RabbitMQ.
- Все ще RabbitMQ, що потребує правильної підготовки та підтримки, який до того ж не зовсім Durable.
То як це вирішити?
Kafka
Попри те, що наша команда мала найбільше знань саме із RabbitMQ, недоліки були занадто критичними, аби будувати новий і важливий продукт. Так ми звернулись до Kafka і почали шукати рішення, які можна використати в побудові інженерної частини нового продукту.
На відміну від RabbitMQ, Kafka — durable out of the box. Що робить її такою?
- Zookeeper — на відміну від RabbitMQ, Kafka має інтегровану сторонню, зовнішню систему, що слідкує за здоров’ям кластерів.
- Kafka пише лог на диск по всіх змінах.
- Heavy usage of hard drive — при цьому швидкий, майже Sequential writes механізм.
- Оптимізована і має в сотні разів кращу швидкість запису даних на диск, що наближає її до швидкості доступу до оперативної пам’яті.
Як не втрачати дані
На етапі, коли ми вже попрацювали з Kafka, перевірили роботу стрімів, нод, відключали зв’язок між нодами та переконались, що все працює, постало питання — як налаштовувати роботу системи так, щоб не втрачати дані? Часткова відповідь — WAL, але ж як не втрачати дані в записі?
Гіпотетичні ситуації — що буде, якщо паблішер, який пише в Kafka, перезапуститься? Що буде, якщо ми асинхронно відправлятимемо меседжі в Kafka та отримаємо out of memory? Що буде, якщо метеорит прилетить в дата-центр? Що буде якщо ж ми просто невірно налаштуємо сам клієнт Kafka? Відповідь — ми втратимо дані.
Ось приклад конкретно наших налаштувань Publisher.
З важливого — виставляємо мінімальний BatchTimeout і обов’язково RequiredAcks — система, що «вимагає» від нод підтвердження про отримання даних. Для фінтеху, RequiredAcks — це must-have.
Дані можна втратити ще до запису в Kafka
Для забезпечення стабільності пеймент-процесору та Kafka ми використали патерн Transactional Outbox. Він надає найбільш гнучкий механізм реплею (перевідправки) даних у разі збою, неправильного стану бази даних тощо. Наявність такого логу дозволяє набагато швидше і точніше перестрімити дані в будь-якій із систем. Як ми це організували?
За допомогою Transactional Outbox можемо стрімити дані за допомогою декількох стрімів у різних бакетах. Це дозволяє працювати з великими об’ємами даних у постгресі. У id ми використовуємо ulid — його відрізняє здатність до сортування, що дозволяє відновлювати порядок івентів.
Система, яку ми створили
Врахувавши всі плюси та мінуси систем вище, ми створили наступну архітектуру:
Payment Gate → 2 таблиці з даними → Order Streamer, що читає таблиці → апдейтить метадані → стрімить і записує їх у Kafka.
Стрімер ми розробили самі — це окремий сервіс, майже як Debezium або Kafka Source. Навіщо написали своє? Відповідь проста — стрімер може працювати як бібліотека. Тобто це не окремий сервіс, який треба деплоїти в інфру, а просто бібліотека, що запускається в горутині або треді, вичитує дані із бази даних і може під‘єднуватись до будь-якої структури бази даних або навіть outbox-таблиці, і публікує ці дані в стрім. Так, замість наявних і складних рішень (Debezium), ми написали свій CDC і вмістили його у 200 рядків коду.
Є стандартизоване рішення, яке може бути створене на основі debezium, scheme register, та головне питання у якому — робота з реплікацією даних. Ми ж побудували власне. Дефолтне, як видно нижче, виглядає набагато складніше.
Плюси
Повний лог всіх платежів і порядок їхнього оновлення.
З додаткових систем додається лише Kafka, яка використовується як «транспорт».
Написання стрімеру з лізером.
Buf/Go/DB. Всі системи повторно використовуються й в архітектурі нічого не треба додавати, окрім Kafka.
Мінуси
WAL amplification — 2x. Outbox-таблиця подвоює кількість записів у WAL, тому що її потрібно наповнювати окремо.
Latency — неможливість вичитувати івенти швидко.. Через використання ulid є проблема з комітами транзакцій і резервування ULID, коміти транзакції відбуваються в різний час.
Стрімінг апдейтів з Outbox, на відміну від отримання з WAL, дорожчий.
Але є проблема
Оновлення підписок на продукти потрібно робити швидше. Поточний delay — 2 хвилини, що є проблемою для списань за підпискою, які ми згодом підсадили на стріми. Тому відразу ж почали брейнштормити — як все ж таки прибрати ліміт на час. Де ж проблема?
Записуючи дані в outbox, транзакції закінчуються в різний момент часу. Тобто транзакція, що почалась першою (№ 1) може закінчитись пізніше за транзакцію, що почалась другою (№ 2). Це означає, що вичитувати дані з outbox-таблиці треба з затримкою — дефолтний delay для нашого рішення = 2 хвилини.
Щоб зменшити таймаут вичитки з outbox є банальне рішення — Lamport’s logical clock. Складний термін, який працює на векторах і векторній математиці. Проте ми реалізували простішу його версію — замість ulid взяли auto_increment і саме його використовуємо для ордерингу івентів в Outbox table. Чому? Саме так можна перевірити, що якийсь з івентів був пропущений.
Якщо івенти з id — 1,2,4 — прийшли, то очевидно, що id 3 треба або почекати й дострімити, або ж третя транзакція була роллбекнута та її з часом можна просто скіпнути. У будь-якому випадку, можна простежити рух івентів, дочекатися їх і відновити порядок.
Streamer future ideas
А чи можна зробити ще краще? Звичайно. І доволі легко. А чому б не читати не власний читач WAL напряму, тобто чому б не повторити імплементацію Debezium на Go?
Ось приклад коду, який читає з WAL з Go. Замість debezium можна зробити легковійну бібліотеку у ті самі 200 строк коду і читати з WAL log самого постгресу.
Це доволі банальний шматок коду, який опрацьовує меседжі з WAL-реплікації, які PostgreSQL надсилає лістенерам, прив‘язаним до реплікейшн слотів. Це не складно і, можливо, для цього не потрібен Debezium чи Kafka sink на Java. Слоти реплікації можна автоматично створювати з коду. Але це вже матеріал для наступної статті.
Висновок
Фінтех — не лише вибаглива до технологій ніша, але й цікава своїми non-functional requirements, які вона накладає:
- Durability.
- Queue replay.
- Strong consistency.
Звичні всім нам рішення при проєктуванні відразу піддаються більш скрупульозним перевіркам, хаос-інжинірінгу та читанню сорсів. На прикладі розкладання RabbitMQ, який by design — fail safe, а на практиці не забезпечує потрібної Durability.
Kafka в цьому випадку працює краще — вона дизайнилась, виходячи з проблем Distributed-систем, тож у неї закладались різного роду механізми консенсусу та орієнтованість на використання жорсткого диска.
Детально попрацювавши з обома системами, ми зробили такі висновки:
RabbitMQ:
- Дуже швидкий і «легкий» (якщо ви знаєте, як з ним працювати).
- Не підходить для фінтеху: відсутність належної надійності, single active consumer, message ordering тощо.
- Відсутність підтримки більшості функцій для Go/Python/Node.js, різні клієнти мають різні можливості.
- Відсутність підтримки стрімінгу.
- Написаний на Erlang і оновлюється в реальному часі без перезапуску.
Kafka:
- Надійний з коробки. Можна почитати тут.
- Дозволяє реплеїти месседжі шляхом оффсетів.
- Має вбудовані механізми роботи з Schema registry.
- Краще скейлиться.
- Важчий в експлуатації, вимагає багато конфігурацій з обох сторін: інфраструктури та коду.
- Написаний на Java, вимагає перезапуску для оновлення версії.
130 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів