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

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)
Преимущества:
Всегда один параметр: Передаётся массив (
Long[],UUID[]и т.д.), размер значения больше не влияет на SQL-текстВсегда один шаблон SQL: Hibernate генерирует стабильный SQL. Никаких
?, ?, ?, ...План в QueryPlanCache один для любого размера входных данных
Короткий SQL → меньше накладных расходов
Семантика эквивалентна 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-шаблон | Кол-во планов | Плюсы | Минусы |
|---|---|---|---|---|
| разный ( | N | простой синтаксис | раздувает кэш, огромные SQL |
| фиксированный на 2^k | ~log₂(N) | уменьшает рост планов | всё равно несколько планов |
| один ( | 1 | лучший вариант, короткий SQL | PostgreSQL-специфично |
Рекомендации
Перейти на
= ANY(:ids)во всех местах, где ожидаются большие коллекции.Добавить ограничения на объём Hibernate Query Plan Cache
С помощью
hibernate.generate_statistics=trueотслеживать поведение кэшаРевизовать старые DAO-методы и переписать дорогие запросы
Проверять heap dump на предмет
QueryPlanCacheпри аномальном росте памяти
Итоги
Использование IN (:ids) с большими коллекциями приводит к взрывному росту Hibernate Query Plan Cache. Это прямой путь к избыточному расходу памяти и снижению производительности
Переход на ANY(:ids) в PostgreSQL полностью устраняет проблему: SQL становится стабильным, план - единым, а кэш - компактным
Этот подход уже успешно применён в продакшене и подтвердил эффективность как нагрузочными тестами, так и heap dump-анализом
