Підводні камені асинхронного обміну повідомленнями
Мене звати Юрій Івон, я співпрацюю з компанією EPAM як Senior Solution Architect. Хотів би поділитися досвідом розробки систем, що використовують асинхронний обмін повідомленнями, а саме можливими проблемами та способами їх вирішення.
Асинхронний обмін повідомленнями є привабливим рішенням для створення масштабованих, слабо пов’язаних систем, що може бути особливо цікавим для розробників без значного досвіду у цьому типі комунікації. Однак без глибокого розуміння викликів і потенційних підводних каменів, впровадження такого обміну може призвести до непередбачуваних наслідків, які перекриють його переваги. У цій статті ми розглянемо найбільш значущі підводні камені, пов’язані з цим, та надамо практичні рекомендації щодо подолання кожного з них.
Транзакції
Одним із типових сценаріїв асинхронного обміну повідомленнями є сповіщення підписників про зміни в даних.

На високому рівні типовий код, який надсилає повідомлення, виглядає так:
- Почати транзакцію бази даних.
- Змінити дані.
- Завершити транзакцію.
- Надіслати повідомлення.
Через порядок виконання операцій повідомлення буде надіслано лише в тому випадку, якщо основна транзакція завершиться успішно, що є позитивним моментом. Однак, якщо четвертий крок зазнає невдачі через проблему з брокером повідомлень або через аварійне завершення роботи сервісу, повідомлення ніколи не дійде до адресатів, що може призвести до проблем з цілісністю даних.
Якщо втрата повідомлення в цьому сценарії є критичною для вашої системи, існують, по суті, лише два практичні підходи для розв’язання проблеми: патерн Transactional Outbox та розподілені транзакції.
Хоча розподілені транзакції теоретично забезпечують найвищий рівень узгодженості в подібних сценаріях, я рідко бачив їх використання на практиці. Це можна пояснити кількома недоліками:
- Тільки обмежена кількість баз даних і брокерів повідомлень підтримують розподілені транзакції.
- Механізм розподілених транзакцій додає затримку, що негативно впливає на продуктивність системи.
- Розподілені транзакції не є на 100% надійними й можуть призводити до неконсистентних результатів через проблеми з мережею, хоча це і рідкісна ситуація.
Таким чином, оптимальним способом забезпечення надійності є патерн Transactional Outbox. Я не збираюся заглиблюватися в специфіку його реалізації, але основна концепція передбачає запис повідомлення в таблицю вихідних повідомлень (outbox) в тій самій базі й в одній транзакції з основними змінами. Як тільки транзакція завершиться, окремий процес або служба отримує повідомлення з цієї таблиці та публікує їх в брокер. Є варіанти, коли служба надсилає повідомлення тільки у випадку, якщо основна програма не змогла його опублікувати, але загальний принцип залишається незмінним.
Має сенс подивитися на те, як зазвичай обробляється таблиця вихідних повідомлень:
- Прочитати запис із таблиці вихідних повідомлень.
- Відправити відповідне повідомлення в шину.
- Видалити запис або позначити його як відправлений.
Якщо з будь-якої причини виконати третій крок не вдасться, під час наступної спроби обробити Outbox те саме повідомлення буде надіслано ще раз. Залежно від реалізації процесорів повідомлень, це може бути або не бути проблемою для споживачів. Однак це важливий аспект, про який слід знати, і пізніше в розділі ми обговоримо, що з цим робити.
Ми говорили про потенційні проблеми під час публікації, але аналогічна ситуація може виникнути й на стороні процесора повідомлень:
- Заблокувати повідомлення для його обробки.
- Почати транзакцію бази даних.
- Змінити дані.
- Завершити транзакцію.
- Підтвердити успішну обробку повідомлення.
Що робити, якщо п’ятий крок не вдається виконати через проблему з брокером повідомлень або збій у вашому сервісі?
На відміну від першого сценарію, описаного в цьому розділі, в такому випадку дані не будуть втрачені. Непідтверджене повідомлення буде оброблено повторно, що призведе до ситуації, схожої на дублювання повідомлення, яке спостерігається в Transactional Outbox. Як вже згадувалося раніше, вплив такого дублювання залежить від того, як реалізовані процесори повідомлень.
Уявімо, що ваш сервіс обробляє повідомлення з командою «створити новий об’єкт». Якщо процесор не має засобів для перевірки чи такий об’єкт вже був створений, будь-який збій на п’ятому кроці може призвести до дублювання — обробка одного і того ж повідомлення знову і знову буде створювати нові об’єкти з ідентичними даними.
Таким чином, найпростіший спосіб уникнути проблем у випадку, якщо Outbox видає дублікати або є проблеми з підтвердженням повідомлень — це зробити логіку процесора ідемпотентною, що вказує нам на інший патерн під назвою Idempotent Consumer. Те, як реалізується цей шаблон, може відрізнятися залежно від типу повідомлень і конкретної бізнес-логіки. Однак основний принцип завжди полягає в тому, щоб мати засоби перевірки, чи було поточне повідомлення вже оброблене. У найпряміший спосіб це може включати зберігання всіх оброблених ідентифікаторів повідомлень. Також цього можна досягти перевіркою існування сутності для команд, які створюють нові сутності, та використанням версіонування для інших. Деякі деталі реалізації версіонування обговоримо далі в розділі «Порядок повідомлень».
Я також хотів би виділити деякі наївні «патерни», яких слід уникати, коли ми маємо справу з обміном повідомленнями в контексті більших бізнес-транзакцій:
- Надсилання сповіщення про зміни в даних перед завершенням транзакції — вочевидь це може призвести до некосистентності між системами, оскільки транзакція може бути відкочена в той час, коли повідомлення вже було оброблено споживачами. Зазвичай це робиться навіть не навмисно і є швидше результатом додавання коду відправки повідомлень всередину методу, неявно загорнутого в транзакцію бази даних.
- Видалення повідомлення з черги перед обробкою — деякі брокери дозволяють обійти шаблон «блокування/підтвердження» та негайно видалити поточне повідомлення з черги. При такому підході у нього не буде другого шансу, а це означає, що якщо обробка завершиться невдало, повідомлення буде втрачено назавжди.
Якщо вам потрібно забезпечити узгодженість між публікацією/обробкою повідомлень і пов’язаними з ними змінами в базі даних, розгляньте патерни Transactional Outbox та Idempotent Consumer, а також уникайте «новачкових» помилок, згаданих вище.
Обробка проблемних повідомлень
Коли під час синхронного виклику якого-небудь WEB API виникає помилка, клієнт дізнається про неї та може відреагувати відповідним чином. Однак відправник повідомлення може знати тільки про успішність публікації та не знати про його подальшу обробку, що значно ускладнює роботу з помилками при асинхронній комунікації.
Обробка повідомлення з черги може завершитися невдало з різних причин. Якщо під час обробки виникає тимчасова помилка, повідомлення може бути повернуто в чергу для повторної спроби. Але що робити, якщо код вважає помилку постійною або дозволену кількість повторних спроб було вичерпано?
Багато розробників знайомі з концепцією Dead Letter Queue (DLQ) і можуть одразу відповісти, що всі повідомлення, які не можуть бути оброблені, повинні надходити туди. Однак як слід поводитися з проблемними повідомленнями та хто несе відповідальність за їх аналіз і повторну обробку? Коротка відповідь полягає в тому, що команда підтримки має бути проінформована про нові недоставлені повідомлення (dead letter messages), а також має бути інструмент, що дозволяє інженерам підтримки переглядати та повторно надсилати повідомлення для обробки.
Основна проблема в тому, що не всі брокери мають зручний інструментарій для подібних цілей:
- Інструмент управління RabbitMQ дозволяє користувачам переглядати перші N повідомлень у черзі, але це не допомагає, якщо їх вже сотні або більше. Крім того, неможливо перемістити лише вибрані повідомлення назад до вихідної черги.
- Консоль Amazon SQS (Simple Queue Service) дозволяє користувачам переглядати та видаляти певні повідомлення в черзі, але немає простого способу надіслати їх повторно до вихідної.
Якщо зручні інструменти управління повідомленнями недоступні, доведеться щось розробляти для цієї задачі. Майте на увазі, що черга — це черга, тобто вона не дозволяє звертатися до довільних елементів. Якщо ви переглянули сто повідомлень у DLQ і з’ясували, що
Немає межі досконалості — ви можете створити кастомний менеджер DLQ, який зробить все, що може забажати інженер підтримки, але оскільки ресурси розробки зазвичай обмежені, краще визначити мінімум, який очікується від таких інструментів.
- Переглянути всі повідомлення та їх вміст — must have.
- Повторно надіслати перші N або всі повідомлення до вихідної черги — must have.
- Видалити перші N або всі повідомлення — must have. Без цього не вийде позбутися проблемних повідомлень.
- Повторно надіслати обрані повідомлення до вихідної черги — nice to have. Оскільки черга є черга, операції з довільними повідомленнями можуть бути недоступні «з коробки», тому я не вважаю цю функцію обов’язковою.
- Видалити обрані повідомлення — nice to have.
- Змінити вміст повідомлення — хоч і може бути зручно мати спосіб виправлення проблемних повідомлень вручну, цю функцію краще повністю вимкнути, оскільки ручне втручання може спричинити неузгодженість даних, яку важко діагностувати.
- Фільтрувати та сортувати повідомлення за їхніми атрибутами — nice to have, але не обов’язково. Ця функція може спростити аналіз DLQ, але її впровадження, скоріше за все, вимагатиме перенесення проблемних повідомлень у базу даних, а також розробки спеціального інструменту для керування ними.
Таким чином, мінімально необхідним набором функцій для інструменту управління DLQ є перегляд всіх повідомлень, повторна відправка перших N або всіх повідомлень у вихідну чергу і видалення перших N або всіх повідомлень. В ідеалі всі видалення, зроблені вручну, повинні реєструватися в контрольному журналі, що дозволить команді підтримки за необхідності відстежити життєвий цикл повідомлення.
Хоча інструмент керування DLQ має вирішальне значення для обробки помилок, сам по собі він недостатній. Для ефективної обробки помилок у рішенні, що використовує асинхронний обмін повідомленнями, знадобляться всі наступні інструменти:
- Сповіщення, які інформують команду підтримки про повідомлення в DLQ. В ідеалі сповіщення має спрацьовувати, як тільки кількість повідомлень стає ненульовою, і далі має нагадувати через рівні проміжки часу, доки DLQ не буде очищено.
- Інформаційна панель, яка надає актуальний статус всіх DLQ у системі.
- Інструмент керування DLQ, як описано раніше в цьому розділі.
- Детальний посібник з усунення проблем для команди підтримки, в якому викладено покрокові процедури обробки повідомлень у DLQ. Цей посібник має містити поширені проблеми, етапи діагностики та шляхи вирішення.
Хочу підкреслити, що обробка помилок часто є найскладнішою частиною асинхронного обміну повідомленнями. Я бачив кілька проєктів, де цьому аспекту не приділили достатньо уваги, що призвело до значних складнощів у підтримці системи пізніше.
Порядок повідомлень
Досить часто розробники очікують надходження повідомлень саме в тому порядку, в якому їх було опубліковано. На жаль, це очікування не завжди є реалістичним, оскільки існує принаймні два випадки, які за своєю суттю змінюють порядок повідомлень незалежно від гарантій, які може надати брокер:
- Повторна обробка повідомлень після перегляду Dead Letter Queue (DLQ) — якщо ваша система змогла обробити будь-які інші повідомлення після того, як хоча б одне з них викликало помилку і потрапило в DLQ, будь-яка спроба повторної обробки означатиме порушення початкового порядку.
- Кілька паралельних процесорів, які читають одну й ту саму чергу — щоб прискорити обробку повідомлень, ви можете запустити кілька процесорів одночасно, однак, якщо вони будуть читати одну й ту саму чергу, порядок обробки неминуче відрізнятиметься від початкової послідовності публікації.
Тому вам треба або запобігти виникненню цих двох ситуацій, або коректно обробляти їх наслідки. Розгляньмо, що можна зробити, щоб запобігти їх виникненню.
- Уникати використання DLQ.
Один із теоретичних підходів полягає в призупиненні подальшої обробки повідомлень з основної черги щоразу, коли виникає помилка на стороні процесора, таким чином уникаючи потреби в окремій DLQ. Команда підтримки відповідатиме за виявлення основної причини, усунення проблеми та відновлення подальшої роботи. І хоча цей підхід може бути життєздатним у деяких випадках, він не часто використовується на практиці через значне збільшення можливого часу простою системи. Тому використання DLQ залишається більш практичним рішенням для більшості сценаріїв.
- Уникати одночасної роботи декількох процесорів з однією і тією ж чергою.
Весь потік повідомлень може бути логічно розбитий на розділи (partitions), кожен з яких може оброблятися окремим процесором незалежно від інших. Наприклад, повідомлення можуть бути розбиті за клієнтом, за типом повідомлення, за типом сутності тощо. Але майте на увазі, що між повідомленнями, що належать до різних розділів, не повинно бути логічних залежностей. В протилежному випадку вам все одно доведеться розв’язувати проблему порядку. Я пам’ятаю проєкт, де для кожного типу сутностей виділяли окремий топік в Kafka. Однак деякі типи мали parent-child зв’язки між собою, що призводило до складної взаємодії між відповідними процесорами, оскільки «діти» могли потрапити в обробку раніше своїх «батьків».
Виявляється, що лише другого випадку зміни порядку можна надійно уникнути, тому нам потрібно знати, як краще поводитися з повідомленнями, порядок слідування яких було порушено.
Варто зазначити, що якщо повідомлення є незалежними й не спираються на послідовність їх «попередників», особливе поводження з аномаліями порядку не потрібне. Типовим прикладом тут є збір телеметрії: навіть якщо стара метрика затрималася і потрапила до процесора вже після новіших даних телеметрії, це ні на що не вплине, споживач просто заповнить прогалину.
На жаль, у більшості випадків порядок повідомлень має значення, і система повинна бути до цього готовою. Ймовірно ваша бізнес-логіка вже обробляє деякі базові проблеми з порядком. Наприклад, якщо сервіс отримує команду створити нову сутність, але сутність з таким ідентифікатором вже існує, або якщо він отримує команду активувати сутність, яка вже є активною.
Однак є безліч ситуацій, коли отримане сервісом повідомлення має оновлювати деякі поля, не покладаючись на якісь додаткові умови бізнес-логіки на кшталт послідовності станів. Поширеним прикладом є синхронізація систем, коли головна система публікує зміни в даних, а підписники застосовують їх, щоб залишатися узгодженими з головною. Типовим підходом до забезпечення порядку в цьому випадку є версіонування.
Існує принаймні два широко використовуваних підходи для забезпечення порядку оновлень на основі версій:
Перевірка базової версії — кожне повідомлення містить базову версію сутності та нову версію, яку буде їй надано у разі успішного оновлення. Система перевіряє, чи збігається базова з поточною версією сутності в базі даних. Якщо так, оновлення виконується. Якщо ні, повідомлення надсилається до Dead Letter Queue (DLQ). По суті, це класична перевірка Optimistic Concurrency, але з додатковим полем для інформування споживачів про нову версію. Пізніше в розділі я трохи докладніше розповім про необхідність цього поля залежно від напрямку потоку даних.
Монотонне версіонування — кожне повідомлення містить поточну версію сутності та її повний вміст. Система перевіряє, чи версія повідомлення вища за поточну версію сутності в базі даних. Якщо ні, повідомлення ігнорується, тим самим гарантуючи обробку лише новіших версій.
Наведена нижче діаграма ілюструє загальну ідею обох підходів.

