Spring + Hibernate:查询计划缓存内存使用

50

我正在使用最新版本的Spring Boot编写应用程序。最近我遇到了增长堆的问题,这些堆无法被垃圾回收。使用Eclipse MAT对堆进行分析后发现,在应用程序运行一小时内,堆增长到630MB,而Hibernate的SessionFactoryImpl使用了整个堆的75%以上。

输入图片说明

我尝试寻找可能与查询计划缓存有关的源,但我唯一找到的东西是这个,但并没有解决问题。属性设置如下:

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

数据库查询都是由Spring的Query Magic生成的,使用像这个文档中介绍的存储库接口。使用这种技术生成了约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
  • Hibernate 4.3.10

已更新文本,包括版本和示例。 - LastElb
дҪ жҳҜеңЁеә”з”ЁзЁӢеәҸзұ»дёӯжҲ–е…¶д»–@Configurationзұ»дёӯиҮӘе·ұй…ҚзҪ®еҗ—пјҹеҰӮжһңжҳҜпјҢиҜ·ж·»еҠ гҖӮ - M. Deinum
不,只有一个连接池(hikaricp),但我想这与此无关?其他所有内容都来自@EnableAutoConfiguration - LastElb
尝试添加新属性hibernate.query.plan_cache_max_size和hibernate.query.plan_parameter_metadata_max_size,其他属性已经过时一段时间了。 - M. Deinum
目前似乎可以工作,但我将这些参数设置得很低(16和128)。 - LastElb
显示剩余3条评论
10个回答

70

我也遇到了这个问题。它基本上归结为在您的IN子句中有可变数量的值,而Hibernate尝试缓存这些查询计划。

关于这个主题有两篇很棒的博客文章。 第一篇

在使用Hibernate 4.2和MySQL的项目中,有一个in-clause查询,例如:select t from Thing t where t.id in (?)。Hibernate缓存这些解析后的HQL查询。具体来说,Hibernate SessionFactoryImpl有一个QueryPlanCache,其中包括queryPlanCacheparameterMetadataCache。但是当in-clause参数数量很大且不同时,这证明是个问题。这些缓存会增长每个不同查询。因此,具有6000个参数的查询与具有6001个参数的查询不同。in-clause查询将扩展到集合中的参数数量。查询计划中包括每个查询中的参数的元数据,包括生成的名称如x10_,x11_等。想象一下,在in-clause参数计数中有4000种不同的变化,每个变化平均有4000个参数。每个参数的查询元数据很快就会在内存中累加,填满堆,因为它无法进行垃圾回收。这将继续,直到所有不同的查询参数计数变化都被缓存或JVM耗尽堆内存并开始抛出java.lang.OutOfMemoryError:Java堆空间。避免in-clauses是一个选择,也可以使用固定的集合大小作为参数(或至少更小的大小)。要配置查询计划缓存的最大大小,请参见属性hibernate.query.plan_cache_max_size,默认为2048(对于具有许多参数的查询来说太大了)。

还有second(也从第一个引用):

