Меня зовут Игорь Симаков, я тимлид Java-разработки в команде маркетплейса. В работе часто сталкиваюсь с продакшн-инцидентами, оптимизацией сервисов и разбором проблем производительности. При анализе одного из продакшн heap dump обнаружил неожиданную картину: почти треть всей памяти занимал Hibernate Query Plan Cache. Причина оказалась нетривиальной - обычные запросы с IN (:ids) порождали тысячи уникальных SQL-планов

В этой статье разберу, как именно возникает эта проблема в Hibernate, почему она особенно критична для PostgreSQL, и покажу практическое решение через = ANY(:ids) с юнит-тестами, подтверждающими поведение

Проблема: QueryPlanCache занимал ~30% heap

Выгрузка heap dump из mat
Выгрузка heap dump из mat
  • SessionFactoryImpl → QueryPlanCache занимает 100+ МБ

  • внутри - тысячи уникальных NativeSQLQueryPlan

Причина: Hibernate для выражения

WHERE s.sku_id IN (:ids)

разворачивает список в:

WHERE s.sku_id IN (?, ?, ?, ..., ?)

То есть разный размер IN → разный SQL-текст → новый ключ кэша → новый план.

В реальном запросе, попавшем в дамп, длина строки доходила до 200+ КБ из-за сотен подставленных параметров

Пример SQL из дампа (227 КБ строка):

SELECT i.item_id              AS itemId,
       MAX(CAST(a.uuid AS TEXT)) AS optionAUuid,
       MAX(CAST(b.uuid AS TEXT)) AS optionBUuid
FROM item_delivery_option ido
JOIN delivery_option o
     ON ido.delivery_option_uuid = o.uuid
JOIN warehouse w
     ON w.uuid = o.warehouse_uuid
    AND w.owner_id = :ownerId
LEFT JOIN delivery_option_type a
       ON o.delivery_option_type_uuid = a.uuid
      AND a.type_code = 'A'
LEFT JOIN delivery_option_type b
       ON o.delivery_option_type_uuid = b.uuid
      AND b.type_code = 'B'
WHERE ido.item_id IN (:itemIds_0, :itemIds_1, :itemIds_2, ... :itemIds_40)

Почему так происходит

Hibernate кэширует план по комбинации:

  • текста SQL

  • количества параметров

Если размеры коллекции меняются (1, 5, 37, 112 элементов) - Hibernate считает каждый SQL уникальным и кладёт в кэш новый план

При частых вызовах DAO с разными размерами списка планов накапливается столько, что кэш преобразуется в потребителя десятков мегабайт

Почему это особенно критично при больших списках

  • Количество уникальных SQL растёт линейно от количества разных размеров списка

  • SQL-строки становятся огромными (бывало более 200 КБ каждая)

  • План кэшируется каждый раз, даже если differs only by placeholder count

  • План-кэш забивает heap, что может приводить к OOM или GC-штормам

Решение: использовать = ANY(:ids) вместо IN

PostgreSQL поддерживает массивы и конструкцию ANY:

WHERE s.sku_id = ANY(:ids)

Преимущества:

  1. Всегда один параметр: Передаётся массив (Long[], UUID[] и т.д.), размер значения больше не влияет на SQL-текст

  2. Всегда один шаблон SQL: Hibernate генерирует стабильный SQL. Никаких ?, ?, ?, ...

  3. План в QueryPlanCache один для любого размера входных данных

  4. Короткий SQL → меньше накладных расходов

  5. Семантика эквивалентна IN(...)

Ограничения и дополнительные настройки Hibernate

Даже после перехода на ANY стоит включить защитные настройки:

spring:
  jpa:
    properties:
      hibernate.query.plan_cache_max_size: 512
      hibernate.query.plan_parameter_metadata_max_size: 128
      hibernate.query.in_clause_parameter_padding: true

Кратко по ключевым параметрам:

hibernate.query.plan_cache_max_size
Ограничивает количество планов. При превышении Hibernate вытесняет старые

hibernate.query.plan_parameter_metadata_max_size
Контролирует кэш параметров (типизированные метаданные)

hibernate.query.in_clause_parameter_padding=true
Если в проекте остались IN, Hibernate будет дополнять список до ближайшей степени двойки (2,4,8,16…)
Это уменьшает число уникальных SQL, но всё равно хуже, чем ANY

Юнит-тесты, демонстрирующие разницу

class AnyTemplateStabilityTest extends BaseTest {

    @Autowired
    private DeliveryMethodSkuDao deliveryMethodSkuDao;
    @Autowired
    private EntityManagerFactory emf;

