Spring + Hibernate: использование кэш-памяти Query Plan

Я программирую приложение с последней версией Spring Boot. Я недавно стал проблемой с растущей кучей, которая не может быть собрана мусором. Анализ кучи с Eclipse MAT показал, что в течение часа после запуска приложения куча выросла до 630 МБ и с Hibernate SessionFactoryImpl, используя более 75% всей кучи.

enter image description here

Я искал возможные источники вокруг кэша Query Plan, но единственное, что я нашел, это то, но это не играло. Свойства были установлены следующим образом:

spring.jpa.properties.hibernate.query.plan_cache_max_soft_references=1024
spring.jpa.properties.hibernate.query.plan_cache_max_strong_references=64

Запросы базы данных генерируются маской Spring Query, используя интерфейсы репозитория, как в этой документации. С этим методом генерируется около 20 различных запросов. Нет других родных SQL или HQL. Образец:

@Transactional
public interface TrendingTopicRepository extends JpaRepository<TrendingTopic, Integer> {
    List<TrendingTopic> findByNameAndSource(String name, String source);
    List<TrendingTopic> findByDateBetween(Date dateStart, Date dateEnd);
    Long countByDateBetweenAndName(Date dateStart, Date dateEnd, String name);
}

или

List<SomeObject> findByNameAndUrlIn(String name, Collection<String> urls);

как пример использования IN.

Вопрос: почему кеш-план запроса продолжает расти (он не останавливается, он заканчивается полной кучей) и как предотвратить это? Кто-нибудь сталкивался с подобной проблемой?

Версии:

  • Spring Boot 1.2.5
  • Спящий режим 4.3.10

Ответы

Ответ 1

Я тоже попал в эту проблему. В основном это сводится к тому, что переменное количество значений в вашем разделе IN и Hibernate пытается кэшировать эти планы запросов.

В этой теме есть две замечательные сообщения в блогах. Первое:

Использование Hibernate 4.2 и MySQL в проекте с запросом в -clause, например: select t from Thing t where t.id in (?)

Hibernate кэширует эти анализируемые запросы HQL. В частности, Hibernate SessionFactoryImpl имеет QueryPlanCache с queryPlanCache и parameterMetadataCache. Но это оказалось проблемой, когда число параметров для -clause велико и меняется.

Эти кеши растут для каждого отдельного запроса. Таким образом, этот запрос с 6000 параметрами не совпадает с 6001.

Запрос в -clause расширяется до количества параметров в коллекции. Метаданные включены в план запроса для каждого параметра в запросе, включая сгенерированное имя типа x10_, x11_ и т.д.

Представьте себе, что в число -clause число параметров варьируется в разных вариантах, каждый из которых имеет в среднем 4000 параметров. Метаданные запроса для каждого параметра быстро складываются в память, заполняя кучу, поскольку сбор мусора невозможен.

Это продолжается до тех пор, пока не будут кэшированы все различные варианты количества параметров запроса или JVM закончится из кучной памяти и начнет метать java.lang.OutOfMemoryError: куча Java-кучи.

Избегание в -clause s является опцией, а также использование фиксированного размера коллекции для параметра (или, по крайней мере, меньшего размера).

Чтобы настроить максимальный размер кеша плана запроса, см. Свойство hibernate.query.plan_cache_max_size, по умолчанию 2048 (слишком просто для запросов со многими параметрами).

И второе (также упоминаемое от первого):

Hibernate внутренне использует кеш, который отображает инструкции HQL (как строки) для запроса планов. Кэш состоит из ограниченной карты, которая по умолчанию ограничена 2048 элементами (настраивается). Все запросы HQL загружаются через этот кеш. В случае пропусков запись автоматически добавляется в кэш. Это делает его очень восприимчивым к трещине - сценарию, в котором мы постоянно помещаем новые записи в кеш, не используя их повторно, и тем самым не позволяем кешу приносить прибыль (даже добавляет некоторые служебные данные управления кэшем). Чтобы все ухудшилось, трудно случайно обнаружить эту ситуацию - вам нужно явно профилировать кеш, чтобы заметить, что у вас есть проблема. Я скажу несколько слов о том, как это можно сделать позже.

Таким образом, переполнение кэша происходит из-за того, что новые запросы генерируются с высокой скоростью. Это может быть вызвано множеством проблем. Два наиболее распространенных из них, которые я видел, - это ошибки в спящем режиме, которые вызывают отображение параметров в инструкции JPQL вместо передачи параметров и использования предложения "in".

Из-за некоторых неясных ошибок в спящем режиме возникают ситуации, когда параметры не обрабатываются правильно и отображаются в запросе JPQL (в качестве примера рассмотрим HHH-6280). Если у вас есть запрос, на который влияют такие дефекты, и он выполняется с высокой скоростью, он будет разбивать ваш кеш плана запроса, потому что каждый сгенерированный запрос JPQL почти уникален (например, содержит идентификаторы ваших объектов).

