一次HDFS Snapshot无法删除的问题排查

前言


众所周知,HDFS有一个十分有用的Snapshot的功能,可以用来保护数据被误删除的情况。可能有人会说了,数据被删除了,我难道不可以从trash目录里把数据再恢复回去吗?HDFS的Snapshot和我们平常说的数据删除进trash目录不太一样,HDFS删除操作进trash目录是一个延时操作的删除策略。如果遇到用户实际执行真正删除的数据操作行为时(这里的指的是这个数据彻底从namespace层面移除掉,连trash里都不存在了),假设我们对数据启用了Snapshot保护的话,这个时候恢复的途径,就能够从HDFS的Snapshot里恢复了。不过从Snapshot进行数据恢复的话,这里会涉及到实际物理数据的拷贝,而不是一个简单的从snapshot目录rename到实际删除掉的路径上去的一个动作。在这点上,snapshot和trash的恢复处理还是有区别的。本文笔者来分享一次我们内部集群发生的HDFS Snapshot无法删除的问题,整个问题的排查过程时间线拉的比较长,中间也是一度绕了很多弯路。

背景


我们内部集群使用HDFS Snapshot的策略是采用daily snapshot的策略进行数据的保护的。简单来说,就是我们只保护24小时以内发生的数据误删行为,如果是超出这个时间之前发生的数据删除丢失,我们是不保证的。因为如果Snapshot hold的时间越长,意味着Snapshot所持有的那些本该清除掉的数据会越来越大。这里大大占据了紧着的集群存储空间。问题发生在突然某一天,我们发现集群的存储空间变得越来越大,并且NN的元数据量的总objects数也一直居高不下。后来发现是因为很多大目录的daily snapshot没有被删除掉。daily snapshot类似如下图所示:
在这里插入图片描述
然后我们迅速检查了与snapshot创建删除相关的脚本,发现其在执行deleteSnapshot删除命令时抛出了NPE的异常,然后导致当天的Snapshot没有被及时删除。然后第二天,后续的daily snapshot又开始进行创建,随后又是没有被删掉。

OK,问题已经发生,当下第一要做的事情是如何删除掉这些多余的snapshot,再删除不掉,集群的存储空间迟早会爆掉的。然后第二步,再来分析其中的root cause。

问题Snapshot的清理


对于这些清理不掉的snapshot,笔者当时尝试用deleteSnapshot再执行了一遍,结果依然是抛出了一个NPE的错误,然后deleteSnapshot依然失败,抛出的异常栈信息如下:

java.lang.NullPointerException
 at org.apache.hadoop.hdfs.server.namenode.INodeFile.storagespaceConsumedNoReplication(INodeFile.java:706)
 at org.apache.hadoop.hdfs.server.namenode.INodeFile.storagespaceConsumed(INodeFile.java:692)
 at org.apache.hadoop.hdfs.server.namenode.snapshot.FileWithSnapshotFeature.updateQuotaAndCollectBlocks(FileWithSnapshotFeature.java:147)
 at org.apache.hadoop.hdfs.server.namenode.snapshot.FileDiff.destroyDiffAndCollectBlocks(FileDiff.java:118)
 at org.apache.hadoop.hdfs.server.namenode.snapshot.FileDiff.destroyDiffAndCollectBlocks(FileDiff.java:38)
 at org.apache.hadoop.hdfs.server.namenode.snapshot.AbstractINodeDiffList.deleteSnapshotDiff(AbstractINodeDiffList.java:94)
 at org.apache.hadoop.hdfs.server.namenode.snapshot.FileWithSnapshotFeature.cleanFile(FileWithSnapshotFeature.java:135)
 at org.apache.hadoop.hdfs.server.namenode.INodeFile.cleanSubtree(INodeFile.java:504)
 at org.apache.hadoop.hdfs.server.namenode.INodeDirectory.cleanSubtreeRecursively

后来查NN的本地log,并没有找到具体是删除到了哪个path所抛出的NPE错误。这时,我们的有了一个假设问题的猜想是:HDFS NN内存里的namespace元数据估计出问题了。

既然snapshot通过命令怎么删也删不掉,而且我们怀疑NN的内存数据又问题了,随后我们将NN进行了重启并且failover到一个刚刚重启过的NN上,然后再次进行deleteSnapshot的执行,snapshot终于被清理掉了。

