Оптимизированное использование httpClient в сценариях с высокой степенью параллелизма.

Java

1. Предпосылки

У нас есть бизнес, который звонит в службу на основе http, предоставляемую другими отделами, с ежедневным объемом звонков в десятки миллионов. Для завершения бизнеса используется httpclient. Поскольку раньше я не мог подняться до qps, я посмотрел на бизнес-код и сделал некоторые оптимизации, которые записаны здесь.

Сравните до и после: до оптимизации среднее время выполнения составляло 250 мс; после оптимизации среднее время выполнения составляло 80 мс, что уменьшило потребление на две трети, а контейнер больше не предупреждал об исчерпании потока, обновляя~

2. Анализ

Первоначальная реализация проекта относительно грубая, то есть инициализировать httpclient каждый раз при выполнении запроса, генерировать объект httpPost, выполнять его, затем извлекать объект из возвращаемого результата, сохранять его как строку и, наконец, явно закройте ответ и клиент. Анализируем и немного оптимизируем:

2.1 httpclient постоянно создает накладные расходы

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

2.2 Накладные расходы на повторное создание TCP-соединений

Трехстороннее рукопожатие TCP и четырехсторонняя волна двух связанных процессов слишком дороги для высокочастотных запросов. Представим, что если нам нужно потратить 5 мс на процесс согласования на запрос, то для одиночной системы с qps 100 мы тратим 500 мс на рукопожатие и волну за 1 секунду. Если вы не являетесь старшим руководителем, мы, программисты, не должны делать такие большие вещи и переходить на метод поддержания активности, чтобы добиться повторного использования соединения!

2.3 Накладные расходы на повторяющийся объект кэширования

В исходной логике использовался следующий код:

HttpEntity entity = httpResponse.getEntity();
String response = EntityUtils.toString(entity);

Здесь мы эквивалентны копированию лишней копии контента в строку, в то время как исходный httpResponse по-прежнему сохраняет копию контента, который нужно потреблять, в случае высокой параллелизма и очень большого контента он будет потреблять много памяти . И нам нужно явно закрыть соединение, некрасиво.

3. Осознайте

Согласно приведенному выше анализу, нам в основном нужно сделать три вещи: одна — одноэлементный клиент, другая — кэшированное соединение проверки активности, а третья — лучше обрабатывать возвращаемые результаты. Не будем говорить об одном, поговорим о двух.

Когда дело доходит до кэширования соединений, легко представить себе пул соединений с базой данных. httpclient4 предоставляет PoolingHttpClientConnectionManager в качестве пула соединений. Далее мы оптимизируем через следующие шаги:

3.1 Определите стратегию поддержания активности

Что касается keep-alive, то в этой статье мы не будем его объяснять, а лишь упомянем один момент: использовать ли keep-alive зависит от ситуации в бизнесе, это не панацея. Еще один момент, есть много историй между keep-alive и time_wait/close_wait.

В этом бизнес-сценарии у нас есть небольшое количество фиксированных клиентов и доступ к серверу в течение длительного времени и очень часто.Это очень удобно для включения поддержки активности

Опять же, keep-alive для http и KEEPALIVE для tcp — это не одно и то же. Вернемся к тексту. Определите стратегию следующим образом:

ConnectionKeepAliveStrategy myStrategy = new ConnectionKeepAliveStrategy() {
    @Override
    public long getKeepAliveDuration(HttpResponse response, HttpContext context) {
        HeaderElementIterator it = new BasicHeaderElementIterator
            (response.headerIterator(HTTP.CONN_KEEP_ALIVE));
        while (it.hasNext()) {
            HeaderElement he = it.nextElement();
            String param = he.getName();
            String value = he.getValue();
            if (value != null && param.equalsIgnoreCase
               ("timeout")) {
                return Long.parseLong(value) * 1000;
            }
        }
        return 60 * 1000;//如果没有约定,则默认定义时长为60s
    }
};

3.2 Настройка PoolingHttpClientConnectionManager

PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(500);
connectionManager.setDefaultMaxPerRoute(50);//例如默认每路由最高50并发,具体依据业务来定

Количество параллелизма также может быть установлено для каждого маршрута.

3.3 Создание http-клиента

httpClient = HttpClients.custom()
                .setConnectionManager(connectionManager)
                .setKeepAliveStrategy(kaStrategy)
                .setDefaultRequestConfig(RequestConfig.custom().setStaleConnectionCheckEnabled(true).build())
                .build();

Примечание. Не рекомендуется использовать метод setStaleConnectionCheckEnabled для удаления закрытой ссылки. Лучше вручную включить поток, который периодически запускает методы closeExpiredConnections и closeIdleConnections, как показано ниже.

public static class IdleConnectionMonitorThread extends Thread {
    
    private final HttpClientConnectionManager connMgr;
    private volatile boolean shutdown;
    
    public IdleConnectionMonitorThread(HttpClientConnectionManager connMgr) {
        super();
        this.connMgr = connMgr;
    }

