Filesystem Cache

Elasticsearch高度依赖操作系统的filesystem cache来提升其数据读取效率,如果我们将大量的系统内存分配给JVM进程(例如将堆内存设置的很大),那么将导致操作系统无法得到足够的内存来作为filesystem cache,这会严重的影响Elasticsearch的性能。

我们知道现代操作系统中的内存都是按页分配的,操作系统在把某些数据从外存读取到内存中的页面上时,会把紧邻在被读取数据后面的几个页面的数据也读到内存中,这叫做文件预读,文件预读会极大的提升硬盘的顺序读取速度。除此之外,数据在被读取到内存中之后,即使用户已经使用完毕,操作系统也不会立即把这些数据所占用的内存给释放掉,这些数据会暂时的保存在内存中,如果此时用户开始读取硬盘上的同一块数据,操作系统会立即从内存中把该数据返回给用户而不需要再去操作硬盘进行数据读取操作,这部分暂时留存在内存中的数据就叫做页缓存(page cache)。

页面置换算法

cache并不会影响进程的正常运行,一旦操作系统发现物理内存不够用,会立即从cache中分配内存页给进程使用。内存页的更新会使用LRU算法,LRU即Least Recent Used,该算法会淘汰最不经常使用的缓存页,其核心原则为“如果一个缓存页在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小”。

首先我们构建一个页面地址到实际页面的映射关系,我们可以根据这个地址对页面进行读取,这个映射关系我们使用哈希表进行存储。

内存地址 内存页
0x010000 page1
0x014096 page2
0x018192 page3
0x022288 page4
0x026384 page5

内存页本身我们使用一个双向链表进行存储,其核心原理如下

  • 一旦一个页面被读取,则把这个页面移动到链表的头部
  • 如果内存空间不足,则删除链表尾部的页面以换取充足的空间

下面是一个go语言实现的LRU算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
package main

import (
"fmt"
"math/rand"
"strconv"
"time"
)

type Page struct {
prev *Page
next *Page
addr int
value []byte
}

var (
table = make(map[int]*Page) // 存储页面地址和页面之间的映射关系
head = Page{addr: -1} // 头部节点的前一个节点
tail = Page{addr: -1} // 尾部节点的后一个节点
capacity = 10 // 内存最多容纳的页数,默认为10
)

// 根据地址获取数据
func get(addr int) []byte {
if addr < 0 {
panic("addr can't be negative number")
}

// 如果不在内存中则需要去硬盘上读取
if _, ok := table[addr]; !ok {
table[addr] = getFromDisk(addr)
}
page, _ := table[addr]

// 如果不位于头部则需要移动到头部
if page.addr != head.next.addr {
// 删除此节点
if page.prev != nil && page.next != nil {
page.prev.next = page.next
page.next.prev = page.prev
}

// 移动此节点到头部
page.next = head.next
page.next.prev = page

page.prev = &head
head.next = page
}

// 如果超出容量限制则需要移除最后一个节点
if getSize() > capacity {
delete(table, tail.prev.addr)

tail.prev.prev.next = &tail
tail.prev = tail.prev.prev
}

return page.value
}

// 模拟从硬盘上面获取数据
func getFromDisk(addr int) *Page {
return &Page{addr: addr}
}

// 获取当前页面数量
func getSize() int {
size := 0
page := head.next
for page.addr >= 0 {
size += 1
page = page.next
}
return size
}

// 打印双向链表
func pretty() {
page := head.next
value := strconv.Itoa(page.addr)
for {
page = page.next
if page.addr < 0 {
break
}
value += "\t⇄\t" + strconv.Itoa(page.addr)
}
fmt.Println(value)
}

func randomNum(min int, max int) int {
return rand.Intn(max-min) + min
}

func main() {
head.next = &tail
tail.prev = &head

capacity = 8

rand.Seed(time.Now().UTC().UnixNano())
for i := 0; i < 15; i++ {
get(randomNum(0, 15))
pretty()
}
}

回到Elasticsearch的问题,假设我们有一个16g内存的机器,如果我们分配了14g内存给JVM进程,那么操作系统就无法充分使用内存作为filesystem cache来为磁盘的读取加速,具体表现就是Elasticsearch的查询变得缓慢。

关闭缓存

在Linux中页缓存是默认开启的,在Linux2.6之后你也可以使用 O_DIRECT 选项来关闭文件读取时的页缓存,这在某些特殊的情况下是有用的。

参考

Help! Linux ate my RAM!
聊聊Linux IO
堆内存:大小和交换 | Elasticsearch: 权威指南 | Elastic
ES内存那点事
LRU和LFU缓存置换算法