Hedged Requests в распределенных системах

Оглавление

  1. Введение
  2. Что такое Hedged Requests и зачем они нужны
  3. Реализация Hedged Requests на Java и Spring Boot
  4. Архитектурный контекст Hedged Requests
  5. Сравнение Hedged Requests с стратегиями Retry и Timeout
  6. Практические рекомендации и подводные камни
  7. Заключение

Введение

В современных распределенных системах производительность и скорость отклика становятся критически важными факторами, напрямую влияющими на качество пользовательского опыта и успех бизнеса в целом. Даже незначительные всплески задержек, особенно в высоконагруженных системах, могут привести к ощутимому ухудшению производительности, снижению пользовательской удовлетворенности и, как следствие, финансовым потерям. Часто такие задержки возникают из-за того, что отдельные запросы “зависают” или значительно замедляются, формируя так называемый “длинный хвост” задержек (tail latency).

Одним из эффективных решений данной проблемы является техника Hedged Requests – подход, при котором клиент отправляет несколько параллельных запросов к разным экземплярам одного и того же сервиса, используя ответ, пришедший первым, а остальные отменяет. Это позволяет существенно снизить вероятность возникновения длительных задержек, так как система не “зависает” в ожидании ответа от одного медленного узла.

В этой статье мы подробно рассмотрим концепцию Hedged Requests, детально разберем сценарии их применения, преимущества и недостатки подхода, а также покажем на конкретных примерах, как реализовать данную технику с помощью Java и Spring Boot. Для наглядности будут представлены схемы (C4 Container и Sequence), примеры кода и практические рекомендации по внедрению и настройке.

Что такое Hedged Requests и зачем они нужны

Hedged Requests (в переводе – “подстраховывающие запросы”, часто говорят “хедж-запросы”) – это прием в распределенных системах, позволяющий снизить “хвостовую” задержку (tail latency) за счет отправки дублирующих запросов к разным узлам и использования самого быстрого ответа. Иными словами, клиент сперва отправляет запрос на основной экземпляр сервиса, а если ответ задерживается – через небольшой интервал посылает тот же запрос на другие реплики. Как только первый из параллельных ответов получен, все остальные запрошенные результаты игнорируются (лишние запросы при необходимости отменяются). Такой подход служит “страховкой” от медленных узлов или сетевых задержек, позволяя системе быстрее получить результат.

Зачем это нужно? В крупных веб-сервисах множество компонент участвуют в обработке запроса, и даже если 99% обращений к каждому компоненту укладываются, скажем, в 100 мс, совокупный запрос пользователя может иногда сильно пробуксовывать из-за одного медленного ответа – это и есть проблема длинного хвоста задержек. Hedged requests адресуют эту проблему: послав запрос сразу (или с небольшим лагом) на несколько серверов, мы значительно повышаем шансы быстро получить результат от хотя бы одного из них. Например, эксперименты Google с BigTable показали, что отправка повторного запроса через 10 мс после основного позволила снизить 99.9-й перцентиль задержки чтения данных с 1800 мс до 74 мс, увеличив общее число запросов всего на 2%. То есть “подстраховка” в виде лишнего запроса для нескольких процентов случаев кардинально сокращает хвост задержек.

Ниже приведена цитата из публикации Google:

For example, in a Google benchmark that reads the values for 1,000 keys stored in a BigTable table distributed across 100 different servers, sending a hedging request after a 10ms delay reduces the 99.9th-percentile latency for retrieving all 1,000 values from 1,800ms to 74ms while sending just 2% more requests. The overhead of hedged requests can be further reduced by tagging them as lower priority than the primary requests.

Ссылка на исследование: The Tail at Scale

