В далекой-далекой галактике были времена стабильности и процветания. Сервис с шестнадцатью инстансами работал на благо человечества. Через Hibernate он ходил в PostgreSQL-базу, доставал необходимые данные и отдавал другим по REST-интерфейсу. Однако спокойные времена прошли. Внезапно один из инстансов упал с OutOfMemoryError. Лучшие программисты hh.ru пустились в погоню за heapdump-ом в поисках ценнейшей информации.
Привет, меня зовут Артем, я — бэкенд-разработчик в hh.ru. В этой статье расскажу о том, как мы чинили одну из ошибок OutOfMemoryError, которая возникла при работе сервиса с базой данных. Сегодня говорим только на бэкендерском!

Следствие вели эвоки
Мы начали расследование ООМ со скачанного нами heapdump-а. Внимание сразу привлекли неестественно большие размеры SessionFactory, где основной объем занимал query plan cache.

Первое, что приходит в голову — сказать "ха, это какой-то кэш, давайте просто его уменьшим и всё". Но уменьшение кэша не решает исходную проблему, ведь если кэш есть, значит он кому-то нужен, а возможно даже и нам. Так что такое queryPlanCache и с чем его едят?
Мы в hh.ru используем в качестве базы данных PostgreSQL, а в Java-коде общаемся с ней через Hibernate — ORM (Object-Relational Mapping) библиотеку. Hibernate помимо простого мапинга Java объектов на таблицы БД предоставляет ещё несколько удобных инструментов:
Во первых, фреймворк Criteria, который позволяет создавать запросы вообще из чистого Java-кода, накидав разных фильтров и логических условий.
Во вторых, язык для написания запросов — JPQL (пришедший на смену HQL), который в большинстве случаев при работе с ORM является более удобной альтернативой чистому SQL.
Мы довольно активно пользуемся этими возможностями для упрощения себе жизни.
Создали запрос на JPQL или на Criteria — после этого Hibernate должен произвести некоторую магию, чтобы сделать из написанного нами SQL, который сможет понять база. Эта магия зовется "компиляцией запроса".
И логично предположить, что сервис в процессе работы часто выполняет однотипные запросы, и компилировать их каждый раз неоптимально. Для этого и существует queryPlanCache — это кэш скомпилированных запросов, уменьшать его = вредить производительности сервиса.
При компиляции запроса Hibernate:
парсит исходный запрос;
строит абстрактное синтаксическое дерево;
запоминает тип входных и выходных параметров;
готовит стейтмент, который будет транслирован в базу данных.
Когда мы делаем “from User where name = :name“, Hiber строит примерно такое дерево:
SELECT
{derived select clause}
user0_.user_id as user_id1_10_
user0_.first_name as first_na2_10_, user0_.last_name as last_nam3_10_
from
users user0_
where
=
first_name
?И вот такой SQL запрос:
select
user0_.user_id as user_id1_10_,
user0_.first_name as first_na2_10_,
user0_.last_name as last_nam3_10_
from
users user0_
where
first_name=? Мы делаем from User where id in (), где в in передаём коллекцию из 3х элементов:
var ids = List.of(1, 2, 3);
session().createQuery(“from User where id in (:ids)”).setParameter(“ids”, ids);Получаем такое дерево:
SELECT
{derived select clause}
user0_.user_id as user_id1_10_
user0_.first_name as first_na2_10_, user0_.last_name as last_nam3_10_
from
users user0_
where
in
user0_.user_id
{synthetic-alias}
id
inList
?
?
?И вот такой запрос:
select
user0_.user_id as user_id1_10_,
user0_.first_name as first_na2_10_,
user0_.last_name as last_nam3_10_
from
users user0_
where
user0_.user_id in (
? , ? , ?
)Пробуем с коллекцией из 4-х элементов — и вот тут уже обращаем внимание на нечто любопытное, что приближает нас к решению задачи:
SELECT
{derived select clause}
user0_.user_id as user_id1_10_
user0_.first_name as first_na2_10_, user0_.last_name as last_nam3_10_
from
users user0_
where
in
user0_.user_id
{synthetic-alias}
id
inList
?
?
?
?Обратили внимание? Там разное количество вопросительных знаков.
Проблема наносит ответный удар
Дело в том, что с точки зрения Hibernate запросы вида “where in”, куда передано разное количество элементов – это разные запросы. И для каждого из них он построит свое синтаксическое дерево и свой стейтмент.
Допустим, мы делаем получение данных батчами, которые регулируются Java кодом. Батчи, скажем, по 10000 элементов. Размер батча определяет максимальное количес��во запрашиваемых элементов, но далеко не все батчи будут большими. Где-то прилетает запрос всего на пару элементов, где-то уже на пару сотен, а то и на тысячу. В итоге при неблагоприятном стечении обстоятельств мы можем получить в кэше 10000 абстрактных синтаксических деревьев. Из которых, кстати, будет сгенерировано 10000 стейтментов в базе. И это только из одного запроса!
Стали думать, что с этим можно сделать. Как я уже писал выше, просто уменьшить размер кэша не прокатит, потому что мы получим большую частоту компиляций и не только повысим нагрузку на инстанс, но и увеличим время ответа на запрос в базу.
Также можно уменьшить размер батча. Как временная мера — вполне может сработать. Мы так и сделали, пока разбирались с причиной проблемы. Но нам не хотелось бы сильно дробить наши з��просы и получать дополнительный оверхед на ровном месте.
Стали искать решение получше.
Новая надежда
В Hibernate с версией 5.2.18 есть возможность существенно сократить количество планов выполнения в кэше. При включенной настройке “hibernate.query.in_clause_parameter_padding=true” будут генерироваться планы выполнения, в которых количество параметров внутри “where in()” будет приведено к следующей степени двойки.
"This is possible because Hibernate is now padding parameters until the next power of 2 number. So, for 3 and 4 parameters, 4 bind parameters are being used. For 5 and 6 parameters, 8 bind parameters are being used.
Cool, right?"
Vlad Mihalcea
Логичный вопрос: как так? Откуда возьмутся недостающие параметры или куда денутся переданные? Посмотрим прямо в сетевой обмен приложения с базой, выполнив снова запрос с тремя параметрами в “where in” и включенной настройкой:

Я отметил стрелками куда смотреть. Hiber просто добавляет последний элемент столько раз, сколько необходимо для формирования нужного количества элементов. В нашем случае добавляется еще один параметр со значением “три”.
Кажется, на этом этапе мы нашли решение проблемы. Включаем настройку — всё работает. Но есть, как говорится, нюансы.
Первый нюанс — это PostgreSQL. Как минимум нашей, 11й версии есть волшебное число 199. При запросе в котором в in передаётся более 199 аргументов база начинает вести себя иначе и иногда можно получить совсем странные планы выполнения на ровном месте. В некоторых случаях в коде мы учитывали этот нюанс, поэтому ограничивали размер батча 199.
Теперь ко включению настройки мы получим увеличение размера батча до следующей степени двойки — это 256. Получается, теперь нам нужно пройтись по всем этим местам в коде и ограничить размер батча до меньшего числа, чтобы у нас получилось 128 и эта настройка его не увеличивала.
Нюанс номер два — это JPA-Criteria. Многие разработчики при работе с ней полагаются на ее внутреннюю логику, на то что она очень умная и сама создаст необходимые переменные во всех нужных местах. Но посмотрим на следующий код:
CriteriaBuilder builder = getSession().getCriteriaBuilder();
CriteriaQuery<User> criteriaQuery = builder.createQuery(User.class);
Root<User> root = criteriaQuery.from(User.class);
List<Integer> ids = List.of(1,2,3);
Predicate wherePredicate = root.get("id").in(ids);
criteriaQuery.select(root).where(wherePredicate);
getSession()
.createQuery(criteriaQuery)
.getResultList();И на дебажный вывод Hiber:
select
user0_.user_id as user_id1_10_,
user0_.first_name as first_na2_10_,
user0_.last_name as last_nam3_10_
from
users user0_
where
user0_.user_id in (
1 , 2 , 3
)Мы передаем в условие “in” массив чисел и Hibernate не стал создавать параметры вовсе. Соответственно, “parameter padding” даже не думает работать — в запрос вставляются просто голые значения. Такое поведение характерно только для коллекции чисел, и у разработчиков Hibernate есть объяснение: числа — это всегда безопасно, это только в строках могут быть SQL-инъекции, поэтому для них создавать параметры и валидировать значение необходимо. Есть одна настройка — literal_handling_mode. Если её установить в значение bind, то Hiber из чисел тоже будет делать параметры. Но сначала посмотрим как будет вести себя коллекция строк.
Передаем в запрос коллекцию строк:
List<String> ids = List.of("Ivan", "Petr", "Obi-Wan");
Predicate wherePredicate = root.get("firstName").in(ids);Получим запрос:
select
user0_.user_id as user_id1_3_,
user0_.first_name as first_na2_3_,
user0_.last_name as last_nam3_3_
from
users user0_
where
user0_.first_name in (
? , ? , ?
)Параметры создаются, но их всё ещё три. Кажется, снова промах. А дело вот в чем: для таких запросов есть специальная фишка — ParameterExpression. Если мы будем передавать параметры в JPA-Criteria именно с использованием ParameterExpression, то дадим Hiber-у понять: из этих параметров нужно всегда создать параметры в SQL-запросе.
Переписываем предыдущий запрос с использованием ParameterExpression:
List<Integer> ids = List.of(1,2,3);
ParameterExpression<Collection> idsExpr = builder.parameter(Collection.class, "ids");
Predicate wherePredicate = root.get("id").in(idsExpr);
criteriaQuery.select(root).where(wherePredicate);
getSession()
.createQuery(criteriaQuery)
.setParameter(idsExpr, ids)
.getResultList();Теперь мы видим в выводе SQL, что нам удалось добиться желаемого результата:
select
user0_.user_id as user_id1_10_,
user0_.first_name as first_na2_10_,
user0_.last_name as last_nam3_10_
from
users user0_
where
user0_.user_id in (
? , ? , ? , ?
)Победа! Осталось только прочитать весь код нашего сервиса и посмотреть, чтобы всё общение с базой данных было в соответствии с вышеизложенным.
Заключение
В результате проведенных исследований после одного OutOfMemoryError мы произвели достаточно большой рефакторинг кода, где устранили сразу несколько проблем.
во-первых, мы включили настройку in_clause_parameter_padding, с помощью которой значительно уменьшили размер query plan cache.
во-вторых, просмотрели все места, где у нас производится забор данных батчами, и скорректировали размер таковых.
и в-третьих, мы перепроверили все использования JPA-критерии, чтобы параметры всегда передавались через ParameterExpression.
Теперь на графики любо-дорого смотреть. Мы уменьшили размер кэша с 328 до 6 мегабайт и мы практически устранили промахи query plan cache.
Да пребудет с вами Сила!
Сцена после титров
Описанное в статье — сложно и не всегда очевидно. От такого можно быстро устать. Однако существуют еще и noCode-решения. Мы сейчас исследуем эту штуку и нам нужна ваша помощь.
Пожалуйста, пройдите короткий опрос из 6 пунктов и расскажите о ваших отношениях с noCode-инстурментами. Спасибо!