Вторая проблема заключается в том, что hibernate обрабатывает запросы с помощью предложения "in" (например, дайте мне все лица, чье фирменное поле является одним из 1, 2, 10, 18). Для каждого отдельного количества параметров в "in" -clause, hibernate будет производить другой запрос - например, select x from Person x where x.company.id in (:id0_) для 1 параметра, select x from Person x where x.company.id in (:id0_, :id1_) для 2 параметров и так далее. Все эти запросы считаются разными, что касается кеша плана запроса, в результате чего снова происходит переполнение кэша. Вероятно, вы можете обойти эту проблему, написав класс утилиты для создания только определенного количества параметров - например, 1, 10, 100, 200, 500, 1000. Если вы, например, передадите 22 параметра, он вернет список из 100 элементы с 22 параметрами, включенными в него, а остальные 78 параметров установлены на невозможное значение (например, -1 для идентификаторов, используемых для внешних ключей). Я согласен, что это уродливый взлом, но он может выполнить свою работу. В результате у вас будет всего 6 уникальных запросов в вашем кеше и, таким образом, уменьшите износ.

Итак, как вы узнаете, что у вас есть проблема? Вы можете написать дополнительный код и показать показатели с количеством записей в кеше, например, над JMX, настроить протоколирование и анализ журналов и т.д. Если вы не хотите (или не можете) изменять приложение, вы можете просто сбросить куча и запустить этот OQL-запрос против него (например, с помощью mat): SELECT l.query.toString() FROM INSTANCEOF org.hibernate.engine.query.spi.QueryPlanCache$HQLQueryPlanKey l. Он выведет все запросы, находящиеся в настоящее время в любом кеше плана запроса, на вашей куче. Должно быть довольно легко определить, зависит ли вас от одной из вышеупомянутых проблем.

Что касается влияния производительности, трудно сказать, так как это зависит от слишком многих факторов. Я видел очень тривиальный запрос, вызывающий 10-20 мс служебных расходов, потраченных на создание нового плана запросов HQL. В общем, если есть кеш где-то, для этого должна быть веская причина - мисс, вероятно, дорогая, поэтому вы должны стараться избегать пропусков как можно больше. И последнее, но не менее важное: вашей базе данных придется обрабатывать большое количество уникальных SQL-запросов тоже, заставляя ее анализировать их и, возможно, создавать разные планы выполнения для каждого из них.

Ответ 2

У меня была такая же проблема с использованием Spring Boot 1.5.7 с данными Spring (Hibernate), и следующая конфигурация решила проблему (утечка памяти):

spring:
  jpa:
    properties:
      hibernate:
        query:
          plan_cache_max_size: 64
          plan_parameter_metadata_max_size: 32

Ответ 3

Начиная с Hibernate 5.2.12 вы можете указать свойство конфигурации спящего режима, чтобы изменить, как литералы должны быть привязаны к основным подготовленным операциям JDBC, используя следующее:

hibernate.criteria.literal_handling_mode=BIND

В документации по Java это свойство конфигурации имеет 3 настройки

  1. AUTO (по умолчанию)
  2. BIND - Увеличивает вероятность кеширования операторов jdbc с использованием параметров привязки.
  3. INLINE - выравнивает значения, а не использует параметры (будьте осторожны с SQL-инъекцией).

Ответ 4

У меня была большая проблема с этим запросомPlanCache, поэтому я сделал монитор кэша Hibernate для просмотра запросов в queryPlanCache. Я использую среду QA как задачу Spring каждые 5 минут. Я нашел, какие запросы IN я должен был изменить, чтобы решить проблему с кешем. Деталь такова: я использую Hibernate 4.2.18, и я не знаю, будет ли полезно использовать другие версии.

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import org.hibernate.ejb.HibernateEntityManagerFactory;
import org.hibernate.internal.SessionFactoryImpl;
import org.hibernate.internal.util.collections.BoundedConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.dao.GenericDAO;

