翻译自:use-lucenes-mmapdirectory-on-64bit

莫慌 - 澄清一些常见的误解

从3.1版本开始,Lucene以及Solr在64位的Windows以及Solaris系统上默认使用MMapDirectory。从3.3版本开始,对Linux系统也进行了支持。这种更改给Lucene以及Solr用户造成了一定的困惑,因为他们的系统与之前的版本比较起来,突然变得有点奇怪。在Lucene与Solr邮件列表中,有大量的用户来询问为什么他们的Java安装后突然消耗掉了三倍的物理内存,或者系统管理员抱怨太耗资源了。然后,顾问们会告诉他们不要去使用MMapDirectory,在solrconfig.xml中用SimpleFSDirectory(较慢)以及NIOFSDirectory(由于JVM的bug#6265734导致在Windows平台上更慢)去替代。从Lucene提交者的视角来看,他们认为使用MMapDirectory对这些平台来说是最好的,这就有点烦人了,因为他们知道,Lucene/Solr可以比之前有更好的性能。但是对于这次变更的常见误解,导致这个伟大的搜索引擎在任何地方都没有得到最优的安装。

在这篇博客中,我将尝试去解释操作系统的基本事实,关于虚拟内存在内核中的处理。以及为什么这个可以显著的提升Lucene的性能(“虚拟的虚拟内存”)。我也会澄清为什么许多人发的博客以及邮件列表中的帖子都是错误的,并且是与MMapDirectory的目的相违背的。在第二部分,我将会展示一些配置的细节与设置,以及你应该小心避免造成类似“mmap failed”这种错误,还有由于Java堆内存分配导致的性能问题。

虚拟内存

让我们从操作系统的内核开始:在软件中通过本地方法去处理I/O这种方法是从20世纪70年代开始的 — 这种模式很简单:不管什么时候你想要从磁盘中获取数据,你需要在操作系统的内核中执行syscall,通过指针指向buffer(例如:Java中的byte[]),并从磁盘获取/写入数据。然后解析buffer中的内容并做你自己的程序逻辑处理。如果你不想执行太多的syscall(因为会消耗太多的处理能力),通常可以在你的软件中使用一个大的buffer,因此可以减少buffer中的数据与磁盘同步的次数。这也是为什么有些人建议将整个Lucene索引加载到Java堆内存中的原因(例如:通过使用RAMDirectory)。

但是所有的现代操作系统,例如Linux,Windows (NT+),MacOS X,以及Solaris提供了一种更好的方式去处理20世纪70年代的这种代码风格,通过使用它们复杂的文件系统缓存以及内存管理特性。一种被称为“虚拟内存”的特性是非常好的选择去处理非常大并且空间密集型的数据结构,例如Lucene的索引。虚拟内存是计算机体系的一部分,实现需要硬件的支持。特别是以内存管理单元(MMU)的形式在CPU中进行构建。它的工作原理很简单:每个进程获取它自己的虚拟地址空间,所有的类库,堆以及栈空间都会映射到这个地址空间中。这个地址空间在大部分的情况下,偏移量都是从0开始,简化了加载程序中的代码,因为不需要重新定位地址指针。每个进程都可以看到并使用一个巨大的整块的线性地址空间。它就是“虚拟内存”,因为与物理内存无关,对进程来说就是这样的。软件可以访问这个巨大的地址空间就好像它是真正的内存一样,而不需要知道其他进程也在消耗内存并且有它们自己的虚拟地址空间。底层的操作系统通过CPU中MMU一起工作,一旦它们第一时间想要获取的时候,会映射它们的虚拟地址到真实的内存中。这是使用页表来完成,它是受位于MMU硬件中TLBstranslation lookaside buffers,它们缓存频繁访问的页面)支持的。这样,操作系统能够给所有运行中的进程的内存需求分配真实有效的内存,对运行中的程序来说完全透明。

图片来自Wikipedia [1]

通过使用虚拟化技术,操作系统还可以做的一件事就是:如果没有足够的物理内存了,它可以决定“交换”出进程不再使用的那些页,为其他进程释放物理内存或者缓存更加重要的文件系统操作。一旦一个进程尝试去获取一个已经被页淘汰的虚拟地址时,它会被重新加载到主内存中并提供给进程使用。进程不需要做任何事情,它完全是透明的。这对应用来说是一件好事,因为它们不需要去知道有多少内存可供使用。但是对类似Lucene这种内存密集型应用来说会有导致很多问题。

Lucene & 虚拟内存

