Статьи

Сравнительный анализ влияния пакетирования в Hystrix

В предыдущей статье  « Пакетные (сворачивающиеся) запросы в Hystrix »  мы рассматривали сворачивающийся API в Hystrix. Проверьте это, прежде чем приступить к этой статье. Представленный пример был довольно искусственным, просто представляющим API. Сегодня давайте рассмотрим пример из полу-реальной жизни и проведем сравнительный анализ. Мы уже использовали  API random.org  некоторое время назад в качестве примера (см. Ваше первое сообщение — обнаружение Akka ), давайте использовать его снова. Представьте, что наше приложение вызывает следующий API-фасад, чтобы получить ровно одно случайное число за запрос ( generateIntegers(1))

public interface RandomOrgClient {
    RandomIntegers generateIntegers(int howMany);
}

Как вы можете видеть, этот метод может легко получить более одного числа. Вы можете спросить, почему он возвращает какой-то причудливый  RandomIntegers класс, а не, скажем  List<Integer>? Ну,  список целых чисел  — это просто структура данных, он не представляет никакой бизнес-концепции и  *random integers* не оставляет места для спекуляций. Тем не менее неудивительно, что реализация — это просто оболочка / декоратор над списком:

public class RandomIntegers extends AbstractList<Integer> {
 
    private final ImmutableList<Integer> values;
 
    public RandomIntegers(Collection<Integer> values) {
        this.values = ImmutableList.copyOf(values);
    }
 
    @Override
    public Integer get(int index) {
        return values.get(index);
    }
 
    @Override
    public int size() {
        return values.size();
    }
 
    public RandomIntegers take(int n) {
        return new RandomIntegers(values.subList(0, n));
    }
 
    public RandomIntegers drop(int n) {
        return new RandomIntegers(values.subList(n, values.size()));
    }
}

Этот неизменный объект значения имеет два дополнительных метода  take(n) и  drop(n) разделить его. Мы будем использовать их в будущем. Чтобы избежать неожиданной задержки и управлять ошибками, мы заключаем клиента в   команду Hystrix :

public class GenerateIntegersCmd extends HystrixCommand<RandomIntegers> {
 
    private final RandomOrgClient randomOrgClient;
    private final int howMany;
 
    public GenerateIntegersCmd(RandomOrgClient randomOrgClient, int howMany) {
        super(Setter
                        .withGroupKey(asKey("random.org"))
                        .andCommandKey(HystrixCommandKey.Factory.asKey("generateIntegers"))
                        .andThreadPoolPropertiesDefaults(
                                HystrixThreadPoolProperties.Setter()
                                        .withCoreSize(100)
                                        .withMaxQueueSize(100)
                                        .withQueueSizeRejectionThreshold(100))
                        .andCommandPropertiesDefaults(
                                HystrixCommandProperties.Setter()
                                        .withExecutionIsolationThreadTimeoutInMilliseconds(2000))
        );
        this.randomOrgClient = randomOrgClient;
        this.howMany = howMany;
    }
 
    @Override
    protected RandomIntegers run() throws Exception {
        return randomOrgClient.generateIntegers(howMany);
    }
 
}

Мы используем чрезвычайно большой пул потоков (100), Netflix  утверждает, что в их случаях достаточно 10-20 , позже мы увидим, как это исправить. Теперь представьте, что у нас есть простая конечная точка, которая должна вызывать этот API для каждого запроса:

@RestController
public class RandomController {
 
    private final RandomOrgClient randomOrgClient;
 
    @Inject
    public RandomController(RandomOrgClient randomOrgClient) {
        this.randomOrgClient = randomOrgClient;
    }
 
    @RequestMapping("/{howMany}")
    public String random(@PathVariable("howMany") int howMany) {
        final HystrixExecutable<RandomIntegers> generateIntsCmd = 
            new GenerateIntegersCmd(randomOrgClient, howMany);
        final RandomIntegers response = generateIntsCmd.execute();
        return response.toString();
    }
 
}

Теперь представьте,  random.org что в среднем задержка составляет 500 мс (полностью составленное число, см.  Здесь  для реальных данных). При нагрузочном тестировании нашего простого приложения с 100 клиентами мы можем ожидать около 200 транзакций в секунду со средним временем отклика, равным половине секунды:

Свертывание (пакетирование) — это сбор одинаковых маленьких запросов в один больший. Это снижает нагрузку на нижестоящие зависимости и сетевой трафик. Однако эта функция имеет цену, заключающуюся в увеличении времени транзакции, потому что перед random.org непосредственным вызовом  мы должны немного подождать, на случай, если другой клиент захочет позвонить  random.org одновременно. Первым шагом для пакетных запросов является реализация  HystrixCollapser:

