Технологии

Как IN (:ids) раздувал Hibernate Query Plan Cache до 100+ МБ и почему ANY(:ids) спас прод

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

Фильтры и сортировка