Меня зовут Игорь Симаков, я тимлид 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-анализом