public class GenerateIntegersCollapser extends HystrixCollapser<RandomIntegers, RandomIntegers, Integer> {
 
    private final int howMany;
    private final RandomOrgClient randomOrgClient;
 
    public GenerateIntegersCollapser(RandomOrgClient randomOrgClient, int howMany) {
        super(withCollapserKey(HystrixCollapserKey.Factory.asKey("generateIntegers"))
                .andCollapserPropertiesDefaults(Setter().withTimerDelayInMilliseconds(100))
                .andScope(Scope.GLOBAL));
        this.howMany = howMany;
        this.randomOrgClient = randomOrgClient;
    }
 
    @Override
    public Integer getRequestArgument() {
        return howMany;
    }
 
    @Override
    protected HystrixCommand<RandomIntegers> createCommand(Collection<CollapsedRequest<RandomIntegers, Integer>> collapsedRequests) {
        final int totalHowMany = collapsedRequests
                .stream()
                .mapToInt(CollapsedRequest::getArgument)
                .sum();
        return new GenerateIntegersCmd(randomOrgClient, totalHowMany);
    }
 
    @Override
    protected void mapResponseToRequests(RandomIntegers batchResponse, Collection<CollapsedRequest<RandomIntegers, Integer>> collapsedRequests) {
        RandomIntegers ints = batchResponse;
        for (CollapsedRequest<RandomIntegers, Integer> curRequest : collapsedRequests) {
            final int count = curRequest.getArgument();
            curRequest.setResponse(ints.take(count));
            ints = ints.drop(count);
        }
    }
}

GenerateIntegersCollapser используется дважды в Hystrix — сначала, когда несколько запросов поступают примерно в одно и то же время. После настроенного окна (в нашем примере 100 мс) все запросы собраны вместе, и Hystrix просит нас создать одну пакетную команду (см . createCommand():). Все, что мы делаем, это вычисляем, сколько всего нам нужно случайных целых чисел, и запрашиваем их все за один раз. Второй раз  GenerateIntegersCollapser используется, когда приходит пакетный ответ, и нам нужно разделить его на отдельные небольшие запросы. Это ответственность  mapResponseToRequests(). Видишь, как мы нарезаем batchResponse на мелкие кусочки? Сначала я использовал умопомрачительную реализацию, reduce чтобы избежать изменчивости:

collapsedRequests.stream().reduce(batchResponse, (leftInBatch, curRequest) -> {
    final int count = curRequest.getArgument();
    curRequest.setResponse(leftInBatch.take(count));
    return leftInBatch.drop(count);
}, (x, y) -> {throw new UnsupportedOperationException("combiner not needed");});

Так что не так много изменений с точки зрения клиентского кода. Теперь давайте немного отметим:

Ой, это довольно разочаровывает. Среднее время отклика выросло с 500 до 600 миллисекунд. В то же время пропускная способность измеряется в транзакциях в секунду ( TPS, генерируемый 100 одновременными потоками) снизился до примерно 160-170 / с со значительной дисперсией. Эти цифры не должны вызывать удивления — для включения пакетной обработки требуется почти каждый запрос, чтобы немного подождать, на случай, если в ближайшем будущем появится другой запрос. Менее стабильные измерения также понятны — задержка каждого отдельного запроса зависит от того, открылось ли свертывающееся окно или собирается закрыться. Хорошо, так в чем же дело, почему мы беспокоимся о разрушении? Вспомните предыдущий пример — с 200 TPS мы сделали 200 сетевых вызовов и запросов к внешнему сервису. 100 одновременных клиентов означали 100 одновременных запросов к службе поддержки. Самый большой выигрыш в пакетировании / свертывании — это снижение нагрузки на выходе, генерируемой нашим кодом. Это означает, что мы меньше загружаем наши зависимости и сглаживаем пики.Просто сравните количество запросов к random.org API с и без пакетирования:

200 запросов (каждый запрашивающий одно случайное число) делятся на 5-7 запросов в секунду, однако гораздо большего размера — в среднем запрашивается около 30 случайных чисел, а не … одно. Это означает, что в течение 100 миллисекунд мы собрали около 30 запросов и свели их вместе. И это, мои дорогие друзья, большое улучшение. Мы немного пожертвовали своей производительностью, чтобы уменьшить генерируемый трафик на порядок. Конечно, пакетное окно в 100 миллисекунд является довольно экстремальным, но даже 10 миллисекунд (едва заметные в нормальных условиях) значительно снижают генерируемую нагрузку примерно до 20 запросов в секунду (а не 200). Этот эксперимент показывает, что даже 10 миллисекундное окно захватывает (в среднем) 9 отдельных запросов и сворачивает их, снижая нагрузку на нисходящий поток. Так что разрушение очень мощное,просто помните, какой тип оптимизации вы действительно хотите достичь.