Як правильно виконати декілька паралельних запитів до мікросервісів
У мікросервісній архітектурі агрегаторам (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 вимагають дотримання чотирьох обов’язкових правил:
- Ізольований пул потоків (Bulkhead): Ніколи не використовуйте
ForkJoinPool.commonPool(). Повільний або завислий мікросервіс займе всі системні потоки та паралізує роботу всього застосунку. - Жорсткі таймаути: Кожен мережевий запит зобов’язаний мати ліміт за часом (
.orTimeout()або таймаут HTTP-клієнта). - Graceful Degradation (Fallback): Збій другорядного сервісу (наприклад, блоку рекомендацій або відгуків) не повинен ламати всю відповідь користувачеві.
- Неблокуючий 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, і розподілене трасування розривається.
Для вирішення цієї проблеми необхідно переносити контекст:
- Обернути
Executorу декоратор, який копіює мапу MDC викликаючого потоку (MDC.getCopyOfContextMap()) і відновлює її в потоці пулу перед запускомRunnableз обов’язковим очищенням уfinally. - У Spring Framework використовувати
TaskDecoratorна рівніThreadPoolTaskExecutor. - Використовувати спеціалізовані бібліотеки контексту, такі як 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 11HttpClient) використовує неблокуючий I/O на основіNIO Selector(epoll у Linux). Метод миттєво відправляє мережевий запит і звільняє викликаючий потік. Поки пакет іде мережею, процесор і потоки застосунку вільні. Системний потік задіюється лише на частки мілісекунди, коли з сокета надійдуть готові байти відповіді.
4. Як захистити систему від лавинного падіння, якщо один з опитуваних мікросервісів почав відповідати за 10 секунд замість 200 мілісекунд (Cascading Failure)?
Відповідь: Необхідно застосувати комплекс захисних патернів:
- Жорсткий таймаут (Client Timeout): налаштування таймауту з’єднання та читання (
connectTimeout,readTimeout) в HTTP-клієнті, а також виклик.orTimeout(Duration)на стадіїCompletableFuture. - Bulkhead (Ізоляція пулів): обмеження максимального числа паралельних запитів до конкретного проблемного сервісу (наприклад, семафором або окремим
ThreadPoolTaskExecutorна 10 потоків). Повільний сервіс заповнить лише свою виділену квоту і не зможе вичерпати глобальний пул потоків застосунку. - 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: логи різних запитів перемішуються, діагностика інцидентів у розподіленій системі стає неможливою.