    @Override
    public void run() {
        try {
            while (!shutdown) {
                synchronized (this) {
                    wait(5000);
                    // Close expired connections
                    connMgr.closeExpiredConnections();
                    // Optionally, close connections
                    // that have been idle longer than 30 sec
                    connMgr.closeIdleConnections(30, TimeUnit.SECONDS);
                }
            }
        } catch (InterruptedException ex) {
            // terminate
        }
    }
    
    public void shutdown() {
        shutdown = true;
        synchronized (this) {
            notifyAll();
        }
    }
    
}

3.4 Уменьшите накладные расходы при использовании httpclient для выполнения метода

Здесь следует отметить, что не закрывайте соединение.

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

res = EntityUtils.toString(response.getEntity(),"UTF-8");
EntityUtils.consume(response1.getEntity());

Однако более рекомендуемый способ — определить ResponseHandler, который удобен вам и мне, и больше не перехватывать исключения и не закрывать потоки самостоятельно. Здесь мы можем посмотреть на соответствующий исходный код:

public <T> T execute(final HttpHost target, final HttpRequest request,
            final ResponseHandler<? extends T> responseHandler, final HttpContext context)
            throws IOException, ClientProtocolException {
        Args.notNull(responseHandler, "Response handler");

        final HttpResponse response = execute(target, request, context);

        final T result;
        try {
            result = responseHandler.handleResponse(response);
        } catch (final Exception t) {
            final HttpEntity entity = response.getEntity();
            try {
                EntityUtils.consume(entity);
            } catch (final Exception t2) {
                // Log this exception. The original exception is more
                // important and will be thrown to the caller.
                this.log.warn("Error consuming content after an exception.", t2);
            }
            if (t instanceof RuntimeException) {
                throw (RuntimeException) t;
            }
            if (t instanceof IOException) {
                throw (IOException) t;
            }
            throw new UndeclaredThrowableException(t);
        }

        // Handling the response was successful. Ensure that the content has
        // been fully consumed.
        final HttpEntity entity = response.getEntity();
        EntityUtils.consume(entity);//看这里看这里
        return result;
    }

Как вы можете видеть, если мы используем resultHandler для выполнения метода execute, метод потребления в конечном итоге будет вызван автоматически, и этот метод потребления выглядит следующим образом:

public static void consume(final HttpEntity entity) throws IOException {
        if (entity == null) {
            return;
        }
        if (entity.isStreaming()) {
            final InputStream instream = entity.getContent();
            if (instream != null) {
                instream.close();
            }
        }
    }

Вы можете видеть, что в конечном итоге он закрывает входной поток.

4. Другое

Вышеуказанные шаги в основном завершают процесс написания httpclient, поддерживающего высокий уровень параллелизма. Вот некоторые дополнительные настройки и напоминания:

4.1 Некоторые настройки тайм-аута httpclient

CONNECTION_TIMEOUT — это время ожидания соединения, SO_TIMEOUT — это время ожидания сокета, они разные. Тайм-аут соединения — это время ожидания перед инициированием запроса, тайм-аут сокета — это тайм-аут ожидания данных.

HttpParams params = new BasicHttpParams();
//设置连接超时时间
Integer CONNECTION_TIMEOUT = 2 * 1000; //设置请求超时2秒钟 根据业务调整
Integer SO_TIMEOUT = 2 * 1000; //设置等待数据超时时间2秒钟 根据业务调整

//定义了当从ClientConnectionManager中检索ManagedClientConnection实例时使用的毫秒级的超时时间
//这个参数期望得到一个java.lang.Long类型的值。如果这个参数没有被设置,默认等于CONNECTION_TIMEOUT,因此一定要设置。
Long CONN_MANAGER_TIMEOUT = 500L; //在httpclient4.2.3中我记得它被改成了一个对象导致直接用long会报错,后来又改回来了
 
params.setIntParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, CONNECTION_TIMEOUT);
params.setIntParameter(CoreConnectionPNames.SO_TIMEOUT, SO_TIMEOUT);
params.setLongParameter(ClientPNames.CONN_MANAGER_TIMEOUT, CONN_MANAGER_TIMEOUT);
//在提交请求之前 测试连接是否可用
params.setBooleanParameter(CoreConnectionPNames.STALE_CONNECTION_CHECK, true);
 
//另外设置http client的重试次数,默认是3次;当前是禁用掉(如果项目量不到,这个默认即可)
httpClient.setHttpRequestRetryHandler(new DefaultHttpRequestRetryHandler(0, false));

4.2 Если nginx настроен, nginx также должен настроить поддержку активности на обоих концах.

В текущем бизнесе ситуация без nginx встречается относительно редко. По умолчанию nginx открывает длинное соединение с клиентом и использует короткое соединение с сервером. Обратите внимание на параметры keepalive_timeout и keepalive_requests на стороне клиента и настройки параметра keepalive на стороне апстрима, значение этих трех параметров здесь повторяться не будем.

Это все мои настройки. Благодаря этим настройкам исходное время запроса 250 мс было успешно сокращено примерно до 80 мс, и эффект был замечательным.


Заканчивать.