Меня зовут Игорь Симаков, я тимлид 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-анализом
PastorGL
Не юзать Hibernate в проде.
Просто не юзать в проде никаких general purpose средств для скаффолдинга чего бы то ни было. Вот так вот — совсем не юзать этот класс инструментов, включая любые «общие» ORM, от слова вообще.
Для быстрого прототипирования — не возбраняется. Они идеально для этого подходят, потому что пытаются охватывать все кейсы на свете, но при этом не заточены ни под один реальный кейс. Но для прода пишите минимальные имплементации, специализированные под ваши задачи, сами.