    private org.hibernate.stat.Statistics stats;

    @BeforeEach
    void beforeEachAnyTemplateStabilityTest() {
        var sfi = emf.unwrap(org.hibernate.engine.spi.SessionFactoryImplementor.class);
        stats = sfi.getStatistics();
        stats.setStatisticsEnabled(true);
        stats.clear();
    }

    @AfterEach
    void afterEachAnyTemplateStabilityTest() {
        var sfi = emf.unwrap(org.hibernate.engine.spi.SessionFactoryImplementor.class);
        stats = sfi.getStatistics();
        stats.setStatisticsEnabled(false);
        stats.clear();
    }

    @Test
    // ANY(:ids) всегда использует один план
    void anyArrayShouldUsesSingleQueryPlan() {
        Long sellerId = 1L;
        // разные размеры массива → один и тот же SQL-шаблон
        deliveryMethodSkuDao.findAllSkuWithDeliveryType(sellerId, 1L);
        deliveryMethodSkuDao.findAllSkuWithDeliveryType(sellerId, 1L,2L,3L,4L,5L,6L,7L,8L,9L,10L);
        deliveryMethodSkuDao.findAllSkuWithDeliveryType(sellerId, 1L,2L,3L,4L,5L,6L,7L,8L,9L,10L,11L,12L,13L,14L,15L,16L);
        deliveryMethodSkuDao.findAllSkuWithDeliveryType(sellerId, 100L,200L,300L,400L,500L,600L,700L,800L);

        // последующие вызовы — hits
        long hits = stats.getQueryPlanCacheHitCount();
        long miss = stats.getQueryPlanCacheMissCount();

        // Должен быть один промах кэша плана
        assertThat(hits).isEqualTo(3L);
        // Должно быть несколько попаданий в кэш
        assertThat(miss).isEqualTo(1L);

        //Разные размеры массива дают 1 miss и множество hit - план один.
    }

    @Test
    // IN (:ids) создаёт новый план при каждом размере
    void inClauseWithVariablePlaceholdersShouldBloatsPlanCache() {
        Long sellerId = 1L;

        // вызовы метода, где WHERE s.sku_id IN (:ids) порождает разное число '?'
        deliveryMethodSkuDao.findAllSkuWithDeliveryTypeIn(sellerId, List.of(1L));
        deliveryMethodSkuDao.findAllSkuWithDeliveryTypeIn(sellerId, LongStream.range(1, 11).boxed().toList());
        deliveryMethodSkuDao.findAllSkuWithDeliveryTypeIn(sellerId, LongStream.range(1, 17).boxed().toList());

        long hits = stats.getQueryPlanCacheHitCount();
        long miss = stats.getQueryPlanCacheMissCount();

        // Для IN всегда создаётся новый план → hits = 0
        assertThat(hits).isZero();
        // И три miss по трём вызовам
        assertThat(miss).isEqualTo(3L);

        // Для каждого нового количества ids - новый SQL и новый план.
    }
}

Сравнение подходов

Вариант

SQL-шаблон

Кол-во планов

Плюсы

Минусы

IN (:ids)

разный (?, ?, ?, ...)

N

простой синтаксис

раздувает кэш, огромные SQL

IN + padding

фиксированный на 2^k

~log₂(N)

уменьшает рост планов

всё равно несколько планов

ANY(:ids)

один (= ANY(?))

1

лучший вариант, короткий SQL

PostgreSQL-специфично

Рекомендации

  1. Перейти на = ANY(:ids) во всех местах, где ожидаются большие коллекции.

  2. Добавить ограничения на объём Hibernate Query Plan Cache

  3. С помощью hibernate.generate_statistics=true отслеживать поведение кэша

  4. Ревизовать старые DAO-методы и переписать дорогие запросы

  5. Проверять heap dump на предмет QueryPlanCache при аномальном росте памяти

Итоги

Использование IN (:ids) с большими коллекциями приводит к взрывному росту Hibernate Query Plan Cache. Это прямой путь к избыточному расходу памяти и снижению производительности

Переход на ANY(:ids) в PostgreSQL полностью устраняет проблему: SQL становится стабильным, план - единым, а кэш - компактным

Этот подход уже успешно применён в продакшене и подтвердил эффективность как нагрузочными тестами, так и heap dump-анализом

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


  1. PastorGL
    19.11.2025 13:56

    Рекомендации

    1. Не юзать Hibernate в проде.

    Просто не юзать в проде никаких general purpose средств для скаффолдинга чего бы то ни было. Вот так вот — совсем не юзать этот класс инструментов, включая любые «общие» ORM, от слова вообще.

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