让我们来加载整个索引或者大部分索引到“内存”(我们已经知道了它仅仅只是虚拟内存)来做个示范。如果我们分配了一个RAMDirectory并加载了所有的索引到里面。我们正在做一件对操作系统不利的事:操作系统尝试优化磁盘访问,所以它已经在物理内存中缓存了所有的磁盘I/O。我们拷贝所有的缓存内容到我们的虚拟地址空间中,消耗掉了大量的物理内存(我们必须等待拷贝操作的发生)。由于物理内存有限,操作系统会可能会决定交换掉我们巨大的RAMDirectory,那么会交换到哪里去?— 再次放到磁盘上(在操作系统的交换文件中)!实际上我们在与操作系统内核做斗争,操作系统通过页淘汰了我们从磁盘加载的东西[2]。所以RAMDirectory不是一个优化索引加载时间的好主意,而且RAMDirectory在垃圾收集与并发方面有更多的问题。因为数据留存在交换空间中,所以Java的垃圾回收器很难在自己的堆管理中去释放内存。这将会导致高磁盘I/O,延长索引的访问时间,以及由于垃圾回收器的失控导致你搜索代码中一分钟长的延迟。

从另一个角度来说,如果我们不使用RAMDirectory去缓存我们的索引,而是使用NIOFSDirectory或者SimpleFSDirectory,我们又得付出另一个代价:我们在代码中需要去操作系统的内核中做非常多的syscall,从磁盘或者文件系统缓存中与我们驻留在Java堆中的buffer之间去拷贝数据。在每个查询请求中都需要去做这个操作,不断重复。

内存映射文件

解决上面问题的办法就是MMapDirectory,它使用虚拟内存以及“mmap”的内存特性[3]去磁盘中获取文件。

我们之前的方法依赖使用syscall在文件系统缓存与本地Java堆之间去拷贝数据。那从文件系统缓存中直接获取数据会怎样?

这就是mmap所做的工作!

基本上,mmap处理Lucene索引就像处理交换文件一样。mmap()通过syscall告诉操作系统内核,将我们整个索引文件映射到之前描述的虚拟地址空间中,使它们看起来好像RAM完全够Lucene使用。然后我们可以在磁盘上访问索引文件就像是在访问一个大型的byete[]数组一样(在Java中,是通过ByteBuffer接口封装好的,以便我们安全的使用)。如果我们通过Lucene的代码去获取虚拟地址空间,不需要做任何的syscall调用。处理器的MMU与TLB会为我们处理好所有的映射关系。如果数据仅仅只存在磁盘上,MMU会进行一次中断,操作系统内核会将数据加载到文件系统缓存中。如果数据已经在缓存中存在了,MMU/TLB则会将其直接映射到文件系统缓存中的物理内存中。现在仅仅只是本地内存获取,再没有其他操作了!我们完全不需要关心buffer的页进页出,所有这些都是由操作系统内核来进行管理。而且,我们也没有并发问题了,唯一的开销就是Java的ByteBuffer(它比真正的byte[]数组要慢,但它是在Java中唯一的方式去使用mmap了,而且它比Lucene自带的其他directory实现更快)对标准的byte[]数组进行包装导致的。我们也不会浪费物理内存了,因为我们直接对操作系统缓存进行操作,避免了我们之前提到过的所有Java GC问题。

这对Lucene/Solr应用意味着什么呢?

  • 我们不要再做与操作系统相违背的事了,所以分配尽量少的堆空间(Java -Xmx)。记住,我们访问索引直接依赖操作系统缓存!这对Java垃圾回收器非常友好。
  • 释放尽可能多的物理内存供操作系统内核当作文件系统缓存使用。记住,我们的Lucene代码直接基于此工作,因此可以减少磁盘与内存之间分页/交换的次数。分配太多的堆内存给Lucene应用会伤性能!在MMapDirectory中,Lucene并不需要这样。

为什么仅仅只运行在64位的操作系统与Java虚拟机上

32位平台其中一个限制是指针的大小,它们可以被0到$2^{32}-1$之间的任何地址引用,大概4G的大小。大部分操作系统限制地址空间为3G,因为剩下的地址空间是为硬件设备及其他类似的东西而保留的。这也就意味着提供给任何进程所有的线性地址空间限制为3G,所以你不能将任何一个比这还大的文件映射到这个“小”地址空间中,当作大byte[]数组使用。当你映射一个大文件时,那就没有可用虚拟空间了(地址就像“房间号”)。由于物理内存在当前系统中的大小已经超过了这个大小,在没有资源浪费的情况下,已经没有足够的地址空间来供映射文件使用(在我们的示例中是“地址空间”,不是物理内存!)。

