От 5 секунд до 1 секунды, считайте «очень» значительную оптимизацию производительности.

Java Архитектура оптимизация производительности
От 5 секунд до 1 секунды, считайте «очень» значительную оптимизацию производительности.

Эта статья является первой подписанной статьей сообщества Nuggets, и ее перепечатка без разрешения запрещена.

Оптимизация производительности, иногда это выглядит как сравнениетехнические нужды. Если код не является невыносимо медленным, очень немногие компании имеют мужество выделять ресурсы для его выполнения. Даже если у вас есть метрики производительности, трудно убедить руководителей сделать улучшение, которое требует от 300 мс до 150 мс, потому что это не имеет ценности для бизнеса.

Это печально, но это печальная реальность.

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

1. Оптимизируйте контекст и цели

Наша оптимизация производительности на этот раз связана с тем, что она достигла невыносимого уровня, а работа по оптимизации выполняется после события и с учетом проблем. Обычно это не проблема, ведь бизнес на первом месте, а итерации проводятся в яме.

Сначала фон. Для оптимизации сервиса на этот раз время ответа на запрос очень нестабильно. По мере увеличения объема данных большинство запросов занимает около 5-6 секунд! Это выше того, что обычные люди могут вынести.

Конечно нужна оптимизация.

Чтобы проиллюстрировать цель оптимизации, я грубо набросал ее топологию. Как показано на рисунке, это набор сервисов микросервисной архитектуры.

image.png

Среди них цель нашей оптимизации — быть в относительно вышестоящем сервисе. Он должен вызвать большое количество нижестоящих поставщиков услуг через интерфейс Feign, получить данные, агрегировать и сплайсировать и, наконец, отправить их клиенту браузера через шлюз zuul и nginx.

Чтобы наблюдать за взаимосвязью вызовов и данными мониторинга между сервисами, мы подключили платформу цепочки вызовов Skywalking и платформу мониторинга Prometheus для сбора важных данных, чтобы можно было принимать решения по оптимизации. Перед оптимизацией нам нужно сначала взглянуть на два технических индикатора, на которые нужно ссылаться для оптимизации.

  • Пропускная способность: количество вхождений в единицу времени. Например, QPS, TPS, HPS и т. д.
  • Среднее время ответа: среднее время, затрачиваемое на запрос.

giphy.gif

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

Цель нашей оптимизации на этот раз — сократить среднее время отклика некоторых интерфейсов до менее 1 секунды, увеличить пропускную способность, то есть увеличить количество запросов в секунду, чтобы одноэкземплярная система могла выполнять больше одновременных запросов.

2. Резко сократить время за счет сжатия

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

вchromeизinspectГлядя на запрашиваемые данные, мы нашли ключевой интерфейс запроса, каждый раз около10MBДанные. Сколько там всего.

Загрузка такого большого объема данных занимает много времени. Как показано на рисунке ниже, это один из моих запросов на домашнюю страницу juejin, в которомcontent download, который представляет время передачи данных по сети. Если пропускная способность пользователя очень низкая, выполнение этого запроса будет очень длительным.

image.png

Для сокращения времени передачи данных по сети можно включить сжатие gzip. Сжатие Gzip — это практика «время-в-пространстве». Для большинства сервисов последним звеном является nginx, и большинство людей будут выполнять сжатие на уровне nginx. Его основная конфигурация выглядит следующим образом:

gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
gzip_disable "MSIE [1-6]\.";

Насколько удивительна степень сжатия? Мы можем взглянуть на этот скриншот. Видно, что после сжатия данные уменьшились с 8,95 МБ до 368 КБ! Он может быть загружен браузером в одно мгновение.

image.png

Но подождите, nginx — это только внешняя часть, это еще не конец, мы также можем делать запросы быстрее.

Пожалуйста, смотрите путь запроса ниже.Из-за использования микросервисов поток запросов усложняется: nginx не вызывает напрямую связанные сервисы, он вызывает шлюз zuul, целевой сервис, который на самом деле вызывает шлюз zuul, целевой сервис Также называются другие службы. Пропускная способность интрасети также является пропускной способностью, и сетевая задержка также влияет на скорость вызова, которую также следует сжимать.

nginx->zuul->服务A->服务E

