文章目录
前言
众所周知,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