继续聊聊HDFS BlockManager扩展性问题
前言
最近一段时间,笔者在业余时间继续对HDFS的扩展性内容进行学习。一句话来概括,越往里深入,越发现里面可以讲的东西越多,涉及到的点也越多。社区在这个方面确实做了很多的讨论。本文所讲述的主题源自于社区JIRA HDFS-7844(Create an off-heap hash table implementation),大意是利用堆外内存(off-heap)来进行元数据的存储。之前我们总是提到的NameNode内存空间使用过大,指的是JVM中的堆内存。而堆外内存是不受JVM管理的,它由操作系统来管理。如果将Block、INode中的数据从堆内移到堆外存储,无疑将会巨大减轻NameNode压力。在下文中,笔者会花一定篇幅介绍此内容,并同时介绍HDFS块优化相关的内容。
堆外内存概述
堆外内存,通俗地理解,就是不受JVM控制的内存。堆外内存在使用上能支持更大空间的内存存储。还有一点好处是堆外内存可以在JVM间共享,减少对象的复制。
在现有JDK中,有2种可以对堆外内存进行使用的API。
第一种,nio包中的ByteBuffer相关的API。如下代码示例:
ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);
第二种,调用Unsafe方法进行堆外内存的使用,示例代码如下:
Unsafe.allocateMemory(1024 * 1024 * 20);
以上2种方法的使用类似于C语言中的malloc方法。
关于堆外内存更多的资料大家可以自行查找资料进行学习。
HDFS堆外内存哈希存储的设计
在了解完堆外存储的基本概念之后,我们重新回到本文的主题。在HDFS-7844中,以off-heap的方式实现了一个哈希存储表。作者在设计此存储表的目的是为了将来存Block块数据、INode文件数据做使用的。
在原设计中,作者并不是直接在哈希表上对内存做直接管理,而是先定义内存管理器类,此内存管理器类是可配的,在这里它有可能就是off-heap的。在内存管理器内部,才是对数据进行直接存入、取出的操作。
相比于off-heap的内存管理器,on-heap的内存管理器显得更复杂一些,它的内部定义了这么几个变量:
1.当前分配空间的起始地址(此地址会随着空间不断分配出去而后移)。
2.最大分配地址。此处我们基本可以认定为无上限,除非我们想要额外指定。
3.buffer map数据。此map存储了起始存储地址到缓存数据的映射。
通过如上3个变量的定义,我们大致可以勾画出下面的一张结构图。
图1-1 内存管理结构图
上图可理解为在一大片的存储空间内,被分割为了各个小的buffer缓冲,这些buffer缓冲数据由他们的起始存储地址作为key进行定位查找。
而off-heap的内存管理则显得简单许多,下面列举部分利用堆外内存进行内存分配管理的代码:
下面首先是定义以及初始化方法,
/**
* NativeMemoryManager is a memory manager which uses sun.misc.Unsafe to
* allocate memory off-heap. This memory will be allocated using the current
* platform's equivalent of malloc().
*/
@Private
@Unstable
public class NativeMemoryManager implements MemoryManager {
static final Logger LOG =
LoggerFactory.getLogger(NativeMemoryManager.class);
private final static Unsafe unsafe;
private final static String loadingFailureReason;
static {
Unsafe myUnsafe = null;
String myLoadingFailureReason = null;
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
myUnsafe = (Unsafe)f.get(null);
} catch (Throwable e) {
myLoadingFailureReason = e.getMessage();
} finally {
unsafe = myUnsafe;
loadingFailureReason = myLoadingFailureReason;
}
}
用Unsafe方法分配地址空间时,会在方法结束后返回一个起始地址,于是这边就不需要人工地进行地址的累加。
public long allocate(long size) {
return unsafe.allocateMemory(size);
}
然后是数据存取方法,
@Override
public byte getByte(long addr) {
return unsafe.getByte(null, addr);
}
@Override
public void putByte(long addr, byte val) {
unsafe.putByte(null, addr, val);
}
如果不使用此空间,则可以进行空间的释放。
public void free(long addr) {
unsafe.freeMemory(addr);
}
假设目前我们已经构建完这块哈希存储表了,我们如何将它有效的运用到目前的Block块数据或者INode文件中呢?首先它可以很好的适配当前DataNode的块组织结构。之前笔者写过一个篇文章介绍过HDFS内部块的组织结构,Block块在DataNode内部被组织为若干个超级大的“链表”,此“链表”并不是由于JDK自带的LinkedList相关类实现的,而是HDFS内部自身实现了一套。如果本文提到的哈希存储表未来能够成功应用到HDFS上,这些链表上的数据将会很好的迁移到哈希表上。
而对于哈希表的数目,我们当然不能仅限只有一个,因为这会带来一定的锁竞争。我们可以维护多个哈希存储表。每次有相应存储表的块数据报告上来的时候,我们就单独锁住需要更新的哈希表即可。
其它优化点
在HDFS-7836的设计文档中,还提到了2点关于块汇报相关的优化点。
第一点,块汇报的拆分。在比较早些时候的块报告的实现中,DataNode每次心跳发送块报告的时候会将所有块信息放在一个message中,一次性发送给NameNode。这会带来很大弊端,如果数据多了,这个信息将会非常大,不仅会导致信息发送慢,还会导致NameNode处理慢。一种更好的做法是将这些storage report进行拆分,每次发送一小部分,分多次发送。在目前HDFS中,已经有类似的配置功能,配置项dfs.blockreport.split.threshold。此配置的意思是给定一个阈值,如果DataNode上总的块数小于此阈值,则块数据分一次性发送,否则按照每个存储目录划分,分多次汇报。
第二点,块汇报信息的压缩。块信息的压缩处理看起来是一个不错的主意。而且目前已经有许多性能较好的压缩算法。如果我们对这些块数据进行了压缩发送,首先它能帮助我们减少很大一部分的网络IO。唯一不足之处可能就是它需要在NameNode端进行一次解压动作。但是以目前计算机处理的速度,这点处理对NameNode本身性能的影响应该是可以忽略的。但比较可惜的一点,据笔者对于此方面的了解,目前HDFS还没实现对于汇报块的压缩功能,大家也慢慢期待吧。
参考资料
[1].https://issues.apache.org/jira/browse/HDFS-7844
[2].https://issues.apache.org/jira/browse/HDFS-7836
[3].https://issues.apache.org/jira/secure/attachment/12700628/BlockManagerScalabilityImprovementsDesign.pdf