External Shuffle Service 引起的NodeManager OOM问题分析

1 现象描述及初步分析

近期公司yarn集群中存在NodeManager因OOM 而挂掉的情况, 且发生OOM前存在大量的Spark Shuffle Services相关信息, 通过分析最近Crash的NodeManager进程的dump信息发现存在大量的Finalizer,占用了大部分分的内存资源,其中dump分析结果如下所示:


External Shuffle Service 引起的NodeManager OOM问题分析_第1张图片
Dominator tree

External Shuffle Service 引起的NodeManager OOM问题分析_第2张图片
Finalizer对象数量分析

附:NodeManager日志信息
java.lang.OutOfMemoryError: GC overhead limit exceeded

通过dump分析可知,OOM原因为:FileInputStream引发的Finalizer对象堆集所致。

该对象为Shuffle Server在处理Shuffle Client数据请求时所创建。 其中包含如下代码部分(Spark1.6):

 /**
     * Sort-based shuffle data uses an index called "shuffle_ShuffleId_MapId_0.index" into a data file
     * called "shuffle_ShuffleId_MapId_0.data". This logic is from IndexShuffleBlockResolver,
     * and the block id format is from ShuffleDataBlockId and ShuffleIndexBlockId.
     */
    private ManagedBuffer getSortBasedShuffleBlockData(
            ExecutorShuffleInfo executor, int shuffleId, int mapId, int reduceId) {
        File indexFile = getFile(executor.localDirs, executor.subDirsPerLocalDir,
                "shuffle_" + shuffleId + "_" + mapId + "_0.index");

        DataInputStream in = null;
        try {
            in = new DataInputStream(new FileInputStream(indexFile));
            in.skipBytes(reduceId * 8);
            long offset = in.readLong();
            long nextOffset = in.readLong();
            return new FileSegmentManagedBuffer(
                    conf,
                    getFile(executor.localDirs, executor.subDirsPerLocalDir,
                            "shuffle_" + shuffleId + "_" + mapId + "_0.data"),
                    offset,
                    nextOffset - offset);
        } catch (IOException e) {
            throw new RuntimeException("Failed to open file: " + indexFile, e);
        } finally {
            if (in != null) {
                JavaUtils.closeQuietly(in);
            }
        }
    }


其中 创建FileInputStream的作用是为了拿到索引文件中的数据偏移量及文件长度,用于读取数据。(备注:读取shuffle数据时也会创建FileInputStream对象,上述代码供献1/2的对象数量。)并且,该实现中每次数据请求都会创建一个FileInputStream对象用于读取索引文件。

2 存在大量FileInputStream相关Fanalizer的原因

其FileInputStream自定义了finalize()方法,因此JVM会为每一个FileInputStream对象创建一个Finalizer引用对象,用于确保FileInputStream最终处理关闭状态。

    /**
     * Cleans up the connection to the file, and ensures that the
     * close method of this file output stream is
     * called when there are no more references to this stream.
     *
     * @exception  IOException  if an I/O error occurs.
     * @see        java.io.FileInputStream#close()
     */
    protected void finalize() throws IOException {
        if (fd != null) {
            if (fd == FileDescriptor.out || fd == FileDescriptor.err) {
                flush();
            } else {

                /*
                 * Finalizer should not release the FileDescriptor if another
                 * stream is still using it. If the user directly invokes
                 * close() then the FileDescriptor is also released.
                 */
                runningFinalize.set(Boolean.TRUE);
                try {
                    close();
                } finally {
                    runningFinalize.set(Boolean.FALSE);
                }
            }
        }
    }

所有的Finalizer组成一个双向链表,其共同维护一个ReferenceQueue,进入队列中的对象可以被gc回收。

同时,JVM中存在一个守护线程:FinalizerThread 其优先级为 8,用于从双向链表中清除进入ReferenceQueue中的Finalizer,以便在下次GC时回收这部分Finalizer。

资源充足的情况下,FinalizerThread线程可以被调度执行,从而ReferenceQueue中的Finalizer会很快被清理掉,从而在GC时释放占用内存。

而在External Shuffle Services 场景中 Shuffle Server作为NodeManager进程中的daemon线程执行,并且其创建了大量提供数据服务 的shuffle-server服务线程(数量默认为NodeManager管理的cores * 2, 因此配制最低的机型拥有48个线程), 该线程优先级为5.

