Павло Щербуха

Logo

Персональна освітня сорінка

Розробка на Node.js, VUE.js, Python, IBM Integration Bus (App Connect Ent) , ORACLE PL/SQL
28 June 2025

Як використати архітектурний шаблон Retry

by Pavlo Shcherbukha

1. Про що цей блог

Виникла цікава оптимізаційна архітектурна задачка. Може я дарма над нею задумався. Сама задачка полягає в тому що:

2. Короткий огдяд рішень від “великих квадратів”

Якщо намалювати діаграму “великів квадартів”, що за звичай розглядають на великих екранах за довгими столами, то виглядадтиме це приблизно так. І НЕ ПРАВИЛЬНО. ВОНО НЕ ПРАЦЕЗДАТНЕ. Це стиль думання монолітом.

pic-204

MermaId діаграма тут буде діаграмам
graph TD ES1["Зовнішній Сервіс 1"] -- "HTTP POST" --> MyApp["Наш додаток"] MyApp -- "Збереження даних" --> DB["База Даних"] DB -- "Читає та збагачує дані " --> MyApp MyApp -- "Відправляє отримані дані" --> ES3["Зовнішній Сервіс 3 (Ненадійний Endpoint)"]

Виникає питання чому?

На цій діаграмі відстні елементи взаємодії пор http.

Якщо “Наш додаток” почне http взаємодію з “Зовнішній Сервіс 3” не закривши з’єднання з “Зовнішній Сервіс 1”, то “Зовнішній Сервіс 1” отримає помилку, що іеіційована http взаємодією з “Зовнішній Сервіс 3”.

Якщо “Наш додаток” почне http взаємодію закривши з’єднання з “Зовнішній Сервіс 1”, але отримає помилку - то вона запишеться десь в логи і ми будемо решгулярно дивитися в ті логи і казати, що: “у вас сервіс не достпний”. А знайти причину не доступності буде досить складно - тому що вона часто криється на мережевому рівні.

А кщо у вас “Зовнішній Сервіс 3” взагалі “ліг” на кілька днів, то треба або зупинити додаток “Наш додаток”, або бути готовим читати велику кількість логів з помилками, взагалі то і не портібних. Але вони будуть споживати дисковий простір, пам’ять та процесорний ресурс. Але, якщо ви зупините додаток, то буде не заслужено страждати “Зовнішній Сервіс 1”, тому що він стукається до “Наш додаток”, а тм нічого немає.

І так складається такий собі ланцюжок неприємностей. Тому на великих екранах за довгими столами вже навчилися малювати жирну лінію і казати що отут буде черга.

pic-205

MermaId діаграма
graph TD subgraph "Взаємодія із Зовнішнім Сервісом 1" ES1["Зовнішній Сервіс 1"] -- "HTTP POST" --> MyApp["Наш додаток отримувач даних"] MyApp -- "Збереження даних" --> DB["База Даних"] DB -- "Читає та збагачує дані " --> MyApp MyApp -- "Відправляє отримані дані" --> MessageQueue["Черга повідомлень для зовнішнього сервіса"] end subgraph "Взаємодія із Зовнішнім Сервісом 3" MessageQueue -- "Отримує повідмолення" --> MyAppConsumer["Наш додаток-споживач повідомлень"] MyAppConsumer -- "Відправляє повідомлення по http" --> ES3["Зовнішній Сервіс 3 (Ненадійний Endpoint)"] end

На цій діаграмі моноліт ““Наш додаток” розбито на два компоненти:

По суті це два не залежні компоненти. Вони зв’язані між собою “м’яким зв’язком”. Тобто, зупинка компонента “Наш додаток-споживач повідомлень” - ніяким чином не вплине на “Наш додаток отримувач даних”. Просто в черзі будуть накопичуватися повідомлення для “Зовнішній Сервіс 3”. А от коли “Наш додаток-споживач повідомлень”запустять, то “Зовнішній Сервіс 3” отримає всі повідмолення, що накопичилися за час простою. Вже трошки краще, але всерівно не зрозуміло, коли даємо відповідь “Зовнішній Сервіс 1” по http. Так як “Зовнішній Сервіс 3” є за визначенням не надійним, то при короткострокових проблемах інфраструктури, чи “Зовнішній Сервіс 3” раптом “втомиться” - то всі ці повідомлення вилетять тільки в лог з помилками. Знову ж таки хорошого в цьому мало, тому, що копирасатися в логах і пояснювати власникам “Зовнішній Сервіс 3”, чому по таймаут (проблеми мережі) нічого не дішло - теж справа безнадійна, особливо коли немає впливу на інфраструктуру. І по діаграмі не зрозуміло, а що робити коли Зовнішній Сервіс 3” почне відповідати помилками.

Отут і настає привід для вивчення і використання архітектурних шаблонів, а саме для цього і використовують 3. Сценарій 2: Асинхронна обробка з можливістю тимчасових помилок (Банківські платежі).

3. Приблизна архітектура Retry pattern на ACE та IBM MQ

Перш ніж малювати архітектуру, спробую розказати, як це прауює в IBM MQ. Найпростіший та рекомендований варіант Retry Pattern, це варіант мінімально вимагає додаткової логіки в ACE, покладаючись на вбудовані можливості IBM MQ. Для цього був зроблений прототипчик. На pic-201 показано flow, що завантажу JSON повідомлення в чергу psh.in2. Це так би мовити основна черга.

На pic-202 показано flow, що вичитує JSON з чреги psh.in2 та пробує відправити його по http. Якщо виникне помилка, то повідомлення буде повернуто в чергу psh.in2.

pic-201

pic-202

Якщо на рівні MQManager не зробити спеціальних налаштувань то повідомленя повернеться в чергу psh.in2 і так буде до повторюватися до тих пір, поки не досягне кількості спроб, щоб влетіти в системну чергу мертвих повідомлень (Dead-Letter Queue - SYSTEM.DEAD.LETTER.QUEUE) менеджера черг, якщо вона налаштована.

Якщо ж ми налаштуємо BackOutQueue то працює це дещо по іншому.

Налаштування черги BackOut показано на pic-203.

pic-203

Цей же самий принцип використаний і в основній задачці. Архітектурна діаграма та її опис наведено нижче

pic-206

MermaId діаграма
graph TD subgraph "Взаємодія із Зовнішнім Сервісом 1" ES1["Зовнішній Сервіс 1"] -- "HTTP POST" --> MyAPI["Наш API (Приймальний Сервіс)"] MyAPI -- "Збереження даних" --> DB["База Даних"] DB -- "Читає та збагачує дані" --> MyAPI MyAPI -- "Публікує повідомлення" --> MessageQueue["Черга Повідомлень (IBM MQ)"] MyAPI -- "HTTP 200 OK" --> ES1 end subgraph "Обробка та Доставка Даних (Наша Система)" MessageQueue -- "Споживає повідомлення (Асинхронно)" --> RetryWorker["Воркер Retry (IBM ACE Flow)"] RetryWorker -- "HTTP POST (Може провалитись)" --> ES3["Зовнішній Сервіс 3 (Ненадійний Endpoint)"] subgraph "Механізм Retry для ES3" RetryWorker -- "Короткочасний збій/Retry" --> MessageQueue MessageQueue -- "Якщо перевищено ліміт спроб (BOTHRESH)" --> DLQ["Черга Dead-Letter (IBM MQ BACKOUT.QUEUE)"] DLQ -- "Моніторинг/Ручне втручання/Переміщення" --> Human["Оператор / Моніторинг"] end end style MyAPI fill:#f9f,stroke:#333,stroke-width:2px style DB fill:#ccf,stroke:#333,stroke-width:2px style MessageQueue fill:#bbf,stroke:#333,stroke-width:2px style RetryWorker fill:#fcf,stroke:#333,stroke-width:2px style ES3 fill:#f66,stroke:#333,stroke-width:2px style DLQ fill:#fbb,stroke:#333,stroke-width:2px