Hibernate内部使用一个缓存,将HQL语句(作为字符串)映射到查询计划。该缓存由有限的映射组成,默认限制为2048个元素(可配置)。所有HQL查询都通过此缓存加载。如果未命中,则会自动将条目添加到缓存中。这使得它非常容易出现抖动——一种情况,在这种情况下,我们不断将新条目放入缓存中,而从未重用它们,从而防止缓存带来任何性能增益(甚至增加了一些缓存管理开销)。更糟糕的是,很难偶然发现这种情况——您必须明确地对缓存进行分析,以注意到您在那里有问题。稍后我将简要介绍如何做到这一点。
因此,缓存抖动是由高速生成新查询引起的。这可能是由多种问题引起的。我见过的最常见的两个问题是——hibernate中的错误导致参数被呈现在JPQL语句中,而不是作为参数传递,并且使用"in" - 子句。
由于hibernate中的某些晦涩错误,存在某些情况,参数未正确处理并呈现到JPQL查询中(例如,请查看HHH-6280)。如果您有一个受此类缺陷影响并且以高速率执行的查询,则它将抖动您的查询计划缓存,因为生成的每个JPQL查询都几乎是唯一的(例如包含您实体的ID)。
第二个问题在于hibernate处理具有"in"子句的查询的方式(例如,给我所有公司id字段为1、2、10、18之一的人员实体)。对于"in"-子句中不同数量的参数,hibernate将产生不同的查询——例如,对于1个参数,select x from Person x where x.company.id in (:id0_),对于2个参数,select x from Person x where x.company.id in (:id0_, :id1_),依此类推。就查询计划缓存而言,所有这些查询都被认为是不同的,从而再次导致缓存抖动。您可能可以通过编写实用程序类来解决此问题,以仅生成某些数量的参数,例如1、10、100、200、500、1000。例如,如果您传递22个参数,则它将返回包含这22个参数的100个元素列表,并将剩余的78个参数设置为不可能的值(例如用于外键的ID的-1)。我同意这是一个丑陋的黑客,但可能会完成工作。因此,您最多只有6个唯一的查询在缓存中,从而减少抖动。
那么您如何找出自己是否存在此问题?您可以编写一些附加代码,并通过JMX公开具有缓存条目数量的指标,调整日志并分析日志等。如果您不想(或无法)修改应用程序,则可以仅转储堆,并针对其运行此OQL查询(例如使用mat):SELECT l.query.toString() FROM INSTANCEOF org.hibernate.engine.query.spi.QueryPlanCache$HQLQueryPlanKey l。它将输出当前位于堆上任何查询计划缓存中的所有查询。很容易发现是否受到前述问题的影响。
至于性能影响,很难说,因为它取决于太多因素。我见过一个非常简单的查询导致

3
非常感谢!我们也遇到了同样的问题,并进行了大量的代码优化工作。然而,在我们启动Tomcat时,只有开启Java选项heapDumpOnOutOfMemoryErrors后才找到了原因。堆转储显示了与您上述描述的问题完全相同的问题。 - Maxim Pavlov
遇到了完全相同的问题。花了一周时间找出原因。最终,堆转储文件给出了答案。之后,搜索了“JPA查询缓存”,最终来到了这里。 - Muneer
嗨。我找到了你的答案,并在我们部署在 Wildfly 10.1 的应用程序中看到了这个问题。同样的应用程序在 Wildfly 16.0.0 中(使用 hibernate 5.3.9)并且使用推荐的属性集生成“清除”查询缓存。奇怪的是,由于默认值是 2048,如何在我们的情况下产生 3.8K 缓存查询?这怎么可能呢? - Apostolos
不知道,我不是Hibernate专家。请在StackOverflow上提出您自己的问题或向Hibernate用户/开发人员咨询。 - Neeme Praks
4
请查看下面的Alex的回答,他提供了一种更简单的方法,只需使用hibernate.query.in_clause_parameter_padding=true,只要您使用的是Hibernate 5.2.17或更高版本即可。 - George Andrews

18

当我使用含有超过10000个参数的IN查询时出现了同样的问题。我的参数数量总是不同,我无法预测,因此我的QueryCachePlan增长速度过快。

对于支持执行计划缓存的数据库系统,如果可能的IN子句参数数量较低,则命中缓存的机会更大。

幸运的是,Hibernate 5.2.18及更高版本提供了填充IN子句参数的解决方案。

Hibernate可以将绑定参数扩展为2的幂次方:4、8、16、32、64,这样,具有5、6或7个绑定参数的IN子句将使用8个IN子句,从而重用其执行计划。

如果您想激活此功能,您需要将此属性设置为true hibernate.query.in_clause_parameter_padding=true

有关更多信息,请参见这篇文章atlassian


1
文章中提到的是 Hibernate 5.2.18 而不是 5.3.0。而 Atlassian 的链接则两者都有提到。 - Martin Dorey

4

从Hibernate 5.2.12开始,您可以通过指定Hibernate配置属性来更改文字如何绑定到底层JDBC prepared语句,方法如下:

hibernate.criteria.literal_handling_mode=BIND

根据Java文档,此配置属性有3个设置

  1. AUTO (默认)
  2. BIND - 使用绑定参数增加jdbc语句缓存的可能性。
  3. INLINE - 内联值而不是使用参数(请注意SQL注入)。

4

我在使用Spring Boot 1.5.7和Spring Data(Hibernate)时遇到了完全相同的问题,以下配置解决了这个问题(内存泄漏):

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