经过上述分析,我们可知NodeManager中有

  • 一个消费Finalizer的FinalizerThread线程,优先级为8
  • 48 个用于生产Finalizer的shuffle-server线程,优先级为5
  • 其它大量线程(如Thread-7872425匿名线程等),此处不一一给出
    "Thread-7872425" #8190511 prio=5 os_prio=0 tid=0x00007f1aa8d51000 nid=0xc671 runnable [0x00007f1a83435000]
    java.lang.Thread.State: RUNNABLE
        at java.io.FileInputStream.readBytes(Native Method)
        at java.io.FileInputStream.read(FileInputStream.java:255)
    

在Java中线程优先级的范围是:1-10,且数字越大优先级越高,线程优先级高仅仅表示线程获取的 CPU时间片的几率高。然而,由于shuffle-server线程数量较多,当Shuffle Client端请求频繁(大量reduce任务Fetch数据)时,shuffle-server线程被调度的机率会比Finalizer线程大,这会导致shuffle-server线程生产Finalizer的速率远大于FinalizerTread线程清理的速率,从而导致Finalizer堆集。

3 实验复现方案

其于如前所述的原因分析:Client端请求频繁时,会导致shuffle-server线程生产Finalizer的速率远大于FinalizerTread线程清理的速率,会导致Finalizer堆集。因此,可增加部分节点的shuffle-server线程,使用问题更易复现。

  • 调整实验集群中一个节点的NodeManager管理的Cores的数量
    操作方法:
    yarn.nodemanager.resource.cpu-vcore = 80 <更大的值>
    或 
    spark.shuffle.io.serverThreads=160
    

备注: 该问题会出现在Shuffle fetch密集的场景(即分布式任务并发度高的场景)。

3.1 模拟实验

复现方案中提出以调参的方式增加服务线程数量,从而增加shuffle-server线程被调度的机率。但复现的前提是要造出大量的密集的Fetch请求,然而,目前测试集群规模无法与生产环境相提并论,不易造出上述场景;而且,用户应用负载及数据的获取不易。因此,进行以下实验模拟真实环境的执行情况。

3.1.1 实验

  • 实验一
    50个线程创建FileInputStream

  • 实验二
    100个线程创建FileInputStream

    上述实验,采用Java 8,采用JVM默认配制,并在相同的节点中进行。

3.1.2 结果

  • 实验一
    Full GC时,ParOldGen(老年代)可以正常回收。
  • 实验二
    Full GC时,ParOldGen(老年代)几乎不能回收,从而引发如下异常:
Exception in thread "shuffle-server-5" java.lang.OutOfMemoryError: GC overhead limit exceeded

异常发生时,Java Heap信息:

Heap
 PSYoungGen      total 465920K, used 226578K [0x0000000795580000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 232960K, 97% used [0x0000000795580000,0x00000007a32c4bb0,0x00000007a3900000)
  from space 232960K, 0% used [0x00000007b1c80000,0x00000007b1c80000,0x00000007c0000000)
  to   space 232960K, 0% used [0x00000007a3900000,0x00000007a3900000,0x00000007b1c80000)
 ParOldGen       total 1398272K, used 1397883K [0x0000000740000000, 0x0000000795580000, 0x0000000795580000)
  object space 1398272K, 99% used [0x0000000740000000,0x000000079551ee20,0x0000000795580000)
 Metaspace       used 2718K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 291K, capacity 386K, committed 512K, reserved 1048576K

3.1.3 实验结果分析

上述实验唯一区别为线程数量不同,即FileInputStream的生产线程与Finalizer线程竞争执行资源的激烈程度不同。

实验一 由于线程数量较少,竞争激烈程度低,FinalizerThread线程可以被调度执行,从而可以从Finalizer链表中清除无引用的对象,进而在GC时回收掉Finalizer.

实验二 线程数量较大,竞争激烈程度高,FinalizerThread线程被调度的机会少,从而Finalizer链表(双向链表)中的对象无法被回收,只能在Heap的 from 区 及 to 区进行拷贝,多个回合后进入old区(老年代)。当FinalizerThread持续被阻塞时,就会发生Finalizer堆满old区的情况。由于Finalizer对象在一个双向链表中相互引用,Full GC 依然会无法回收,最终会引发:“java.lang.OutOfMemoryError: GC overhead limit exceeded”。

在真实的NodeManager中,除了存在shuffle-server线程外,还存在大量其它大量线程(有些线程也会产生FileInputStream)。在负载较高时,这些线程都会与FinalizerThread发生竞争,从而降低FinalizerThread执行的机率。

  • 附1:GC overhead limit exceeded发生的原因

This message means that for some reason the garbage collector is taking an excessive amount of time (by default 98% of all CPU time of the process) and recovers very little memory in each run (by default 2% of the heap)

  • 附2:实验过程中jstack快照
"shuffle-server-28" #37 prio=5 os_prio=31 tid=0x00007faea901f000 nid=0x8503 waiting for monitor entry [0x000070000e6e7000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at java.lang.ref.Finalizer.add(Finalizer.java:51)
    "- waiting to lock <0x000000074127c070> (a java.lang.Object)"
    at java.lang.ref.Finalizer.(Finalizer.java:82)
    at java.lang.ref.Finalizer.register(Finalizer.java:87)
    at java.lang.Object.(Object.java:37)
    at java.io.InputStream.(InputStream.java:45)
    at java.io.FileInputStream.(FileInputStream.java:123)
    at java.io.FileInputStream.(FileInputStream.java:93)
    at TestThread.run(Test.java:32)
    
    
"shuffle-server-26" #35 prio=5 os_prio=31 tid=0x00007faea88c9800 nid=0x8103 waiting for monitor entry [0x000070000e4e1000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at java.lang.ref.Finalizer.add(Finalizer.java:51)
    "- waiting to lock <0x000000074127c070> (a java.lang.Object)"
    at java.lang.ref.Finalizer.(Finalizer.java:82)
    at java.lang.ref.Finalizer.register(Finalizer.java:87)
    at java.lang.Object.(Object.java:37)
    at java.io.InputStream.(InputStream.java:45)
    at java.io.FileInputStream.(FileInputStream.java:123)
    at java.io.FileInputStream.(FileInputStream.java:93)
    at TestThread.run(Test.java:32)
    
    ......
    
"Finalizer" #3 daemon prio=8 os_prio=31 tid=0x00007faeaa00d800 nid=0x3103 waiting for monitor entry [0x000070000c37e000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at java.lang.ref.Finalizer.remove(Finalizer.java:61)
    "- waiting to lock <0x000000074127c070> (a java.lang.Object)"
    at java.lang.ref.Finalizer.runFinalizer(Finalizer.java:93)
    - locked <0x00000007bacfba70> (a java.lang.ref.Finalizer)
    at java.lang.ref.Finalizer.access$100(Finalizer.java:34)
    at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:210)

4 结论

通过上述分析及实验可知,NodeManager发生OOM的主要原因为:其内部线程生产Finalizer的速率大于FinalizerTread线程清理的速率,从而使Finalizer链表(双向链表)中无法回收的对象,只能在Heap的 from 区 及 to 区进行拷贝,多个回合后进入old区(老年代)。当FinalizerThread持续被阻塞时,就会发生Finalizer堆满old区的情况,由于Finalizer对象在一个双向链表中相互引用,Full GC依然会无法回收,最终会引发:“java.lang.OutOfMemoryError: GC overhead limit exceeded”。

5 拟解决方案

shuffle-server线程存在为获取得Shuffle IndexFile中reduce任务对应数据的偏移量及数据长度而创建FileInputStream的情况,且原有方案中每次获取都重新打开一次文件,即创建一个FileInputStream对象。 因此,可以引入缓存机制减少读取该文件的次数。

  1. 引入缓存机制减少读取该文件的次数。

    一个IndexFile中包含一个APP在该节点中的所有数据索引,因此引入缓存具有
    较大收益。

    Spark-15074中已引入缓存特性,且#SPARK-21501对缓存方案进行了完善,
    因此可merge官方feature 达到缓解问题的目的。
    缺点:缓存只能涵盖读取IndexFile时产生FileInputStream的场景,仅覆盖
    Shuffle Server中1/2的FileInputStream对象。

除此之外,还可以使用以下方式进行调整:

  1. 使用Files NIO替换FileInputStream
    因为该问题主要是FileInputStream中实现了finalize()方法所置。
    缺点:不能减少文件频繁读写的开销, 对Netty等的影响暂无法评估。

  2. 减少shuffle-server线程数量,降低FileInputStream产生速率,通过参数io.serverThreads调整。
    缺点:机型较多,一种配制可能不能满足三种机型, 且不合适的配制可能影响作业的执行效率,目前缺少数据支撑。

综上所述:为将风险降至最低,可以先尝试 1 或1、3结合的方案。最后尝试1、2结合方案(事实证明1、2结合可以有效解决问题)。

文献:http://www.oracle.com/technetwork/java/javamail/finalization-137655.html 中提到Finalizer产生的原因及一些处理办法。

你可能感兴趣的:(External Shuffle Service 引起的NodeManager OOM问题分析)