Пояснення архітектурної діаграми (“великих квадратів”):

"External Service 1 Interaction" (Взаємодія із Зовнішнім Сервісом 1):

    Зовнішній Сервіс 1 (ES1): Відправляє дані на наш API.

    Наш API (Приймальний Сервіс): Отримує дані, успішно їх обробляє (зберігає в базу даних), вичитує їх і трасформує та закидає повідомелння з даними в чергу і негайно відповідає ES1 зі HTTP 200 OK. Це робить взаємодію з ES1 синхронною та швидкою, незважаючи на подальшу обробку.

    База Даних (DB): Зберігає отримані дані. Це перше місце, де дані стають "безпечними" і зберігаються.

"Data Processing & Delivery (Our System)" (Обробка та Доставка Даних - Наша Система):

    Черга Повідомлень (Напр. IBM MQ): Це ключовий елемент архітектури. Замість прямого виклику ненадійного сервісу 3,  Воркер Retry асинхронно відправляє підготовлене повідомлення до черги.

        Перевага: Черга виступає як буфер.

    Воркер Retry (IBM ACE Flow): Це окремий компонент (у моєму випадку, це  ACE Flow), який споживає повідомлення з черги. Він відповідає за фактичну відправку повідомлення на Зовнішній Сервіс 3.

        Оскільки він працює асинхронно, він може мати власну внутрішню логіку retry.

    Зовнішній Сервіс 3 (Ненадійний Endpoint): Це той самий "вередливий" сервіс, який може мати короткочасні та довготривалі проблеми.

"Retry Mechanism for ES3" (Механізм повторних спроб для Сервісу 3):

    Короткочасний збій/Retry: Якщо RetryWorker не може достукатися до ES3 (мережа, таймаут, перший запит не прийнятий), він відкатує транзакцію (викидає Exception). Повідомлення повертається до тієї ж Черги Повідомлень, але з лічильником відкатів, і чекає на BOINTERVAL перед наступною спробою.

    Черга Dead-Letter (DLQ - Напр. IBM MQ BACKOUT.QUEUE): Якщо повідомлення перевищує заданий ліміт спроб (ваші BOTHRESH), воно автоматично переміщується до DLQ.

    Оператор / Моніторинг: DLQ є сигналом про серйозніші проблеми. Оператори або системи моніторингу повинні стежити за цією чергою, щоб виявляти постійно проблемні повідомлення та втручатися вручну (наприклад, виправити дані, перевірити доступність ES3, перемістити повідомлення для повторної обробки вручну).

Ця архітектура чітко розділяє синхронну відповідь першому сервісу від асинхронної, стійкої доставки даних третьому сервісу, компенсуючи його ненадійність за рахунок буферизації та механізмів retry в черзі повідомлень. Це класичний приклад шаблону “Producer-Consumer” з додатковими шарами стійкості.

Додаткові міркування

Треба мати на увазі, що час очікування повторної спроби retry  налаштовується на рівні MQManager, 
тому, в цей час інші повідомлення в черзі оброблятися не будуть, якщо не виконати додаткових дій. 
Найпростіша дія, це запустити кілька додаткових  екземплярів цього ж обробника вказавши при 
deployment ACE Flow кількість додаткових екземплярів. 

І тут додатково: якщо це відмова третього сервісу - 
то кілька обробників просто швидко зібльшать кількість поввідомлент в  BACKOUT.QUEUE.

Якщо ж є велика вірогідність, що в черзі можуть бути "отруйні" повідмолення - то в цьому випадку 
такий підхід допоможе тому що поки один екземпляр  "мучиться" з "отруйним" повідомленням, 
інші екземпляри успішно оброблять нормальні. 

Прочитавши цю архітектуру, виникає бажання додати ще один flow, що через якийсь період 
часу переклажає повідомлення з BACKOUT.QUEUE в основну INPUT.QUEUE на повторну обробку. 

В деяких випадках це допустимо і мені не раз приходиилося це робити. 
Але виникає інша проблема: Між кількома "нормальними" повідомленнями  у вас буде лежати 
пачка "отруйних" повідомлень з  BACKOUT.QUEUE, що зразу ж вплине на швидкість обробки 
"нормальних" повідомлень.  

