Рисунок 1. Где живут микросекунды: от стойки и рыночной ленты до кода на Java.
Я потратил десять лет на то, чтобы помочь банкам, биржам и торговым компаниям сделать Java достаточно быстрой для торговли — часто доходя до нескольких десятков микросекунд от разъема до разъема (wire-to-wire). Чему это меня научило: высокочастотный трейдинг — это не какая-то отдельная дисциплина. Это та же самая разработка, просто с более высокими ставками. Торговые компании борются с теми же врагами, что и вы (паузы сборщика мусора, холодные JIT-компиляторы, избыточное создание объектов, необъяснимый «хвост» задержек), просто они не могут позволить себе их игнорировать. И ваше приложение, возможно, находится в гораздо более враждебной среде, чем настроенная торговая стойка: разделяемое «железо», шумные соседи, троттлинг контейнеров и автоскейлеры, передающие трафик холодным JVM.
Если вы не занимаетесь трейдингом, «хвост» всё равно бьет по вашему карману. Команды, которые не видят свои P99 или не доверяют им, просто покупают запас прочности (headroom): дополнительные инстансы, целевую загрузку CPU на уровне 35–55%, поды, которые держат «разогретыми» на всякий случай. Каждый урок ниже поможет вам вернуть этот запас прочности с помощью фактов и измерений.
Урок 1: Измеряйте тот «хвост», который реально чувствуют ваши пользователи
НА ТОРГОВОЙ ПЛОЩАДКЕ
У одного маркет-мейкера опционов, с которым я работал, был прекрасный медианный показатель в 2 мкс внутри его ценового движка, но 99-й перцентиль взрывался в пятьдесят раз каждый раз, когда просыпался сборщик мусора.
В ВАШЕМ ПРИЛОЖЕНИИ
Ваши пользователи тоже не ощущают среднее значение; они ощущают свой худший запрос. Средние величины сглаживают пики, а один-единственный перцентиль может их скрыть. Только «сырые» данные показывают их во всей красе. Полусекундная задержка может скрываться внутри вполне благопристойного P99 (Рисунок 2), и увидит ли её P99 вообще, зависит от временного окна, по которому вы проводите измерения (Рисунок 3).
Рисунок 2. Одна минута при 1000 транзакций в секунду. Задержка в 500 мс тормозит 500 транзакций — это слишком мало, чтобы сдвинуть P99.
Рисунок 3. Та же задержка, измеренная в окнах от одной секунды до одного часа. Максимум составляет 500 мс в каждом из них. P99 замечает задержку только тогда, когда она заполняет более 1% окна, а усреднение ежесекундных P99 дает числа, которые не являются P99 ни для чего.
Стройте графики P99.9 и максимум вместе с P99 по полным гистограммам, а не по агрегированным срезам. Интервальные логи HdrHistogram созданы именно для этого. И проверьте, не усредняет ли ваш инструмент APM показатели P99 за интервал при уменьшении масштаба; это та самая штриховая линия на Рисунке 3. Пока вы не научитесь видеть «хвост», вы не сможете сказать, какие из ваших запасных инстансов действительно защищают от него.
Проверьте это за пять минут: добавьте P99.9 и максимум на панель мониторинга задержек вашего важнейшего сервиса и посмотрите на один загруженный час с разрешением в 1 минуту.
Урок 2: Нельзя исправить то, чего не видишь
НА ТОРГОВОЙ ПЛОЩАДКЕ
Система измерений создается до того, как начнется любая оптимизация, и проверяется извне процесса, потому что внутриприложенные метки времени поступают из той же системы, которую вы наблюдаете. В одном банке отдел торговли акциями измеряет задержки прямо на коммутаторе с помощью захвата пакетов (packet capture) и аппаратных меток времени.
В ВАШЕМ ПРИЛОЖЕНИИ
Вам не нужен сетевой ответвитель (tap): ваш балансировщик нагрузки или система мониторинга реальных пользователей (RUM) выполняют ту же работу, замеряя время запросов извне процесса. Затем избегайте двух ловушек:
- Согласованное упущение (Coordinated omission). Генератор нагрузки, который ожидает окончания задержки перед отправкой следующего запроса, тихонько стирает ваши худшие замеры. Отправляйте запросы с фиксированной частотой.
- Нечестные тесты. Начинайте любое сравнение с прогона A/A, сравнивая две одинаковые конфигурации, чтобы увидеть, насколько цифры колеблются сами по себе. Затем воспроизведите производственный трафик и проверьте скучные настройки. Сравнение JVM в одной социальной сети выглядело однобоким, пока кто-то не заметил, что на двух сторонах были установлены разные уровни логирования. Такая же точность подскажет вам, когда безопасно использовать меньший парк серверов или более высокий целевой уровень CPU.
Чтобы увидеть, тормозит ли сама платформа, запустите jHiccup в одном из сервисов в качестве -javaagent. Он запускает поток, который не делает ничего, кроме сна и записи того, насколько запоздал его подъем. Поскольку этот поток не выполняет никакой ваш код, любая задержка, которую он видит, исходит от JVM или её окружения: пауз GC и других точек безопасности (safepoint), ОС, гипервизора или шумного соседа (Рисунок 4). Чтобы увидеть только паузы окружения, добавьте -c: тогда jHiccup также запускает холостую контрольную JVM, которую ваше приложение и его GC не могут затронуть.
Его молчание — тоже доказательство. FX-брокер зафиксировал паузы в 360 мс, которые jHiccup никогда не регистрировал, поэтому виновник должен был находиться внутри процесса: вызов Runtime.availableProcessors(), который приложение делало каждые 15 секунд.
Рисунок 4. Вывод jHiccup: регулярные паузы GC и один выброс в 180 мс, который среднее значение никогда бы не показало.
Иногда ответ появляется только тогда, когда вы накладываете шкалу времени задержек на графики самой операционной системы. Одна биржа гонялась за выбросами до 100 мс, которые возникали с точностью часовщика каждые 30 секунд, в то время как логи JIT и GC молчали. (Виновник будет назван в конце).
Проверьте это за пять минут: запустите jHiccup в качестве -javaagent в одном сервисе на один день и наложите его максимум на ваш P99.9. Если оба пика совпадают, изучите платформу и добавьте -c, чтобы отделить GC от окружения; если скачет только приложение, ищите внутри.
Урок 3: Убедитесь, что ваша JVM получает те мощности CPU, за которые вы платите
НА ТОРГОВОЙ ПЛОЩАДКЕ
Торговые системы изолируют ядра, поэтому планировщик и «тот самый блуждающий агент мониторинга» работают в другом месте. В одной торговой фирме этот блуждающий агент был вполне реален: инструменты мониторинга, сканировавшие карты памяти JVM, вызывали многосекундные пики задержек, и удаление этих инструментов стало важным шагом к стабильному торговому дню.
В ВАШЕМ ПРИЛОЖЕНИИ
Изоляция ядер — это избыточно для большинства сервисов, но два урока всё же применимы.
Знайте, кто делит с вами железо: сборщики логов, побочные контейнеры (sidecars) APM и сканеры безопасности повсеместно воруют процессорное время. В одном банке очереди на запуск CPU (run queues) заметно выросли после того, как агент безопасности был развернут на всей инфраструктуре.
Следите за лимитами CPU в Kubernetes. Современный способ «выстрелить себе в ногу» — это слишком жестко установленный лимит. JVM рассчитывает размер потоков GC и JIT на основе этого лимита, а потоки вашего приложения обычно — нет: обработчики запросов, асинхронные исполнители (executors) и циклы событий (event loops) могут работать одновременно. Когда они исчерпывают квоту, лимит начинает троттлить всю вашу JVM, включая потоки GC и JIT, порождая паузы, которые не объяснит ни один профайлер (Рисунок 5). API-шлюз одной финтех-компании упирался в лимит в 2 CPU при 99–100% периодов планирования, подвергнутых троттлингу. Команды обычно решают это добавлением реплик, но исправить лимит дешевле: для критичного к задержкам сервиса выделите целые ядра, сделав request равным limit; при использовании политики статического менеджера CPU в kubelet они станут эксклюзивными ядрами — это аналог изоляции ядер в Kubernetes. В противном случае рассчитывайте лимиты с запасом на всплески (GC, JIT и ваши пулы одновременно), а не на среднюю нагрузку, и подгоняйте под них размер пулов потоков. Если вы полностью убираете лимит, задайте -XX:ActiveProcessorCount, поскольку без лимита JVM оценивает себя по всему узлу.
Почему лимит CPU замораживает абсолютно всё. Linux применяет лимиты CPU в Kubernetes в рамках 100-миллисекундных периодов. Лимит в 2 CPU покупает 200 мс процессорного времени за период. Если восемь потоков, в основном из ваших собственных пулов, запускаются на восьми ядрах одновременно, они тратят это время примерно за 25 мс, и каждый поток в контейнере, включая сборщик мусора, оказывается «заморожен» на оставшиеся ~75 мс.
Рисунок 5. Лимит в 2 CPU с восемью занятыми потоками: 25 мс работы, затем 75 мс «заморозки» в каждом 100-миллисекундном периоде.
Проверьте это за пять минут: сравните container_cpu_cfs_throttled_periods_total с container_cpu_cfs_periods_total для ваших чувствительных к задержкам подов. Это пятиминутная проверка, которая положила конец многонедельным расследованиям.
Урок 4. «Холодные» JVM работают медленно именно тогда, когда вы загружены больше всего
НА ТОРГОВОЙ ПЛОЩАДКЕ
Она биржевой трейдер спросил меня: «Почему первые транзакции имеют задержку 1 мс по сравнению с 0,013 мс в дальнейшем?» Это 77-кратный штраф на первой сделке при открытии рынка. Каждый метод запускается в интерпретаторе и попадает в оптимизированный код C2 только после тысяч профилированных вызовов, поэтому первые сделки выполняются на самом медленном коде, который есть у JVM. Торговая система обычно перезапускается один раз в день и прогревается перед звонком (при наличии правильных технологий).
В ВАШЕМ ПРИЛОЖЕНИИ
Каждый деплой, масштабирование и перепланирование запускают новую JVM, а новая JVM работает медленнее всего первые несколько тысяч запросов. Таким образом, под, который ваш автоскалер добавляет во время всплеска нагрузки, оказывается самым медленным из имеющихся у вас в тот момент, когда он нужен больше всего. Взрывной трафик делает ситуацию еще хуже: распродажа или AI-агент, превращающий один запрос в десятки вызовов сервиса, попадают на только что масштабированные, еще «холодные» поды. Если P99 ухудшается по мере добавления емкости, обычно дело в этом. Вот почему команды держат запасные поды разогретыми «на всякий случай». Решите проблему прогрева, и вы сможете работать с меньшими ресурсами.
Ваши основные варианты — от наименьших усилий к наибольшим:
Проверьте это за пять минут: постройте график P99 в зависимости от возраста пода (минут с момента запуска) для одного сервиса. Если первые несколько минут выделяются, вы платите налог на прогрев при каждом событии масштабирования.
Уроки 5–9. Быстрые результаты
5. Расстояние стоит денег
Свет по оптоволокну распространяется со скоростью около 5 нс на метр, поэтому трейдеры арендуют место в стойках прямо на бирже. Вы не можете купить кросс-коннект в облаке, но принцип остается прежним: сорок хопов сервиса, микросервисы, общающиеся через зоны доступности, база данных, оставшаяся в другом регионе с 2021 года. Кроме того, есть налог на сеть: невыставленный TCP_NODELAY — это классическая загадочная задержка примерно в 40 мс, а непулированные соединения и незавершенные сессии TLS добавляют круговые запросы (round-trips) к каждому хопу.
Никакие настройки JVM не окупят то, что отдает ленивая топология. Межзонное общение выставляет счет дважды: один раз в вашем P99, другой — в инфляции счета.
6. Выбирайте инстансы, которые не будут простаивать под нагрузкой
Торговые стойки отключают энергосбережение BIOS, потому что засыпающему ядру требуются микросекунды на пробуждение. Ваш аналог — инстанс с возможностью берста (burstable), чьи кредиты CPU исчерпываются в середине всплеска; используйте семейства с фиксированной производительностью для работы, чувствительной к задержкам. На большинстве x86-инстансов vCPU — это гиперпоток, а не ядро, однако JVM выделяет потоки сборщика мусора и JIT так, будто каждый из них является ядром.
Управляемые платформы не устраняют проблему, они ее скрывают. Один сервис на Spring в AWS Fargate показал скачки в несколько сотен миллисекунд, в то время как P99 пауз сборщика мусора составлял здоровые 3,3 мс: хост морил контейнер голодом. Следите за простоями там, где их показывает ваша платформа. На EC2 проверьте CPU steal, а на типах с возможностью берста — CPUCreditBalance. На VMware проверьте CPU Ready (%RDY). На Fargate, где вы не видите хост, сравните CPU задачи с ее резервом, проверьте троттлинг cgroup и позвольте максимуму jHiccup показать вам простои. Устранение простоя стоит дешевле, чем расширение парка для его маскировки.
7. Держите парк на актуальных поколениях процессоров
Инженеры торговых систем оптимизируют код под сам процессор. Они дополняют горячие структуры данных, чтобы два потока не боролись за одну и ту же 64-байтную строку кэша (ложное совместное использование), и заранее покупают новые поколения процессоров ради их более широких векторных инструкций.
Вы не будете дополнять строки кэша, но вы выбираете поколения инстансов, а в парках накапливаются старые. Это обходится вам в два раза дороже. Новые поколения обычно дешевле в расчете на единицу работы, а старое «железо» может не иметь инструкций, которые в противном случае использовала бы JVM. Один путь с интенсивным использованием SHA в SaaS-компании работал в четыре раза быстрее на Ice Lake, чем на Skylake образца 2017 года, в котором отсутствуют инструкции SHA.
8. Перестаньте создавать мусор
Самая дешевая пауза сборщика мусора — та, которую вы никогда не вызываете. Упомянутый в Уроке 1 маркетмейкер на рынке опционов снизил уровень аллокаций до 10 МБ в секунду, и его сборщик работал 0,3% времени. Вам не нужны их подвиги. Сокращение объема избыточных аллокаций, которые никто не профилировал, решит проблему на большей части пути, потому что частота аллокаций управляет частотой сборки мусора, частота сборки управляет вашим хвостом (tail latency), и то и другое влияет на ваш счет за облако. Классика при 5000 запросах в секунду:
log.debug("processed order " + order.getId() + " in " + elapsedMs + "ms");
создает строку при каждом запросе, даже когда отладка выключена. Параметризованная форма почти ничего не аллоцирует при выключенной отладке (в худшем случае упаковывая числа в обертки):
log.debug("processed order {} in {}ms", order.getId(), elapsedMs);
Сто байт на один вызов — это почти два гигабайта мусора в час с одной строки, а в загруженном сервисе сотки таких строк. Профилируйте аллокации с помощью JFR или async-profiler, прежде чем что-либо менять; виновники редко оказываются там, где вы предполагаете.
9. Не позволяйте сборщику мусора устанавливать целевые показатели кучи и процессора
Торговые компании работают в обход сборщика мусора. Некоторые держат данные вне кучи (off-heap), в памяти, которую сборщик никогда не сканирует, часто через sun.misc.Unsafe (чьи методы доступа к памяти теперь рекомендовано к удалению в пользу Foreign Function & Memory API, ставшего финальным в JDK 22), чтобы GC никогда не мог поставить ее на паузу. Другие настраивают размер молодого поколения так, чтобы сборка мусора не запускалась в течение торговой сессии, а затем перезагружаются после закрытия рынка.
Урок заключается не в том, чтобы слепо их копировать. Каждое героическое обходное решение указывает на место, где платформа доставляет боль, а поскольку у вашего приложения нет звонка о закрытии, у него нет и спокойного окна для перезагрузки.
Ваши обходные пути мягче, но проистекают из того же страха: кучи ограничены, потому что «большие кучи вызывают паузы», парки мелких JVM и серверы с низкой нагрузкой. Например, один рекламный сервер, работающий с парой сотен инстансов, масштабировался при 50% загрузки процессора, потому что «SLA постоянно нарушается при запуске GC». Маленькие поды по-прежнему могут иметь смысл, но сборщик не должен быть тем, кто это решает.
Урок 10. Ваша JVM — это выбор, а не константа
Все, о чем говорилось выше, работает на любой JVM. Большинство команд выбирают свою JVM один раз и больше никогда к этому не возвращаются; торговые компании проводят повторное соревнование на своем собственном трафике всякий раз, когда меняются ставки. Многие фирмы, с которыми я работаю, выбрали Azul Prime для своих самых чувствительных к задержкам систем. JVM затрагивает три вещи, которые этот пост уже затронул: прогрев, сам машинный код и сборку мусора.
Прогрев. ReadyNow сохраняет профиль JIT и принятые решения между перезапусками, поэтому «горячий» код компилируется раньше (Рисунок 6), а деоптимизации меньше влияют на P99 при изменении трафика. Optimizer Hub добавляет распределение профилей по всему парку и вынесенную компиляцию; Azul сообщает об одном предприятии, которое использует более 10 000 JVM таким образом и сокращает расходы на облако более чем на 20%.
Рисунок 6. Иллюстративный прогрев: Prime, запускаемый на основе профиля ReadyNow, достигает стабильного состояния на тысячи транзакций раньше.
Машинный код. Falcon JIT от компании Azul создан на базе LLVM, который не отстает от новых наборов инструкций x86 и Arm и выполняет автовекторизацию для большего числа форм циклов, чем проход SuperWord в C2 (используя, например, AVX2 и AVX-512 на x86). В опубликованном компанией Azul бенчмарке Kafka Prime с совместно работающими Falcon и C4 обрабатывал на 30% больше трафика, удерживая P99 ниже 200 мс, и достиг на 45% более высокой максимальной пропускной способности (Рисунок 7). Базовой линией служила установка 2023 года на стандартном OpenJDK; в описании от Azul не указаны его версия или сборщик мусора.
Рисунок 7. Опубликованный Azul бенчмарк Apache Kafka: три брокера на i3en.2xlarge, одинаковое железо для обоих JVM. Полезная емкость — это максимальная нагрузка, при которой P99 остается ниже 200 мс.
Сборка мусора. G1, сборщик по умолчанию, приостанавливает работу приложения для сбора мусора в молодом и смешанном поколениях, что подходит для многих служб. Поколенческий ZGC (JDK 21+) и Shenandoah (поколенческий начиная с JDK 25) выполняют практически всю работу параллельно, с типичными паузами менее миллисекунды. Их слабое место — всплеск трафика: когда выделение памяти опережает сборщик, ZGC приостанавливает выделяющие потоки, а Shenandoah переключается на регулировку темпа или цикл остановки мира (stop-the-world). Сборщик C4 в Azul Prime также производит компактизацию параллельно и создан для того, чтобы справляться с такими пиками на больших кучах. В примере использования MEMX фондовая биржа США сообщает, что ее ключевое приложение с Prime демонстрирует время отклика P99.9 в 80 раз лучше.
Когда стандартный OpenJDK достаточен
Azul Prime оправдывает себя в долго работающих, чувствительных к задержкам сервисах с запасом прочности. Это неподходящий инструмент, если:
- Экземпляры слишком малы. На одном или двух vCPU JIT и параллельный сборщик конкурируют с вашим приложением за свободные ресурсы, если вы не вынесете компиляцию: для этого случая существует Cloud Native Compiler от Optimizer Hub, обязательно протестируйте его.
- Процессы быстро запускаются и завершаются. Пакетные задания, запуски CI, инструменты командной строки и недолговечные функции завершают работу до того, как оптимизирующий JIT окупит затраты на свой запуск.
- Проблема не в JVM. Никакая JVM не исправит сеть, базу данных или архитектуру.
В противном случае протестируйте ее так, как это делают трейдеры: запуск A/A, затем canary-тест A/B на продакшн-трафике, оценивая результат по P99.9, максимуму и количеству экземпляров.
Главный вывод
Задержка — это свойство всего стека. У хвоста распределения много хозяев: процессор, который действительно достается вашей JVM, ваш прогрев (warm-up), топология, ваши экземпляры и поколения процессоров, скорость выделения памяти, целевые показатели кучи и процессора, а также ваша JVM. Исправьте одну проблему, и ее место займет следующая по значимости.
Помните биржу из Урока 2, у которой выбросы возникали как по часам каждые 30 секунд? Виновником была запись Linux на диск страниц, которые находились в измененном состоянии более 30 секунд (параметр vm.dirty_expire_centisecs, по умолчанию равный 3000). Не JVM и не приложение, а стек.
То, что отличает торговый зал от вашего приложения, — это не физика или талант, а ставки и бюджет. Начинайте с хвоста, двигайтесь вниз по стеку и помните, что ваши пользователи, как и рынки, запоминают только ваш худший момент.
Десять проверок на эту неделю
Проверьте на собственном трафике
- Оцените Azul Prime: запросите пробную версию и выполните запуск A/A, а затем canary-тест A/B для сервиса, чувствительного к задержкам.
- Повторите бенчмарк Kafka: .
- Пообщайтесь с инженером: azul.com/contact. Подготовьте показатели P99.9, данные о троттлинге и цели по автоскейлингу.
А теперь отправляйтесь на поиски своих выбросов.
Часто задаваемые вопросы
Что вызывает высокую задержку P99 в Java-приложениях? Высокая задержка P99 в Java обычно обусловлена несколькими факторами во всем стеке: ограничениями CPU в Kubernetes, которые замедляют всю JVM, «холодными» JVM после деплоев и масштабирования, паузами сборки мусора, высокой интенсивностью выделения памяти и лишними сетевыми переходами. Средние значения скрывают эти задержки, поэтому командам следует строить графики P99.9 и максимальных значений по полным гистограммам. Azul Prime устраняет три из этих причин: ReadyNow для прогрева, компилятор Falcon JIT для машинного кода и сборщик мусора C4 для борьбы с паузами.
Как уменьшить время прогрева Java после деплоев и автоскейлинга? Каждый деплой, масштабирование вверх и повторное планирование запускают «холодную» JVM, которая работает медленнее всего первые несколько тысяч запросов. Возможные варианты варьируются от запросов на прогрев и кэша AOT в OpenJDK (проект Leyden, JDK 24 и новее) до снимков CRaC в таких сборках, как Azul Zulu и GraalVM Native Image. Azul Prime ReadyNow повторно использует профиль JIT и решения компилятора, записанные во время предыдущих запусков, поэтому «горячий» код компилируется заранее.
Почему мой Java-сервис зависает в Kubernetes, хотя использование процессора выглядит нормальным? Слишком жесткий лимит CPU может замедлить всю JVM, включая сборщик мусора и потоки JIT, как только занятые пулы потоков исчерпают выделенную квоту. Эти зависания проявляются в виде всплесков задержки, которые не объясняются профилировщиками. Чтобы проверить это, сравните container_cpu_cfs_throttled_periods_total с container_cpu_cfs_periods_total для подов, чувствительных к задержкам — это пятиминутная проверка, рекомендуемая инженерным отделом Azul.
Как снизить затраты на облако для Java без ущерба для задержек? Команды, которые не могут доверять своему P99, часто покупают запас прочности «на всякий случай»: дополнительные экземпляры, целевое использование процессора от 30 до 55 процентов и постоянный прогрев подов. Измерение хвоста распределения по полным гистограммам позволяет заменить этот запас прочности реальными данными. Azul Optimizer Hub, используемый вместе с Azul Prime, распространяет профили JIT по всему кластеру и разгружает компиляцию, а по данным Azul, одно из крупных предприятий использует более 10 000 JVM таким образом, сократив расходы на облако более чем на 20%.
Как выбрать JVM для Java-сервисов с низкими задержками? Проведите сравнительное тестирование на собственном трафике, начав с запуска A/A для двух идентичных конфигураций, чтобы увидеть, насколько сильно показатели меняются сами по себе. Затем сравните кандидатов по параметрам прогрева, качеству машинного кода и поведению сборщика мусора при всплесках трафика. В опубликованном Azul бенчмарке Apache Kafka решение Azul Prime обработало на 30% больше трафика, удерживая P99 ниже 200 мс, и достигло на 45% более высокой максимальной пропускной способности по сравнению со стандартным OpenJDK. Узнайте больше об Azul Prime.












