Ключевой сервис в недавнем проекте вызвал ряд проблем с GC из-за специфики бизнеса. После долгих поисков и попыток проблема наконец-то была решена. Ниже приведены записи процесса и сбора урожая.
Справочная информация
Эта услуга предназначена для обеспечения функции сортировки продуктов, и бизнес-требования заключаются в следующем:
- Товары делятся на страны, и товары в каждой стране разные.
- Каждый элемент имеет поле первичного ключа
goodsId
, и есть одномерная матрица признаков, которая сохраняется как одномерный массив с плавающей запятой длиной 128. - При сортировке предоставьте матрицу признаков условий запроса
A
, и партия альтернативных товаровgoodsId
(до 5000), то взять входную матрицуA
Умножьте его на матрицу характеристик всех продуктов-кандидатов, чтобы получить соответствующий балл каждого продукта и вернуть его. - Коллекция продуктов нуждается в регулярном обновлении, и коллекция продуктов каждой страны обновляется отдельно.
Здесь вы можете увидеть особенность этого сервиса: каждый запрос должен найти максимум 5000 массивов float[128]! Как хранить эти данные, действительно проблема.
Решение, которое мы приняли, состоит в том, чтобы создать в памяти большую карту, а структура представляет собойMap<String, Map<String, float[128]>
. Внешний слой содержит сопоставление стран с товарными коллекциями, а внутренний MapgoodsId
к его матрице признаков. Мы подсчитали количество данных, и приблизительная оценка такова, что память, занимаемая одной внутренней картой, составляет около 350 МБ, а память всей внешней большой карты составляет около 2 ГБ.
Чтобы обеспечить понимание, простая иллюстрация карты выглядит следующим образом:
nation1:
goodsId1: 特征矩阵1
goodsId2: 特征矩阵2
...
nation2:
goodsId1: 特征矩阵1
goodsId2: 特征矩阵2
...
вахтенный офицерНа этом этапе мы должны спросить, почему мы не используем централизованные кеши, такие как Redis, а напрямую помещаем данные в память?
Что ж, перед написанием этой статьи я провел стресс-тестирование и обнаружил, что производительность Redis на самом деле не так высока. Например, заявленное официальное значение OPS для одного экземпляра 100 000+ действительно может быть достигнуто, но что означает это число? Это означает, что запрос на получение занимает 0,01 мс, а MGET размером 1000 — 10 мс! Это все еще без задержки сети. Я измерил ключи MGET 5000 локально (сервер и клиент находятся на локальной физической машине и виртуальной машине соответственно), и задержка составляет от 40 до 60 мс (в этом сценарии значение не слишком велико, около 1 КБ, и это не вызвало значительная производительность. снижение). Разместите статью здесь:Фантазия производительности и суровая реальность Redis
Другая идея — использовать Redis плюс локальный кеш. Однако в этом сценарии есть миллионы фрагментов данных и нет горячих точек, поэтому локальное кэширование сложно сделать эффективным.
Ближе к дому. С этой картой основной интерфейс сервиса прост в обращении:
- Вход: принять один
nation
параметры, наборgoodsId
и матрица признаков условий запросаA
, также с плавающей запятой[128] - в соответствии с
nation
иgoodsId
Найдите матрицу характеристик продукта, а затем просуммируйтеA
Умножьте, чтобы получить соответствующий балл продукта.
Эффект первого издания: При нормальном количестве запросов в секунду средняя задержка составляет менее 10 мс.
Предыстория объяснена. Кошмар вот-вот начнется~
Возникла проблема с GC
После выхода в интернет все было идеально. Однако после работы в течение определенного периода времени восходящая служба начинает прерываться по тайм-ауту или даже нерегулярно сливается, и продолжительность каждого времени очень мала. После некоторого расследования мы заметили, что при возникновении проблемы индикатор службы TP99 резко возрастает, как показано на следующем рисунке:
Время отклика иногда достигает 1 секунды! В случае отсутствия аномалий в журнале, мы можем только подозревать, что GC работает, поэтому я нашел журнал GC, чтобы выяснить это.
Следите за журналом GC
Ниже приведен журнал gc, когда параметры JVM принимают -Xmx4g -Xmx4g, версия Java: OpenJDK 1.8.0_212.
{Heap before GC invocations=393 (full 5):
PSYoungGen total 1191936K, used 191168K [0x000000076ab00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 986112K, 0% used [0x000000076ab00000,0x000000076ab00000,0x00000007a6e00000)
from space 205824K, 92% used [0x00000007b3700000,0x00000007bf1b0000,0x00000007c0000000)
to space 205824K, 0% used [0x00000007a6e00000,0x00000007a6e00000,0x00000007b3700000)
ParOldGen total 2796544K, used 2791929K [0x00000006c0000000, 0x000000076ab00000, 0x000000076ab00000)
object space 2796544K, 99% used [0x00000006c0000000,0x000000076a67e750,0x000000076ab00000)
Metaspace used 70873K, capacity 73514K, committed 73600K, reserved 1114112K
class space used 8549K, capacity 9083K, committed 9088K, reserved 1048576K
4542.168: [Full GC (Ergonomics) [PSYoungGen: 191168K->167781K(1191936K)] [ParOldGen: 2791929K->2796093K(2796544K)] 2983097K->2963875K(3988480K), [Metaspace: 70873K->70638K(1114112K)], 2.9853595 secs] [Times: user=11.28 sys=0.00, real=2.99 secs]
Heap after GC invocations=393 (full 5):
PSYoungGen total 1191936K, used 167781K [0x000000076ab00000, 0x00000007c0000000, 0x00000007c0000000)
eden space 986112K, 0% used [0x000000076ab00000,0x000000076ab00000,0x00000007a6e00000)
from space 205824K, 81% used [0x00000007b3700000,0x00000007bdad95e8,0x00000007c0000000)
to space 205824K, 0% used [0x00000007a6e00000,0x00000007a6e00000,0x00000007b3700000)
ParOldGen total 2796544K, used 2796093K [0x00000006c0000000, 0x000000076ab00000, 0x000000076ab00000)
object space 2796544K, 99% used [0x00000006c0000000,0x000000076aa8f6d8,0x000000076ab00000)
Metaspace used 70638K, capacity 73140K, committed 73600K, reserved 1114112K
class space used 8514K, capacity 9016K, committed 9088K, reserved 1048576K
}
Некоторая информация, которую можно получить из журнала:
- Если в JDK 8 не указан метод gc, по умолчанию используется комбинация Parallel Scavenge + Parallel Old. Это даже не CMS.
- Причина этого Full GC в том, что старость заполнена, а STW приостанавливается на 3 секунды...
Скорректировать стратегию gc
Поскольку есть проблема с GC, его необходимо настроить. Вот некоторые из моих попыток:
- Сборщик мусора заменен на CMS
- Так как в старости места мало, дайте ей больше места! Увеличьте размер всей кучи и увеличьте размер памяти старого поколения.
Ниже приведены экспериментальные результаты и выводы:
- Переходить на CMS бесполезно, но даже хуже. Думаю, причина в том, что CMS больше зависит от количества ядер ЦП, а мы ограничиваем количество ядер до очень низкого уровня в среде докеров, что приводит к незначительному улучшению параллельной обработки CMS. Даже иногда из-за плотной памяти старости тоже появится
Concurrent Mode Failure
, введите итоговую линейную полную сборку мусора и займите больше времени. - После увеличения памяти кучи количество обычных сборщиков мусора и полных сборщиков мусора уменьшается, но одиночный сборщик мусора работает медленнее. Не удалось решить проблему.
Прилагается место аварии CMS:
[GC (CMS Initial Mark) [1 CMS-initial-mark: 4793583K(5472256K)] 4886953K(6209536K), 0.0075637 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[CMS-concurrent-mark-start]
03:05:50.594 INFO [XNIO-2 task-8] c.shein.srchvecsort.filter.LogFilter ---- GET /prometheus?null took 3ms and returned 200
{Heap before GC invocations=240 (full 7):
par new generation total 737280K, used 737280K [0x0000000640000000, 0x0000000672000000, 0x0000000672000000)
eden space 655360K, 100% used [0x0000000640000000, 0x0000000668000000, 0x0000000668000000)
from space 81920K, 100% used [0x0000000668000000, 0x000000066d000000, 0x000000066d000000)
to space 81920K, 0% used [0x000000066d000000, 0x000000066d000000, 0x0000000672000000)
concurrent mark-sweep generation total 5472256K, used 4793583K [0x0000000672000000, 0x00000007c0000000, 0x00000007c0000000)
Metaspace used 66901K, capacity 69393K, committed 69556K, reserved 1110016K
class space used 8346K, capacity 8805K, committed 8884K, reserved 1048576K
[GC (Allocation Failure) [ParNew: 737280K->737280K(737280K), 0.0000229 secs][CMS[CMS-concurrent-mark: 1.044/1.045 secs] [Times: user=1.36 sys=0.05, real=1.05 secs]
(concurrent mode failure): 4793583K->3662044K(5472256K), 3.8206326 secs] 5530863K->3662044K(6209536K), [Metaspace: 66901K->66901K(1110016K)], 3.8207144 secs] [Times: user=3.82 sys=0.00, real=3.82 secs]
Heap after GC invocations=241 (full 8):
par new generation total 737280K, used 0K [0x0000000640000000, 0x0000000672000000, 0x0000000672000000)
eden space 655360K, 0% used [0x0000000640000000, 0x0000000640000000, 0x0000000668000000)
from space 81920K, 0% used [0x0000000668000000, 0x0000000668000000, 0x000000066d000000)
to space 81920K, 0% used [0x000000066d000000, 0x000000066d000000, 0x0000000672000000)
concurrent mark-sweep generation total 5472256K, used 3662044K [0x0000000672000000, 0x00000007c0000000, 0x00000007c0000000)
Metaspace used 66901K, capacity 69393K, committed 69556K, reserved 1110016K
class space used 8346K, capacity 8805K, committed 8884K, reserved 1048576K
}
Кстати, вот некоторые подводные камни, которые встречаются в процессе, в основном ограничения среды docker/k8s, некоторые из которых до сих пор не устранены. Давайте поговорим об этом позже.
- jstat Невозможно указать процесс, так как процесс Java равен 0.
- Похоже, что аварийный дамп OOM собрать не так-то просто.
- VisualVM плохо подключается.
Как избежать проблем с GC
Подумайте: в чем проблема?
На самом деле причина этой проблемы GC очевидна: в силу специфики бизнеса мы держим в памяти несколько больших объектов карты, и можно не сомневаться, что они войдут в старость. Однако эти объекты не бессмертны! Время от времени, поскольку данные необходимо обновлять, будут создаваться некоторые новые объекты карты, а старые объекты карты теряют свои ссылки и должны быть восстановлены GC. Из-за массового роста памяти в пожилом возрасте приходится выполнять Major GC, и за один раз приходится высвобождать большой объем памяти, поэтому сложно сократить это время до особо низкого уровня.
Поскольку проблема заключается в большом объекте карты, решение естественно: избегать использования больших объектов карты, а точнее - не помещать такие большие данные в память кучи.
Если данные не помещаются в память кучи, они либо помещаются из кучи (прямая память в процессе), либо из процесса.
Внепроцессные решения — это, очевидно, базы данных, а как насчет внутрипроцессных решений? Существует два типа внутрипроцессных баз данных (таких как Berkeley DB) и кэши вне кучи. И база данных для меня слишком тяжелая, на самом деле все, что я хочу, это функция карты. Поэтому было принято решение дополнительно исследовать кеши вне кучи.
Кроме того: я не буду повторять здесь детали схемы кэширования Java, а обращусь к разделу кэширования в «Изучаем архитектуру с Kaitao»:
Тип кеша Java
- Кэш кучи: используйте память кучи Java для хранения объектов. Преимущество заключается в том, что она не требует сериализации/десериализации и работает быстро. Недостатком является то, что на нее влияет GC. Может быть реализован с использованием Guava Cache, Ehcache 3.x, MapDB.
- Кэш вне кучи: кешированные данные хранятся вне кучи, разрывая оковы JVM.При чтении данных требуется сериализация/десериализация, которая намного медленнее, чем кеш в куче. Его можно реализовать с помощью Ehcache 3.x, MapDB.
- Кэш диска: данные все еще существуют при перезапуске JVM, но данные кэша кучи/кеша вне кучи будут потеряны и должны быть перезагружены. Его можно реализовать с помощью Ehcache 3.x, MapDB.
- Распределенный кэш: нечего сказать, Redis…
кэш вне кучи
Просто опубликуйте две статьи:
Кратко резюмируя:
- Кэш в куче может вызвать проблемы с производительностью GC, когда объем данных огромен. Кэш вне кучи разрешим.
- Принцип реализации кэша вне кучи таков:
Unsafe
Класс напрямую манипулирует памятью процесса, поэтому ему необходимо контролировать переработку памяти и сериализацию/десериализацию с объектами Java, поскольку он знает только байты вне кучи, но не объекты Java. - Такую полезную, но не простую в реализации функцию конечно лучше всего обратиться к фреймворку. Поддерживаемые фреймворки: mapdb, ohc, ehcache3 и т. д. Ehcache3 заряжает, ohc самый быстрый.
Таким образом, решение использовать ohc. Адрес официального сайтаздесь.
дизайн кода
Идеи:
- Учитывая гибкость, выберите режим стратегии. см. вышеКэши вне кучи, о которых вы не зналиконец текста.
- Поскольку он по-прежнему используется в качестве карты, пусть инкапсулированный класс инструмента наследует интерфейс Map. один из ohc
OHCache
Объект представляет собой кэш вне кучи, и я инкапсулирую его как карту для хранения данных страны. Естественно их будет несколькоOHCache
. - Кроме того, обратите внимание, что
OHCache
Сам класс наследуетсяCloseable
интерфейс, то есть вызов егоClose()
Метод может освободить свои ресурсы, то есть восстановить память. Следовательно, класс инкапсулированного инструмента также должен наследоватьCloseable
, а при обновлении данных о стране вызвать замену исходного объекта картыClose()
метод освобождения памяти. Проверено и возможно. - Поскольку он используется в Spring Boot и является фреймворком кэширования, естественно захотеть адаптировать его к системе кэширования Spring. Еще не реализовано.
Эффект
На картинке ниже показано улучшениеДо, при использовании карты в куче ситуация с сборкой мусора, вызванная обновлением карты при обновлении данных:
Видно, что Young GC уже давно, а есть и Major GC. Фактическое время GC намного больше, чем на графике (индикатор актуатора), Major GC больше 1 секунды.После использования улучшения памяти вне кучи я уменьшил память кучи JVM, чтобы оставить достаточно памяти для вне кучи, эффект:
- Эффект тот же. . . По-прежнему будут серьезные проблемы с сборкой мусора! фото не буду выкладывать.
- Средняя задержка изменилась с исходных 10 мс до 40 мс...
Сделайте еще один шаг вперед
Вот, не буду продавать (се) (бу) закрыть (ся) саб (цю), прямо скажем причину проблемы.
Яма 1: Чтение больших файлов
Это обновление также внесло изменения: источник данных, используемый для обновления карты, был изменен с базы данных на файлы на s3, и эти файлы будут весить сотни МБ. и мы использовалиCommonsIO
изreadLines()
метод. Что ж, он загрузит все содержимое файла в кучу, и никакой сборщик мусора не виноват!
После переключения на перечислитель строк проблема с сборщиком мусора окончательно исчезла. Нет больше Major GC.
Замена карты происходит на изображении ниже:
Время основного GC равно нулю!
Яма 2: Сериализация
ohc требует, чтобы вы предоставили метод сериализации ключа и значения, передайтеByteBuffer
. Из-за моей молодости и невежества я использовал его сноваApache
Инструмент сериализации преобразует объект в массив байтов в куче в соответствии с методом сериализации JDK, а затем копирует его вByteBuffer
середина.
Решение состоит в том, чтобы работать напрямуюByteBuffer
, пользовательский метод сериализации. После модификации также решена проблема с задержкой.
окончательный эффект
TP99 стабилен в течение 13 мс! Пока, пончики, о нет, пока, глюки~Затем прикрепите изменение общей памяти процесса при замене карты:
Спасибо за просмотр!
постскриптум
Пользуюсь ohc online уже несколько месяцев без проблем. Однако в последнее время было много онлайн-сигналов тревоги.После некоторого расследования выяснилось, что я использовал фиксированную емкость, а затем значение loadFactor по умолчанию было 0,75. По мере того, как сохраняется все больше и больше товарных индексов (общее количество товаров на полках увеличивается), он фактически превышает мою заданную емкость, и тогда... некоторые индексы перезаписываются. Эта яма заслуживает внимания и настоящим записана. По сути, использование компонента в производстве — это понимание каждого его аспекта.