案例分析:分库分表后,我的应用崩溃了

news2025/1/13 13:06:15

今天我们主要分析一个案例,那就是分库分表后,我的应用崩溃了。

前面介绍了一种由于数据库查询语句拼接问题,而引起的一类内存溢出。下面将详细介绍一下这个过程。

假设我们有一个用户表,想要通过用户名来查询某个用户,一句简单的 SQL 语句即可:

select * from user where fullname = "xxx" and other="other";

为了达到动态拼接的效果,这句 SQL 语句被一位同事进行了如下修改。他的本意是,当 fullname 或者 other 传入为空的时候,动态去掉这些查询条件。这种写法,在 MyBaits 的配置文件中,也非常常见。

List<User> query(String fullname, String other) {
        StringBuilder sb = new StringBuilder("select * from user where 1=1 ");
        if (!StringUtils.isEmpty(fullname)) {
            sb.append(" and fullname=");
            sb.append(" \" + fullname + "\");
        }
        if (!StringUtils.isEmpty(other)) {
            sb.append(" and other=");
            sb.append(" \" + other + "\");
        }
        String sql = sb.toString();
        ...
 }

大多数情况下,这种写法是没有问题的,因为结果集合是可以控制的。但随着系统的运行,用户表的记录越来越多,当传入的 fullname 和 other 全部为空时,悲剧的事情发生了,SQL 被拼接成了如下的语句:

select * from user where 1=1

数据库中的所有记录,都会被查询出来,载入到 JVM 的内存中。由于数据库记录实在太多,直接把内存给撑爆了。

在工作中,由于这种原因引起的内存溢出,发生的频率非常高。通常的解决方式是强行加入分页功能,或者对一些必填的参数进行校验,但不总是有效。因为上面的示例仅展示了一个非常简单的 SQL 语句,而在实际工作中,这个 SQL 语句会非常长,每个条件对结果集的影响也会非常大,在进行数据筛选的时候,一定要小心。

内存使用问题

拿一个最简单的 Spring Boot 应用来说,请求会通过 Controller 层来接收数据,然后 Service 层会进行一些逻辑的封装,数据通过 Dao 层的 ORM 比如 JPA 或者 MyBatis 等,来调用底层的 JDBC 接口进行实际的数据获取。通常情况下,JVM 对这种数据获取方式,表现都是非常温和的。我们挨个看一下每一层可能出现的一些不正常的内存使用问题(仅限 JVM 相关问题),以便对平常工作中的性能分析和性能优化有一个整体的思路。

首先,我们提到一种可能,那就是类似于 Fastjson 工具所产生的 bug,这类问题只能通过升级依赖的包来解决,属于一种极端案例。具体可参考这里

Controller 层

Controller 层用于接收前端查询参数,然后构造查询结果。现在很多项目都采用前后端分离架构,所以 Controller 层的方法,一般使用 @ResponseBody 注解,把查询的结果,解析成 JSON 数据返回。

这在数据集非常大的情况下,会占用很多内存资源。假如结果集在解析成 JSON 之前,占用的内存是 10MB,那么在解析过程中,有可能会使用 20M 或者更多的内存去做这个工作。如果结果集有非常深的嵌套层次,或者引用了另外一个占用内存很大,且对于本次请求无意义的对象(比如非常大的 byte[] 对象),那这些序列化工具会让问题变得更加严重。

因此,对于一般的服务,保持结果集的精简,是非常有必要的,这也是 DTO(Data Transfer Object)存在的必要。如果你的项目,返回的结果结构比较复杂,对结果集进行一次转换是非常有必要的。互联网环境不怕小结果集的高并发请求,却非常恐惧大结果集的耗时请求,这是其中一方面的原因。

Service 层

Service 层用于处理具体的业务,更加贴合业务的功能需求。一个 Service,可能会被多个 Controller 层所使用,也可能会使用多个 dao 结构的查询结果进行计算、拼装。

Service 的问题主要是对底层资源的不合理使用。举个例子,有一回在一次代码 review 中,发现了下面让人无语的逻辑:

//错误代码示例

int getUserSize() {
      List<User> users = dao.getAllUser();
      return null == users ? 0 : users.size();
}

这种代码,其实在一些现存的项目里大量存在,只不过由于项目规模和工期的原因,被隐藏了起来,成为内存问题的定时炸弹。

Service 层的另外一个问题就是,职责不清、代码混乱,以至于在发生故障的时候,让人无从下手。这种情况就更加常见了,比如使用了 Map 作为函数的入参,或者把多个接口的请求返回放在一个 Java 类中。

//错误代码示例

Object exec(Map<String,Object> params){
        String q = getString(params,"q");
        if(q.equals("insertToa")){
            String q1 = getString(params,"q1");
            String q2 = getString(params,"q2");
            //do A
        }else if(q.equals("getResources")){
            String q3 = getString(params,"q3");
            //do B
        }
        ...
        return null;
}

这种代码使用了万能参数和万能返回值,exec 函数会被几十个上百个接口调用,进行逻辑的分发。这种将逻辑揉在一起的代码块,当发生问题时,即使使用了 Jstack,也无法发现具体的调用关系,在平常的开发中,应该严格禁止。

ORM 层

ORM 层可能是发生内存问题最多的地方,除了本课时开始提到的 SQL 拼接问题,大多数是由于对这些 ORM 工具使用不当而引起的。

举个例子,在 JPA 中,如果加了一对多或者多对多的映射关系,而又没有开启懒加载、级联查询的时候就容易造成深层次的检索,内存的开销就超出了我们的期望,造成过度使用。

另外,JPA 可以通过使用缓存来减少 SQL 的查询,它默认开启了一级缓存,也就是 EntityManager 层的缓存(会话或事务缓存),如果你的事务非常的大,它会缓存很多不需要的数据;JPA 还可以通过一定的配置来完成二级缓存,也就是全局缓存,造成更多的内存占用。

一般,项目中用到缓存的地方,要特别小心。除了容易造成数据不一致之外,对堆内内存的使用也要格外关注。如果使用量过多,很容易造成频繁 GC,甚至内存溢出。

JPA 比起 MyBatis 等 ORM 拥有更多的特性,看起来容易使用,但精通门槛却比较高。

这并不代表 MyBatis 就没有内存问题,在这些 ORM 框架之中,存在着非常多的类型转换、数据拷贝。

举个例子,有一个批量导入服务,在 MyBatis 执行批量插入的时候,竟然产生了内存溢出,按道理这种插入操作是不会引起额外内存占用的,最后通过源码追踪到了问题。

这是因为 MyBatis 循环处理 batch 的时候,操作对象是数组,而我们在接口定义的时候,使用的是 List;当传入一个非常大的 List 时,它需要调用 List 的 toArray 方法将列表转换成数组(浅拷贝);在最后的拼装阶段,使用了 StringBuilder 来拼接最终的 SQL,所以实际使用的内存要比 List 多很多。

事实证明,不论是插入操作还是查询动作,只要涉及的数据集非常大,就容易出现问题。由于项目中众多框架的引入,想要分析这些具体的内存占用,就变得非常困难。保持小批量操作和结果集的干净,是一个非常好的习惯。

分库分表内存溢出

分库分表组件

如果数据库的记录非常多,达到千万或者亿级别,对于一个传统的 RDBMS 来说,最通用的解决方式就是分库分表。这也是海量数据的互联网公司必须面临的一个问题。

根据切入的层次,数据库中间件一般分为编码层、框架层、驱动层、代理层、实现层 5 大类。典型的框架有驱动层的 sharding-jdbc 和代理层的 MyCat。

MyCat 是一个独立部署的 Java 服务,它模拟了一个 MySQL 进行请求的处理,对于应用来说使用是透明的。而 sharding-jdbc 实际上是一个数据库驱动,或者说是一个 DataSource,它是作为 jar 包直接嵌入在客户端应用的,所以它的行为会直接影响到主应用。

这里所要说的分库分表组件,就是 sharding-jdbc。不管是普通 Spring 环境,还是 Spring Boot 环境,经过一系列配置之后,我们都可以像下面这种方式来使用 sharding-jdbc,应用层并不知晓底层实现的细节:

@Autowired
private DataSource dataSource;

我们有一个线上订单应用,由于数据量过多的原因,进行了分库分表。但是在某些条件下,却经常发生内存溢出。

分库分表的内存溢出

一个最典型的内存溢出场景,就是在订单查询中使用了深分页,并且在查询的时候没有使用“切分键”。使用前面介绍的一些工具,比如 MAT、Jstack,最终追踪到是由于 sharding-jdbc 内部实现所引起的。

这个过程也是比较好理解的,如图所示,订单数据被存放在两个库中。如果没有提供切分键,查询语句就会被分发到所有的数据库中,这里的查询语句是 limit 10、offset 1000,最终结果只需要返回 10 条记录,但是数据库中间件要完成这种计算,则需要 (1000+10)*2=2020 条记录来完成这个计算过程。如果 offset 的值过大,使用的内存就会暴涨。虽然 sharding-jdbc 使用归并算法进行了一些优化,但在实际场景中,深分页仍然引起了内存和性能问题。

下面这一句简单的 SQL 语句,会产生严重的后果:

select * from order order by updateTime desc limit 10 offset 10000

这种在中间节点进行归并聚合的操作,在分布式框架中非常常见。比如在 ElasticSearch 中,就存在相似的数据获取逻辑,不加限制的深分页,同样会造成 ES 的内存问题。

另外一种情况,就是我们在进行一些复杂查询的时候,发现分页失效了,每次都是取出全部的数据。最后根据 Jstack,定位到具体的执行逻辑,发现分页被重写了。


private void appendLimitRowCount(final SQLBuilder sqlBuilder, final RowCountToken rowCountToken, final int count, final List<SQLToken> sqlTokens, final boolean isRewrite) {
        SelectStatement selectStatement = (SelectStatement) sqlStatement;
        Limit limit = selectStatement.getLimit();
        if (!isRewrite) {
            sqlBuilder.appendLiterals(String.valueOf(rowCountToken.getRowCount()));
        } else if ((!selectStatement.getGroupByItems().isEmpty() || !selectStatement.getAggregationSelectItems().isEmpty()) && !selectStatement.isSameGroupByAndOrderByItems()) {
            sqlBuilder.appendLiterals(String.valueOf(Integer.MAX_VALUE));
        } else {
            sqlBuilder.appendLiterals(String.valueOf(limit.isNeedRewriteRowCount() ? rowCountToken.getRowCount() + limit.getOffsetValue() : rowCountToken.getRowCount()));
        }
        int beginPosition = rowCountToken.getBeginPosition() + String.valueOf(rowCountToken.getRowCount()).length();
        appendRest(sqlBuilder, count, sqlTokens, beginPosition);
    }

如上代码,在进入一些复杂的条件判断时(参照 SQLRewriteEngine.java),分页被重置为 Integer.MAX_VALUE。

总结

今天以 Spring Boot 项目常见的分层结构,介绍了每一层可能会引起的内存问题,我们把结论归结为一点,那就是保持输入集或者结果集的简洁。一次性获取非常多的数据,会让中间过程变得非常不可控。最后,我们分析了一个驱动层的数据库中间件,以及对内存使用的一些问题。

很多时候我们把这些耗时又耗内存的操作,写了非常复杂的 SQL 语句,然后扔给最底层的数据库去解决,这种情况大多数认为换汤不换药,不过是把具体的问题冲突,转移到另一个场景而已。
在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1516238.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

阿里云价格战的背后,难以言说附送阿里云服务器优惠价格明细表

2024阿里云服务器优惠活动政策整理&#xff0c;阿里云99计划ECS云服务器2核2G3M带宽99元一年、2核4G5M优惠价格199元一年&#xff0c;轻量应用服务器2核2G3M服务器61元一年、2核4G4M带宽165元1年&#xff0c;云服务器4核16G10M带宽26元1个月、149元半年&#xff0c;云服务器8核…

leetcode110.平衡二叉树

之前没有通过的样例 return语句只写了一个 return abs(l-r)<1缺少了 isBalanced(root->left)&&isBalanced(root->right);补上就好了 class Solution { public:bool isBalanced(TreeNode* root) {if(!root){return true;}int lgetHeight(root->left);i…

阿里云国际配置DDoS高防(非中国内地)加速线路

DDoS高防&#xff08;非中国内地&#xff09;加速线路只能与DDoS高防&#xff08;非中国内地&#xff09;保险版或无忧版实例结合使用。您将业务&#xff08;部署在中国内地以外地域&#xff09;接入DDoS高防&#xff08;非中国内地&#xff09;实例防护后&#xff0c;可以通过…

upload 上传文件后在下次弹框打开时清空上次上传的内容

文章目录 需求分析 需求 upload 上传文件后在下次弹框打开时清空上次上传的内容 分析 arco-design 暂时无法实现该需求&#xff0c;所以继续使用了 elementPlus 的解决方案 获取 Token const getToken () > {return localStorage.getItem(TOKEN_KEY); };页面 <a-f…

2024年将人力RPO项目当蓝海项目吗?

随着科技的快速发展和全球化趋势的加强&#xff0c;人力资源外包(RPO)项目在过去的几年中异军突起&#xff0c;成为企业优化人力资源配置、降低运营成本的重要手段。然而&#xff0c;到了2024年&#xff0c;我们是否还能将人力RPO项目视为一片尚待开发的蓝海呢? 从市场角度来看…

Ansible管理主机的清单------------inventory

一、 Ansible组成 INVENTORY&#xff1a;Ansible管理主机的清单 /etc/ansible/hosts 需要管理的服务清单,(将你需要管理的主机 、地址 或者名字 写入此文件) MODULES&#xff1a;Ansible执行命令的功能模块&#xff0c;多数为内置核心模块&#xff0c;也可自定义 PLUGINS&…

C goto 语句

C 语言中的 goto 语句允许把控制无条件转移到同一函数内的被标记的语句。 注意&#xff1a;在任何编程语言中&#xff0c;都不建议使用 goto 语句。因为它使得程序的控制流难以跟踪&#xff0c;使程序难以理解和难以修改。任何使用 goto 语句的程序可以改写成不需要使用 goto 语…

DL-丙氨酸(DL-Alanine)为维生素B6原材料 直接发酵法有望成为其主流制备方法

DL-丙氨酸&#xff08;DL-Alanine&#xff09;为维生素B6原材料 直接发酵法有望成为其主流制备方法 丙氨酸可分为D-丙氨酸、L-丙氨酸以及DL-丙氨酸三种类型。DL-丙氨酸又称DL-Alanine&#xff0c;指D-丙氨酸和L-丙氨酸的外消旋混合物。DL-丙氨酸外观呈无色至白色针状结晶或结晶…

前端框架vue的样式操作,以及vue提供的属性功能应用实战

✨✨ 欢迎大家来到景天科技苑✨✨ &#x1f388;&#x1f388; 养成好习惯&#xff0c;先赞后看哦~&#x1f388;&#x1f388; &#x1f3c6; 作者简介&#xff1a;景天科技苑 &#x1f3c6;《头衔》&#xff1a;大厂架构师&#xff0c;华为云开发者社区专家博主&#xff0c;…

关于原型的一些总结

猛然发现太久没去复习了&#xff0c;于是复习了一些知识&#xff0c;顺便冒个泡。本次主要总结的知识点关于原型&#xff0c;再文章后半部分有原型相关的题&#xff0c;感兴趣的可直接观看。 一、原型 1.什么是原型 简单理解&#xff0c;原型就是一个对象&#xff0c;通过原…

【MySQL性能优化】- 一文了解MVCC机制

MySQL理解MVCC &#x1f604;生命不息&#xff0c;写作不止 &#x1f525; 继续踏上学习之路&#xff0c;学之分享笔记 &#x1f44a; 总有一天我也能像各位大佬一样 &#x1f3c6; 博客首页 怒放吧德德 To记录领地 &#x1f31d;分享学习心得&#xff0c;欢迎指正&#xff…

压缩json字符串

GZIPOutputStream 需要关闭&#xff0c;而 ByteArrayOutputStream 不需要关闭。具体原因如下&#xff1a; GZIPOutputStream&#xff1a;GZIPOutputStream是一种过滤流&#xff0c;它提供了将数据压缩为GZIP格式的功能。当使用此类的实例写入数据时&#xff0c;它会对数据进行压…

Seata:实现分布式事务的利器

Seata&#xff1a;实现分布式事务的利器 Seata是一种开源的分布式事务解决方案&#xff0c;旨在解决分布式系统中的事务一致性问题。本文将介绍Seata的概念和原理&#xff0c;探讨其在分布式应用程序中的应用场景&#xff0c;并讨论其对于构建可靠的分布式系统的重要性。 Seata…

网赚人,为什么都退圈了?

今儿的话题多少有些悲观。 因为曾经辉煌的网赚圈也开始下滑&#xff0c;从没想过这一天会来的如此之快。最近一直说经济下行影响实体&#xff0c;我想着跟咱互联网人没关系啊&#xff0c;他们做实体的只针对本地客户&#xff0c;咱互联网人针对全国客户。还怕没人了&#xff1…

让短视频博主脾气变好的5款工具!

啊啊啊啊&#xff01;就想问几句&#xff01;谁在职场上脾气变差了&#xff01; 虽然在职场上总会有几天不想上班也是比较正常的事情&#xff0c; 但有的工作做着就有种摔鼠标发疯&#xff01; 考虑中不少做短视频博主一直想用却不知道的工具&#xff0c;也是专门给大家整理…

iTOP-3588开发板快速启动手册Windows安装串口终端调试串口常见问题(一)

2.4.1 设备管理器找不到端口 问题一&#xff1a;win10或者win11设备管理器找不到端口&#xff0c;怎么办&#xff1f; 解决方法&#xff1a; 一 可能是被隐藏了 1 首先进入到“设备管理器”中&#xff0c;找到如下图的位置。 2 点击“查看”&#xff0c;并找到的“显示隐藏…

中小学生校服订购系统lw 微信小程序-python+java+node.js+php

作为一个校服订购系统&#xff0c;数据流量是非常大的&#xff0c;因而&#xff0c;系统的制定需要达到方便使用、实际操作灵便的规定。所以&#xff0c;在设计方案校服订购系统时&#xff0c;应完成下列总体目标&#xff1a; (1)页面应美观大方友善&#xff0c;查找应便捷方便…

Linux:设置别名命令alias

相关阅读 Linuxhttps://blog.csdn.net/weixin_45791458/category_12234591.html?spm1001.2014.3001.5482 在Linux中alias命令用于为一串字符&#xff08;常代表命令&#xff09;设置一个别名&#xff0c;该别名在Bash读取并解析一行命令时会被展开。 下面是该命令的语法。 用…

征战PRO开发板XILINX VIVADO XC7A35T

征战PRO开发板经过几个月的设计准备工作&#xff0c;终于成功投板&#xff0c;来看看它是怎么一步一步变成PCB板的吧。 PCB图 CAD图 PCB裸板 裸板做出来还挺好看的。 大家可以看到我们板子上的丝印是非常丰富的&#xff0c;基本将管脚映射关系都在PCB板上体现出来了&…

(学习日记)2024.03.10:UCOSIII第十二节:多优先级

写在前面&#xff1a; 由于时间的不足与学习的碎片化&#xff0c;写博客变得有些奢侈。 但是对于记录学习&#xff08;忘了以后能快速复习&#xff09;的渴望一天天变得强烈。 既然如此 不如以天为单位&#xff0c;以时间为顺序&#xff0c;仅仅将博客当做一个知识学习的目录&a…