在64位的平台上那就不一样了:$2^{64}-1$是一个非常大的数字,它已经超过了18万亿个字节,所以对于地址空间就没有实际的限制了。可惜的是,大部分硬件(MMU,CPU的总线系统)以及操作系统对用户态应用的地址空间的限制是47位(windows:43位)[4]。但是仍然有足够多的地址空间去映射万亿字节的数据。

常见误解

如果你仔细阅读过我说的关于虚拟内存的叙述,那么你可以轻松验证下面这些都是对的:

  • MMapDirectory不会消耗额外的内存,可映射索引文件的大小不会受你服务器可用物理内存的限制。通过mmap()的文件,我们仅仅保留地址空间而不是内存!记住,64位平台上的地址空间是免费的。

  • MMapDirectory不会将全部索引加载到物理内存中。它为什么要这么做?为了方便获取,我们仅仅要求操作系统映射文件到地址空间中,而没有要求更多。Java以及操作系统提供了将整个文件加载到RAM(如果它够用的话)中的选择,但是Lucene并不会这样去做(我们可能会在后续的版本中这样做)。
  • 当“top“命令报告内存使用很多时,MMapDirectory不会使服务器过载。“top”(Linux)有三列跟内存相关:“VIRT”,“RES”,以及“SHR”。第一个(VIRT,virtual)表示虚拟地址空间的分配(在64位平台上是免费使用的)。当IndexWriter进行合并时,这个数字会是你索引大小或者物理内存的数倍。如果你仅仅只打开了一个IndexReader,它大约等于分配的堆内存(-Xmx)加上索引大小。它不会显示进程使用的物理内存。第二个(RES,resident)表示分配给进程可操作的(物理)内存,它应该是你Java堆空间的大小。最后一个(SHR,shared)表示已经分配的虚拟地址空间有多少可以给其它进程共享。如果你有几个Java应用使用MMapDirectory去获取同一个索引,你会看到这个数字变大。通常,你会看到共享系统库,JAR文件,以及进程可执行本身(也会被映射)所需要的空间。

怎么配置我的操作系统以及Java虚拟机让MMapDirectory得到最佳的使用?

首先,Linux以及Solaris/Windows上的默认设置就非常好了。但是有些固执的系统管理员想要去控制一切(因为缺乏理解)。虚拟地址空间的最大数量可以由应用来分配。所以检查“ulimit -v”与“ulimit -m“是否都显示“unlimited”,否则的话在使用MMapDirectory打开索引的时候可能会出现“mmap failed“的错误。如果这个错误在有大量的大索引,每个索引都有许多段的系统中仍然会发生时,你需要去调整/etc/sysctl.conf中的内核参数:vm.max_map_cout的默认值为65530,你需要增加这个值的大小。我认为在Windows以及Solaris系统上也有类似的设置,但是这个就需要读者自己去找一下如何使用了。

对于Java虚拟机的配置,你需要重新思考内存的使用情况:只给真正需要的堆空间大小,留给操作系统尽可能多的内存。根据经验:不要给Lucene/Solr分配超过物理内存$1/4$的堆内存,让剩下的内存给操作系统缓存使用。如果你的服务器上有多个应用在运行,按需进行调整。通常来说,物理内存越多越好,但是不需要让物理内存跟你的索引大小一样大。内核在从索引中频繁使用的页面进行分页这方面做的非常好。

一个好的可能是你已经通过“top“(正确解读,见上)与类似的”iotop“(可以安装,例如:在Ubuntu Linux上面通过apt-get install iotop)命令进行了检查,并配置了最优的系统。如果你的系统为Lucene进程在做大量的置换操作(swap in/out),那么减少你的堆大小,因为你可能使用的太多了。如果你看到了大量的磁盘I/O,买更多的RUM(Simon Willnauer[5],那么被映射的文件就不需要一直页进页出了,最后:买SSD.

Happy mmapping!

参考

  1. http://en.wikipedia.org/wiki/Virtual_memory
  2. https://www.varnish-cache.org/trac/wiki/ArchitectNotes
  3. http://en.wikipedia.org/wiki/Memory-mapped_file
  4. http://en.wikipedia.org/wiki/X86-64#Virtual_address_space_details
  5. 我注:这个不知道是不是作者在调侃别人写错了单词,我觉得这个地方应该是RAM。因为需要将索引加载到文件系统的缓存中,如果内存不够用了,那么就需要丢弃掉一些数据,然后需要时再去磁盘上获取。