题图:繁花田野与晚霞(图源:AI创作)
21CTO导读:一条 JPQL 查询语句差点导致我们的用户注册平台崩溃。以下是事件经过以及使平台速度提升 15 倍的修复方案。
我们来想象一下一种情况。
你有一个批量导入任务,它会解析 XML 文件,并为新客户生成交易模板。它已经过测试,最多可以处理 3000 个模板,运行良好。
然后,一场入职活动一下子就抛出了 15,000 个模板,然后应用程序便崩溃了。处理时间超过了90分钟。然后开始出现内存不足的错误,整个程序便彻底崩溃了。
那一周没人动过代码。那么到底发生了什么?
代码库的某个地方有一个类似这样的查询:
"SELECT t FROM Template t JOIN FETCH t.collectionA JOIN FETCH t.collectionB WHERE t.id = :id")TemplatefindWithDetails("id") Long id);
它的读取效果很好,编译也正常,甚至只用一条记录测试时,也能返回正确的数据。
这个问题只有在规模化之后才会显现,而且非常隐蔽。但在探讨原因之前,我们先来明确一下它 JOIN FETCH 到底是什么:那就是预加载。所以,让我们先退一步。
每个 ORM 都提供了两种获取相关数据的方法。两者都试图解决同一个问题,但它们的取舍却截然不同。
急性装载:现在就把所有东西都带上
你请求获取一个用户及其订单,ORM 的逻辑基本上是“我会在一次访问中获取用户及其所有相关数据”。
在底层,它会构建一个查询,通常是一个LEFT JOIN:ORM 立即发送以下单个查询:
SELECT u.id, u.username, o.id, o.amount, o.order_date FROMusers u LEFT JOIN orders oON u.id = o.user_idWHERE u.id = 1;
它会产生如下的结果:
如果延迟获取同一用户,ORM 的逻辑就会转变为“现在只给我基本信息,如果有人真的需要订单,我会再去获取”。
这要分两步进行。
首先,我们来做一个仅针对实体本身的轻量级查询:
SELECT id , username FROM users WHERE id = 1;
此时ORM 不会直接获取订单,而是放置一个代理,基本上是一个空心占位符,用来代替实际数据。
然后,一旦你的代码实际接触到它(user.getOrders()无论user.orders.length是什么),代理就会被唤醒并立即发出第二个查询,仅当您尝试访问orders属性时才会运行的SQL:
SELECT id, amount, order_dateFROMordersWHEREuser_id =1;
这就是矛盾所在。延迟加载可以避免过度获取数据,但可能会悄悄地产生数百个小查询。而预先加载则通过一次性获取所有数据来解决这个问题,这正是它JOIN FETCH如此吸引人的原因。
这就是我们遇到的 bug 的背景。
JOIN FETCH
预加载是否完全按预期工作?问题出在同一个查询中同时预加载两个集合。
假设一个模板在集合 A 中有 5 条记录,在集合 B 中有 5 条记录。你期望返回 10 行结果,但实际上却返回了 25 行。
下面的情况正是如此。每个点代表 Hibernate 向你的应用程序返回的单个模板的一行。
现在,假设要用 15,000 个模板而不是一个,每个模板的集合又包含 10 到 20 条记录,那么最终内存中会占用 5000 万行数据,而原本应该只需要处理几十万行。
正是这一点使得这种漏洞很难被发现。
如果这两个集合被映射为List(Hibernate 称之为“bag”),应用程序甚至会拒绝启动。
MultipleBag FetchException
当启动时会收到错误信息,开发者会立即知道出了问题。
但这些都被映射为Set. 因此应用程序启动正常,测试也通过了。只有当实际生产环境负载较高时,交叉连接才会开始消耗大量的堆内存。
这就是陷阱。小样本、小规模测试,一切看起来都很正常。然后,一旦遇到大规模用户导入日,页面就可能在凌晨两点崩溃。
三步走,没什么特别的:
这种组合(庞大的查询加上严重膨胀的行数)基本上是笛卡尔积错误的特征。
1. 将查询拆分为两个。
与其使用一个查询连接两个集合,不如分别获取每个集合。Hibernate 的会话缓存会在后台自动将它们重新组合成一个实体,无需交叉连接。
Template findWithCollectionA( Long id);Template findWithCollectionB( Long id);
2. 让批量获取处理其余部分。
对于其他所有情况,@BatchSize它告诉 Hibernate 使用子句批量获取子记录IN,而不是对每个实体的每个集合执行一次查询。
(mappedBy ="template")(size = 20)private Set<CollectionAItem> collectionA;
3. 定期清除会话
批量导入操作会在一个较长的事务中运行,Hibernate 的一级缓存会持续增长。通过定期添加flush()和调用,可以防止缓存本身成为内存瓶颈。
使用clear()它真的有效吗?行数减少了约 9 倍。处理时间减少了 15 倍。而凌晨 2 点真正重要的内存溢出崩溃问题,也停止发生了。
Set这并不能避免这个问题。它只是把故障延迟到生产环境流量发现它为止。Hibernate 非常擅长隐藏 SQL 代码。大多数情况下,这无疑是一大优势。但是当数据集足够大时,它就不再是优势了 ,反而会成为开发者的负担。
你是否遇到过只有在实际流量到来时才会出现的 Hibernate 或 JPA 的 bug?欢迎在评论区分享。
作者:场长
参考:
https://code.likeagirl.io/hibernate-turned-11-000-records-into-50-million-rows-2927c5886dca
本篇文章为 @ 万能的大雄 创作并授权 21CTO 发布,未经许可,请勿转载。
内容授权事宜请您联系 webmaster@21cto.com或关注 21CTO 微信公众号。
该文观点仅代表作者本人,21CTO 平台仅提供信息存储空间服务。
请扫描二维码,使用微信支付哦。