public class CacheMonitor {

private final Logger logger  = LoggerFactory.getLogger(getClass());

@PersistenceContext(unitName = "MyPU")
private void setEntityManager(EntityManager entityManager) {
    HibernateEntityManagerFactory hemf = (HibernateEntityManagerFactory) entityManager.getEntityManagerFactory();
    sessionFactory = (SessionFactoryImpl) hemf.getSessionFactory();
    fillQueryMaps();
}

private SessionFactoryImpl sessionFactory;
private BoundedConcurrentHashMap queryPlanCache;
private BoundedConcurrentHashMap parameterMetadataCache;

/*
 * I tried to use a MAP and use compare compareToIgnoreCase.
 * But remember this is causing memory leak. Doing this
 * you will explode the memory faster that it already was.
 */

public void log() {
    if (!logger.isDebugEnabled()) {
        return;
    }

    if (queryPlanCache != null) {
        long cacheSize = queryPlanCache.size();
        logger.debug(String.format("QueryPlanCache size is :%s ", Long.toString(cacheSize)));

        for (Object key : queryPlanCache.keySet()) {
            int filterKeysSize = 0;
            // QueryPlanCache.HQLQueryPlanKey (Inner Class)
            Object queryValue = getValueByField(key, "query", false);
            if (queryValue == null) {
                // NativeSQLQuerySpecification
                queryValue = getValueByField(key, "queryString");
                filterKeysSize = ((Set) getValueByField(key, "querySpaces")).size();
                if (queryValue != null) {
                    writeLog(queryValue, filterKeysSize, false);
                }
            } else {
                filterKeysSize = ((Set) getValueByField(key, "filterKeys")).size();
                writeLog(queryValue, filterKeysSize, true);
            }
        }
    }

    if (parameterMetadataCache != null) {
        long cacheSize = parameterMetadataCache.size();
        logger.debug(String.format("ParameterMetadataCache size is :%s ", Long.toString(cacheSize)));
        for (Object key : parameterMetadataCache.keySet()) {
            logger.debug("Query:{}", key);
        }
    }
}

private void writeLog(Object query, Integer size, boolean b) {
    if (query == null || query.toString().trim().isEmpty()) {
        return;
    }
    StringBuilder builder = new StringBuilder();
    builder.append(b == true ? "JPQL " : "NATIVE ");
    builder.append("filterKeysSize").append(":").append(size);
    builder.append("\n").append(query).append("\n");
    logger.debug(builder.toString());
}

private void fillQueryMaps() {
    Field queryPlanCacheSessionField = null;
    Field queryPlanCacheField = null;
    Field parameterMetadataCacheField = null;
    try {
        queryPlanCacheSessionField = searchField(sessionFactory.getClass(), "queryPlanCache");
        queryPlanCacheSessionField.setAccessible(true);
        queryPlanCacheField = searchField(queryPlanCacheSessionField.get(sessionFactory).getClass(), "queryPlanCache");
        queryPlanCacheField.setAccessible(true);
        parameterMetadataCacheField = searchField(queryPlanCacheSessionField.get(sessionFactory).getClass(), "parameterMetadataCache");
        parameterMetadataCacheField.setAccessible(true);
        queryPlanCache = (BoundedConcurrentHashMap) queryPlanCacheField.get(queryPlanCacheSessionField.get(sessionFactory));
        parameterMetadataCache = (BoundedConcurrentHashMap) parameterMetadataCacheField.get(queryPlanCacheSessionField.get(sessionFactory));
    } catch (Exception e) {
        logger.error("Failed fillQueryMaps", e);
    } finally {
        queryPlanCacheSessionField.setAccessible(false);
        queryPlanCacheField.setAccessible(false);
        parameterMetadataCacheField.setAccessible(false);
    }
}

private <T> T getValueByField(Object toBeSearched, String fieldName) {
    return getValueByField(toBeSearched, fieldName, true);
}

@SuppressWarnings("unchecked")
private <T> T getValueByField(Object toBeSearched, String fieldName, boolean logErro) {
    Boolean accessible = null;
    Field f = null;
    try {
        f = searchField(toBeSearched.getClass(), fieldName, logErro);
        accessible = f.isAccessible();
        f.setAccessible(true);
    return (T) f.get(toBeSearched);
    } catch (Exception e) {
        if (logErro) {
            logger.error("Field: {} error trying to get for: {}", fieldName, toBeSearched.getClass().getName());
        }
        return null;
    } finally {
        if (accessible != null) {
            f.setAccessible(accessible);
        }
    }
}

private Field searchField(Class<?> type, String fieldName) {
    return searchField(type, fieldName, true);
}

private Field searchField(Class<?> type, String fieldName, boolean log) {

    List<Field> fields = new ArrayList<Field>();
    for (Class<?> c = type; c != null; c = c.getSuperclass()) {
        fields.addAll(Arrays.asList(c.getDeclaredFields()));
        for (Field f : c.getDeclaredFields()) {

            if (fieldName.equals(f.getName())) {
                return f;
            }
        }
    }
    if (log) {
        logger.warn("Field: {} not found for type: {}", fieldName, type.getName());
    }
    return null;
}
}

Ответ 5

У нас также был QueryPlanCache с растущим использованием кучи. У нас были IN-запросы, которые мы переписали, и, кроме того, у нас есть запросы, которые используют пользовательские типы. Оказалось, что класс Hibernate CustomType неправильно реализовывал equals и hashCode, создавая тем самым новый ключ для каждого экземпляра запроса. Это теперь решено в Hibernate 5.3. См. Https://hibernate.atlassian.net/browse/HHH-12463. Вам все еще нужно правильно реализовать equals/hashCode в ваших userTypes, чтобы он работал правильно.

Ответ 6

У меня была похожая проблема, проблема в том, что вы создаете запрос, а не используете PreparedStatement. Так что здесь происходит для каждого запроса с различными параметрами, он создает план выполнения и кэширует его. Если вы используете подготовленное утверждение, то вы должны увидеть значительное улучшение используемой памяти.