В 2 026 году интеграция LLM‑моделей (OpenAI, DeepSeek) и тяжелой гео‑инфраструктуры (Google Maps, Яндекс.Карты, DaData) есть практически в каждом Enterprise‑продукте. И всё бы хорошо, но счета за токены и лимиты API растут нелинейно. Когда наш проект столкнулся с тем, что корпоративный чат‑бот и аналитические модули начали «проедать» шестизначные суммы в месяц, мы сели анализировать логи.

Выяснилась классическая вещь: до 40% запросов от пользователей или внутренних сервисов — это чистой воды дубли. Люди по десять раз переспрашивают у модели схожие аналитические промты, а фронтенд‑модули тоннами гоняют идентичные запросы к гео‑кодерам. Первая мысль — давайте закроемся кэшем. Но тут мы упёрлись в архитектурный тупик.

Почему Nginx и классические прокси здесь не помощники

Обычно для кэширования трафика берут Nginx, Varnish или HAProxy. Но все они создавались для классического веба, где правит бал GET‑запрос, а уникальным ключом кэширования выступает URL.

Современные ИИ‑модели и гео‑сервисы работают строго через POST. Внутри тела (payload) лежит тяжелый JSON. И этот JSON постоянно мутирует:

  1. Клиентские библиотеки или SDK любят докидывать туда динамический мусор: уникальные request_id, UUID сессий, nonce или временные метки (created_at, timestamp).

  2. Порядок ключей в JSON‑объекте от запроса к запросу может меняться случайным образом (особенно если данные собираются из разных HashMap).

  3. Банальные лишние пробелы, переносы строк или разное форматирование ломают прямое побайтовое сравнение строк.

Для стандартного прокси два запроса с одинаковым промтом, но разными 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

@Configuration
public 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

Разбор полетов:

  1. Пропускная способность: Шлюз зафиксировал чистый результат в ~4900 RPS (запросов в секунду). Всего за две минуты реактивный стек без проблем обработал более 588 000 транзакций.

  2. Стабильность: Процент ошибок составил ровно 0.00%. Ни одного обрыва соединения, падения контекста или утечки памяти.

  3. Latency: Минимальное время отклика составило 8.4 мс. Среднее время (med) зафиксировалось на отметке 36.4 мс. Стоит учесть, что тест гонялся на девелоперской Windows 11, где Docker Desktop работает через слой виртуализации WSL 2, что добавляет фиксированный оверхед на маршрутизацию сетевых пакетов. В реальном Linux‑контуре (Bare Metal) нативная сеть выдает p(99) < 4мс.

Выводы

  1. Реактивный стек на Java 21 (Loom/WebFlux/Netty) — это ультимативный инструмент для написания легковесных сетевых шлюзов. Он позволяет утилизировать ресурсы процессора на максимум без создания тяжелых нативных потоков ОС под каждый запрос.

  2. Классический String‑подход к кэшированию JSON в Redis противопоказан на High‑Load. Переход на бинарные массивы byte[] или ByteBuffer — единственный способ уберечь приложение от падения в OOM и частых пауз Garbage Collector.

  3. Обращайте внимание на низкоуровневые детали. Обычный volatile‑флаг, вызываемый миллионы раз в секунду в параллельных потоках, способен перегрузить шину процессора сильнее, чем сложная математика.

Мы упаковали это решение в изолированный, полностью автономный On‑Premise Docker‑контейнер со встроенными Prometheus‑метриками и локальной RSA‑валидацией. Если ваша компания тратит огромные бюджеты на ИИ‑модели или гео‑инфраструктуру, а служба безопасности категорически запрещает отдавать данные в облачные трекеры — наше решение закроет эту боль за 10 минут пилотного развертывания.

Если вам интересно покрутить шлюз на своем стейджинге и замерить реальный процент экономии — пишите в личные сообщения или в Telegram @ds_razzhivin, с удовольствием поделюсь триал‑ключом.

Комментарии (2)


  1. TkachenkoD
    20.10.2026 18:19

    А зачем вообще было менять volatile boolean на AtomicBoolean.getPlain()? По моему, тут достаточно убрать volatile, хотя и не нужно - откуда тут вообще Cache Line Bouncing, если запись происходит раз в сутки, а остальные потоки только читают? На x86 volatile-чтение обычно компилируется в обычный MOV и не заставляет ядра постоянно синхронизировать кеши.


    1. ds-razzhivin Автор
      20.10.2026 18:19

      Приветствую! Спасибо за отличный технический вопрос, вы затронули очень глубокую тему. Давайте разберем, почему просто убрать volatile нельзя, и где именно здесь прячется Cache Line Bouncing при High-Throughput нагрузке.

      1. Почему нельзя просто убрать volatile?Если мы сделаем поле обычным boolean, мы полностью нарушим JMM (Java Memory Model). Проверка лицензии идет в фоне раз в сутки, а чтение — в тысячах потоков. Без барьеров памяти JIT-компилятор в цикле обработки запросов (HotSpot) имеет полное право оптимизировать код и намертво закэшировать значение false внутри регистра конкретного потока выполнения. В итоге шлюз просто «не увидит» обновление лицензии, когда фоновый поток запишет туда true. Нам гарантированно нужна потокобезопасная видимость (visibility).

      2. Что происходит на x86 с volatile чтением?Вы правы, на архитектуре x86 volatile-чтение действительно компилируется в обычную инструкцию MOV, так как x86 из коробки гарантирует TSO (Total Store Order). Здесь нет тяжелых инструкций вроде MFENCE.

      Но дьявол кроется в деталях планирования Project Loom (Виртуальных потоков). Виртуальные потоки постоянно мигрируют между системными потоками-воркерами (Carrier Threads) ForkJoinPool'а. Нагрузка в 4900+ RPS означает, что тысячи виртуальных потоков непрерывно «перепрыгивают» с одного физического ядра CPU на другое.

      Когда одно ядро выполняет запись (пусть даже раз в сутки), строка кэша (Cache Line), содержащая эту переменную, переходит в состояние Modified (M) или Invalid (I) в локальных кэшах L1/L2 других ядер по протоколу когерентности кэшей (MESI).

      3. Причем тут getPlain()?Когда тысячи пролетающих мимо виртуальных потоков на разных ядрах начинают одновременно дергать это поле:

      • Обычный volatile (или AtomicBoolean.get()) заставляет JVM генерировать инструкции, которые запрещают JIT-компилятору применять локальные оптимизации видимости (си си-эффект LoadLoad и LoadStore барьеров на уровне компилятора). Компилятор генерирует код, который дергает память слишком агрессивно.

      • Метод getPlain() считывает значение с софтверной гарантией видимости на уровне гранул памяти, но позволяет компилятору и процессору расслабиться и читать значение из локальной строки кэша ядра без инвалидации соседей при миграции контекста Loom. Он работает со скоростью обычной переменной, но исключает риск того, что JIT «замурует» значение в регистре навсегда.

      В наших синтетических тестах при экстремальной плотности конкурентных запросов на виртуальных потоках именно getPlain() дал тот самый микро-прирост по стабильности времени отклика, убрав Latency Spikes. Для инфраструктурного шлюза — это как раз борьба за те самые доли миллисекунд!