Важно понимать, что hedged requests борются именно со случайными всплесками задержек (например, из-за Garbage Collection, локальной ресурсоемкой задачи, потери сетевого пакета и пр.). В типичном случае причина медлительности – не сам запрос пользователя, а внешние факторы: например, временная загрузка сервера или сбой сетевого пакета. При hedging эти факторы маскируются: если основной запрос попал на “тормозящий” экземпляр, дубль на другой ноде, скорее всего, ответит быстрее, и проблема не проявится на уровне пользователя. Google Zanzibar – пример реальной системы, где применяются hedged requests для борьбы с хвостовыми задержками: если внутренний запрос выполняется слишком долго, Zanzibar автоматически посылает параллельный запрос к другой реплике и использует самый первый успешный ответ. Тот же прием упоминается и в контексте Amazon: например, в DynamoDB клиенты могут дублировать медленные операции чтения – “пусть два запроса соревнуются, а выигрывает самый быстрый”. Таким образом, hedged requests повышают предсказуемость времени отклика системы, особенно под нагрузкой.

Реализация Hedged Requests на Java и Spring Boot

Реализовать логику hedged requests можно на уровне клиента сервиса (т.е. в коде, отправляющем запросы к другим сервисам). В Spring Boot (Java) это достигается с помощью асинхронных вызовов и параллельного выполнения. Ниже рассмотрим два подхода: через стандартные CompletableFuture/ExecutorService (подход для синхронного REST-клиента) и с помощью реактивного WebClient (Spring WebFlux).

Параллельные вызовы с помощью CompletableFuture и ExecutorService

В этом подходе мы запускаем несколько параллельных запросов с использованием пула потоков и берем результат самого быстрого из них. Например, пусть у нас есть сервис PaymentService с несколькими репликами, и мы хотим вызвать метод getPaymentStatus с хеджированием. Можно сделать так:

@RestController
@RequestMapping("/api")
public class PaymentController {
    private final ExecutorService executor = Executors.newFixedThreadPool(4);
    private final RestTemplate restTemplate = new RestTemplate();
    private final List<String> replicaUrls = List.of(
        "http://service1.internal/payments/",
        "http://service2.internal/payments/"
    );

    @GetMapping("/payment-status/{id}")
    public ResponseEntity<String> getPaymentStatus(@PathVariable String id) throws Exception {
        // Запускаем параллельно запросы ко всем URL реплик
        List<CompletableFuture<String>> futures = replicaUrls.stream()
            .map(url -> CompletableFuture.supplyAsync(() -> {
                    try {
                        return restTemplate.getForObject(url + id, String.class);
                    } catch (Exception e) {
                        return null; // в случае ошибки вернется null
                    }
                }, executor)
            )
            .toList();
        // Ожидаем самый быстрый успешно завершившийся CompletableFuture
        CompletableFuture<Object> firstDone = CompletableFuture.anyOf(futures.toArray(new CompletableFuture[0]));
        String result = (String) firstDone.get();  // получаем результат первого завершившегося
        // Отмена остальных запросов, если они ещё идут, чтобы не нагружать систему
        futures.forEach(f -> f.cancel(true));
        return ResponseEntity.ok(result);
    }
}

В этом коде список replicaUrls содержит адреса нескольких реплик сервиса. С помощью CompletableFuture.supplyAsync мы одновременно отправляем запросы на все адреса (используя RestTemplate для простоты), передавая их в свой пул потоков executor. Метод CompletableFuture.anyOf возвращает новый future, который завершится, как только завершится любой из исходных. Мы берем результат первого успешно завершившегося запроса и затем отменяем остальные (вызывая cancel(true)), чтобы прервать лишнюю работу. Таким образом, клиент получит ответ с минимальной задержкой – от самой быстрой реплики.

Примечание: В реальном коде стоит дополнительно обрабатывать сценарии ошибок и таймаутов. Например, если все параллельные вызовы вернут ошибку или null, то и итоговый ответ должен быть ошибочным. Также следует предусмотреть общий таймаут ожидания (например, использовать firstDone.get(2, TimeUnit.SECONDS)), чтобы избежать бесконечного ожидания.

Реактивный подход с помощью Spring WebClient (WebFlux)

Spring WebClient (из Spring WebFlux) идеально подходит для реализации конкурирующих запросов за счет реактивной модели. Мы можем одновременно инициировать несколько HTTP-запросов и затем использовать оператор Reactor firstWithValue (или похожий) для выбора первого успешного результата. Например:

@Component
public class ShippingClient {
    private final WebClient webClient = WebClient.create();
    private final List<String> replicaUrls = List.of(
        "http://warehouse1.internal/stock/",
        "http://warehouse2.internal/stock/"
    );

