天天看点

CPU连续飙升的背后是 “道德的沦丧” 还是 “人性的泯灭”

        最近负责的一个老项目CPU时不时的就会飘到90%以上,然后宕掉,这个项目是一个汽车类的商城支持在线下单预约试驾等功能,咨询品牌方说是做了一波投放,但是没有提前通知我们,看了一波日志其实请求量也不是特别大。

        我们的系统是部署在阿里云上的,下面是简单的系统机构图:

CPU连续飙升的背后是 “道德的沦丧” 还是 “人性的泯灭”

        理论上按照这个配置没理由cpu利用率没理由飙升这么高,以至于品牌方和PM频繁收到阿里的告警短信,由于问题出现的突然,快速解决问题的途径当然是重启,拉了一波dump日志之后,就果断重启了,但是尴尬的是刚起来一台,准备起另一台的时候这一台就挂了,没办法,只能先挂一个维护页面,然后把nginx上所有的节点都拿掉,然后挨个重启好了再挂到nginx上,问题短时间得到了解决。

        开始调查问题,查看日志后发下日志里再频繁的GC,而且cpu占用率最高的几个线程都是再GC,但是这只能证明代码存在内存泄漏的风险,cpu为什么利用率这么高肯定不是这个原因,咨询以前的同事说以前也出现过类似的情况,说是sku和库存表数据当时超过百万,但是其中有一半是无效的数据了,这里解释小sku及库存就是商品的最小单位,当时他们的处理办法是直接干掉了几十万的数据,然后我查了下表发现此时有效的sku和库存都已经超过了百万,感觉问题就在这里。

        首先判断了下为么这么多库存,因为这是个汽车网站行业比较特殊,有上千家经销商,每个经销商对应一款车都有库存的维护和校验逻辑,因此如果一个车有20款型号颜色的话,那么乘以上千家经销商那就是,每个商品有上万的sku和库存数据,想到这里果断的查了下top前10的请求,果然商品详情页面和库存查询接口调用频率最高,库存接口甚至返回了2M的json数据,锁定问题开始看代码。

        查看代码发现商品详情接口,将一个巨大的对象扔到了redis中存储,商品信息一版包括基本信息/属性/图片/sku库存等,在当前这个项目中巨大的数据就是sku库存了,一个长度上万的list对象并且代码中还会for循环去迭代这个长度上万的对象做对象转换也就是这个for循环会new上万个对象,转完了之后将这个2M左右的大对象直接扔到redis中,下次再来直接从redis中读取,按照常规操作来讲这样没问题,但是问题就处在这个项目不常规,数据量太大了,首先redis中存储的一定是非结构化的数据,至少不是直接可以拿来返回出去的,而是需要java代码处理转换的,只要是处理转换就一定是需要计算的,而计算就一定需要消耗cpu的性能,可想而知,这么大的一个对象转换一定需要不小的cpu消耗。

        果然我们对这个接口做了一下压测,只压到20个并发,cpu就飙升到了89%,cpu负载到8,而且抛却cpu不谈,光说内存消耗这一点也是很可怕的,每个人来访问都会从redis中取出一个2M的大对象放到,jvm的堆内存中,那么也就是说如果同时有1000个人来访问,直接就占用2G的堆内存了,仅仅是这一个请求而已,真实情况是一个页面有好多请求,轻轻松松,就可以OOM~~~  这里也提醒一下大家,对于大对象一定要,显示的去给他做内存回收,用完了给他设置个null,否则他的回收时间一定比你认为的久,和容易内存泄漏。

     OK,以上一波分析我们有两个问题要解决:

  1.  避免每个请求进来都去redis中获取数据进而导致转换成java对象时产生的巨大的CPU消耗。
  2.  每个请求进来都会再堆内存产生一个2M大小的区域存放这个大对象,容易OOM。

        那么怎么解决这两个问题呢,其实想一下就知道了,我们无非是想要这样的结果,所有请求过来都拿到同一个大对象,也就是拿到的都是jvm上同一个地址的大对象,这样的话可以同时解决上面提到的两个问题,因为是直接从jvm中取出来的,所以不需要再次耗费大量的CPU性能去转换成java识别的对象(PS:当然这里从jvm取出来的东西一定也是会转换的,但是消耗跟从redis里取出来的东西再转换,效率上差的不是一点半点),第二个问题现在每个请求进来取的都是同一个jvm的对象也不会每次都new对象存储了,所以这个问题也就解决了。

        那么方案想好了,技术上的实现呢?其实上面说的这种方案大家一下就会想到定义一个全局变量之类的,比如搞个全局的map来存储,但是这样的话这个map什么时候过期呢?这就需要再进行一系列的开发和设计了,然而我们遇到的问题前辈们肯定遇到过,当然也有相关的实现方案。

        谷歌提供的一个Guava工具包,对于这个工具包就不展开了,这是一系列工具的集合都很好用,其中有一个Guava Cache就是我们要用到的功能,他其实就是一个local缓存,底层使用的就是CurrentHashMap,并且具备自动失效过期缓存的能力,并且还具备一些抗并发的能力,比如他提供了两种缓存方法,expireAfterWrites和refreshAfterWrite,前者如果缓存过期了的话在并发的情况下所有请求都会回源去数据库或你代码里指定的容器中获取数据,这个时候压力会上升,而后者在缓存货期了的情况下,并发时,只会放一个请求回源去查数据库或代码指定的容器中获取数据,其他请求会直接返回旧的缓存的数据。

        好吧!这里不多说Guava Cache的能力,这个有很多帖子介绍大家不了解的话去了解下就ok了,所以我们就选用了refreshAfterWrite这种缓存方法,并且设置过期时间2分钟,缓存最大数量的话设置为500,因为我们毕竟一个key有2M的内存,500个的话就占了1G堆内存了,不能让他再继续累加下去,Guava会自动根据key的使用频率清除不常用的key。

        按照上述方式修改了一版代码发布后,再做一波压测验证下效果,这里说一下之前压测的结果,我们是找了两个商品一个是sku数据比较大的20000条的,压的时候并发20个,TPS 是1.59/s,然后找了个数据量比较小的sku有3000条的,也是20个并发,TPS是6/s ,这一波修复完之后又分别压了这两个商品,得到的效果是,大的商品TPS 是 3.2/s,小的商品TPS 是15/s,从TPS上来看性能提升了1倍,但是cpu的利用率依然飘的很高还是89%,但是负载这个指标从8降到了4,到这里其实我们已经看到我们的优化是有效的,那么为什么cpu利用率还是没有下来呢?答案就是肯定还有代码导致大量的数据需要计算,继续看代码。

        这边再说一下,因为是老项目所以并没有使用到前后端分离,依然使用的是java+jsp的项目,大家都知道jsp也是java代码,也是服务端运行的,因此这里的代码如果过于复杂,过于庞大,也会耗费很多服务器性能的,基于这个点我去找了下这个请求对应的jsp页面,果不其然,java代码中将我们缓存的这个大对象放到了model中返回给jsp使用,另外为了给前端js使用,jsp中使用了自定义的el表达式  var project = “${fl:toJsonString(product)}”  ,这里的product就是前面说的里面包含几万数据的大对象,问题点就处在这里,这个操作其实底层就是去调用了个java方法,做了个toJson的操作。

        想象一下把一个带有几万的数据的大对象,去做json转换,先不说性能,光时间就会花费很久,他转换的时候需要占用大量的cpu去计算,所以并发上来了cpu指定飘起来,然后看了一波代码这个转换出来的对象只是为了给前端使用的,再jsp中根本不需要,所以为什么不直接在java中直接转成json字符串,然后返回给前端页面呢,这样至少在缓存周期内我只需要调用一次cpu去做一次toJson的转换即可,否则每个请求过来都会转换一波。

        解决方案很简单就是在java代码里另外起一个Guava缓存去缓存一下这个toJson后的字符串即可,代码改完后发布生产,直接压100个并发,大的商品TPS是24/s,小的商品TPS是120/s,从这个数据上来看,已经比最早没有优化前提升了20几倍,至此问题得到了解决,这次事故给我们敲响了警钟,redis不是万能的,程序跑起来也不是我们想象中那么快,数据量越大他的处理逻辑就越复杂,消耗的性能也就越高,所以对于大对象,大家一定要慎用,能拆分就拆分,不能拆分,一定做好缓存,使用大对象的时候也要注意使用的方法是否正确,是否必要,是否有其他更优雅的用法。