赞
踩
生产环境中流量高峰期会出现短时间的redis异常,主要报错如下:
根据redisson官方所述,RedisTimeoutException可能是多种原因造成的:
其中1,5,6点很容易确认,可以排除。接下来要考虑的就是2,3,4这几点。
在redisson中,Netty 线程负责发送命令到 Redis 服务器并接收响应。
它们处理底层的网络 I/O 操作,包括建立连接、读取和写入数据等。Netty 线程使用非阻塞的 I/O 模型,可以高效地处理多个并发连接和请求。
Redisson 通过配置参数 nettyThreads 来控制 Netty 线程的数量。增加 nettyThreads 的值可以提供更多的线程来处理并发的网络请求,从而增加 Redisson 与 Redis 之间的通信能力。然而,过多的线程数量可能会增加系统资源的消耗,因此需要根据实际情况进行适当的调整。
尝试将以下值作为 nettyThreads 的设置:32、64、128、256。
查看redisson客户端集群配置参数发现,生产环境中nettyThreads配置为32,而线上流量确实比较高,因此考虑将其调整为64。
而redis连接池最大为64,正常是够的。
根据github上redisson的#4381问题讨论,还进行了以下参数的优化:
1. 移除了fst解码器,因为此解码器是旧版本使用的,新版本使用默认的解码器就可以了
2. 设置keepAlive: true,该参数不指定的话默认为false
3. 调整了重试相关的参数,如超时时间和重试次数等
优化上线后,发现错误数量确实减少了,但还是存在少量报错。说明以上的优化是有一定效果的,但不是根本原因。最终经过多番排查发现,其实是第四点,也就是服务器CPU限制导致的。
生产环境是部署在k8s上,hpa扩容策略是根据cpu来扩容的。每次扩容后,新增的pod在刚开始启动的几分钟内,因为各种资源和配置项加载需要消耗较多的cpu,经过几分钟之后才会恢复到正常水平。在此期间,进入到该pod的请求就会由于cpu负载太高导致出现redis访问超时的问题。
出现错误日志的host和时间刚好与扩容的主机和扩容时间能对应上,这也证明了确实是此问题导致的。
想要判断pod的cpu是否达到了瓶颈,可以通过Prometheus的container_cpu_cfs_throttled_periods_total
和container_cpu_cfs_periods_total
这两个指标来计算。
CFS是linux系统默认的CPU调度器,用于公平地分配CPU时间片给运行在容器中的进程。当容器的CPU使用超过其资源限制时,CPU CFS会对容器进行限制。
container_cpu_cfs_throttled_periods_total
指标表示容器在 CPU CFS 中发生 CPU 限制的总周期数。每个周期的持续时间取决于 CPU CFS 的配置和容器的限制情况。该指标可以用于监控容器是否经历了 CPU 限制,并可以帮助评估容器的 CPU 使用情况和性能。如果这个值较高或持续增长,说明容器的 CPU 使用可能接近或超出了其资源限制,可能需要调整容器的资源配置或进行性能优化。
而container_cpu_cfs_periods_total
指标表示容器在 CPU CFS 中获得的总周期数。
注意,这两个指标均是针对单个容器的
通过统计一段时间内CPU受限周期数占总调度周期数的比例,可以判断出在这段时间内容器的cpu使用是否正常。
这也是上文中判断新启的pod在刚开始的几分钟内CPU被打满的依据。
通过查看上述指标发现,pod启动的几分钟cpu占用率高的问题分为两种:刚开始20s内,系统初始化消耗较高的CPU;流量进入之后,大概有1分钟左右的高CPU时间。
这个可以通过设置startUp探针看出来。假设startUp探针设置为90s,则CPU在刚开始的20s内会比较高,随后恢复正常。经过90s后,流量进入pod,此时CPU又重新开始飙升。
此时最直接的办法是增加pod申请的CPU资源,保证新启动的pod有足够CPU使用。但实际发现,这个值需要大到一定程度才行。而且系统平稳后太大的CPU就比较浪费了,根据CPU利用率来进行扩缩容的HPA策略也会受到很大影响。
既然此路行不通,那接下来就得分析原因并进行优化了。
第一个20s内,系统刚启动时要对一些资源进行初始化,必然要消耗cpu,优化空间不大,且时间较短可以忽略,主要问题在于第二个阶段。
将第二个阶段,也就是流量刚进入的1分钟内的pod的线程状态通过jstack
命令dump出来进行分析。主要是找到占用cpu高的进程id,比如说是1,然后每隔10s做一次dump:
jstack 1 > dum1.txt
jstack 1 > dum2.txt
jstack 1 > dump3.txt
...
最后对dump文件进行分析,发现其中占用cpu时间最长的为JVM线程。具体如下:
dump1:
"C2 CompilerThread0" #7 daemon prio=9 os_prio=0 cpu=21429.68ms elapsed=157.09s tid=0x00007f46e95c9620 nid=0x27 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
Compiling: 18353 ! 4 com.xxxx (298 bytes)
"C1 CompilerThread0" #8 daemon prio=9 os_prio=0 cpu=5738.56ms elapsed=157.09s tid=0x00007f46e95911a0 nid=0x28 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
No compile task
dump2:
"C2 CompilerThread0" #7 daemon prio=9 os_prio=0 cpu=28433.22ms elapsed=167.28s tid=0x00007f46e95c9620 nid=0x27 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
Compiling: 26006 4 org.springframework.core.ResolvableType::forType (115 bytes)
"C1 CompilerThread0" #8 daemon prio=9 os_prio=0 cpu=6308.80ms elapsed=167.28s tid=0x00007f46e95911a0 nid=0x28 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
No compile task
dump3:
"C2 CompilerThread0" #7 daemon prio=9 os_prio=0 cpu=33404.71ms elapsed=174.78s tid=0x00007f46e95c9620 nid=0x27 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
Compiling: 26736 ! 4 org.apache.logging.log4j.core.async.AsyncLoggerConfig::log (82 bytes)
"C1 CompilerThread0" #8 daemon prio=9 os_prio=0 cpu=6399.88ms elapsed=174.78s tid=0x00007f46e95911a0 nid=0x28 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
No compile task
dump4:
"C2 CompilerThread0" #7 daemon prio=9 os_prio=0 cpu=36742.54ms elapsed=183.60s tid=0x00007f46e95c9620 nid=0x27 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
No compile task
"C1 CompilerThread0" #8 daemon prio=9 os_prio=0 cpu=6600.54ms elapsed=183.60s tid=0x00007f46e95911a0 nid=0x28 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
No compile task
后面几个都差不多。
一个应用程序可能有数百万行代码,但实际上真正的热点代码(经常执行的)只是其中很少的一部分。
这部分代码会对程序的性能有较大的影响。出于性能优化考虑,JVM会使用JIT(Just in Time)机制来对这部分代码进行优化。具体的说,就是使用C1和C2编译器将热点代码编译成机器码。这样当程序运行的时候,这部分热点代码就无需解释执行了,而是直接作为机器码运行。
在Java早期,有两种类型的JIT编译器:客户端与服务端。客户端编译器适用于桌面版程序,而服务端编译器适用于服务端程序。客户端编译器在应用启动的时候就开始运行,而服务端编译器则会判断代码是否是热点代码,如果是才会运行。尽管服务端JIT编译过程比较慢,但它生成的机器码性能更好。
现如今,jdk同时配备了客户端和服务器JIT编译器。这两种编译器都试图优化应用程序代码。在应用程序启动期间,使用客户端JIT编译器编译代码。随后对于热点代码,JVM将使用服务器JIT编译器进行编译。这在JVM中称为分层编译(tiered compilation)。Jdk1.8之后默认是开启分层编译的。
客户端和服务器JIT编译器也被称为C1和C2编译器。因此,客户端JIT编译器使用的线程称为C1编译器线程,服务器JIT编译器使用的线程称为C2编译器线程。
所谓热点代码,就是指在一定时间内执行次数达到某个阈值的代码。在Server模式下由JVM参数CompileThreshold指定,默认为10000。可通过
java -XX:+PrintFlagsFinal | grep Compile
查看。
默认情况下,C1和C2编译器线程数取决于程序所运行的设备/容器的CPU数量。如下图所示:
CPUs | C1 threads | C2 threads |
---|---|---|
1 | 1 | 1 |
2 | 1 | 1 |
4 | 1 | 2 |
8 | 1 | 2 |
16 | 2 | 6 |
32 | 3 | 7 |
当然,你可以通过JVM参数-XX:CICompilerCount=N
来调整C1和C2的线程数,其中C1线程数为N的1/3,C2线程数为N的2/3。举个例子,当N=6时,则JVM会创建2个C1线程,4个C2线程。
当C1,C2占用Cpu非常高时,如果这种Cpu占用高的情况是间歇而不是连续的,且对程序没有太大影响,可以忽略它。否则,下面是可能的一些解决方案。
-XX:-TieredCompilation
通过-XX:-TieredCompilation
关闭分层编译(注意,-XX:+TieredCompilation表示开启)。但是,副作用是程序的性能会下降。需要注意的是,Jdk1.8之后默认使用分层编译,关闭之后,将直接使用C2编译器,而不再使用C1。
-XX:TieredStopAtLevel=N
如果Cpu高峰是C2编译器造成的,那可以尝试单独关闭C2编译器。通过-XX:TieredStopAtLevel=3
将编译级别设置为3,可以使C1生效而C2关闭。编译级别如下图所示:
Compilation level | Description |
---|---|
0 | Interpreted Code |
1 | Simple C1 compiled code |
2 | Limited C1 compiled code |
3 | Full C1 compiled code |
4 | C2 compiled code |
-XX:+PrintCompilation
此参数将打印程序的编译过程,帮助开发人员进一步调整编译过程。
-XX:ReservedCodeCacheSize=N
JIT编译器编译/优化的代码将存储在JVM内存的代码缓存区,该区域的默认大小为240MB(251658240Byte)。可以通过此参数增加其大小。比如说-XX:ReservedCodeCacheSize=512m
将会使代码缓存区增加到512M。
增加代码缓冲区大小有可能降低Cpu占用。
-XX:CICompilerCount
增加C2编译器线程数。通常情况下,C2编译器线程数会根据应用程序所在设备的CPU数量自动分配,不需要手动调整。增加线JIT程数可能会缩短编译时间,但会导致更高的Cpu占用。
[1].https://blog.csdn.net/xiaoyi52/article/details/133277904
[2].https://github.com/redisson/redisson/issues/4381
[3]. https://devm.io/java/jvm-c2-c2-cpu
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。