    public Mono<StockResponse> getStockInfo(String itemId) {
        // Формируем Mono для каждого запроса к репликам
        List<Mono<StockResponse>> calls = replicaUrls.stream()
            .map(url -> webClient.get().uri(url + itemId)
                                 .retrieve()
                                 .bodyToMono(StockResponse.class)
                                 .timeout(Duration.ofMillis(500)) // отдельный таймаут на запрос
                                 .onErrorResume(e -> Mono.empty()) // при ошибке возвращаем пустой Mono
            )
            .toList();
        // Используем оператор, выбирающий *первый из* полученных значений:
        return Mono.firstWithValue(calls)   // берем первое полученное значение (игнорируя ошибки/пустые)
                   .doOnSuccess(value -> {
                       // при успехе можем при необходимости отменить другие запросы (они отменятся автоматически при отмене контекста)
                   });
    }
}

Здесь мы создаем List<Mono<StockResponse>> – по одному Mono для запроса к каждому из URL реплик. Метод Mono.firstWithValue(...) из Project Reactor возвращает Mono, который завершится первым из поступивших значений. То есть, как только один из запросов вернет успешный результат, этот результат будет передан дальше, а остальные Mono либо отменяются, либо игнорируются. Мы также добавили для каждого вызова timeout(Duration.ofMillis(500)) – это ограничивает время ожидания ответа от каждой реплики, и onErrorResume – чтобы в случае ошибки на конкретной реплике просто считать ее как отсутствие ответа (Mono.empty) и не срывать весь race. В итоге клиент метода getStockInfo получит Mono, который завершится как только будет получен первый ответ от какого-либо склада, либо завершится пустотой/ошибкой, если ни одна реплика не ответила вовремя.

Отмена лишних запросов: При реактивном подходе фреймворк сам отменит оставшиеся подписки, как только вы отписываетесь от результата первого Mono. Тем не менее, стоит использовать контексты или явную отмену, если требуется послать сигнал отмены на сервер (например, gRPC серверу). В вышеописанных примерах мы просто игнорируем запоздалые ответы.

Настройка таймаутов и количество параллельных запросов

При реализации hedged requests важно правильно подобрать параметры конфигурации: сколько копий запроса допустимо, с какой задержкой слать дублирующий запрос, каковы таймауты ожидания и т.д. Неправильная настройка может либо не дать выигрыша (если слишком долго ждать перед повтором), либо излишне нагрузить систему (если слать слишком много дублей на каждый чих). Вот несколько рекомендаций:

  • Задержка перед вторым запросом (hedging delay): Часто выбирается около 95-го перцентиля нормальной задержки для данного типа операций. Идея в том, что 95% запросов успеют ответить сами, а только самые медленные 5% получат дублирующий запрос. В примере Google выше – 10 мс задержки перед хедж-запросом привели к ~5% дополнительной нагрузки. В других случаях задержку можно настроить статически (например, 100 мс) или динамически – например, на основе измеренной средней или перцентильной задержки предыдущих запросов данного рода. В фреймворке gRPC, например, параметр hedgingDelay можно задать явно в конфигурации метода (либо не задавать вовсе, тогда все дубли отправляются сразу одновременно).
  • Максимальное число дублей: Как правило, эффективно делать 1 дополнительный запрос (итого 2 параллельных запроса на операцию). Редко имеет смысл более 2–3, ибо выигрыш от третьего и последующих резко падает, а нагрузка растет. В gRPC задан жесткий максимум – не более 5 одновременных запросов при hedging (параметр maxAttempts). Выбирайте число попыток в зависимости от числа доступных реплик и требуемой надежности. Например, если у сервиса обычно 2 реплики, ставьте maxAttempts=2. Если 3 реплики, можно maxAttempts=2 или 3 (но 3 даст +200% к нагрузке в худшем случае).
  • Таймауты и Deadline: Всегда задавайте общий дедлайн для операции клиента, чтобы hedged requests не продолжали гонку бесконечно. Например, если пользовательский запрос SLA – 2 секунды, то после 2 секунд надо прекратить попытки и вернуть ошибку. В gRPC общий дедлайн действует на всю цепочку хедж-запросов. В ручной реализации можно использовать CompletableFuture.anyOf с таймаутом ожидания или Mono.timeout на итоговом Mono. Также полезно на серверных клиентах (RestTemplate, WebClient) ставить таймаут сокета/подключения.
  • Фильтрация ошибок: Решите, какие ошибки считать фатальными, а на какие можно попытаться ответить за счет дубля. Например, если одна реплика вернула HTTP 500, имеет смысл ждать ответ от другой реплики (вдруг у нее все ок). А если пришел HTTP 400 (Bad Request) – дублирование бессмысленно, запрос некорректен и везде упадет. В gRPC для этого служит список nonFatalStatusCodes – коды, при которых можно продолжать слать hedge-запросы. В собственном коде вы можете реализовать логику: при ошибке типа X – не пытаться больше, вернуть ошибку сразу.
  • Приоритеты и квоты: Поскольку hedged requests увеличивают нагрузку, возможно введение ограничений. Например, Google упоминали о снижении приоритета у повторных запросов, чтобы они не вытесняли основные. В gRPC есть механизм throttling для hedge- и ретрай-запросов – специальный токен-бакет, уменьшающий частоту повторов при участившихся неудачах. В самодельной реализации стоит хотя бы вести метрику: сколько дополнительных запросов отправлено, и не начинают ли они перегружать систему.
  • Идемпотентность: Hedged-запросы должны быть идемпотентными! То есть дубль не должен приводить к дублю побочного эффекта. Поэтому обычно данную технику применяют только к запросам-чтениям (GET) или условно идемпотентным операциям. Если же очень нужно хеджировать запись (как в DynamoDB), то приходится добавлять специальную логику на сервере, чтобы двойная запись не испортила данные.

