⏱️ Розділ 19 · Питання #28

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

У мікросервісній архітектурі агрегаторам (API Gateway, Backend-For-Frontend) часто потрібно зібрати дані з декількох незалежних сервісів (профіль користувача, історія замовлень,...


🟢 Junior Level

У мікросервісній архітектурі агрегаторам (API Gateway, Backend-For-Frontend) часто потрібно зібрати дані з декількох незалежних сервісів (профіль користувача, історія замовлень, баланс бонусів).

Чому послідовні виклики — антипатерн

Якщо виконувати запити послідовно (один за одним), загальний час відповіді дорівнює сумі затримок усіх сервісів:

// ❌ Послідовно: загальний час = 200мс + 300мс + 150мс = 650мс
User user = userService.getUser(userId);              // 200 ms
List<Order> orders = orderService.getOrders(userId);   // 300 ms
Bonus bonus = bonusService.getBonus(userId);          // 150 ms

Паралельне виконання з CompletableFuture.allOf()

При паралельному запуску запити виконуються одночасно. Загальний час відповіді дорівнює часу відповіді найповільнішого сервісу ($\max(200, 300, 150) = 300\text{мс}$):

// ✅ Паралельно: загальний час = max(200, 300, 150) = 300 ms (більш ніж у 2 рази швидше)
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(
    () -> userService.getUser(userId), customExecutor
);
CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(
    () -> orderService.getOrders(userId), customExecutor
);
CompletableFuture<Bonus> bonusFuture = CompletableFuture.supplyAsync(
    () -> bonusService.getBonus(userId), customExecutor
);

// Очікуємо завершення всіх паралельних викликів:
CompletableFuture.allOf(userFuture, ordersFuture, bonusFuture).join();

// Збираємо підсумковий DTO (виклики join() тут миттєві, оскільки дані вже отримані):
UserProfileDto profile = new UserProfileDto(
    userFuture.join(),
    ordersFuture.join(),
    bonusFuture.join()
);

🟡 Middle Level

1. Архітектурний чек-лист для надійного Scatter-Gather

Паралельні виклики в production вимагають дотримання чотирьох обов’язкових правил:

  1. Ізольований пул потоків (Bulkhead): Ніколи не використовуйте ForkJoinPool.commonPool(). Повільний або завислий мікросервіс займе всі системні потоки та паралізує роботу всього застосунку.
  2. Жорсткі таймаути: Кожен мережевий запит зобов’язаний мати ліміт за часом (.orTimeout() або таймаут HTTP-клієнта).
  3. Graceful Degradation (Fallback): Збій другорядного сервісу (наприклад, блоку рекомендацій або відгуків) не повинен ламати всю відповідь користувачеві.
  4. Неблокуючий HTTP-клієнт: Використання блокуючого RestTemplate усередині supplyAsync витрачає потік пулу на очікування байтів із сокета. Краще використовувати java.net.http.HttpClient або WebClient.

2. Реалізація з Fallback та таймаутами

@Service
public class AggregationService {

    private final UserClient userClient;
    private final OrderClient orderClient;
    private final RecommendationClient recClient;
    private final Executor microserviceExecutor;

    public CompletableFuture<DashboardDto> getDashboardAsync(Long userId) {
        // Критичний сервіс: при збої або таймауті повертаємо помилку
        CompletableFuture<User> userFuture = userClient.getUserAsync(userId, microserviceExecutor)
            .orTimeout(2, TimeUnit.SECONDS);

        // Критичний сервіс із таймаутом
        CompletableFuture<List<Order>> ordersFuture = orderClient.getOrdersAsync(userId, microserviceExecutor)
            .orTimeout(3, TimeUnit.SECONDS);

        // Некритичний сервіс: при помилці/таймауті віддаємо порожній список (graceful fallback)
        CompletableFuture<List<Recommendation>> recsFuture = recClient.getRecommendationsAsync(userId, microserviceExecutor)
            .orTimeout(1, TimeUnit.SECONDS)
            .exceptionally(ex -> {
                log.warn("Recommendation service failed or timed out: {}", ex.getMessage());
                return Collections.emptyList(); // Деградація сервісу
            });

        // Комбінуємо результати
        return CompletableFuture.allOf(userFuture, ordersFuture, recsFuture)
            .thenApply(v -> new DashboardDto(
                userFuture.join(),
                ordersFuture.join(),
                recsFuture.join()
            ));
    }
}

🔴 Senior Level

1. Проблема втрати контексту (MDC / Tracing) між потоками

Головна прихована загроза асинхронних викликів у мікросервісах — втрата контексту розподіленого трасування (TraceId, SpanId) та контексту безпеки (SecurityContext).

Фреймворки логування (Logback, SLF4J MDC) використовують ThreadLocal. При передачі завдання в пул потоків через supplyAsync робочий потік бере завдання з черги з порожнім MDC, і логи запиту розпадаються на незв’язані фрагменти.

Рішення: Декорування Executor

public class MdcDecoratingExecutor implements Executor {
    private final Executor delegate;

    public MdcDecoratingExecutor(Executor delegate) {
        this.delegate = delegate;
    }

    @Override
    public void execute(Runnable command) {
        // 1. Захоплюємо контекст поточного (викликаючого) потоку:
        Map<String, String> contextMap = MDC.getCopyOfContextMap();

        delegate.execute(() -> {
            // 2. Встановлюємо контекст у робочому потоці:
            Map<String, String> previous = MDC.getCopyOfContextMap();
            if (contextMap != null) {
                MDC.setContextMap(contextMap);
            } else {
                MDC.clear();
            }
            try {
                command.run();
            } finally {
                // 3. Очищаємо контекст, щоб не отруїти потік із пулу для наступного завдання:
                if (previous != null) {
                    MDC.setContextMap(previous);
                } else {
                    MDC.clear();
                }
            }
        });
    }
}

2. Блокуючий I/O vs Неблокуючий I/O (NIO)

Часта помилка — запускати 100 запитів RestTemplate через CompletableFuture.supplyAsync:

RestTemplate в supplyAsync:
[ Thread 1 ] ===> Відправив TCP ===> [СПИТЬ І ЧЕКАЄ БАЙТИ 500мс] ===> Десеріалізація
* При 200 запитах потрібно 200 потоків ОС!

NIO HttpClient (java.net.http.HttpClient.sendAsync):
[ Thread ] ===> Реєстрація в Selector/epoll ===> Потік ОС вільний!
                                                     │ (пакет прилетів із мережі)
                                                     ▼
Selector будить робочий потік для обробки тіла відповіді.

При високих навантаженнях завжди використовуйте неблокуючий клієнт:

HttpClient httpClient = HttpClient.newBuilder()
    .executor(clientWorkers)
    .connectTimeout(Duration.ofSeconds(1))
    .build();

CompletableFuture<String> responseFuture = httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
    .thenApply(HttpResponse::body);

3. Java 21: Structured Concurrency (StructuredTaskScope)

У Java 21 з’явилася альтернатива складним графам CompletableFuture для паралельних запитів — Structured Concurrency:

public DashboardDto getDashboardStructured(Long userId) throws InterruptedException, ExecutionException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        // Запуск підзавдань у легковагих віртуальних потоках:
        Supplier<User> userSub = scope.fork(() -> userClient.getUser(userId));
        Supplier<List<Order>> ordersSub = scope.fork(() -> orderClient.getOrders(userId));
        Supplier<Bonus> bonusSub = scope.fork(() -> bonusClient.getBonus(userId));

        // Очікуємо завершення всіх або скасовуємо всі при першій помилці (Fail-fast):
        scope.join();
        scope.throwIfFailed(ExecutionException::new);

        // Повертаємо результат
        return new DashboardDto(userSub.get(), ordersSub.get(), bonusSub.get());
    }
}

