一次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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章