Как правильно выполнить несколько параллельных запросов к микросервисам
В микросервисной архитектуре агрегаторам (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: логи разных запросов перемешиваются, диагностика инцидентов в распределенной системе становится невозможной.