但是这只是问题的刚开始,我们并不知道真正的root cause是什么。我们起初只是以为这是一个偶发性的问题,以为重启NN能够暂时解决这个问题了,没想到几天后,snapshot无法删除的问题马上又出现了。后来我们果断的先停用了snapshot功能,并且保留了当时出问题NN当时的fsimage文件。随后准备开始后续的进一步问题的分析。

Snapshot NPE异常代码层面的分析


我们对抛出NPE异常的Snapshot代码逻辑进行了分析,抛出异常的地方是

  public final long storagespaceConsumedNoReplication() {
    
    
    FileWithSnapshotFeature sf = getFileWithSnapshotFeature();
    if(sf == null) {
    
    
      return computeFileSize(true, true);
    }

    // Collect all distinct blocks
    long size = 0;
    Set<Block> allBlocks = new HashSet<Block>(Arrays.asList(getBlocks()));
    List<FileDiff> diffs = sf.getDiffs().asList();
    for(FileDiff diff : diffs) {
    
    
      BlockInfoContiguous[] diffBlocks = diff.getBlocks();  <===== diff is null
      if (diffBlocks != null) {
    
    
        allBlocks.addAll(Arrays.asList(diffBlocks));
      }
    }
    for(Block block : allBlocks) {
    
    
      size += block.getNumBytes();
    }
    // check if the last block is under construction
    BlockInfoContiguous lastBlock = getLastBlock();
    if(lastBlock != null &&
        lastBlock instanceof BlockInfoContiguousUnderConstruction) {
    
    
      size += getPreferredBlockSize() - lastBlock.getNumBytes();
    }
    return size;
  }

然后通过进入 sf.getDiffs()所对应的类FileDiffList,此类继承自父类AbstractINodeDiffList。这里的本质问题是说在AbstractINodeDiffList这个list里面存在了null element。但是纵观这个list类的插入方法,只有下面这个addDiff的方法会做插入的操作。

  /** Add an {@link AbstractINodeDiff} for the given snapshot. */
  final D addDiff(int latestSnapshotId, N currentINode) {
    
    
    return addLast(createDiff(latestSnapshotId, currentINode));
  }

而且在程序执行每次addDiff的时候,这个diff都是经过上面createDiff的操作生成出来的,理应不会存在null被插入diffList的情况。

在这块的代码分析上,我们陷入了一个困境。在随后的代码层面的修改上,我们做了下面2步改进操作:

1)跳过diff里的null项
2)打印出与snapshot diff有关的path路径信息

后来重新部署了上述改动后,NN依然会在其它别的遍历diffList的地方报出NPE错误,另外path信息对我们的帮助并不足够多。随后,我们尝试在线下能够复现这个问题,在生产集群调试这种问题代价太高而且存在风险性。

线下Snapshot问题恢复失败


通过在线上部署完新的代码后,依然难以帮助我们找到问题的root cause。于是我们打算将之前备份的fsimage文件拷贝到别的机器上做纯NN模式测试(无JN,DN,HA模式),这部分的操作步骤可参考笔者所写的博文:HDFS NameNode fsimage文件corrupt了,怎么办

另外,我们也在社区上查找是否有相关的JIRA issue与我们碰到的这个问题相关。在这个过程中,我们找到了2个与deleteSnapshot极为相关的JIRA,HDFS-9406(FSImage may get corrupted after deleting snapshot)和HDFS-13101(Yet another fsimage corruption related to snapshot)。前一个issue在我们的版本中已经存在了,所以我们只验证了HDFS-13101这个issue。最终我们在当前我们的Hadoop版本里成功复现了后面这个issue。但是后面再进一步分析,HDFS-13101和我们的snapshot场景还不太一样。

第一,HDFS-13101删除snapshot时,会涉及到同时2个snapshot。
第二,它存在数据跨snapshot rename的情况。

我们的使用场景只会出现一个数据目录对应1个snapshot存在的情况,只有删除上一个snapshot,才能开始创建下一个snapshot。因此我们后来分析认为HDFS-13101也不是我们这个问题的fix方法。

既然在社区上都没找到这个类似的issue,那么是否是我们内部代码的改动导致的一个snapshot bug呢?我们越来越怀疑是我们内部改动的逻辑导致的一个bug。

HDFS内部代码改动的重新梳理分析


我们针对出问题的代码方法AbstractINodeDiff#addDiff进行调用逻辑的分析,终于找到了一个令人怀疑的属于我们内部的改动逻辑。