Архитектурный контекст Hedged Requests

Рис. 1: контейнеры микросервиса с Hedging.

Пользователь посылает запрос в сервис A, который выполняет бизнес-логику и параллельно запрашивает данные у сервисов B. Сервис B имеет несколько экземпляров (реплик) – A обращается сразу к двум репликам (B1 и B2) и использует первый полученный ответ. Запрос ко второй реплике отправляется с небольшой задержкой, поэтому при нормальной работе ответ B1 приходит первым. Если же B1 “тормозит”, сервис A получит ответ от B2 и вернет его пользователю, не дожидаясь запоздавшего B1.

На диаграмме выше зеленым цветом отмечен клиентский сервис A (выполняющий hedging), синим – две реплики сервиса B. Пунктирная рамка показывает, что B1 и B2 – это экземпляры одного логического сервиса B. Пользовательский запрос поступает в A, далее A делает первичный запрос на B1. Спустя заданную задержку A делает дублирующий запрос на B2. Получив первый ответ (в примере быстрее ответила реплика 2), A возвращает его пользователю. Медленный ответ от B1 либо отклоняется (не используется), либо отменяется через механизм отмены запроса.

Чтобы наглядно понять порядок взаимодействий при хеджировании, рассмотрим диаграмму последовательности:

Рис. 2: Диаграмма последовательности: пример гонки двух параллельных запросов (hedged requests).

На ней изображено, как клиент (например, фронтенд или другой сервис) вызывает Service A, а тот обращается к двум репликам Service B. Шаги помечены цифрами:

  1. Пользователь отправляет запрос в Service A.
  2. Service A делает запрос к первой реплике (B1).
  3. Не получив быстро ответа, A через некоторое время посылает дублирующий запрос ко второй реплике (B2).
  4. Реплика B2 быстрее обрабатывает запрос и возвращает ответ в A (этот ответ приходит первым).
  5. Service A отправляет реплике B1 команду отменить выполнение запроса (так как результат уже не нужен) – либо просто игнорирует поздний ответ от B1.
  6. Service A возвращает полученный результат пользователю (тем самым значительно снизив время отклика по сравнению с ситуацией ожидания B1).

Обратите внимание: если в шаге 4 первой ответила реплика B1, то сценарий был бы симметричным – A отменил бы запрос B2 и вернул результат B1. Hedged requests таким образом всегда выигрывают время на медленных случаях, однако в быстром случае работают почти как обычный запрос (второй запрос либо не успеет отправиться, либо придет позднее и будет отменен).