Якщо проблемне повідомлення з «Перевірки базової версії» буде повторно оброблено, його буде успішно застосовано до цільової системи. У прикладі з «Монотонним версіонуванням» проблемне повідомлення не надсилається до DLQ, оскільки воно не містить жодної нової інформації та може бути безпечно проігнороване.
Кожен з цих механізмів має свої плюси й мінуси, і щоб зробити усвідомлений вибір, важливо розуміти ключові характеристики кожного.
- За підходу перевірки базової версії помилка при обробці одного повідомлення призводить до помилок обробки всіх наступних для тієї ж сутності, аж доки проблему не буде усунуто та всі проблемні повідомлення не будуть повторно відправлені на обробку.
- Монотонне версіонування передбачає відправлення повного вмісту сутності з кожним повідомленням, що може спричинити значні накладні витрати. Щоб їх зменшити, можна логічно розділити весь набір полів на кілька частин і призначити кожній з них окреме поле версії. У випадку змін в даних потрібно буде надсилати лише ті групи полів, в яких ці зміни відбулися. Логіка керування версіями в цьому варіанті залишається незмінною, але тепер застосовується на більш детальному рівні. Підхід з перевіркою базової версії не має такого обмеження.
- Підхід з перевіркою базової версії може використовувати будь-який генератор, якщо він гарантує унікальні версії для однієї й тієї ж сутності, тому навіть простий GUID підійде. На відміну від цього, монотонне версіонування потребує централізованого генератора зі збереженням поточного стану. Якщо рішення вже використовує реляційну базу даних, це не має бути проблемою, оскільки більшість реляційних баз даних підтримують послідовності (sequences) або пропонують спеціалізований тип стовпця для цієї мети, такий як «rowversion» у SQL Server. Якщо в базі даних немає подібної функціональності, для розв’язання проблеми можна використати зовнішні сервіси, такі як ZooKeeper або Redis. Однак використання таких сервісів додає складності, якщо вони ще не є частиною рішення.
Тут я хотів би наголосити, що звичайні часові мітки слід уникати для цілей версіонування сутностей, оскільки вони недостатньо точні для визначення порядку подій. Різні сервери можуть давати трохи різні часові мітки в той самий момент часу через відхилення годинників (clock skew), і роздільна здатність часових міток може бути недостатньою для надійного визначення порядку подій, що відбуваються дуже близько одна від одної.
- Обидва підходи запобігають обробці дублікатів повідомлень, але з перевіркою базової версії дублікати накопичуватимуться в DLQ замість того, щоб бути ігнорованими. Однак, якщо застосувати монотонно зростаючий генератор версій, перевірку базової версії можна розширити логікою ігнорування застарілих повідомлень, ефективно відкидаючи дублікати без засмічення DLQ.
- Підхід монотонного версіонування підходить лише для сценаріїв, коли головна система, що володіє сутністю, надсилає оновлення даних або команди своїм споживачам, оскільки тільки головна система може вказати нову версію сутності в повідомленні. Підхід з перевіркою базової версії дозволяє здійснювати комунікацію в обидві сторони. Коли споживачеві потрібно надіслати команду до головної системи, йому достатньо відправити базову версію. Головна система згенерує нову версію і надішле її назад усім споживачам таким самим способом через повідомлення.
Можуть бути й інші проблеми з порядком повідомлень і складніші способи їх вирішення. Але я хотів би підкреслити, що це питання дуже актуальне для будь-якого рішення, що використовує асинхронний обмін повідомленнями, і надати кілька базових рекомендацій, як його можна вирішити.
Наведені вище міркування можна узагальнити наступним чином:
- Завжди, коли у вас є Dead Letter Queue, існує висока ймовірність отримання повідомлень в неправильному порядку.
- Залежно від природи повідомлень особлива обробка порушень порядку може і не знадобитися, як у випадку з телеметрією.
- Версіонування — це типове рішення для запобігання неконсистентності даних, спричиненої проблемами з порядком повідомлень. Існує кілька варіантів його реалізації, і вибір залежить від багатьох факторів, розглянутих раніше в цьому розділі.
Керування контрактами
У світі HTTP REST такі інструменти, як Swagger та OpenAPI Specification, відіграють важливу роль у визначенні, документуванні та тестуванні API-контрактів. Вони допомагають розробникам стандартизувати визначення API, забезпечуючи узгодженість і прозорість у комунікації між різними сервісами та системами.
Однак асинхронна комунікація створює інший набір проблем. На відміну від синхронних API, де модель запит-відповідь проста, асинхронний обмін повідомленнями передбачає складнішу схему взаємодії. Повідомлення надсилаються без негайного очікування відповіді, а комунікація може охоплювати кілька систем, черг і брокерів.
Через цю складність керування контрактами для асинхронних API часто вимагало значних ручних зусиль. Розробникам доводилося ретельно документувати формати повідомлень і шаблони взаємодії та забезпечувати сумісність між різними компонентами системи. Цей ручний процес легко призводить до помилок і невідповідностей, що може ускладнити проблеми інтеграції та збільшити накладні витрати на технічне обслуговування.
Щоб розв’язувати ці проблеми та зменшити ймовірність помилок, краще використовувати спеціалізовані інструменти та фреймворки.
Найбільш повним фреймворком для керування асинхронними контрактами, доступним на цей чаc, є AsyncAPI. Він надає спільну «мову» та набір інструментів, що дозволяє розробникам визначати та документувати API у стандартизованому форматі. Це додатково спрощує створення документації, дозволяє генерувати код клієнта та сервера, а також покращує загальну сумісність та легкість обслуговування систем, які базуються на асинхронному обміні повідомленнями.
Навіть якщо ваш проєкт з будь-якої причини не готовий використовувати AsyncAPI, все одно важливо мати надійний і прозорий спосіб керування структурою повідомлень. CloudEvents — ще одна високорівнева специфікація, яка орієнтована на опис структури повідомлень у поширених форматах. Вона може спростити керування схемами повідомлень незалежним від технології способом, що дає можливість вільно перемикатися між форматами серіалізації в майбутньому.
Добре відомі двійкові формати, такі як Protobuf і Avro, за своєю суттю надають можливості для визначення схем повідомлень, які гарантують, що дані можуть бути легко перевірені, серіалізовані та десеріалізовані в різних системах. Ці формати також полегшують керування версіями та еволюцію схем, що робить їх надійним вибором для довгострокового обслуговування.
Для проєктів, що використовують повідомлення у форматі JSON, Pact.io теж буде корисним інструментом. Він призначений для тестування контрактів, керованих споживачем, що дозволяє командам визначати та перевіряти контракти між сервісами для забезпечення сумісності. Це допомагає підтримувати чітко визначені структури повідомлень і гарантує, що будь-які зміни в API не порушать існуючу функціональність.
Так чи інакше, управління контрактами має бути максимально ефективним.
Тестування
Тестування взаємодії між компонентами, що залежать від обміну повідомленнями, є складнішим завданням, ніж тестування набору компонентів, які взаємодіють синхронно. У цьому випадку автоматизовані інтеграційні тести є необхідними, причому всі компоненти, що беруть участь в бізнес-сценаріях, мають бути запущені.
Одним із найважливіших аспектів, що потребує уваги на додачу до регулярних тестів, є випадки, пов’язані з аномаліями доставки повідомлень:
- Що відбувається з повідомленнями, які неможливо обробити.
- Як обробляються дубльовані повідомлення.
- Як обробляються повідомлення, отримані в неочікуваному порядку.
Найпростіший спосіб реалізації такого роду тестів — це публікація зразкової серії проблемних повідомлень безпосередньо з тестового скрипта та очікування результатів.
Також важливо перевірити поведінку системи у випадку проблем з підключенням:
- Недоступність брокера під час публікації — переконатися, що повідомлення зрештою буде надіслано після відновлення з’єднання з брокером.
- Недоступність брокера між отриманням повідомлення споживачем та підтвердженням повідомлення — переконатися, що повідомлення зрештою буде оброблено повторно.
Ці умови зазвичай важче імітувати, і загалом є три рівні, на яких можна спричинити збої в підключенні:
- Код застосунку — він може містити спеціальні обробники, що симулюють відсутність підключення, якщо застосунку надано відповідні інструкції через конфігурацію або команди.
- Мережева інфраструктура — якщо ви можете контролювати мережеве середовище навколо ваших сервісів за допомогою firewall або service mesh, це може бути найгнучкішим підходом.
- Брокер повідомлень — ви можете за потреби зупиняти або перезапускати брокер повідомлень, хоча цей підхід може не підходити для хмарних сервісів.
Я б однозначно рекомендував останні два, оскільки перший підхід забруднює код програми. Строго кажучи, тестування поведінки системи в разі недоступності її окремих компонентів повинно проводитися незалежно від того, використовує вона асинхронний зв’язок чи ні. Однак через вищу складність асинхронних систем я вважаю таке тестування в цьому випадку ще важливішим.
Ще одна складність полягає в остаточній узгодженості (eventual consistency) асинхронних систем, де немає певного моменту, коли ваші умови точно будуть виконані. Відправник може надіслати команду споживачам, але час обробки цієї команди може відрізнятися залежно від споживача. Щоб врахувати це в автоматизованому тестуванні, в перевірку умов необхідно додати повтори та тайм-аути для обробки можливих затримок.
Деякі проєкти не використовують постійне середовище для інтеграційних тестів, а замість цього запускають усі компоненти рішення в контейнерах. Це дозволяє розгортати рішення будь-де незалежно від конкретного хмарного середовища. Такий підхід має свої переваги та недоліки, які виходять за рамки цієї статті, але слід зазначити, що деякі хмарні брокери повідомлень, такі як Azure Service Bus, наразі можуть ще не мати доступного контейнеризованого еквівалента. Тому, застосовуючи цей підхід для інтеграційного тестування, переконайтеся, що обраний вами брокер повідомлень можна запустити у контейнері.
Коли повідомлення використовуються для синхронізації даних між кількома системами, часто необхідно створити інструмент для порівняння даних, який періодично перевірятиме їхню узгодженість. Це може привносити значні труднощі через цілу низку факторів, але без подібного інструменту буде дуже складно забезпечити надійність синхронізації даних.
Висока доступність та аварійне відновлення
Кожен сучасний популярний брокер повідомлень має можливості для реалізації високої доступності та аварійного відновлення, хоча складність їх налаштування та обмеження можуть відрізнятися.
Перша проблема високої доступності та аварійного відновлення полягає в тому, що ті, хто додає брокер повідомлень у рішення, можуть не повністю враховувати пов’язані зусилля з налаштування та обслуговування. Досить легко запустити Kafka або будь-який інший брокер локально за допомогою загальнодоступного образа Docker, але налаштування конфігурації для не-PaaS брокера в продакшені та її підтримка є зовсім іншим завданням. Тому завжди враховуйте операційну складність технології при прийнятті подібних рішень.
Хмарні рішення зазвичай мають вбудоване резервування (redundancy), підтримують аварійне перемикання на інший регіон і загалом значно простіші у налаштуванні. Однак я наполегливо рекомендую уважно прочитати документацію про підтримувані можливості та обмеження. Кілька років тому я розробляв архітектуру для системи, яка використовувала Azure Service Bus і мала бути розгорнута у кількох регіонах. Тоді я був здивований, виявивши, що не існує вбудованого механізму для реплікації повідомлень між регіонами. В результаті, якщо якийсь регіон виходив з ладу, всі необроблені повідомлення в ньому втрачалися.
На той момент мені довелося переглянути дизайн, але, схоже, проблему нарешті вирішили та геореплікація для Azure Service Bus вже доступна, хоч і у preview.
Іншою поширеною помилкою є відкладання конфігурації з високою доступністю на майбутнє й обмеження її лише продакшеном. Зазвичай це робиться для економії коштів, але часто в кінцевому результаті виявляється дорожчим через можливі неприємні наслідки. Рішення має бути ретельно протестоване у конфігурації з високою доступністю перед запуском у продакшен, і команда повинна набути досвіду як у налаштуванні, так і в її підтримці. Можливо, навіть код доданку потребуватиме змін для підтримки конфігурації брокера з високою доступністю.
Важливо розуміти складність реалізації високої доступності та аварійного відновлення для брокера повідомлень і забезпечувати належне планування та підготовку задовго до запуску системи в продакшен.
Компроміс між затримкою та пропускною здатністю
При використанні асинхронних систем комунікації, таких як брокери повідомлень, одним із важливих компромісів є баланс між затримкою (latency) та пропускною здатністю (throughput). Цей баланс є дуже важливим для забезпечення оптимальної роботи інфраструктури обміну повідомленнями.
Затримка (latency) означає час, необхідний для передачі повідомлення від відправника до одержувача. Низька затримка є важливою в системах, де необхідна обробка в реальному або майже реальному часі, таких як платформи для біржових торгів або системи живого спілкування. Однак досягнення низької затримки вимагає приоритезації швидкої доставки повідомлень, що може обмежити загальну пропускну здатність системи.
Пропускна здатність (throughput) означає кількість повідомлень, які система може обробити за певний період часу. Висока пропускна здатність є життєво важливою для проєктів, що працюють з великими об’ємами даних, таких як системи збору телеметрії або рішення для онлайн-аналітики.
Існує велика ймовірність того, що налаштування за замовчуванням не відповідатимуть вашим потребам щодо затримки та пропускної здатності. Щоб досягти бажаного рівня продуктивності, вам потрібно буде змінити або конфігурацію брокера, або код вашого клієнта, або і те, і інше. Кожна технологія має свої власні способи керування цими параметрами, тому за конкретними рекомендаціями зверніться до офіційної документації або інших статей на цю тему.
Технологічна прив’язка та відсутність абстракції
Розробники зазвичай намагаються максимально абстрагувати конкретний рушій бази даних, що є правильною практикою. Але чомусь це не завжди стосується брокерів повідомлень. Я бачив кілька проєктів, де додавали мінімальний шар абстракції лише для спрощення тестування. Це дозволяло використання заглушок для відправника або отримувача, але деякі деталі реалізації, специфічні для брокера, все ж просочувалися за межі абстракції. Однак мені відомі проєкти, які мігрували з одного брокера повідомлень на інший; один з них навіть пройшов таку міграцію двічі.
Якщо ви не будуєте рішення з наднизькою затримкою, завжди краще використовувати бібліотеки, які додають рівень абстракції, щоб спростити тестування, зменшити обсяг шаблонного коду та мінімізувати зусилля, необхідні для міграції з однієї платформи обміну повідомленнями на іншу. Як приклад можу навести наступні бібліотеки:
- Для .NET — Rebus, MassTransit, NServiceBus.
- Для Java — JMS (Java Message Service), Apache Camel, Spring Integration. Зверніть увагу, що JMS є лише специфікацією API для обміну повідомленнями, тоді як Apache Camel і Spring Integration є повноцінними фреймворками з вбудованими можливостями для маршрутизації повідомлень, трансформації та інших абстракцій вищого рівня поверх базового API для обміну повідомленнями.
Я думаю, що пошук еквівалентів для інших технологічних стеків не має бути проблемою, але пам’ятайте, що будь-яка така бібліотека підтримує лише обмежений набір брокерів повідомлень.
Висновки
Якщо ви розглядаєте можливість впровадження асинхронного обміну повідомленнями у ваше рішення, ретельно зважте, чи дійсно його переваги перекривають виклики, обговорені в цій статті. Існують ситуації, коли без нього можна обійтися, наприклад:
- Коли сервісу потрібно реагувати на події з іншого сервісу, веб-хуки або періодичне опитування (polling) можуть бути прийнятними альтернативами. Але майте на увазі, що часті опитування можуть призвести до проблем із продуктивністю.
- Якщо вам потрібно просто запустити довготривале завдання та відреагувати на його завершення, може бути достатньо підходу, що використовується в шаблоні REST API для довготривалих завдань.
- Коли сервісу потрібно надіслати дані до основної системи, і асинхронна комунікація розглядається лише для забезпечення швидкості роботи цього сервісу, добре подумайте. Можливо, продуктивність основної системи не така вже й погана, і ви можете дозволити собі передавати дані синхронно. Що важливіше, асинхронні оновлення основної системи можуть призвести до конфліктів і потреби у складній логіці їх вирішення.
- Якщо вам потрібна черга вхідних даних для їх послідовної обробки, залежно від масштабу вашої системи, може бути достатньо простої таблиці у звичайній базі даних, яка періодично опитується процесорами даних.
Деякі з цих альтернатив все ще можуть вимагати розв’язання певних проблем, розглянутих у статті, але не всіх одночасно.
І якщо вам так чи інакше потрібен асинхронний обмін повідомленнями, врахуйте всі згадані підводні камені, щоб оцінити та реалізувати рішення належним чином.
Ця стаття доступна також англійською на Medium.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
28 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів