Elasticsearch之储存原理和优化
存储原理
上篇介绍了在 ES
内部索引的写处理流程,这个流程是在 ES
的内存中执行的,数据被分配到特定的分片和副本上之后,最终是存储到磁盘上的,这样在断电的时候就不会丢失数据。
具体的存储路径可在配置文件 ../config/elasticsearch.yml
中进行设置,默认存储在安装目录的 Data
文件夹下。
建议不要使用默认值,因为若ES
进行了升级,则有可能导致数据全部丢失:
path.data: /path/to/data //索引数据
path.logs: /path/to/logs //日志记录
分段存储
索引文档以段
的形式存储在磁盘上,索引文件被拆分为多个子文件
,则每个子文件叫作段
,每一个段
本身都是一个倒排索引
,并且段具有不变性,一旦索引的数据被写入硬盘,就不可再修改。
在底层采用了分段存储模式
,使它在读写时几乎完全避免了锁
的出现,大大提升了读写性能。
段被写入到磁盘后会生成一个提交点,提交点是一个用来记录所有提交后段信息的文件。
一个段一旦拥有了提交点,就说明这个段只有读的权限,失去了写的权限
。相反, 当段在内存中时,就只有写的权限,而不具备读数据的权限,意味着不能被检索
。
段
的概念提出主要是因为:在早期全文检索中为整个文档集合建立了一个很大的倒排索引,并将其写入磁盘中。如果索引有更新,就需要重新全量创建一个索引来替换原来的索引。这种方式在数据量很大时效率很低,并且由于创建一次索引的成本很高,所以对数据的更新不能过于频繁,也就不能保证时效性。
索引文件分段存储并且不可修改,那么新增、更新和删除
如何处理呢?
新增
,新增很好处理,由于数据是新的,所以只需要对当前文档新增一个段就可以了。删除
,由于不可修改,所以对于删除操作,不会把文档从旧的段中移除而是通过新增一个.del
文件,文件中会列出这些被删除文档的段信息。这个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。更新
,不能修改旧的段来进行反映文档的更新,其实更新相当于是删除
和新增
这两个动作组成。会将旧的文档在 .del 文件中标记删除,然后文档的新版本被索引到一个新的段中。可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就会被移除。
段
被设定为不可修改具有一定的优势也有一定的缺点,优势主要表现在:
- 不需要
锁
,如果从来不更新索引,那就不需要担心多进程同时修改数据的问题。 - 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升。
- 其它缓存(像
Filter
缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。 - 写入单个大的倒排索引允许数据被压缩,减少磁盘
I/O
和需要被缓存到内存的索引的使用量。
段
的不变性的缺点如下:
- 当对旧数据进行删除时,旧数据不会马上被删除,而是在
.del
文件中被标记为删除。而旧数据只能等到段更新时才能被移除,这样会造成大量的空间浪费。 - 若有一条数据频繁的更新,每次更新都是新增新的标记旧的,则会有大量的空间浪费。
- 每次新增数据时都需要新增一个段来存储数据。当段的数量太多时,对服务器的资源例如文件句柄的消耗会非常大。
- 在查询的结果中包含所有的结果集,需要排除被标记删除的旧数据,这增加了查询的负担。
延迟写策略
介绍完了存储的形式,那么索引写入到磁盘的过程是怎样的?是否是直接调 Fsync物理性地写入磁盘?
答案是显而易见的,如果是直接写入到磁盘上,磁盘的 I/O
消耗上会严重影响性能。那么当写数据量大的时候会造成 ES
停顿卡死,查询也无法做到快速响应。如果真是这样ES
也就不会称之为近实时全文搜索引擎
了。
为了提升写的性能,ES
并没有每新增一条数据就增加一个段到磁盘上,而是采用延迟写的策略。每当有新增的数据时,就将其先写入到内存中,在内存和磁盘之间是文件系统缓存。
当达到默认的时间(1 秒钟)或者内存的数据达到一定量时,会触发一次刷新(Refresh
),将内存中的数据生成到一个新的段上并缓存到文件缓存系统 上,稍后再被刷新到磁盘中并生成提交点。
这里的内存使用的是ES
的 JVM
内存,而文件缓存系统使用的是操作系统的内存。
新的数据会继续的被写入内存,但内存中的数据并不是以段的形式存储的,因此不能提供检索功能。由内存刷新到文件缓存系统的时候会生成新的段,并将段打开以供搜索使用,而不需要等到被刷新到磁盘。
在Elasticsearch
中,写入和打开一个新段的轻量的过程叫做Refresh
(即内存刷新到文件缓存系统)。默认情况下每个分片会每秒自动刷新一次。这就是为什么我们说Elasticsearch
是近实时搜索,因为文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。
我们也可以手动触发 Refresh
,POST /_refresh
刷新所有索引,POST /nba/_refresh
刷新指定的索引。
**注意:**尽管刷新是比提交轻量很多的操作,它还是会有性能开销。当写测试的时候, 手动刷新很有用,但是不要在生产环境下每次索引一个文档都去手动刷新。而且并不是所有的情况都需要每秒刷新。
假如正在使用 Elasticsearch
索引大量的日志文件, 想优化索引速度而不是近实时搜索。这时可以在创建索引时在Settings
中通过调大refresh_interval = "30s"
的值 , 降低每个索引的刷新频率,设值时需要注意后面带上时间单位,否则默认是毫秒。当refresh_interval=-1
时表示关闭索引的自动刷新。
虽然通过延时写的策略可以减少数据往磁盘上写的次数并提升了整体的写入能力,但是我们知道文件缓存系统也是内存空间,属于操作系统的内存,只要是内存都存在断电或异常情况下丢失数据的危险。
为了避免丢失数据,Elasticsearch
添加了事务日志(Translog
),事务日志记录了所有还没有持久化到磁盘的数据
添加了事务日志后整个写索引的流程如上图所示:
- 一个新文档被索引之后,先被写入到内存中,但是为了防止数据的丢失,会追加一份数据到事务日志中。
- 不断有新的文档被写入到内存,同时也都会记录到事务日志中。这时新数据还不能被检索和查询。
- 当达到默认的刷新时间或内存中的数据达到一定量后,会触发一次
Refresh
,将内存中的数据以一个新段形式刷新到文件缓存系统中并清空内存。这时虽然新段未被提交到磁盘,但是可以提供文档的检索功能且不能被修改。 - 随着新文档索引不断被写入,当日志数据大小超过
512M
或者时间超过30
分钟时,会触发一次Flush
- 内存中的数据被写入到一个新段同时被写入到文件缓存系统,文件系统缓存中数据通过
Fsync
刷新到磁盘中,生成提交点,日志文件被删除,创建一个空的新日志。
通过这种方式当断电或需要重启时,ES
不仅要根据提交点去加载已经持久化过的段,还需要工具Translog
里的记录,把未持久化的数据重新持久化到磁盘上,避免了数据丢失的可能。
段合并
由于自动刷新流程每秒会创建一个新的段 ,这样会导致短时间内的段数量暴增。而段数目太多会带来较大的麻烦。每一个段都会消耗文件句柄、内存和 CPU
运行周期。更重要的是,每个搜索请求都必须轮流检查每个段然后合并查询结果,所以段越多,搜索也就越慢。
Elasticsearch
通过在后台定期进行段合并来解决这个问题。小的段被合并到大的段,然后这些大的段再被合并到更大的段。
段合并的时候会将那些旧的已删除文档
从文件系统中清除。被删除的文档不会被拷贝到新的大段中。合并的过程中不会中断索引和搜索。
段合并在进行索引和搜索时会自动进行,合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中,这些段既可以是未提交的也可以是已提交的
合并结束后老的段会被删除
,新的段被Flush
到磁盘,同时写入一个包含新段且排除旧的和较小的段的新提交点,新的段被打开可以用来搜索。
段合并的计算量庞大, 而且还要吃掉大量磁盘 I/O
,段合并会拖累写入速率,如果任其发展会影响搜索性能。Elasticsearch
在默认情况下会对合并流程进行资源限制,所以搜索仍然有足够的资源很好地执行。
性能优化
存储设备
磁盘在现代服务器上通常都是瓶颈。Elasticsearch
重度使用磁盘,磁盘能处理的吞吐量越大,节点就越稳定。
这里有一些优化磁盘I/O
的技巧:
使用 SSD
。比机械磁盘优秀多了。
使用 RAID 0
。条带化RAID
会提高磁盘 I/O
,代价显然就是当一块硬盘故障时整个就故障了。不要使用镜像或者奇偶校验 RAID 因为副本已经提供了这个功能。
使用多块硬盘,并允许Elasticsearch
通过多个path.data
目录配置把数据条带化分配到它们上面。
不要使用远程挂载的存储,比如NFS
或者 SMB/CIFS
。这个引入的延迟对性能来说完全是背道而驰的。
内部索引优化
Elasticsearch
为了能快速找到某个 Term
,先将所有的Term
排个序,然后根据二分法查找 Term
,时间复杂度为 logN
,就像通过字典查找一样,这就是 Term Dictionary
。
现在再看起来,似乎和传统数据库通过B-Tree
的方式类似。但是如果 Term
太多,Term Dictionary
也会很大,放内存不现实,于是有了 Term Index
。
就像字典里的索引页一样,A
开头的有哪些 Term
,分别在哪页,可以理解 Term Index
是一棵树。这棵树不会包含所有的 Term
,它包含的是Term
的一些前缀。通过 Term Index
可以快速地定位到Term Dictionary
的某个 Offset
,然后从这个位置再往后顺序查找。
在内存中用FST
方式压缩 Term Index
,FST
以字节的方式存储所有的 Term
,这种压缩方式可以有效的缩减存储空间,使得Term Index
足以放进内存,但这种方式也会导致查找时需要更多的CPU
资源。
对于存储在磁盘上的倒排表同样也采用了压缩技术减少存储所占用的空间。
调整配置参数
调整配置参数建议如下:
- 给每个文档指定有序的具有压缩良好的序列模式
ID
,避免随机的UUID-4
这样的ID
,这样的ID
压缩比很低,会明显拖慢Lucene
。 - 对于那些不需要聚合和排序的索引字段禁用
Doc values
。Doc Values
是有序的基于document=>field value
的映射列表。 - 不需要做模糊检索的字段使用
Keyword
类型代替Text
类型,这样可以避免在建立索引前对这些文本进行分词。 - 如果搜索结果不需要近实时的准确度,考虑把每个索引的
index.refresh_interval
改到30s
- 如果在做大批量导入,导入期间可以通过设置这个值为
-1
关掉刷新,还可以通过设置index.number_of_replicas: 0
关闭副本。别忘记在完工的时候重新开启它。 - 避免深度分页查询建议使用
Scroll
进行分页查询。普通分页查询时,会创建一个from+size
的空优先队列,每个分片会返回from+size
条数据,默认只包含文档ID
和得分Score
给协调节点。 - 如果有
N
个分片,则协调节点再对(from+size)×n
条数据进行二次排序,然后选择需要被取回的文档。当from
很大时,排序过程会变得很沉重,占用CPU
资源严重。 - 减少映射字段,只提供需要检索,聚合或排序的字段。其他字段可存在其他存储设备上,例如
Hbase
,在ES
中得到结果后再去Hbase
查询这些字段。 - 创建索引和查询时指定路由
Routing
值,这样可以精确到具体的分片查询,提升查询效率。路由的选择需要注意数据的分布均衡。