Сравнение Hedged Requests с стратегиями Retry и Timeout

Hedged requests – это не единственная техника повышения надежности. Часто используются более простые подходы: Retry (повтор запросов при ошибке) и Timeout (ограничение максимального ожидания). Рассмотрим основные отличия и совместимость этих методов:

  • Ограничение времени ожидания (Timeout): Таймаут задает предел, сколько клиент готов ждать ответа. Если время вышло – запрос прекращается (например, закрывается соединение) и обычно считается неуспешным. Таймаут никак не пытается получить успешный результат – он просто избавляет систему от чрезмерного ожидания. В контексте hedging таймауты по-прежнему необходимы (нельзя ждать бесконечно всех дублей), но сами по себе таймауты не улучшают хвостовые задержки – они лишь прерывают слишком долгие запросы. Пользователь в таком случае получит ошибку или деградированный ответ. Hedged request же стремится предотвратить ситуацию таймаута, предоставив альтернативный путь к результату раньше, чем наступит дедлайн.
  • Повторные попытки (Retry): Ретраи – это повтор запроса после неудачи. Обычно клиент сначала дожидается ошибки или истечения таймаута, и только затем выполняет новый запрос на тот же или другой узел. Это помогает, если сбой кратковременный или узел совсем не ответил. Однако ретраи увеличивают общую латентность: сначала ждем таймаут, потом начинаем заново. Hedged requests можно представить как особый случай “опережающего ретрая”: клиент не дожидается явного сбоя, а отправляет параллельный запрос, если подозревает, что первый запаздывает. По сути, hedging – это превентивный retry, запускаемый раньше, чем система решит что первый запрос провален. Если же оба (или все) параллельных запроса не удались, то результат будет как при обычном ретрае – ошибка пользователю (но полученная чуть быстрее, так как не ждали последнего таймаута впустую).
  • Нагрузка на систему: Hedged requests, как и ретраи, повышают нагрузку – но по-разному. Ретраи добавляют последовательной нагрузки (после неудачи), а hedging – параллельной. В худшем случае hedging может увеличить трафик почти в N раз (где N – число дублей) для отдельных запросов. Ретраи же увеличивают среднюю задержку (при сбоях), но не создают пиковой одновременной нагрузки. Грамотная комбинация: использовать таймаут + небольшой retry для редких системных сбоев, а hedged requests – для борьбы с редкими тормозами при нормальных ответах. Например, gRPC сначала выполняет hedged запросы параллельно, но если приходит ошибка особого типа (не “фатальная”) – он может сразу запустить еще один дубль, не дожидаясь таймаута. Таким образом сочетаются идеи ретраев и hedging.
  • Сценарии применения: Ретраи подходят, когда сбои явные (соединение отклонено, отказ сервиса и пр.), и обычно в сочетании с экспоненциальной паузой чтобы не усилить аварию. Hedged requests эффективны, когда есть множество реплик с одинаковыми данными и проблема – в редких долгих откликах без явного отказа. Timeout-ы же нужны всегда как предохранитель. В устойчивой системе могут применяться все три техники: например, запрос от пользователя может иметь таймаут 1 с; клиент сразу шлет запрос на 2 реплики (hedging) с задержкой 100 мс; если ни одна не ответила, получают таймаут и делают ретрай через 500 мс на свежий экземпляр.
СтратегияВремя откликаНадёжностьНагрузка
TimeoutСреднееСредняяНизкая
RetryВысокоеВысокаяСредняя
Hedged RequestsМинимальноеВысокаяВысокая

Практические рекомендации и подводные камни