Переваги StructuredTaskScope:

  • Якщо впав userClient, ShutdownOnFailure миттєво скасовує виклики до orderClient і bonusClient, заощаджуючи мережеві та серверні ресурси.
  • Термін життя потоків суворо обмежений блоком try-with-resources (немає витоків фонових завдань).

4 Tricky Questions

1. Що відбувається з контекстом трасування (TraceId, SpanId, MDC) при паралельних запитах через CompletableFuture, і як його не втратити?

Відповідь: Оскільки MDC (Mapped Diagnostic Context) реалізований на базі ThreadLocal, змінні контексту прив’язані до конкретного потоку ОС. Коли підзавдання передається в Executor через supplyAsync або thenApplyAsync, воно виконується іншим потоком із пулу, у якого ThreadLocal порожній. У результаті в логах зникає traceId, і розподілене трасування розривається.

Для вирішення цієї проблеми необхідно переносити контекст:

  1. Обернути Executor у декоратор, який копіює мапу MDC викликаючого потоку (MDC.getCopyOfContextMap()) і відновлює її в потоці пулу перед запуском Runnable з обов’язковим очищенням у finally.
  2. У Spring Framework використовувати TaskDecorator на рівні ThreadPoolTaskExecutor.
  3. Використовувати спеціалізовані бібліотеки контексту, такі як Micrometer Context Propagation (ContextRegistry та ContextSnapshot).