之前我们做NN性能优化的时候,发现setTimes这个call只是改了path的access time,但是持的是写锁操作,对NN的影响比较大,于是将setTimes的持写锁操作转为了读写的操作。

  static boolean setTimes(
      FSDirectory fsd, INode inode, long mtime, long atime, boolean force,
      int latestSnapshotId) throws QuotaExceededException {
    
    
    fsd.readLock();  <---- swicth from write lock to read lock
    try {
    
    
      return unprotectedSetTimes(fsd, inode, mtime, atime, force,
                                 latestSnapshotId);
    } finally {
    
    
      fsd.readUnlock();
    }
  }

问题就是出自这里,在随后的unprotectedSetTimes的逻辑里,INode类的setModificationTime和setAccessTime其实涉及到了snapshot diff的改动。

  private static boolean unprotectedSetTimes(
      FSDirectory fsd, INode inode, long mtime, long atime, boolean force,
      int latest) throws QuotaExceededException {
    
    
    // remove writelock assert due to HADP-35711
    // assert fsd.hasWriteLock();
    boolean status = false;
    if (mtime != -1) {
    
    
      inode = inode.setModificationTime(mtime, latest);
      status = true;
    }

    // if the last access time update was within the last precision interval,
    // then no need to store access time
    if (atime != -1 && (status || force
        || atime > inode.getAccessTime() + fsd.getFSNamesystem().getAccessTimePrecision())) {
    
    
      inode.setAccessTime(atime, latest);
      status = true;
    }
    return status;
  }

    /** Set the last modification time of inode. */
  public final INode setModificationTime(long modificationTime,
      int latestSnapshotId) {
    
    
    recordModification(latestSnapshotId);
    setModificationTime(modificationTime);
    return this;
  }

在每次做modifcation time或access time的时候,它会将最后一个snapshot diff的时间记录为上次的时间,然后修改当前的时间为最新的时间。因为转变为了读写操作,就会存在多线程并发执行diff更新操作的情况。也就是说,之前的AbstractINodeDiff#addDiff会存在并发执行的可能。Snapshot diffList本质结构上是个ArrayList,ArrayList不是thread-safe的。因此出现了null的情况。

笔者在测试ArrayList的时候也是复现了null被插入到ArrayList的情况,测试代码如下:

  @Test
  public void test() throws InterruptedException {
    
    
    ArrayList<String> array = new ArrayList<>();
    
    int numThreads = 100;
    Thread[] threads = new Thread[numThreads];
    for (int i = 0; i < numThreads; i++) {
    
    
      threads[i] = new Thread() {
    
    

        @Override
        public void run() {
    
    
          array.add(System.currentTimeMillis() + "");
        }

      };
    }
    for (int i = 0; i < numThreads; i++) {
    
    
      threads[i].start();
    }

    for (int i = 0; i < numThreads; i++) {
    
    
      threads[i].join();
    }
    System.out.println("Array size: " + array.size());
    System.out.println(array);
    for (int i = 0; i < numThreads; i++) {
    
    
      if(array.get(i) == null) {
    
    
        System.out.println("Detect null element: " + i);
      }
    }
  }

setTimes忽略snapshot diff更新的改动


找到了问题的root cause之后,我们马上着手对代码进行了改动,我们并不想回退我们之前改动的逻辑。于是我们采用了在setTimes的方法里忽略掉snapshot diff更新的改动,以此让这个setTimes变成纯时间值的更新操作。鉴于setModificationTime/setAccessTime有同时被其它方法所引用到,我们新增了专属setTimes的调用方法,方法如下所示:

  public final INode setAccessTimeWithoutSnapshot(long accessTime, int latestSnapshotId) {
    
    
    setAccessTime(accessTime);
    return this;
  }

总结


至此本文所阐述的snapshot无法删除的问题最终是解决了,这个整条问题排查的时间线其实是拉的比较长的。这次问题带给我们的教训是要更加review好每次commit的代码逻辑,同时要保证有足够的test case来确保合入代码的安全性。否则问题排查起来将会走很多的弯路。在本文所述的问题里,我们当时并没有对setTimes从写锁转读锁的逻辑改动里,去仔细评估其潜在的风险点。

参考资料


[1].https://issues.apache.org/jira/browse/HDFS-9406
[2].https://issues.apache.org/jira/browse/HDFS-13101

猜你喜欢

转载自blog.csdn.net/Androidlushangderen/article/details/113446906
今日推荐