Ниже собраны советы при внедрении hedged requests, а также потенциальные проблемы:

  • Мера, а не панацея: Используйте hedging для тех запросов, которые критичны по latency и выполняются относительно часто. Нет смысла дублировать редкие административные операции или тяжелые изменяющие запросы. Также, если ваша система и так выдерживает p99 < 50 мс, возможно лишние усложнения ни к чему.
  • Контроль нагрузки: Помните, что hedged requests увеличивают потребление ресурсов. Если каждая пользовательская операция вдруг начнет дублировать все подзапросы, нагрузка возрастет лавинообразно. На графике зависимость задержки от нагрузки обычно есть точка перегиба: сначала hedging улучшает хвостовые задержки, но после определенного уровня загрузки выгода нивелируется, и доп. запросы только усугубляют ситуацию. Поэтому при росте трафика убедитесь, что система справляется с лишними запросами. Вводите ограничение на число одновременных hedged-запросов (например, не более X дублей в секунду) либо динамически отключайте hedging при высокой загрузке. Новейшие алгоритмы предлагают умные стратегии – например, слать дубль только если есть свободная реплика, иначе ждать.
  • Синхронизация и “спурты”: Похожая проблема – одновременные всплески. Если у всех клиентов одинаково настроена задержка, то в случае небольшой заминки в сети десятки клиентов могут разом запустить дубль. Это похоже на “толчки” нагрузки. Чтобы сгладить эффект, иногда добавляют небольшую случайность (jitter) в задержку перед повторным запросом, чтобы не все клиенты хеджировались синхронно.
  • Отмена запросов: Желательно, чтобы сервис, получив ненужный уже запрос, мог отменить его выполнение. Hedged requests подразумевают отмену хвостовых копий, поэтому используйте протоколы с поддержкой отмены (например, gRPC Cancel, HTTP/2 RST_STREAM). Если такого нет, “лишний” запрос все равно доработает до конца и потребит ресурсы. В некоторых системах (например, MySQL) отменить запрос нельзя – это делает hedging менее эффективным, так как будет высокая лишняя нагрузка.
  • Логирование и трассировка: Дополнительные запросы могут запутать логирование и распределенный трекинг. Будет полезно помечать hedged-запросы специальными заголовками или атрибутами (например, X-Hedging: true и номер попытки) – чтобы отличать их в логах. Также настройте Distributed Tracing (например, Zipkin, Jaeger) так, чтобы параллельные запросы отображались корректно (в виде нескольких дочерних спанов от одного родительского). Это поможет отлаживать поведение системы с hedging.
  • Не применять вслепую: Прежде чем включать hedged requests, проанализируйте метрики задержек. Если проблема хвостов актуальна (большой размах между median и p95/p99), тогда hedging может помочь. Но если задержки стабильно низкие или система перегружена (хвост большой из-за постоянной высокой нагрузки) – лучше сначала оптимизировать систему или добавить ресурсы. Hedging не решит проблему недостаточной мощности, он помогает лишь от случайных “бросков” задержки на отдельных узлах.
  • Тестирование и контроль: Внедрив hedged requests, измеряйте эффект – как изменилась распределение задержек и насколько выросла нагрузка (RPS, CPU). Возможно, понадобится тонко подрегулировать задержку или отключить hedging для некоторых типов запросов. Также следите за отказоустойчивостью: если дополнительный запрос пойдет на ту же базу данных, не станет ли она узким местом? Иногда дублирование запросов вниз по цепочке бессмысленно, если упираемся в один шардинг. В таких случаях hedging стоит применять на самом нижнем уровне (например, между репликами БД, а не на уровне сервисов).

Заключение

Техника Hedged Requests является мощным инструментом для борьбы с хвостовыми задержками в распределенных системах, позволяя обеспечить стабильность и предсказуемость времени отклика даже при высокой нагрузке и случайных всплесках задержек. Используя параллельные запросы к разным репликам и принимая первый полученный ответ, мы значительно сокращаем негативное влияние единичных медленных узлов, что напрямую улучшает пользовательский опыт.

Однако внедрение Hedged Requests требует вдумчивого подхода и тонкой настройки. Важно правильно подобрать задержки перед запуском дополнительных запросов, ограничить их максимальное количество и обеспечить корректную отмену ненужных запросов, чтобы не создать дополнительную нагрузку на систему. Также стоит учитывать, что данная техника наиболее эффективна для идемпотентных операций и не должна применяться вслепую для всех типов запросов.

Используя приведенные в статье рекомендации, схемы и примеры реализации на Java и Spring Boot, вы сможете грамотно интегрировать Hedged Requests в свою архитектуру, добившись оптимального баланса между скоростью отклика, надежностью и ресурсопотреблением вашей распределенной системы.

Loading