Чтобы все вызовы между Feign проходили через канал сжатия, требуется дополнительная настройка. МыspringbootСлужбы, которые можно обрабатывать с помощью прозрачного сжатия okhttp.

Добавьте его зависимости:

<dependency>
	<groupId>io.github.openfeign</groupId>
	<artifactId>feign-okhttp</artifactId>
</dependency>

Включить конфигурацию сервера:

server:
  port:8888
  compression:
    enabled:true
    min-response-size:1024
    mime-types:["text/html","text/xml","application/xml","application/json","application/octet-stream"]

Включить конфигурацию клиента:

feign:
  httpclient:
    enabled:false
  okhttp:
    enabled:true

После этих сжатий среднее время отклика нашего интерфейса напрямую сокращается с 5-6 секунд до 2-3 секунд, и эффект оптимизации очень значителен.

Конечно же, мы также сделали статью о результирующем наборе, в данных, возвращаемых во внешний интерфейс, были упрощены неиспользуемые объекты и поля. Но в целом эти изменения травмоопасны и требуют большой корректировки кода, поэтому у нас на это ограничены силы, и эффект, естественно, ограничен.

3. Параллельный сбор данных и быстрое реагирование

Далее необходимо углубиться во внутреннюю логику кода для анализа. Выше мы упоминали, что пользовательский интерфейс на самом деле является интерфейсом агрегации данных. Каждый из его запросов через Feign обращается к интерфейсам десятков других сервисов для получения данных, а затем сращивает результирующий набор.

Почему это медленно? Потому что все эти запросы серийные! Фальшивые вызовы — это удаленные вызовы, то есть вызовы с интенсивным сетевым вводом-выводом, ожидающие большую часть времени.Если данные удовлетворительны, он очень подходит для параллельных вызовов.

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

Результаты анализа неоднозначны, эти интерфейсы можно условно разделить на категории А и Б по логике вызова. Во-первых, вам нужно запросить интерфейс класса A, а после объединения данных данные будут использоваться классом B. Но в классах A и B нет требований к порядку.

image.png

То есть мы можем разбить этот интерфейс на две части, которые выполняются последовательно, и данные можно получать параллельно в какой-то части.

Затем попробуйте преобразовать его в соответствии с этим результатом анализа.Используя CountDownLatch в пакете concurrent, легко реализовать функцию параллельного извлечения.

CountDownLatch latch = new CountDownLatch(jobSize);
//submit job
executor.execute(() -> { 
    //job code
	latch.countDown(); 
}); 
executor.execute(() -> { 
	latch.countDown(); 
}); 
...
//end submit
latch.await(timeout, TimeUnit.MILLISECONDS); 

Результаты очень удовлетворительные, время, затрачиваемое на наш интерфейс, сократилось почти вдвое! В настоящее время время интерфейса сократилось до менее 2 секунд.

Вы можете спросить, почему бы не использовать параллельные потоки Java? Для ямы параллельных потоков, вы можете обратиться к этой статье. Крайне не рекомендуется его использовать.

«Яма параллельного потока, вы не знаете, если вы не наступите на нее, вы будете потрясены, когда наступите на нее»

Будьте осторожны с параллельным программированием, особенно в бизнес-коде. Мы создали выделенный пул потоков для поддержки этой функции параллельного сбора данных.

final ThreadPoolExecutor executor = new ThreadPoolExecutor(100, 200, 1, 
            TimeUnit.HOURS, new ArrayBlockingQueue<>(100)); 

Сжатие и распараллеливание — самые эффективные средства в нашей оптимизации. Они напрямую отсекают большую часть времени запроса, что очень эффективно. Но нас все равно это не устраивает, потому что на каждый запрос по-прежнему приходится больше 1 секунды.

4. Классификация кеша для дальнейшего ускорения

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

for(List){
    client.getData();
}

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

Кэширование играет огромную роль в оптимизации большинства приложений. Однако из-за сравнения компрессии и параллельных эффектов эффект кэширования в нашем сценарии не очень очевиден, но все же сокращает время запроса примерно на 30-40 миллисекунд.

Мы делаем это.

В первую очередь сделаем часть логики кода простой, подходящей дляCache Aside PatternДанные схемы размещаются в распределенном кэше Redis. В частности, при чтении сначала прочитайте кеш, а затем прочитайте базу данных, когда кеш не может быть прочитан; при обновлении сначала обновите базу данных, а затем удалите кеш (отложенное двойное удаление). Таким образом, можно решить большинство сценариев кэширования с простой бизнес-логикой, а также решить проблему непротиворечивости данных.