在这里,您可能会遇到性能损失。如果您修复了计划缓存大小,但仍未修复填充缓存的实际查询 - 所有缓存都可能被填满了那个糟糕的查询,没有空间缓存其他查询。因此,缓存可能大部分时间都忙于处理那个糟糕的查询,而其他查询的性能可能会下降,因为它们没有得到适当的缓存或者从缓存中过早地被清除。 - X X

4

TL;DR:尝试用ANY()替换IN()查询或消除它们

解释:
如果一个查询包含IN(...),那么每个值的数量都会创建一个计划,因为查询每次都不同。 因此,如果你有IN('a','b','c')和IN('a','b','c','d','e') - 这些是两个不同的查询字符串/计划要缓存。这个 answer 提供了更多信息。
在ANY(...)的情况下,可以传递单个(数组)参数,因此查询字符串将保持不变,并且准备好的语句计划将被缓存一次(下面给出示例)。

原因:
这行代码可能导致问题:

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

在“urls”集合中的每个值的数量下,它在底层生成不同的IN()查询。
警告: 您可能会在不知情的情况下使用IN()查询。 例如Hibernate等ORM可能会在后台生成它们-有时在意想不到的地方,有时以非最佳方式。 因此,请考虑启用查询日志以查看实际查询。
解决方法: 以下是(伪)代码,可以解决此问题:
query = "SELECT * FROM trending_topic t WHERE t.name=? AND t.url=?"
PreparedStatement preparedStatement = connection.prepareStatement(queryTemplate);
currentPreparedStatement.setString(1, name); // safely replace first query parameter with name
currentPreparedStatement.setArray(2, connection.createArrayOf("text", urls.toArray())); // replace 2nd parameter with array of texts, like "=ANY(ARRAY['aaa','bbb'])"

但是:不要将任何解决方案视为现成的答案。在进入生产之前,一定要在实际/大数据上测试最终性能——无论您选择哪个答案。
为什么?因为IN和ANY都有优缺点,如果使用不当,它们可能会带来严重的性能问题(请参见下面的参考文献中的示例)。还要确保使用参数绑定以避免安全问题。
参考文献: 通过更改1行代码使Postgres性能提高100倍- Any(ARRAY[])与ANY(VALUES())的性能比较 使用=any()时未使用索引,但使用in - IN和ANY的不同性能 了解SQL Server查询计划缓存 希望这可以帮助到您。一定要留下反馈,无论它是否起作用,以帮助像您一样的人。谢谢!

2

我遇到了类似的问题,问题是因为你正在创建查询而不是使用PreparedStatement。因此,每个具有不同参数的查询都会创建一个执行计划并将其缓存。如果您使用预处理语句,则应该看到内存使用情况得到重大改善。

最初的回答:

你需要使用PreparedStatement来代替查询语句,这样可以减少内存占用并提高性能。


你能给一个带有查询和预编译语句的代码示例吗? - undefined

1
我在queryPlanCache方面遇到了一个大问题,因此我做了一个Hibernate缓存监视器来查看queryPlanCache中的查询。 我在QA环境中使用它作为一个每5分钟的Spring任务。 我发现我需要更改哪些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;
}
}

1
我们还有一个QueryPlanCache,堆使用量不断增加。我们有IN查询需要重写,此外我们还有使用自定义类型的查询。结果发现Hibernate类CustomType没有正确实现equals和hashCode,从而为每个查询实例创建了一个新键。这在Hibernate 5.3中已得到解决。请参见https://hibernate.atlassian.net/browse/HHH-12463。您仍然需要正确实现equals/hashCode以使其正常工作。

1
我们遇到了查询计划缓存过快增长和老年代堆也随之增长的问题,因为GC无法收集它。罪魁祸首是JPA查询在IN子句中使用了超过200000个ID。为了优化查询,我们使用连接而不是从一个表中获取ID并将其传递给其他表的选择查询。

1

Alex的回答有助于理解在Hibernate中可用的子句参数填充属性。

但是提到的属性hibernate.query.in_clause_parameter_padding=true并没有按预期工作。

我们可以通过启用Hibernate日志来验证它。

spring.jpa.show-sql=true

spring.jpa.properties.hibernate.format_sql=true

以上两个属性将在日志中打印Hibernate生成的查询。

发现下面提到的属性可行。

  1. spring.jpa.properties.hibernate.query.in_clause_parameter_padding=true

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接