Ці "отруйні" повідомлення будуть курсувати між  BACKOUT.QUEUE  та INPUT.QUEUE безкінечну кількість разів. 

В стандартному варіанті немає можливості в IBM MQ  зберегти власні лічильники чи власні timestampts - 
як це можна зробти в RabbitMQ. Тому такий флов, якщо і потрібен, так тільки для того, щоб один раз 
запустити його вручну, адміністраторм, для разового перекладання повідромлень.

Є сенс подумати про Flow  який буде переглядати чергу BACKOUT.QUEUE та надсилати повідомлення суппорт, адміністраторам чи логувати специфічним чином наявність повідомлень в BACKOUT.QUEUE.

Додаткові поля в повідомлення IBM MQ  можна додати, якщо IBM MQManager 
налаштований на приймання та оборобка RFH2  заголовків. Але щось мені ні 
разу не зустрічалося такого MQManager. Тому в більшості випадків це не реально.  

4. Приблизна архітектура рішення на Node.js та RabbitMQ

Тут наведена діаграма тої ж архітектури, тільки якби її реалізовували на RabbitMQ та з Node.js чи Pyhton. Можна проаналізувати і її. Відверто кажучи - цей варіант мені здається більш зрозумілим і “красивим”.

pic-207

MermaId діаграма
graph TD subgraph "Взаємодія із Зовнішнім Сервісом 1" ES1["Зовнішній Сервіс 1"] -- "HTTP POST" --> MyAPI_Node["Наш API (Node.js)"] MyAPI_Node -- "Збереження даних" --> DB_Node[(База Даних)] MyAPI_Node -- "HTTP 200 OK" --> ES1 end subgraph "Обробка та Доставка Даних (Наша Система Node.js + RabbitMQ)" DB_Node -- "Тригер/API-виклик/Воркер" --> DataProducer_Node["Node.js Data Producer"] DataProducer_Node -- "JSON Трансформація та Збагачення" --> DataProducer_Node DataProducer_Node -- "Публікує повідомлення" --> RabbitMQ["Брокер повідомлень (RabbitMQ)"] subgraph "RabbitMQ Retry Mechanism" RabbitMQ -- "Відправляє до" --> OriginalQueue["Основна Черга (Напр. 'data.to.es3')"] OriginalQueue -- "Споживає" --> Worker_Node["Node.js Worker (для ES3)"] Worker_Node -- "HTTP POST (Може провалитись)" --> ES3_Node["Зовнішній Сервіс 3 (Ненадійний Endpoint)"] Worker_Node -- "Відхилення (NACK)" --> OriginalQueue OriginalQueue -- "Якщо NACKed/TTL Expired" --> DLX["Dead Letter Exchange (DLX)"] DLX -- "Маршрутизує до" --> RetryQueue["Retry Черга (З TTL для затримки)"] RetryQueue -- "TTL минає" --> DLX --> OriginalQueue RetryQueue -- "Якщо перевищено retry ліміт" --> FailedQueue["Failed/Parked Черга"] FailedQueue -- "Моніторинг/Ручне втручання" --> Human_Node["Оператор / Моніторинг"] end end style MyAPI_Node fill:#f9f,stroke:#333,stroke-width:2px style DB_Node fill:#ccf,stroke:#333,stroke-width:2px style RabbitMQ fill:#A0D468,stroke:#333,stroke-width:2px style OriginalQueue fill:#E4E6EA,stroke:#333,stroke-width:2px style Worker_Node fill:#fcf,stroke:#333,stroke-width:2px style ES3_Node fill:#f66,stroke:#333,stroke-width:2px style DLX fill:#ADD8E6,stroke:#333,stroke-width:2px style RetryQueue fill:#FFB6C1,stroke:#333,stroke-width:2px style FailedQueue fill:#E6B0AA,stroke:#333,stroke-width:2px