Однако просто сделать это недостаточно, потому что некоторая бизнес-логика очень сложна, а обновленный код сильно разбросан, что не подходит для использования.Cache Aside PatternСделайте макияж. Мы узнали, что некоторые данные имеют следующие характеристики:

  1. Эти данные после трудоемкого сбора будут использоваться снова в экстремальные сроки.
  2. Требования согласованности бизнес-данных для них можно контролировать в течение нескольких секунд.
  3. Для использования этих данных кросс-код, кросс-поток, использование различными способами

В ответ на эту ситуацию мы разработали очень недолговечный кеш-память в куче: через 1 секунду данные будут признаны недействительными, а затем снова прочитаны из базы данных. Добавление ноды для вызова интерфейса сервера — 1к раз в секунду, а мы напрямую сократили до 1 раза.

Здесь используется LoadingCache от Guava, а вызовы интерфейса Feign сокращаются на порядки.

LoadingCache<String, String> lc = CacheBuilder
      .newBuilder()
      .expireAfterWrite(1,TimeUnit.SECONDS)
      .build(new CacheLoader<String, String>() {
      @Override
      public String load(String key) throws Exception {
            return slowMethod(key);
}});

5. Оптимизация индекса MySQL

Наша бизнес-система использует базу данных MySQL, потому что в ней не участвует профессиональный администратор баз данных, а таблица данных создается с использованием JPA. В ходе оптимизации было обнаружено большое количество неразумных показателей, естественно, их следует оптимизировать.

Поскольку SQL очень чувствителен, я расскажу только о некоторых правилах оптимизации индексов, встречающихся в процессе оптимизации, думаю, вы можете провести аналогии и в своей бизнес-системе.

Индексы очень полезны, но имейте в виду, что если вы выполняете функциональные операции с полями, индексы работать не будут. Общий сбой индекса, возможны следующие две ситуации:

  • Тип поля индекса запроса отличается от типа данных, переданных пользователем, и требуется неявное преобразование. Например, для поля типа varchar параметр int передается в
  • Между двумя запрашиваемыми таблицами используются разные наборы символов, поэтому связанное поле нельзя использовать в качестве индекса.

Оптимизация индекса MySQL, самое основное - следовать принципу крайнего левого префикса, когда есть три поля a, b, c, если условие запроса использует a, или a, b, или a, b, c, тогда мы создать индекс (a, b, c), который содержит a и ab. Конечно, строки также могут иметь префиксы и индексироваться, но в обычных приложениях они менее распространены.

Иногда оптимизатор MySQL выбирает неправильный индекс, нам нужно использоватьforce indexУказывает индекс для использования. В JPA nativeQuery используется для написания SQL-запросов, привязанных к базе данных MySQL, и мы стараемся избегать этой ситуации, насколько это возможно.

Другая оптимизация заключается в уменьшении таблицы возврата. Поскольку InnoDB принимаетB+树, но если непервичный ключевой индекс не используется, кластеризованный индекс будет найден сначала через вторичный индекс, а затем данные будут расположены. Еще один шаг, генерируется таблица возврата. использовать覆盖索引, что позволяет в определенной степени избежать возврата к таблице и является широко используемым методом оптимизации. Конкретный метод заключается в том, чтобы поместить запрашиваемое поле вместе с индексом, чтобы создать совместный индекс, который является способом изменения пространства во времени.

6. JVM-оптимизация

Я обычно ставлю оптимизацию JVM на последнее кольцо. Более того, если в системе нет серьезной проблемы с зависанием или OOM, она не будет активно переоптимизировать ее.

К сожалению, наше приложение из-за большого объема памяти (8 ГБ+) часто зависает под параллельным сборщиком по умолчанию JDK1.8. Хоть и не очень часто, но каждые несколько секунд серьезно сказывались на плавности некоторых запросов.

В начале программы она была голой под JVM, GC информации и OOM, ничего не осталось. Для записи информации GC мы внесли следующие изменения.

Первым шагом является добавление различных параметров для устранения неполадок GC.

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/xxx.hprof  -DlogPath=/opt/logs/ -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -XX:+PrintTenuringDistribution -Xloggc:/opt/logs/gc_%p.log -XX:ErrorFile=/opt/logs/hs_error_pid%p.log