2. Якщо один із п’яти паралельних мікросервісів впав із помилкою 500, чи перериваються інші чотири в CompletableFuture.allOf()?

Відповідь: Ні, не перериваються. Метод CompletableFuture.allOf(f1, f2, f3, f4, f5) очікує на завершення абсолютно всіх переданих стадій незалежно від того, завершилися вони успішно чи аварійно.

Навіть якщо перший сервіс упаде через 10 мс, а п’ятий сервіс зависне на 10 секунд, allOf() чекатиме повні 10 секунд. Лише після фізичного завершення всіх п’яти стадій allOf() перейде у стан збою і викине CompletionException. Для реалізації патерну Fail-Fast (миттєвого скасування інших завдань при першій помилці) потрібно або писати власний координатор промісів, або використовувати Java 21 StructuredTaskScope.ShutdownOnFailure().

3. У чому різниця між викликом CompletableFuture.supplyAsync(() -> restTemplate.getForObject(...)) і httpClient.sendAsync(...)?

Відповідь: Різниця полягає в блокуванні потоків ОС:

  • CompletableFuture.supplyAsync(...) з блокуючим клієнтом (RestTemplate або Feign) виділяє робочий потік пулу, який висить заблокованим увесь час, поки пакет іде мережею і сервер формує відповідь (I/O Wait). Якщо 100 одночасних запитів чекають на відповідь по 2 секунди, вам знадобиться 100 фізичних потоків ОС, які простоюють марно.
  • httpClient.sendAsync(...) (Java 11 HttpClient) використовує неблокуючий I/O на основі NIO Selector (epoll у Linux). Метод миттєво відправляє мережевий запит і звільняє викликаючий потік. Поки пакет іде мережею, процесор і потоки застосунку вільні. Системний потік задіюється лише на частки мілісекунди, коли з сокета надійдуть готові байти відповіді.

4. Як захистити систему від лавинного падіння, якщо один з опитуваних мікросервісів почав відповідати за 10 секунд замість 200 мілісекунд (Cascading Failure)?

Відповідь: Необхідно застосувати комплекс захисних патернів:

  1. Жорсткий таймаут (Client Timeout): налаштування таймауту з’єднання та читання (connectTimeout, readTimeout) в HTTP-клієнті, а також виклик .orTimeout(Duration) на стадії CompletableFuture.
  2. Bulkhead (Ізоляція пулів): обмеження максимального числа паралельних запитів до конкретного проблемного сервісу (наприклад, семафором або окремим ThreadPoolTaskExecutor на 10 потоків). Повільний сервіс заповнить лише свою виділену квоту і не зможе вичерпати глобальний пул потоків застосунку.
  3. Circuit Breaker (Resilience4j): відстеження відсотка невдалих викликів і таймаутів. При перевищенні порогу ланцюг розмикається (State: OPEN), і наступні запити до сервісу миттєво відхиляються з поверненням fallback-відповіді без здійснення реальних мережевих викликів, даючи віддаленому сервісу відновитися.

🎯 Шпаргалка для інтерв’ю

Чек-лист паралельного виклику мікросервісів

Вимога Як реалізувати Що запобігає
Паралелізм CompletableFuture.allOf() Знижує latency із суми до максимуму.
Ізоляція потоків (Bulkhead) Окремий кастомний Executor на модуль Зависання одного сервісу не вбиває весь застосунок.
Таймаути orTimeout(limit, unit) + socket timeout Зависання потоків при мережевих втратах.
Деградація (Fallback) .exceptionally(...) для некритичних даних Збій відгуків не ламає відкриття картки товару.
Трейсинг (MDC) Декоратор Executor із копіюванням контексту Втрата TraceId у логах мікросервісів.
Неблокуючий I/O java.net.http.HttpClient.sendAsync Заощаджує сотні потоків ОС при очікуванні мережі.

Антипатерни на співбесіді

  • ❌ Використання ForkJoinPool.commonPool(): повільні HTTP-запити заблокують паралельні стріми та інші частини JVM.
  • ❌ Відсутність таймаутів на паралельних викликах: зависання однієї залежності підвішує весь шлюз.
  • ❌ Блокуючий join() на кожному запиті по черзі: перетворює паралельний код назад на послідовний.
  • ❌ Ігнорування MDC: логи різних запитів перемішуються, діагностика інцидентів у розподіленій системі стає неможливою.

Пов’язані теми