17611538698
webmaster@21cto.com

Hibernate 如何将 11,000 条记录转换成 5000 万行数据

图片

题图:繁花田野与晚霞(图源:AI创作)

21CTO导读:一条 JPQL 查询语句差点导致我们的用户注册平台崩溃。以下是事件经过以及使平台速度提升 15 倍的修复方案。

我们来想象一下一种情况。

你有一个批量导入任务,它会解析 XML 文件,并为新客户生成交易模板。它已经过测试,最多可以处理 3000 个模板,运行良好。

然后,一场入职活动一下子就抛出了 15,000 个模板,然后应用程序便崩溃了。处理时间超过了90分钟。然后开始出现内存不足的错误,整个程序便彻底崩溃了。

图片

那一周没人动过代码。那么到底发生了什么?

看起来完全没问题的查询


代码库的某个地方有一个类似这样的查询:

@Query(  "SELECT t FROM Template t JOIN FETCH t.collectionA JOIN FETCH t.collectionB WHERE t.id = :id")TemplatefindWithDetails(@Param(  "id"Long id);

它的读取效果很好,编译也正常,甚至只用一条记录测试时,也能返回正确的数据。

这个问题只有在规模化之后才会显现,而且非常隐蔽。但在探讨原因之前,我们先来明确一下它 JOIN FETCH 到底是什么:那就是预加载。所以,让我们先退一步。

快速回顾:急加载与懒加载


每个 ORM 都提供了两种获取相关数据的方法。两者都试图解决同一个问题,但它们的取舍却截然不同。

图片


急性装载:现在就把所有东西都带上

你请求获取一个用户及其订单,ORM 的逻辑基本上是“我会在一次访问中获取用户及其所有相关数据”。

在底层,它会构建一个查询,通常是一个LEFT JOIN:ORM 立即发送以下单个查询:

SELECT u.id, u.username, o.id, o.amount, o.order_date FROM  users u LEFT JOIN orders o  ON u.id = o.user_idWHERE u.id = 1;

它会产生如下的结果:

  • 很好:只需一次数据库往返。数据一旦上传成功,一切即可使用。
  • 糟糕的做法:如果该用户有 10,000 个订单,无论你是否需要,你都会将它们全部加载到内存中。
  • 延迟加载:等我真正提出要求后再加载。


如果延迟获取同一用户,ORM 的逻辑就会转变为“现在只给我基本信息,如果有人真的需要订单,我会再去获取”。

这要分两步进行。

首先,我们来做一个仅针对实体本身的轻量级查询:

SELECT id , username FROM users WHERE id = 1;

此时ORM 不会直接获取订单,而是放置一个代理,基本上是一个空心占位符,用来代替实际数据。

然后,一旦你的代码实际接触到它(user.getOrders()无论user.orders.length是什么),代理就会被唤醒并立即发出第二个查询,仅当尝试访问orders属性时才会运行的SQL:

SELECT id, amount, order_dateFROM  ordersWHERE  user_id =1;


  • 优点:初始查询速度快。如果最终不需要相关数据,则可以节省内存和带宽。
  • 糟糕:这就是 N+1 问题的由来。获取 100 个用户并循环读取他们的订单,你会发出 1 个查询,再加上另外 100 个查询(每个用户一个查询),你的应用就会卡顿。


这就是矛盾所在。延迟加载可以避免过度获取数据,但可能会悄悄地产生数百个小查询。而预先加载则通过一次性获取所有数据来解决这个问题,这正是它JOIN FETCH如此吸引人的原因。

这就是我们遇到的 bug 的背景。

JOIN FETCH

预加载是否完全按预期工作?问题出在同一个查询中同时预加载两个集合。

事情的真相是这样的。当你JOIN FETCH在同一个查询中使用两个不同的集合时,Hibernate 并不会像你预期的那样并排获取它们,而是会进行交叉连接。


假设一个模板在集合 A 中有 5 条记录,在集合 B 中有 5 条记录。你期望返回 10 行结果,但实际上却返回了 25 行。

下面的情况正是如此。每个点代表 Hibernate 向你的应用程序返回的单个模板的一行。

现在,假设要用 15,000 个模板而不是一个,每个模板的集合又包含 10 到 20 条记录,那么最终内存中会占用 5000 万行数据,而原本应该只需要处理几十万行。

回顾为何无人发现


正是这一点使得这种漏洞很难被发现。

如果这两个集合被映射为List(Hibernate 称之为“bag”),应用程序甚至会拒绝启动。

MultipleBag FetchException

当启动时会收到错误信息,开发者会立即知道出了问题。

但这些都被映射为Set. 因此应用程序启动正常,测试也通过了。只有当实际生产环境负载较高时,交叉连接才会开始消耗大量的堆内存。

这就是陷阱。小样本、小规模测试,一切看起来都很正常。然后,一旦遇到大规模用户导入日,页面就可能在凌晨两点崩溃。

我们是如何追踪到它的


三步走,没什么特别的:

  1. 从实时事件中提取了堆转储文件,并使用 JProfiler 进行了分析。结果发现数百万个重复的实体实例,远远超过实际记录数。
  2. 启用了 Hibernate SQL 日志记录。发现一条生成的 SQL 语句长达数千个字符。这通常是交叉连接的第一个线索。
  3. 检查了 Hibernate 统计信息并确认了这一点:从数据库返回的行数与我们实际存储的实体数量相差甚远。

这种组合(庞大的查询加上严重膨胀的行数)基本上是笛卡尔积错误的特征。

解决方案将分为三部分


1. 将查询拆分为两个。

与其使用一个查询连接两个集合,不如分别获取每个集合。Hibernate 的会话缓存会在后台自动将它们重新组合成一个实体,无需交叉连接。

@Query("SELECT t FROM Template t JOIN FETCH t.collectionA WHERE t.id = :id")Template findWithCollectionA(@Param("id") Long id);@Query("SELECT t FROM Template t JOIN FETCH t.collectionB WHERE t.id = :id")Template findWithCollectionB(@Param("id") Long id);

2. 让批量获取处理其余部分。

对于其他所有情况,@BatchSize它告诉 Hibernate 使用子句批量获取子记录IN,而不是对每个实体的每个集合执行一次查询。

@OneToMany(mappedBy ="template")@BatchSize(size = 20)private Set<CollectionAItem> collectionA;

3. 定期清除会话

批量导入操作会在一个较长的事务中运行,Hibernate 的一级缓存会持续增长。通过定期添加flush()和调用,可以防止缓存本身成为内存瓶颈。

使用clear()它真的有效吗?

图片


行数减少了约 9 倍。处理时间减少了 15 倍。而凌晨 2 点真正重要的内存溢出崩溃问题,也停止发生了。

我建议注意以下几点:


  • 每个查询只能包含一个集合JOIN FETCH。
如果需要第二个集合,请为其提供单独的查询或使用@BatchSize.
  • 实际上,应该查看生成的 SQL 语句,而不仅仅是你的 JPQL 语句。看似简洁的三行代码,底层可能隐藏着一个极其复杂的查询。
  • 将行数与实际表大小进行比较。如果 Hibernate 获取的行数远远超过实际存在的行数,则说明某些东西在进行倍增运算。
  • Set这并不能避免这个问题。它只是把故障延迟到生产环境流量发现它为止。
  • 长时间运行的导入操作需要定期清理会话。必须按计划刷新和清除缓存,否则一级缓存本身就会成为泄漏点。


Hibernate 非常擅长隐藏 SQL 代码。大多数情况下,这无疑是一大优势。但是当数据集足够大时,它就不再是优势了 ,反而会成为开发者的负担。

你是否遇到过只有在实际流量到来时才会出现的 Hibernate 或 JPA 的 bug?欢迎在评论区分享。

作者:场长

参考:

https://code.likeagirl.io/hibernate-turned-11-000-records-into-50-million-rows-2927c5886dca

评论

我要赞赏作者

请扫描二维码,使用微信支付哦。

分享到微信