Таким образом, мы можем взять сгенерированный файл GC и загрузить его вgceasyи другие платформы для анализа. Вы можете просмотреть пропускную способность JVM, задержку каждого этапа и т. д.

Второй шаг — открыть информацию сборщика мусора SpringBoot и получить доступ к мониторингу Promethus.

Добавьте зависимости в pom.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

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

management.endpoints.web.exposure.include=health,info,prometheus

image.png

Понаблюдав за производительностью JVM, мы переключились на сборщик мусора G1. G1 имеет максимальную цель паузы, что может сделать наше время GC более плавным. В основном он имеет следующие параметры настройки:

  • -XX:MaxGCPauseMillisУстановите целевое время паузы, и G1 попытается его достичь.
  • -XX:G1HeapRegionSizeУстановите маленький размер кучи. Это значение представляет собой степень числа 2, не слишком большую и не слишком маленькую. Если вы не знаете, как его установить, оставьте значение по умолчанию.
  • -XX:InitiatingHeapOccupancyPercentКогда использование всей памяти кучи достигает определенного процента (по умолчанию 45%), начинается фаза параллельной маркировки.
  • -XX:ConcGCThreadsКоличество потоков, используемых параллельным сборщиком мусора. Значение по умолчанию зависит от платформы, на которой работает JVM. Модификации не рекомендуются.

После перехода на G1 эта непрерывная пауза волшебным образом исчезла! В течение этого периода было много проблем с переполнением памяти, но с благословением артефакта MAT они наконец были легко решены.

7. Другие оптимизации

С точки зрения инженерной структуры и архитектуры, если есть изъян, то роль оптимизации кода фактически ограничена, как в нашем случае.

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

Чтобы повторно использовать коллекцию карт, некоторые учащиеся используют метод очистки для очистки после каждого использования.

map1.clear();
map2.clear();
map3.clear();
map4.clear();

Данные в этих картах очень специфичны, и метод очистки немного особенный, его временная сложность составляет O(n), что приводит к большим временным затратам.

public void clear() {
    Node<K,V>[] tab;
    modCount++;
    if ((tab = table) != null && size > 0) {
        size = 0;
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

Та же потокобезопасная очередь сConcurrentLinkedQueue, его метод size() имеет очень высокую временную сложность и почему-то используется коллегами — все это убийцы производительности.

public int size() {
        restartFromHead: for (;;) {
            int count = 0;
            for (Node<E> p = first(); p != null;) {
                if (p.item != null)
                    if (++count == Integer.MAX_VALUE)
                        break;  // @see Collection.size()
                if (p == (p = p.next))
                    continue restartFromHead;
            }
            return count;
        }
}

Кроме того, очень медленно реагируют некоторые сервисные веб-страницы из-за сложной бизнес-логики и медленного выполнения клиентского JavaScript. Эта часть оптимизации кода должна быть выполнена коллегами по внешнему интерфейсу.Как показано на рисунке, используя вкладку производительности Chrome или Firefox, легко найти трудоемкий внешний код.

image.png

8. Резюме

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

Некоторые проблемы производительности могут быть решены на уровне бизнес-требований или на уровне архитектуры. Любая оптимизация, которая была доведена до уровня кода и требует вмешательства программистов, достигла ситуации, когда сторона спроса и сторона архитектуры больше не могут двигаться или больше не хотят двигаться.

Оптимизация производительности должна сначала собрать информацию, выявить узкие места, взвесить ЦП, память, сеть, ввод-вывод и другие ресурсы, а затем попытаться уменьшить среднее время отклика и повысить пропускную способность.

Кэширование, буферизация, объединение в пулы, сокращение конфликтов блокировок, асинхронность, параллелизм и сжатие — все это распространенные методы оптимизации. В нашем сценарии наибольшую роль играют сжатие данных и параллельные запросы. Конечно, с помощью других методов оптимизации наш бизнес-интерфейс был напрямую сокращен с 5-6 секунд до менее 1 секунды, но этот эффект оптимизации все еще очень впечатляет. Предполагается, что он не будет оптимизирован в течение длительного времени в будущем.

⚠️Эта статья является первой подписанной статьей сообщества Nuggets, и её перепечатка без разрешения запрещена.