Cpu负载高导致Redis(Redisson)超时问题的分析

概述

生产环境中流量高峰期会出现短时间的redis异常,主要报错如下:

  • Redis server response timeout
  • RedisTimeoutException: Command execution timeout for command: (PING)
  • Command still hasn’t been written into connection!

根据redisson官方所述,RedisTimeoutException可能是多种原因造成的:

  1. Redis服务器负载高,无法及时响应请求。
  2. 用于redis底层通信的Netty线程繁忙,也就是说Netty的线程池基本满载运行,没有多余的线程可用了。可以考虑增加netty线程池大小。
  3. Redis线程池用满了,没有空余的线程处理新的连接,导致新的redis操作一直在等待可用连接。可以考虑增加redis线程池大小。
  4. 服务器CPU限制。在某些托管环境中(如K8S)会限制服务器CPU使用,从而影响连接到Redis时的应用程序性能。
  5. 不稳定的网络和TCP数据丢失。
  6. Redis供应商限制并发连接数。

其中1,5,6点很容易确认,可以排除。接下来要考虑的就是2,3,4这几点。

Netty线程池优化

在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限制优化

优化上线后,发现错误数量确实减少了,但还是存在少量报错。说明以上的优化是有一定效果的,但不是根本原因。最终经过多番排查发现,其实是第四点,也就是服务器CPU限制导致的。

生产环境是部署在k8s上,hpa扩容策略是根据cpu来扩容的。每次扩容后,新增的pod在刚开始启动的几分钟内,因为各种资源和配置项加载需要消耗较多的cpu,经过几分钟之后才会恢复到正常水平。在此期间,进入到该pod的请求就会由于cpu负载太高导致出现redis访问超时的问题。

出现错误日志的host和时间刚好与扩容的主机和扩容时间能对应上,这也证明了确实是此问题导致的。

CPU使用限制指标

想要判断pod的cpu是否达到了瓶颈,可以通过Prometheus的container_cpu_cfs_throttled_periods_totalcontainer_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

后面几个都差不多。

JIT

一个应用程序可能有数百万行代码,但实际上真正的热点代码(经常执行的)只是其中很少的一部分。

这部分代码会对程序的性能有较大的影响。出于性能优化考虑,JVM会使用JIT(Just in Time)机制来对这部分代码进行优化。具体的说,就是使用C1和C2编译器将热点代码编译成机器码。这样当程序运行的时候,这部分热点代码就无需解释执行了,而是直接作为机器码运行。

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 -version | grep Compile查看。

JIT线程数

默认情况下,C1和C2编译器线程数取决于程序所运行的设备/容器的CPU数量。如下图所示:

CPUsC1 threadsC2 threads
111
211
412
812
1626
3237

当然,你可以通过JVM参数-XX:CICompilerCount=N 来调整C1和C2的线程数,其中C1线程数为N的1/3,C2线程数为N的2/3。举个例子,当N=6时,则JVM会创建2个C1线程,4个C2线程。

C1,C2高Cpu占用的解决方案

当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 levelDescription
0Interpreted Code
1Simple C1 compiled code
2Limited C1 compiled code
3Full C1 compiled code
4C2 compiled code

-XX:+PrintCompilation

此参数将打印程序的编译过程,帮助开发人员进一步调整编译过程。

-XX:ReservedCodeCacheSize=N

JIT编译器编译/优化的代码将存储在JVM内存的代码缓存区,该区域的默认大小为240MB(251658240Byte)。可以通过此参数增加其大小。比如说-XX:ReservedCodeCacheSize=512m将会使代码缓存区增加到512M。
增加代码缓冲区大小有可能降低Cpu占用。

-XX:CICompilerCount

增加C2编译器线程数。通常情况下,C2编译器线程数会根据应用程序所在设备的CPU数量自动分配,不需要手动调整。增加线JIT程数可能会缩短编译时间,但会导致更高的Cpu占用。

当然,修改这些参数不一定有效甚至有可能带来负面的影响,比如ReservedCodeCacheSize默认大小通常是够的,禁用C2可能会导致请求响应时间变长。因此需要进行严格的测试或者考虑其他解决方案。参考资料4中对应用发布或重启时存在cpu抖动飙高问题进行了比较详细的分析,值得借鉴。

经过测试,禁用C2时发布或重启pod时不再出现CPU飙升问题,但接口的响应时间有所增加;调整Tier4CompileThresholdTier4InvocationThreshold的值之后,可以有效缓解发布或重启pod时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
[4]. https://www.zhihu.com/question/21093419/answer/2792944172

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值