Пояснення архітектурної діаграми (Node.js + RabbitMQ):

"Взаємодія із Зовнішнім Сервісом 1":

    Наш API (Node.js): Залишається аналогічним, приймає запит, зберігає в базу даних і відповідає HTTP 200 OK. Node.js тут виступає як ефективна платформа для швидких I/O операцій.

    База Даних (DB): Зберігає дані.

"Обробка та Доставка Даних (Наша Система Node.js + RabbitMQ)":

    Node.js Data Producer: Цей Node.js воркер читає дані з DB (або активується тригером), збагачує та трансформує їх.

    Брокер повідомлень (RabbitMQ): Замість IBM MQ, тут використовується RabbitMQ. Producer публікує повідомлення в RabbitMQ.

"RabbitMQ Retry Mechanism" (Механізм Retry в RabbitMQ):

    Основна Черга ('data.to.es3'): Повідомлення спочатку надходить сюди.

    Node.js Worker (для ES3): Це Node.js додаток (споживач), який читає повідомлення з 'Основної Черги'. Він виконує HTTP POST запит до Зовнішнього Сервісу 3.

    Зовнішній Сервіс 3 (Ненадійний Endpoint): Той самий проблемний сервіс.

    Механізм Retry в RabbitMQ (основна відмінність):

        Відхилення (NACK): Якщо 'Node.js Worker' не може доставити повідомлення до ES3 (через збій, таймаут тощо), він відхиляє повідомлення (NACK) з опцією requeue: false. Це запобігає негайному поверненню повідомлення в оригінальну чергу.

        Dead Letter Exchange (DLX) і Dead Lettering: 'Основна Черга' конфігурується з аргументом x-dead-letter-exchange. Коли повідомлення відхиляється, воно автоматично перенаправляється в DLX.

        Retry Черга (З TTL для затримки): DLX маршрутизує повідомлення до спеціальної 'Retry Черги'. Ця 'Retry Черга' налаштована з x-message-ttl (Time-To-Live) та x-dead-letter-exchange, що вказує на той самий DLX, з якого повідомлення прийшло.

            Як це працює: Повідомлення перебуває в 'Retry Черзі' протягом встановленого TTL. Після закінчення TTL, воно "dead-lettered" назад у DLX, який потім маршрутизує його назад до 'Основної Черги'. Це створює цикл повторних спроб із затримкою.

        Якщо перевищено retry ліміт: 'Node.js Worker' (або логіка в 'Retry Черзі' через x-max-length) може вести власний лічильник спроб (наприклад, у заголовках повідомлення). Якщо лічильник досягає певного порогу, повідомлення переміщується до 'Failed/Parked Черги'.

        Failed/Parked Черга: Це аналог BACKOUT.QUEUE в IBM MQ. Сюди потрапляють повідомлення, які не вдалося обробити після всіх спроб.

        Оператор / Моніторинг: Як і раніше, ця черга потребує моніторингу та, можливо, ручного втручання.

Ключові відмінності порівняно з IBM MQ + ACE:

Розподіл функціоналу Retry: У RabbitMQ механізм затримки та перенаправлення для retry (через TTL, DLX та окрему Retry Queue) є більш явним і налаштовується на рівні RabbitMQ. В IBM MQ це відбувається через BOINTERVAL та BOTHRESH на самій вхідній черзі.

Управління лічильником спроб: У RabbitMQ часто доводиться реалізовувати логіку лічильника спроб (наприклад, у заголовках повідомлення) у самому воркері Node.js. У IBM MQ MQMD.BackoutCount є вбудованим і автоматично підтримується менеджером черг.

Гнучкість Retry-сценаріїв: RabbitMQ, з його DLX/TTL механізмом, надає більшу гнучкість для створення складних retry-графіків (наприклад, експоненціальна затримка) без потреби в окремих воркерах, які постійно повертають повідомлення.

Стек технологій: Відхід від корпоративного стеку IBM до більш "легковажних" рішень Node.js та RabbitMQ.

5. Висновки

Які висновки з наведених прикладів можна зробити.

tags: