使用lettuce导致的堆内存溢出问题.md
发表于:2023-01-02 | 分类: Redis

一、复现条件

  1. 准备环境 :SpringBoot 整合 Redis
  2. 测试服务,设置 VM 参数 -Xmx100m 启动。
  3. JMeter 压测某个业务接口,并发 200

二、异常情况:Lettuce客户端 Netty框架内部原因导致。

1、压测

使用 Redis 的业务接口 ,产生 OutOfDirectMemoryError(堆外内存溢出),如图:

2、详细错误信息:

lettuce.core.RedisException: io.netty.util.internal.OutOfDirectMemoryError: 
failed to allocate 46137344 byte(s) of direct memory (used: 58720256, max: 100663296

3、源码分析:

public final class PlatformDependent {
     // 直接内存大小,可通过 “-Dio.netty.maxDirectMemory”参数设置。
     private static final long DIRECT_MEMORY_LIMIT;
     
     // 默认的最大直接内存大小 ,方法略。
     // 大概意思还是:先获取“Eclipse OpenJ9”的“sun.misc.VM”参数,如果没有则获取JVM的“-XX:MaxDirectMemorySize”作为默认值。
     private static final long MAX_DIRECT_MEMORY = maxDirectMemory0();
     
     static {
        // 其他赋值操作,略
         
      // 给 直接内存大小赋值,如果有设置 "-Dio.netty.maxDirectMemory" 参数,则使用用户设置的,如果没有则使用默认的
      logger.debug("-Dio.netty.maxDirectMemory: {} bytes", maxDirectMemory);
      DIRECT_MEMORY_LIMIT = maxDirectMemory >= 1 ? maxDirectMemory : MAX_DIRECT_MEMORY;
                
     }

    private static void incrementMemoryCounter(int capacity) {
        if (DIRECT_MEMORY_COUNTER != null) {
            long newUsedMemory = DIRECT_MEMORY_COUNTER.addAndGet(capacity);
            
            //关键判断:如果 netty内部使用的内存大小 大于 “直接内存大小”的话,就抛出 "OutOfDirectMemoryError"异常。
            if (newUsedMemory > DIRECT_MEMORY_LIMIT) {
                DIRECT_MEMORY_COUNTER.addAndGet(-capacity);
                throw new OutOfDirectMemoryError("failed to allocate " + capacity
                                                 + " byte(s) of direct memory (used: " + (newUsedMemory - capacity)
                                                 + ", max: " + DIRECT_MEMORY_LIMIT + ')');
            }
        }
    }
 }    

4、总结原因:

1)、Springboot 2.x 以后默认使用 Lettuce作为操作 redis 的客户端。它是使用 netty 进行网络通信的。

2)、从spring-boot-starter-data-redis(2.1.14.RELEASE) 依赖可以看出内置使用的确实是 Lettuce 客户端,
分析源码得知,lettuce 使用的 netty 框架,引用的netty包netty-common-4.1.49.Final.jar里面有一个PlatformDependent.java类 ,
底层有个-Dio.netty.maxDirectMemory 参数,会自己校验堆外内存是否大于当前服务可使用的内存,如果大于则抛出 OutOfDirectMemoryError(堆外内存溢出)。
显然,这是属于 Netty(netty-common-4.1.49.Final.jar)的bug导致堆外内存溢出的。

5、解决方案:

​不能使用-Dio.netty.maxDirectMemory 只调大堆外内存,这只能延迟bug出现的时机,不能完全解决该问题。想解决有如下两个方案:

升级 Lettuce客户端,期待新版本会解决该问题。

排除 Lettuce客户端,切换使用没有该问题的 Jedis 客户端。

PS: Netty 框架性能更好,吞吐量更大,但是Springboot默认使用的Lettuce 客户端对Netty的支持不够好;

Jedis客户端虽然没有Netty更快,但胜在稳定,没有上述bug。

spring-boot-starter-data-redis 依赖 切换使用 Jedis客户端

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.14.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<!-- 引入 redis 依赖-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    <exclusions>
        <exclusion>
            <groupId>io.lettuce</groupId>
            <artifactId>lettuce-core</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<!-- 排除 lettuce 客戶端,引入 Jedis客戶端-->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
</dependency>
```    

## 三、正常情况:其他原因综合导致

1、压测

Redis 使用 Jedis 客户端后,继续 Jmeter压测,200并发

2、详细错误信息
```text
Exception in thread "File Watcher" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at java.lang.String.toLowerCase(String.java:2578)
	at java.io.WinNTFileSystem.hashCode(WinNTFileSystem.java:640)
	at java.io.File.hashCode(File.java:2132)
	at org.springframework.boot.devtools.filewatch.FileSnapshot.hashCode(FileSnapshot.java:72)
	at java.util.HashMap.hash(HashMap.java:338)
	at java.util.HashMap.put(HashMap.java:611)
	at java.util.HashSet.add(HashSet.java:219)
	at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:70)
	at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
	at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
	at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
	at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
	at org.springframework.boot.devtools.filewatch.FolderSnapshot.collectFiles(FolderSnapshot.java:67)
	at org.springframework.boot.devtools.filewatch.FolderSnapshot.<init>(FolderSnapshot.java:58)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher$Watcher.getCurrentSnapshots(FileSystemWatcher.java:280)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher$Watcher.scan(FileSystemWatcher.java:254)
	at org.springframework.boot.devtools.filewatch.FileSystemWatcher$Watcher.run(FileSystemWatcher.java:239)
	at java.lang.Thread.run(Thread.java:745)
2022-06-04 01:34:33.183 ERROR 11988 --- [o-10000-exec-27] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : 
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; 
nested exception is java.lang.OutOfMemoryError: Java heap space] with root cause

java.lang.OutOfMemoryError: Java heap space

3、Jvisualvm - Visual GC 界面分析:

测试服务前,设置启动参数 VM options: -Xmx100m ,意思是本次测试的服务 JVM 最大堆内存只有 100M。

这次虽然还是 OutOfMemoryError,但是原因已经变了,变成了“GC overhead limit exceeded”,
已经没有前面由于框架问题导致的异常信息了。这次异常是属于“正常情况”。

4、真正造成OutOfMemoryError的原因

由于本次测试设置了服务JVM堆内存最大只有 100M。测试的并发数 200,瞬间吞吐量压力过大,导致Old Gen 区直接占满。

JVM的GC过程会因为STW,只不过停顿短到不容易感知。当引起停顿时间的98%都是在进行 GC,但是结果只能得到小于2%的堆内存恢复时,
就会抛出 java.lang.OutOfMemoryError: GC overhead limit exceeded这个错误。

这个错误其实就是空闲内存与GC之间平衡的一个限制,当经过几次GC之后,只有少于2%的内存被释放,也就是很少的空闲内存,可能会再次被快速填充,
这样就会触发再一次的GC。这就是一个恶性循环了,CPU大部分的时间在做GC操作,没有时间做具体的业务操作,
可能几毫秒的任务需要几分钟都无法完成,整个应用程序就形同虚设了。

5、解决方案

我这里主要是限制了JVM `-Xmx100m`模拟高并发场景导致的,重启一下服务或者调大`-Xmx`,减少并发数,都是能解决的

而实际生产环境会有更多复杂的因素影响综合导致,因此要考虑有哪些原因会造成“性能瓶颈”,具体情况要具体分析;
​造成性能瓶颈往往在“中间件的选取 ”和 业务代码的编写(业务代码主要表现在数据库的IO操作过多或者数据量多大),
可以优先从这两个方面入手优化,最后才是硬件上的扩容升级

上一篇:
网络安全
下一篇:
MyBatis