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