压测时优化
CAS的重构后,新系统上线需要进行压测,以应对嘉立创和立创总流量。当时的情况是做了一些压测,在arms上发现服务的YGC非常频繁,没有发现FGC。
因为服务使用K8S进行发布,也没有设置任何的JVM参数,基于上面的信息,我们首先确定的是JVM的年轻代过小,默认JVM的年轻代和老年代是1:2,目前容器最大的默认内存是2G,又观察到元空间最多只使用120多M,因此就设计了JVM的参数,如下:
-Xmx1536M -Xms1536M -Xmn1024M -XX:MaxMetaspaceSize=192M -XX:MetaspaceSize=192M
通过上面参数的调整,同时节点数增加到8个,压测时YGC有了明显的改善,基本保持在每分钟8次左右。
小贴士:其实我们大部分面向客户的应用,每次请求需要的资源不多,且返回后就会释放,一般不存在大对象的情况,YGC可以回收大部分内存,老年代存储的都是Spring的对象以及一些静态变量等信息,不会占用很大的空间,此类应用可以留足一部分堆内存给老年代,其余的都可以分配给年轻代,以降低YGC的频次。
最好的优化方式是观察系统的GC和堆内存情况,每个应用合理调整JVM参数。
线上情况分析优化
系统当时是周六晚上线,周日上午时观察到arms的情况,大概每分钟8次,在可接受的范围。
按照以往的情况,周一上午就会迎来高峰期,因此当天在10点左右时,我们有查看了监控数据,当时单个节点的并发数是50左右。下图是当时的JVM情况。
上图中我们当时分析得出的一些结论:YGC太频繁,基本2-3秒一次。每次YGC需要20毫秒左右。此时请求的平均处理时间是30毫秒左右,系统没有明显的卡顿,几乎无影响。
当到下午高峰期的时候,我们的单个节点的并发数达到了85左右,YGC也达到了每分钟40-50次,单次YGC的时间无明显变化,但是整个系统比较正常。
基于当天的观察,决定晚上发布时调整JVM的内存大小,以降低YGC频率,频繁YGC会导致系统卡顿,在系统异常、网络异常时可能引发FullGC,引发严重问题,所以还是决定先提升内存。主要基于两点:
1、YGC是因为请求并发比较大导致的。
2、以前的服务器是4台4C16G的机器,此次增加了立创商城的请求,目前的总配置是16C16G,而且明显CPU使用率极低,所以打算每个节点升级为2C4G。
优化后的配置如下:
-Xmx3584M -Xms3584M -Xmn3072m -XX:MaxMetaspaceSize=256M -XX:MetaspaceSize=256M
因为我们看到老年代增长是缓慢的,此次配置年轻代占用3G,老年代只有512M,最大限度的把内存用在处理请求上。
经过第一次优化,当时上线后的GC状态还是可以接受的,然后在同一时间观察此次的优化效果,结果如下图:
从上图可以看出:每分钟的YGC有了明显的下降,同时YGC的时间也缩短了,当然单次YGC的时间是没有变化的。到这里,虽然YGC的次数还是比想象中频繁,但我们基本上达到了优化的目的。
新的发现,方向转变
在优化的过程中,我们心中也有了一些疑惑,每分钟10次的YGC,从图上看可以知道,YGC基本是Eden区满了导致的,现在Eden区超过了2G,那一分钟就相当于20G的流量打满,每秒钟一个节点就有340M左右流量,平均每个请求要6M多。在观察了几个流量最大的请求后,和开发人员沟通确认后,发现这些请求并没有进行大数据量的查询,不应该有这么大的流量才对。
另外在配置k8s的参数时,也发现了一个问题,我们明明初始节点设置了2个,但是为什么每次发版的时候都会有8个节点?是因为请求量太大,所以引起的扩容吗?
小贴士:保持一个怀疑的心,和自己经验不否时,要多进行思考,别忽略一些小细节,因为不知道什么时候可能造成灾难性的后果。
SpringSession存在的问题
我们想到灰度节点是没有请求的,看下灰度节点的JVM情况如何,因此有了下图:
当看到上图的结果时,突然意识到所有YGC的原因和请求无关,那也就意味着我们开始优化的方向是有问题,虽然提升硬件资源的方式缓解了问题,但是并没有根本上解决问题。
优化SpringSession配置
分析到此时,我们的排查方向发生了一次大的转折,既然不是外部请求,那就是内部有定时任务相关的处理可能导致的问题。一方面让开发人员确认是否存在定时任务涉及到大量数据的查询,另一方面看下arms的监控有没有什么异常,于是发现了下图(此图不是上面那个灰度节点的图,看历史数据有误,只能拿当时的其它节点数据截图):
在上面我们发现了一个Redis清理过期会话的任务,且每次处理达到了40s,于是接着跟踪请求的处理,如下图:
在上面发现每次请求要访问Redis超过了2万次,首先怀疑到可能是这里的请求导致了问题。既然问题到了代码这里,那就直接源码看一看。
在前面的图中,我们发现调用链的请求在 RedisOperationsSessionRepository 类中 cleanupExpiredSessions 方法执行的,因此找到以下代码:
既然提供了配置,于是我们修改了配置,本地测试,确实发现定时任务可以通过配置修改,测试环境没有问题,等晚上发布上线。
这里其实我们没有盲目直接修改配置,而是深入了解了SpringSession的设计,了解了修改这个参数对我们的服务是影响不大的。以前学习SpringSession的时候,主要集中在用,对原理也有一点了解,以往认为会话过期使用的是Redis的过期策略,此次才发现,这里有一些复杂的设计,是因为SpringSession的有自己的需求, 当然也造成了很大的问题。
发布上线后,观察JVM的监控以及定时任务的执行,发现并没有解决YGC频繁的问题,但是定时任务确实已经按照新的配置执行的(改为了1个小时执行一次)。
传统利器排查问题
在arms上排查问题的时候,也可以看到线程占用CPU的情况,当时有看到 redisMessageListenerContainer- 的线程名字,在灰度节点上是占用CPU最多的线程,但是整体CPU占用并不高,就没有重视,导致这条线索没有跟踪,有可能更快排查到问题。
系统上了容器之后,因为没有服务器权限,无法通过命令行去调试,在arms上也可以借助arthas去实时跟踪查看JVM状态,但是没有权限,也担心开启影响线上服务,于是找运维进入容器排查问题。
jstat -gcutil pid 1000 1000
观察服务的GC情况,确认确实是频繁YGC,而且确实是因为Eden区满导致的。
接着打算dump应用的内存,查看内存中到底是什么对象?
jmap -dump:format=b,live,file=abc.hprof pid
拿到内存文件大概有1.8G,使用 MemoryAnalyzer 工具分析,看到内部有效对象一共才几十M,也没有找到直接有效的数据用于分析原因。运维同学提出可以抓包看下,使用 tcpdump命令进行了抓包(具体tcpdump的使用,自行查找相关资料),得到下面的日志:
上图中我们看到大量的 SpringSession 的创建事件,但是我们的灰度节点并没有接受请求,那这些数据哪里来的呢?根据上面的信息,我们在源码中发现了下图内容:
在代码的排查过去中发现,SpringSession 提供了很多的扩展点,在Session创建、过期、删除的时候都可以扩展实现,因此为了保障扩展可以实现,就需要监听到其它服务节点的会话的事件,这样就造成了集群中的每个节点其实都在处理这些事件,但是我们CAS并不需要这些,CAS是自己创建了一套会话机制,因此原则上并不依赖SpringSession 的会话机制。
记得我们在前面提到过,我们的其它项目也用到了SpringSession ,但是并没有类似的现象,我们又排查了我们自定义组件 xxx-session-utils 的代码,发现如下代码:
至此,找到了可能的原因,那就改造测试下,经于开发人员沟通确认,我们CAS可以完全去除掉SpringSession 的依赖,于是排除依赖,在UAT环境测试后,发布上线。
第二天早上观察JVM的情况,我们发现频繁YGC的问题已经解决了,现在每分钟大概只有1次YGC。至此,问题的根因排查完毕,且调整解决了问题。
在我们排查问题的同时,龚工也对内存dump文件进入了深入的分析,也定位到了内存空间上的根因。分析文档:2023-05-26 CAS 内存YGC异常(线程问题) - 软件部 - ERP系统 - JLCConfluence (sz-jlc.com)
K8S问题分析
回到我们前面的另一个疑问:为什么我们发布的时候会直接启动8个节点。
我们先把当时进行JVM调优前,K8S的核心配置介绍一下:
# 启动时,启动2个容器节点
replicaCount: 2
# 容器资源限制
resources:
# 容器最大CPU和内存限制,超过后,会被K8S直接kill掉
limits:
cpu: 2
memory: 2048Mi
# 用于K8S申请启动一个容器的最小资源,也作为在进行节点伸缩时的评估参数
requests:
cpu: 200m
memory: 1024Mi
autoscaling:
# 容器自动伸缩开启
enabled: true
# 最小保留2个节点
minReplicas: 2
# 最大开启8个节点
maxReplicas: 8
# CPU达到resources.request.cpu的80%以上时,进行节点扩容
targetCPUUtilizationPercentage: 80
# 内存达到resources.request.memory的80%以上时,进行节点扩容
targetMemoryUtilizationPercentage: 80
在上面的配置中,我们看到默认启动了2个节点,事实上是启动类8个节点,结合我们自动伸缩的配置,我们明白是因为容器自动扩容到8个节点。
因为我们的JVM配置参数是
-Xmx1536M -Xms1536M -Xmn1024M -XX:MaxMetaspaceSize=192M -XX:MetaspaceSize=192M
上面的配置已经大于 resources.request.memory 的配置,超过此配置的80%就会扩容,由于我们启动时JVM的内存已经超过了此配置,所以一定会进行扩容,又因为不会超过 resources.limits.memory 最大限制,所以容器启动也是正常的。
因为JVM的最大堆内存和最小堆内存设置成一样,主要是为了防止垃圾回收期在进行垃圾回收时进行堆空间的伸缩消耗,鉴于这样的原因,我们在第一次JVM参数优化的时候,对K8S的配置也进行了调整。
# 启动时,启动2个容器节点
replicaCount: 6
# 容器资源限制
resources:
# 容器最大CPU和内存限制,超过后,会被K8S直接kill掉
limits:
cpu: 2
memory: 4Gi
# 用于K8S申请启动一个容器的最小资源,也作为在进行节点伸缩时的评估参数
requests:
cpu: 2
memory: 4Gi
autoscaling:
# 容器自动伸缩开关
enabled: false
# 最小保留2个节点
minReplicas: 2
# 最大开启8个节点
maxReplicas: 8
# CPU达到resources.request.cpu的80%以上时,进行节点扩容
targetCPUUtilizationPercentage: 80
# 内存达到resources.request.memory的80%以上时,进行节点扩容
targetMemoryUtilizationPercentage: 80
调整的配置点主要如下:
1、启动时启动6个容器节点
2、容器资源申请和限制都设置一样,为2核CPU和4G内存。以配合JVM中堆内存的设置。
3、关闭容器的自动伸缩。
容器中JVM参数深入优化
前端我们对JVM和K8S的配置进行了优化,解决了频繁YGC的问题,原本以为此次优化已经结束,但是一个告警信息让我们又继续深入这个话题。
从告警信息可以看到我们的容器因为OOM被kill掉了,观察当时的JVM状态,其实是比较平稳的(如下图所示),可以确定是因为容器中的pod使用的内存超过了限制(k8s中配置的4G)。
回到我们的JVM参数,当时的参数是这样的,其实我们除了堆内存和元空间内存外,我们还保留了256M的内存给pod使用。然后结合上图中的非堆内存,使用的字节数是226M,是不是还有其它资源也占用了内存,导致OOM的?
-Xmx3584M -Xms3584M -Xmn3072m -XX:MaxMetaspaceSize=256M -XX:MetaspaceSize=256M
目前虽然容器会偶尔的OOM,但是整体上对我们的请求处理影响不大,因为此应用是面向客户的应用,且流量和影响面都非常大,因此决定在其他低流量的应用上进行参数调优,然后再应用回当前应用。
直接内存参数优化
售后系统的请求量比较低,且影响面也比较小。以往我们的ECS的上的应用大部分都是512M的堆内存,所以就以售后系统做一次参数调优。售后系统打算使用1G的内存作为pod的限制。
在前面的问题中我们猜测是有其它资源使用了额外的内存,但具体是什么呢?另外还有前面OOM时JVM监控的参数时,也有疑问,我们知道在Java8之后,元空间替代了永久代,直接使用了堆外内存,那上图上中的非对内存是包含元空间的内存吗?上图中元空间使用了131M内存,非堆内存使用了226M,如果226M包含了元空间内存,那我们剩余的内存应该足够的,应该不会引起OOM才对。对于这里非堆内存的解释,阿里云的相关文档也没有具体的解释。但查找资料的时候,一个JVM参数进入了我们的视野,那就是 -XX:MaxDirectMemorySize 最大直接内存。
Java开发中我们很少会直接使用JDK的API去获取系统内存处理业务,因为Java的优势就是垃圾回收机制,让我们的代码开发中再也不象C++那样进行内存释放了。但是JDK也提供了这样的能力,尤其在NIO的场景下,Netty就使用了堆外内存完成了它的零拷贝机制,而我们切好在微服务的调用中也使用了Netty。会不会是因为这个内存导致的OOM呢?
在查阅了相关的资料了解到:
1、Java8默认可使用的最大直接内存为最大堆内存的87.5%
2、Java11默认可使用的最大直接内存等于最大堆内存
3、Netty确实在使用堆外内存时,也会根据这个参数来进行内存的管理。
结合我们的JVM参数设置,从理论上讲,是很有可能因为直接内存的默认设置导致突破pod的内存限制,导致OOM。
结合阿里相关文档的推荐设置,1G的内存推荐600M作为堆内存。因此设计了如下参数,还是预留了104M的内存:
-Xmx600M -Xms600M -Xmn300m -XX:MaxDirectMemorySize=128M -XX:MaxMetaspaceSize=192M -XX:MetaspaceSize=192M
发布验证后,过了两天又出现了OOM,难道是我们预留的空间还是不足?因此又做了如下优化:
-Xmx512M -Xms512M -Xmn256m -XX:MaxDirectMemorySize=128M -XX:MaxMetaspaceSize=192M -XX:MetaspaceSize=192M
重新提高了预留的内存,观察几天后,未出现问题,但是到底是什么资源占用了多少内存,我们依旧无法确认,短期观察没有问题,时间久了会不会还出现,因此我们需要对Java应用的内存使用进行一次深挖掘。
NMT内存问题分析
经过前面的优化,我们1G的内存,最终提供给JVM堆的内存只有512M,而且还担心出现OOM,因此还需要对这个内存的使用情况进行分析,以便于后续设计合适的JVM参数。
Java提供了NMT(Native Memory Tracking)技术,用于我们分析JVM内存的使用情况。只需要在启动参数上加上:-XX:NativeMemoryTracking=[off | summary | detail] ,默认是off,排查问题的时候建议使用 summary即可,detail会打印到量的信息,但是会占用较多的内存。
测试的时候,开始的时候设置的参数是detail,但是系统启动不成功,一启动就被容器kill掉,后续改为summary,就可以启动了,发现是内存不足引起的。
当我们启动中增加了NMT的参数后,即可以使用下面的命令查看内存使用情况:
jcmd pid VM.native_memory scale=MB
上图中我们发现,除了我们前面提到的堆内存、元空间内存和直接内存,系统还有很多的地方使用其它内存。
注意:不同版本JVM可能打印出来的内存项是不一样的,网上其它版本中Class中还明确列出了元空间的情况。
针对于上面的内存信息,我们再针对性的做一些优化。
线程配置优化
默认64位系统,JVM默认的线程栈大小位1M,我们可以通过 -Xss512k 参数进行配置。但是过小的设置可能导致程序运行时出现栈内存溢出的问题,这个可以根据具体的应用情况适当进行调整。另外线程总数也会影响使用的内存大小。针对于线程数的优化,也可以提升内存的使用率。
整体优化两个方面:
1、-Xss 栈大小参数优化,需要结合应用特点设置大小。
2、线程数限制。这个指我们tomcat的线程限制、数据库连接池、自定义线程池等等,要根据应用的特点、请求数等设置合理的值,控制线程池的最大线程数等参数,因为CPU核数有限,提高线程数不一定能提高系统的处理性能。
代码缓存配置优化
内存的分析中有一部分是代码的缓存,代码缓存应该我们最熟悉的是在eclipse的配置文件 eclipse.ini 中看到的,即 -XX:ReservedCodeCacheSize 配置。
Java8中,64位操作系统默认的代码缓存大小是240M,但是图中我们看到的是249M,为什么不相同,这个原因还不清楚。我们在图中可以看到真正使用的是31M。因此我们可以调整这个参数设置,以便节省出一定的内存。
代码缓存主要JIT用来缓存已编译方法生成的本地代码,用来提高程序运行时的性能。当代码缓存空间不足时,JIT编译器将停止编译本地代码,虽然不影响程序的正常执行,但是应用的运行速度可能降低一个数量级,而且这个问题也很难被发现。因此修改这些配置一定要慎重。
常用的参数:
1、-XX:ReservedCodeCacheSize 设置可以申请的最大代码缓存大小。
2、-XX:InitialCodeCacheSize 设置初始化时的代码缓存大小。
除了以上两个核心的配置外,还有十几个相关的调优参数,这里不再赘述,有兴趣的可以查找相关的资料了解。
GC的配置优化
垃圾回收时也会占用一部分额外的内存。Java8默认使用的CMS垃圾回收器,我们了解CMS的回收过程中会使用多线程进行处理,因此可以设置这些配置,来控制和优化我们的系统。JVM默认是根据系统的CPU核心数来设置启动垃圾回收器的线程数。
因为容器环境的特殊性,JVM也提供了额外的其它参数用来配合容器环境进行调优。
阿里推荐容器中JVM使用容器的相关参数:
-XX:+UseContainerSupport -XX:InitialRAMPercentage=70.0 -XX:MaxRAMPercentage=70.0
可以识别容器的cpu和内存限制,同时堆内存可以根据容器的限制使用百分比,避免调整了容器限制时,还需要调整JVM的堆参数设置。
注意:容器中使用的JVM参数跟Java的具体版本相关,一定要核查具体的小版本是否支持对应的参数设置。JVM的参数配置同样如此,可能跟具体的编译器相关。
arthas内存分析
这一节属于后续的补充,对我们了解内存的情况和arms的监控信息有一定的帮助。
在我们最初的arms的JVM监控图中,发现Survivor区一直占用都是7M左右,按照年轻代默认8:1:1的比例,S区理应会分配几百M的空间,目前监控上显示只有7M,是不是可以调整比例释放出多余的空间呢?这个是最初引入这章内容的原因。
进入容器内部,下载arthas,启动arthas,使用 memory 命令
在上图中,我们发现了 arthas 也把内存空间分为了堆和非堆两部分,每部分又分为几类,其中非堆内存的最大值和我们在ams上看到的监控一样,都是744M,而且作为同一家的产品,应该获取监控数据的方式应该也差不多,那基本上确定监控上看到的信息就是这里的信息。
关于非堆内存,这里的信息和NMT中的信息不完全一样,在 arthas 中可以直接看到 direct 这一项就是我们前面提到的直接内存,使用了很低,但是看到 max 列是无数据,这个是因为我们没有显示的配置这一项,默认值为我们前面介绍直接内存的信息。另外还又一项 compressed_class_space 这一项是指针压缩使用的内存,一般情况下,这一项也不会占用很大,只是这一项和NMT中的哪一项对应,目前是没有深入研究的。总的来说,我们在这里看到了另外一部分内存的信息,结合NMT中的信息,让我们堆Java应用的内存使用有了更加深入的了解。
回到我们的重点,S区看到的最大值只有6M,这个和我们的预期严重不符。经过一番资料的排查:
1、-XX:SurvivorRatio默认值为8,只是针对Serial GC和CMS GC有效。
2、在JDK7/8默认都是Parallel GC,Parallel GC会根据自己情况自动调整。针对这一项,最初我的印象是要使用CMS,就需要显示配置,但在前段时间设置JVM时,有同事说JDK8默认就是CMS垃圾回收器,当时查看arms的监控,确实看到JVM的参数中配置了CMS垃圾回收器。当时没有过度深究,所以也导致了上面出现的S区只有6M的问题。
后续对这个问题进行了深入了解发现,运维组在设计发布的流水线中,当没有设置JVM参数时,提供了默认的一些参数(arms看到的就是这个),当我们主动设置JVM参数时,这些默认值是不会和我们设置的参数合并,而是直接使用我们的参数,所以导致了错误的印象。因此,当我们自己配置JVM参数时,一定要对应的垃圾回收器配置上。
内存分析后优化
结合上面的优化,我们针对此服务的JVM参数调整为:
-XX:+UseContainerSupport -Xmx512M -Xms512M -Xmn256m -XX:ReservedCodeCacheSize=128M -XX:MaxDirectMemorySize=128M -XX:-UseAdaptiveSizePolicy -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:MaxMetaspaceSize=192M -XX:MetaspaceSize=192M
此次调整都是在目前的范围内进行调整的,因此没有任何问题。其中有参数没调整的原因:
1、-Xss 没调整是因为担心影响,需要考虑的比较多,担心风险大于收益。
2、-XX:InitialCodeCacheSize 初始化代码缓存,可能导致一开始申请过大的内存,其实没有使用,所以结合实际情况只使用了31M的情况,没有设置初始化值。
3、-XX:InitialRAMPercentage=70.0 -XX:MaxRAMPercentage=70.0 这两个容器参数没有调整的原因是因为我们有些服务请求量非常小,当使用内存只有1G的时候,甚至2G的使用,使用这个比例其实不合适,还是需要根据容器的限制进行比例的调整。再则使用此参数就不需要设置 -Xmx 、-Xms 这两个参数了,那么当需要调整年轻代和年老代的大小的时候,就需要使用 -XX:NewRatio 参数,但是此参数不支持小数形式,因此只能设置到1,导致不能更灵活的扩大年轻代的比例。
总结
通过此次的问题分析和优化,我们对于如何设计JVM参数有了一个比较清晰的认识,虽然期间走过很多的弯路,但是我们也有很多额外的收获。互联网提供的各种推荐,只是一个相对笼统的约定,应用的不同,环境的不同,都需要进行一定的调整,都需要进行验证和测试,然后结合自己的经验进行调整。
收获
此次经验有以下收获,分享如下:
1、容器中的JVM参数设计与ECS的配置区别很大。使用ECS的时候,我们很少关注堆外内存的占用,默认我们服务器的内存都比较大,所有Java应用都共享同样的物理内存,且我们预留空间都比较充足,很少出现因内存不足OOM后kill掉应用。容器中的应用每一个都是隔离的,一旦内存设置不合理,就会被直接kill掉,因此在使用k8s时,需要更加关注JVM的参数配置。
2、关注细节,在问题排查中时,除了关注问题本身之外,碰到不符合自己经验的现象时,需要多问一句为什么。
3、开发人员常用的工具或者Java自带的命令,能在我们问题追踪中起到很大的作用。运维也有一些自己常用的工具,关键时刻可以起到非常重要的作用,团队之间相互合作,能够便于我们更好的集思广益。
4、总结了嘉立创使用容器时的一些最佳实践和JVM参数设置的最佳实践。
5、优化了前端NG应用的一些模板配置,降低了内存,避免了前端应用的无效扩容。
6、优化了发布过程中的流水线设计,降低了灰度节点启动数量,减少了资源浪费。