В 2026 году интеграция LLM-моделей (OpenAI, DeepSeek) и тяжелой гео-инфраструктуры (Google Maps, Яндекс.Карты, DaData) есть практически в каждом Enterprise-продукте. И всё бы хорошо, но счета за токены и лимиты API растут нелинейно. Когда наш проект столкнулся с тем, что корпоративный чат-бот и аналитические модули начали «проедать» шестизначные суммы в месяц, мы сели анализировать логи.
Выяснилась классическая вещь: до 40% запросов от пользователей или внутренних сервисов — это чистой воды дубли. Люди по десять раз переспрашивают у модели схожие аналитические промты, а фронтенд-модули тоннами гоняют идентичные запросы к гео-кодерам. Первая мысль — давайте закроемся кэшем. Но тут мы упёрлись в архитектурный тупик.
Почему Nginx и классические прокси здесь не помощники
Обычно для кэширования трафика берут Nginx, Varnish или HAProxy. Но все они создавались для классического веба, где правит бал GET-запрос, а уникальным ключом кэширования выступает URL.
Современные ИИ-модели и гео-сервисы работают строго через POST. Внутри тела (payload) лежит тяжелый JSON. И этот JSON постоянно мутирует:
-
Клиентские библиотеки или SDK любят докидывать туда динамический мусор: уникальные
request_id, UUID сессий,nonceили временные метки (created_at,timestamp). -
Порядок ключей в JSON-объекте от запроса к запросу может меняться случайным образом (особенно если данные собираются из разных HashMap).
-
Банальные лишние пробелы, переносы строк или разное форматирование ломают прямое побайтовое сравнение строк.
Для стандартного прокси два запроса с одинаковым промтом, но разными request_id — это абсолютно разные сущности. Кэш летит в мусорку. Писать кастомные Lua-скрипты под Nginx для глубокого парсинга, стриппинга и сортировки тяжелых JSON на лету — удовольствие для сильных духом. Поэтому мы решили написать свой легковесный, реактивный On-Premise шлюз на Java.
Архитектура шлюза: Loom, WebFlux и первые грабли
Чтобы шлюз не стал бутылочным горлышком и не добавлял задержек (latency), стек должен быть максимально быстрым. Мы выбрали Java 21 (Project Loom / Виртуальные потоки), Spring WebFlux и Netty. Смысл в том, чтобы удерживать тысячи параллельных RPS на одном-двух ядрах процессора, используя неблокирующий ввод-вывод.
Сердцем нашего шлюза стал CacheProxyHandler. Логика простая: перехватываем входящий POST-запрос, очищаем JSON от метаданных по гибкому конфигу провайдера, сортируем ключи по алфавиту, считаем SHA-256 и проверяем его в Redis.
Вот как выглядит базовая конфигурация маршрутов в нашем application.yaml:
yaml
proxy: routes: openai: base-url: "https://openai.com" ttl-minutes: 120 exclude-fields: ["request_id", "user_session_id", "created_at"] deepseek: base-url: "https://deepseek.com" ttl-minutes: 180 exclude-fields: ["generation_id", "client_timestamp", "trace_id"]
А вот так — первая (как нам тогда казалось, идеальная) реализация реактивного конвейера. Мы использовали стандартный ReactiveRedisTemplate<String, String> для работы с кэшем:
java
// Первая наивная версия пайплайнаreturn request.bodyToMono(String.class) .defaultIfEmpty("") .flatMap(bodyString -> { String normalizedBody = normalizeJson(providerKey, bodyString); String cacheKey = "proxy:cache:" + generateSha256(normalizedBody); return redisTemplate.opsForValue().get(cacheKey) .flatMap(cachedBody -> buildResponse(cachedBody, HttpStatus.OK, "HIT")) .switchIfEmpty( Mono.defer(() -> fetchFromOriginalApi(routeConfig.baseUrl(), bodyString) .flatMap(freshBody -> redisTemplate.opsForValue().set(cacheKey, freshBody, Duration.ofMinutes(2)) .thenReturn(freshBody)) .flatMap(freshBody -> buildResponse(freshBody, HttpStatus.OK, "MISS")) ) ); });
Внутри normalizeJson мы использовали старый добрый Jackson: парсили строку в JsonNode, рекурсивно удаляли поля из списка exclude-fields и собирали JSON обратно со строгой алфавитной сортировкой ключей.
Грабли №1: Как мы забили кучу (Heap) строковым мусором
Мы раскатили этот код на тестовом контуре и запустили k6 для симуляции 3000+ RPS. Через минуту графики утилизации памяти поползли вверх, а время отклика p(99) начало хаотично прыгать от 5 до 120 мс. Приложение регулярно ловило микро-паузы Stop-The-World.
Профайлер показал очевидное: StringRedisSerializer, который используется под капотом реактивного шаблона по умолчанию, на каждый входящий запрос выделял память в куче под новые массивы чаров (char[]) и строки, перекодируя бинарные данные из Redis в Java-строки (UTF-8). При высокой плотности запросов Garbage Collector просто не успевал вычищать этот строковый мусор.
Оптимизация: Переходим на бинарные рельсы
Чтобы убрать лишнюю аллокацию объектов, мы решили полностью отказаться от промежуточной конвертации JSON в строки при общении с базой. Пусть прокси-сервер перекладывает сырые байты из сетевых сокетов Netty напрямую в Redis и обратно.
Мы переписали конфигурацию Redis, жестко зафиксировав бинарный raw-пасс для значений:
java
@Configurationpublic class WebClientConfig { @Bean public ReactiveRedisTemplate<String, byte[]> reactiveRedisTemplate(ReactiveRedisConnectionFactory factory) { RedisSerializationContext<String, byte[]> context = RedisSerializationContext .<String, byte[]>newSerializationContext(RedisSerializer.string()) // Ключ — String .value(RedisSerializer.byteArray()) // Значение — сырой byte[] (Zero-Copy эффект) .build(); return new ReactiveRedisTemplate<>(factory, context); }}
В самом хэндлере CacheProxyHandler логика чтения и записи изменилась на работу с массивом байт:
java
return redisTemplate.opsForValue().get(cacheKey) .flatMap(cachedBytes -> { // Превращаем байты из Redis напрямую в строку ответа для клиента String cachedBody = new String(cachedBytes, StandardCharsets.UTF_8); return buildResponse(cachedBody, HttpStatus.OK, "HIT"); }) .switchIfEmpty( Mono.defer(() -> fetchFromOriginalApi(routeConfig.baseUrl(), cleanTargetPath, bodyString, request) .flatMap(freshBody -> { byte[] bytes = freshBody.getBytes(StandardCharsets.UTF_8); return redisTemplate.opsForValue().set(cacheKey, bytes, Duration.ofMinutes(routeConfig.ttlMinutes())) .thenReturn(freshBody); }) .flatMap(freshBody -> buildResponse(freshBody, HttpStatus.OK, "MISS")) ) );
Результат: нагрузка на GC упала в 4 раза, куча стабилизировалась, а среднее время отклика на Cache HIT опустилось до физического минимума сети.
Грабли №2: Эффект Cache Line Bouncing на проверке лицензий
Поскольку наш прокси поставляется как закрытый Enterprise On-Premise продукт, внутри крутится фоновый шедулер, который раз в сутки читает файл лицензии и проверяет его криптографическую RSA-2048 подпись. Результат проверки писался в обычную переменную:
java
private volatile boolean isLicenseValid = false;
Метод isLicenseActive() вызывался внутри реактивного HTTP-фильтра BillingWebFilter на каждый входящий запрос, чтобы мгновенно заблокировать шлюз, если лицензия отозвана или истекла.
Казалось бы, переменная меняется раз в сутки, потоки её только читают, какие проблемы? Но под экстремальной нагрузкой Project Loom раскидывает тысячи виртуальных потоков по разным физическим ядрам процессора. Ключевое слово volatile заставляет процессор при каждом чтении обходить быстрый локальный L1/L2 кэш ядра и проверять состояние строки памяти на аппаратном уровне через системную шину (протокол MESI).
Ядра процессора начали тратить драгоценные такты на непрерывную синхронизацию этой ячейки памяти между собой. Это классический низкоуровневый эффект Cache Line Bouncing.
Как починили: Мы заменили volatile boolean на AtomicBoolean и перешли на метод getPlain():
java
private final AtomicBoolean isLicenseValid = new AtomicBoolean(false);public boolean isLicenseActive() { return isLicenseValid.getPlain(); // Чтение БЕЗ volatile-барьера памяти}
Метод getPlain() считывает значение без барьера памяти (Memory Barrier), позволяя каждому ядру процессора забрать флаг из своего локального кэша со скоростью света. Для бизнес-логики лицензий это абсолютно безопасно: даже если какое-то ядро узнает об истечении лицензии на пару миллисекунд позже фонового шедулера — это ни на что не повлияет, зато системная шина процессора теперь полностью свободна.
Нагрузочное тестирование: Выжимаем максимум на Grafana k6
Для финального стресс-теста мы использовали утилиту k6. Нагрузку генерировали со следующими параметрами:
-
Конкурентность: 200 параллельных виртуальных пользователей (VUs).
-
Длительность: 2 минуты непрерывного штурма.
-
Пропорция трафика: 90% Cache HIT (повторные схожие запросы) и 10% Cache MISS (уникальные промты).
-
Окружение: Чтобы не слить баланс на реальном API OpenAI и убрать влияние внешнего интернета, рядом в Docker-сети был поднят легковесный Nginx в качестве Mock-сервера.
Итоговый лог консоли k6 по окончании теста:
text
█ TOTAL RESULTS checks_total.......: 1176522 9801.80/s checks_succeeded...: 100.00% 1176522 out of 1176522 checks_failed......: 0.00% 0 out of 1176522 ✓ status is 200 ✓ has X-Cache-Status header HTTP http_req_duration..............: avg=40.33ms min=8.4ms med=36.4ms max=1.51s p(90)=59.41ms p(95)=69.96ms http_req_failed................: 0.00% 0 out of 588261 http_reqs......................: 588261 4900.90/s NETWORK data_received..................: 88 MB 736 kB/s data_sent......................: 178 MB 1.5 MB/s
Разбор полетов:
-
Пропускная способность: Шлюз зафиксировал чистый результат в ~4900 RPS (запросов в секунду). Всего за две минуты реактивный стек без проблем обработал более 588 000 транзакций.
-
Стабильность: Процент ошибок составил ровно 0.00%. Ни одного обрыва соединения, падения контекста или утечки памяти.
-
Latency: Минимальное время отклика составило 8.4 мс. Среднее время (
med) зафиксировалось на отметке 36.4 мс. Стоит учесть, что тест гонялся на девелоперской Windows 11, где Docker Desktop работает через слой виртуализации WSL 2, что добавляет фиксированный оверхед на маршрутизацию сетевых пакетов. В реальном Linux-контуре (Bare Metal) нативная сеть выдаетp(99) < 4мс.
Выводы
-
Реактивный стек на Java 21 (Loom/WebFlux/Netty) — это ультимативный инструмент для написания легковесных сетевых шлюзов. Он позволяет утилизировать ресурсы процессора на максимум без создания тяжелых нативных потоков ОС под каждый запрос.
-
Классический String-подход к кэшированию JSON в Redis противопоказан на High-Load. Переход на бинарные массивы
byte[]илиByteBuffer— единственный способ уберечь приложение от падения в OOM и частых пауз Garbage Collector. -
Обращайте внимание на низкоуровневые детали. Обычный
volatile-флаг, вызываемый миллионы раз в секунду в параллельных потоках, способен перегрузить шину процессора сильнее, чем сложная математика.
Мы упаковали это решение в изолированный, полностью автономный On-Premise Docker-контейнер со встроенными Prometheus-метриками и локальной RSA-валидацией. Если ваша компания тратит огромные бюджеты на ИИ-модели или гео-инфраструктуру, а служба безопасности категорически запрещает отдавать данные в облачные трекеры — наше решение закроет эту боль за 10 минут пилотного развертывания.
Если вам интересно покрутить шлюз на своем стейджинге и замерить реальный процент экономии — пишите в личные сообщения или в Telegram @ds_razzhivin, с удовольствием поделюсь триал-ключом.
ссылка на оригинал статьи https://habr.com/ru/articles/1096954/