ElasticSearch存储

ElasticSearch存储,第1张

使用ElasticSearch快一年了,自认为相关API使用的还比较6,产品提的一些搜索需求实现起来都从心应手;但是前几天同事的一个问题直接将我打回到小白,同事问了句:“ ElasticSearch的索引是怎么存储的?删除文档和更新文档是怎么实现的? ”,当时我就懵逼了,说了句得查一下,好尴尬......

回想起来发现自己从来都没有深入了解过这些细节,于是便觉得非常有必要对ElasticSearch的文档存储做一次深入的了解,知其然不知其所以然对于我们来说是远远不够的,在ElasticSearch中,文档( Document )存储的介质分为内存和硬盘两种:

同时,ElasticSearch进程自身的运行也需要内存空间,必须保证ElasticSearch进程有充足的运行时内存。为了使ElasticSearch引擎达到最佳性能,必须合理分配有限的内存和硬盘资源。

ElasticSearch引擎把文档数据写入到倒排索引( Inverted Index )的数据结构中,倒排索引建立的是分词( Term )和文档( Document )之间的映射关系,在倒排索引中,数据是面向词( Term )而不是面向文档( Document )的。

举个栗子,假设我们有两个文档,每个文档的content域包含如下内容:

1. The quick brown fox jumped over the lazy dog

2. Quick brown foxes leap over lazy dogs in summer

文档和词之间的关系如下图:

字段值被分析之后,存储在倒排索引中,倒排索引存储的是分词( Term )和文档( Doc )之间的关系,简化版的倒排索引如下图:

从图中可以看出,倒排索引有一个词条的列表,每个分词在列表中是唯一的,记录着词条出现的次数,以及包含词条的文档。实际中ElasticSearch引擎创建的倒排索引比这个复杂得多。

段是倒排索引的组成部分,倒排索引是由段( Segment )组成的,段存储在硬盘( Disk )文件中。 索引段不是实时更新的,这意味着,段在写入硬盘之后,就不再被更新。在删除文档时,ElasticSearch引擎把已删除的文档的信息存储在一个单独的文件中,在搜索数据时,ElasticSearch引擎首先从段中执行查询,再从查询结果中过滤被删除的文档 。这意味着,段中存储着被删除的文档,这使得段中含有”正常文档“的密度降低。

多个段可以通过段合并(Segment Merge) *** 作把“已删除”的文档将从段中物理删除,把未删除的文档合并到一个新段中,新段中没有”已删除文档“,因此,段合并 *** 作能够提高索引的查找速度 ;但是,段合并是IO密集型 *** 作,需要消耗大量的硬盘IO。

在ElasticSearch中,大多数查询都需要从硬盘文件(索引的段数据存储在硬盘文件中)中获取数据;因此, 在全局配置文件elasticsearch.yml 中,把结点的路径(Path)配置为性能较高的硬盘,能够提高查询性能 。默认情况下,ElasticSearch使用基于安装目录的相对路径来配置结点的路径,安装目录由属性path.home显示,在home path下,ElasticSearch自动创建config,data,logs和plugins目录,一般情况下不需要对结点路径单独配置。结点的文件路径配置项:

path.data:设置ElasticSearch结点的索引数据保存的目录,多个数据文件使用逗号隔开。

例如,path.data: /path/to/data1,/path/to/data2

path.work:设置ElasticSearch的临时文件保存的目录;

映射参数index决定ElasticSearch引擎是否对文本字段执行分析 *** 作,也就是说分析 *** 作把文本分割成一个一个的分词,也就是标记流( Token Stream ),把分词编入索引,使分词能够被搜索到:

1) 当index为analyzed时,该字段是分析字段,ElasticSearch引擎对该字段执行分析 *** 作,把文本分割成分词流,存储在倒排索引中,使其支持全文搜索。

2)当index为not_analyzed时,该字段不会被分析,ElasticSearch引擎把原始文本作为单个分词存储在倒排索引中,不支持全文搜索,但是支持词条级别的搜索;也就是说,字段的原始文本不经过分析而存储在倒排索引中,把原始文本编入索引,在搜索的过程中,查询条件必须全部匹配整个原始文本。

3)当index为no时,该字段不会被存储到倒排索引中,不会被搜索到。

字段的原始值是否被存储到倒排索引,是由映射参数store决定的,默认值是false,也就是,原始值不存储到倒排索引中。

映射参数index和store的区别在于:

store用于获取(Retrieve)字段的原始值,不支持查询,可以使用投影参数fields,对stroe属性为true的字段进行过滤,只获取(Retrieve)特定的字段,减少网络负载;

index用于查询(Search)字段,当index为analyzed时,对字段的分词执行全文查询;当index为not_analyzed时,字段的原始值作为一个分词,只能对字段的原始文本执行词条查询;

如果设置字段的index属性为not_analyzed,原始文本将作为单个分词,其最大长度跟UTF8 编码有关,默认的最大长度是32766Bytes(约32KB),如果字段的文本超过该限制,那么ElasticSearch将跳过( Skip )该文档,并在Response中抛出异常消息:

operation[607]: index returned 400 _index: ebrite _type: events _id: 76860 _version: 0 error: Type: illegal_argument_exception Reason: "Document contains at least one immense term in field="event_raw" (whose UTF8 encoding is longer than the max length 32766), all of which were skipped. Please correct the analyzer to not produce such terms. The prefix of the first immense term is: '[112, 114,... 115]...', original message: bytes can be at most 32766 in lengthgot 35100" CausedBy:Type: max_bytes_length_exceeded_exception Reason: "bytes can be at most 32766 in lengthgot 35100"

可以在字段中设置ignore_above属性,该属性值指的是字符数量,而不是字节数量;由于一个UTF8字符最多占用3个字节,因此,可以设置

“ignore_above”:10000

这样,超过30000字节之后的字符将会被分析器忽略,单个分词( Term )的最大长度是30000Bytes。

The value for ignore_above is the character count, but Lucene counts bytes. If you use UTF-8 text with many non-ASCII characters, you may want to set the limit to 32766 / 3 = 10922 since UTF-8 characters may occupy at most 3 bytes.

默认情况下,大多数字段被索引之后,能够被搜索到。 倒排索引是由一个有序的词条列表构成的,每一个词条在列表中都是唯一存在的,通过这种数据存储模式,你可以很快查找到包含某一个词条的文档列表

但是,排序和聚合 *** 作采用相反的数据访问模式,这两种 *** 作不是查找词条以发现文档,而是查找文档,以发现字段中包含的词条。ElasticSearch使用列式存储实现排序和聚合查询。

文档值( doc_values )是存储在硬盘上的数据结构,在索引时( index time )根据文档的原始值创建,文档值是一个列式存储风格的数据结构,非常适合执行存储和聚合 *** 作,除了字符类型的分析字段之外,其他字段类型都支持文档值存储。默认情况下,字段的文档值存储是启用的,除了字符类型的分析字段之外。如果不需要对字段执行排序或聚合 *** 作,可以禁用字段的文档值,以节省硬盘空间。

字符类型的分析字段,不支持文档值(doc_values),但支持fielddata数据结构。fielddata数据结构存储在JVM的堆内存中。相比文档值(数据存储在硬盘上),fielddata字段(数据存储在内存中)的查询性能更高。默认情况下,ElasticSearch引擎在第一次对字段执行聚合或排序查询时(query-time),创建fielddata数据结构;在后续的查询请求中,ElasticSearch引擎使用fielddata数据结构以提高聚合和排序的查询性能。

在ElasticSearch中,倒排索引的各个段( segment )的数据存储在硬盘文件上,从整个倒排索引的段中读取字段数据之后,ElasticSearch引擎首先反转词条和文档之间的关系,创建文档和词条之间的关系,即创建顺排索引,然后把顺排索引存储在JVM的堆内存中。把倒排索引加载到fielddata结构是一个非常消耗硬盘IO资源的过程。因此,数据一旦被加载到内存,最好保持在内存中,直到索引段( segment )的生命周期结束。

默认情况下,倒排索引的每个段( segment ),都会创建相应的fielddata结构,以存储字符类型的分析字段值,但是,需要注意的是,分配的JVM堆内存是有限的,Fileddata把数据存储在内存中,会占用过多的JVM堆内存,甚至耗尽JVM赖以正常运行的内存空间,反而会降低ElasticSearch引擎的查询性能。

fielddata会消耗大量的JVM内存,因此,尽量为JVM设置大的内存,不要为不必要的字段启用fielddata存储。通过format参数控制是否启用字段的fielddata特性,字符类型的分析字段,fielddata的默认值是paged_bytes,这就意味着,默认情况下,字符类型的分析字段启用fielddata存储。一旦禁用fielddata存储,那么字符类型的分析字段将不再支持排序和聚合查询。

loading属性控制fielddata加载到内存的时机,可能的值是lazy,eager和eager_global_ordinals,默认值是lazy。

lazy:fielddata只在需要时加载到内存,默认情况下,在第一次搜索时,fielddata被加载到内存中;但是,如果查询一个非常大的索引段(Segment),lazy加载方式会产生较大的时间延迟。

eager:在倒排索引的段可用之前,其数据就被加载到内存,eager加载方式能够减少查询的时间延迟,但是,有些数据可能非常冷,以至于没有请求来查询这些数据,但是冷数据依然被加载到内存中,占用紧缺的内存资源。

eager_global_ordinals:按照global ordinals积极把fielddata加载到内存。

ElasticSearch使用 JAVA_OPTS 环境变量( Environment Variable )启动JVM进程,在 JAVA_OPTS 中,最重要的配置是: -Xmx参数控制分配给JVM进程的最大内存,-Xms参数控制分配给JVM进程的最小内存 。(在ElasticSearch启动命令后面可以通过参数方式配置,如: /usr/local/elasticsearch/bin/elasticsearch -d --Xmx=10g --Xms=10g )通常情况下,使用默认的配置就能满足工程需要。

ES_HEAP_SIZE 环境变量控制分配给JVM进程的堆内存( Heap Memory )大小,顺排索引( fielddata )的数据存储在堆内存( Heap Memory )中。

大多数应用程序尝试使用尽可能多的内存,并尽可能把未使用的内存换出,但是,内存换出会影响ElasticSearch引擎的查询性能,推荐启用内存锁定,禁用ElasticSearch内存的换进换出。

在全局配置文档 elasticsearch.yml 中,设置 bootstrap.memory_lock 为ture,这将锁定ElasticSearch进程的内存地址空间,阻止ElasticSearch内存被OS换出( Swap out )。

通过学习,算是对ElasticSearch索引存储及更新有了一个较深的了解,至少能让我从容去面对同事的提问,但与此同时给我敲响了警钟,在使用一门技术的同时,更应该去了解它具体的原理,而不仅仅是停留在使用级别;在学习的路上,我们仍需要更加努力......

倒排索引被写入磁盘后是不可变的,ES解决不变性和更新索引的方式是使用多个索引,利用新增的索引来反映修改,在查询时从旧的到新的依次查询,最后来一个结果合并。

ES底层是基于Lucene,最核心的概念就是 Segment(段) ,每个段本身就是一个倒排索引。

ES中的Index由多个段的集合和 commit point(提交点) 文件组成。

提交点文件中有一个列表存放着所有已知的段,下面是一个带有1个提交点和3个段的Index示意图:

Doc会先被搜集到内存中的Buffer内,这个时候还无法被搜索到,如下图所示:

每隔一段时间,会将buffer提交,在flush磁盘后打开新段使得搜索可见,详细过程如下:

下面展示了这个过程完成后的段和提交点的状态:

通过这种方式,可以使得新文档从被索引到可被搜索间的时间间隔在数分钟,但是还不够快。因为磁盘需要 fsync ,这个就成为性能瓶颈。我们前面提到过Doc会先被从buffer刷入段写入文件系统缓存(很快),那么就自然想到在这个阶段就让文档对搜索可见,随后再被刷入磁盘(较慢)。

Lucene支持对新段写入和打开,可以使文档在没有完全刷入硬盘的状态下就能对搜索可见,而且是一个开销较小的 *** 作,可以频繁进行。

下面是一个已经将Docs刷入段,但还没有完全提交的示意图:

我们可以看到,新段虽然还没有被完全提交,但是已经对搜索可见了。

引入refresh *** 作的目的是提高ES的实时性,使添加文档尽可能快的被搜索到,同时又避免频繁fsync带来性能开销,依靠的就是文件系统缓存OS cache里缓存的文件可以被打开(open/reopen)和读取,而这个os cache实际是一块内存区域,而非磁盘,所以 *** 作是很快的,这就是ES被称为近实时搜索的原因。

refresh默认执行的间隔是1秒,可以使用 refreshAPI 进行手动 *** 作,但一般不建议这么做。还可以通过合理设置 refresh_interval 在近实时搜索和索引速度间做权衡。

index segment刷入到os cache后就可以打开供查询,这个 *** 作是有潜在风险的,因为os cache中的数据有可能在意外的故障中丢失,而此时数据必备并未刷入到os disk,此时数据丢失将是不可逆的,这个时候就需要一种机制,可以将对es的 *** 作记录下来,来确保当出现故障的时候,已经落地到磁盘的数据不会丢失,并在重启的时候可以从 *** 作记录中将数据恢复过来。elasticsearch提供了translog来记录这些 *** 作,结合os cached segments数据定时落盘来实现数据可靠性保证(flush)。

文档被添加到buffer同时追加到translog:

进行 refresh *** 作,清空buffer,文档可被搜索但尚未 flush 到磁盘。translog不会清空:

每隔一段时间(例如translog变得太大),index会被flush到磁盘,新的translog文件被创建,commit执行结束后,会发生以下事件:

下面示意图展示了这个状态:

translog记录的是已经 在内存生成(segments)并存储到os cache但是还没写到磁盘的那些索引 *** 作 (注意,有一种解释说,添加到buffer中但是没有被存入segment中的数据没有被记录到translog中,这依赖于写translog的时机,不同版本可能有变化,不影响理解),此时这些新写入的数据可以被搜索到,但是当节点挂掉后这些未来得及落入磁盘的数据就会丢失,可以通过trangslog恢复。

当然translog本身也是磁盘文件,频繁的写入磁盘会带来巨大的IO开销,因此对translog的追加写入 *** 作的同样 *** 作的是os cache,因此也需要定时落盘(fsync)。translog落盘的时间间隔直接决定了ES的可靠性,因为宕机可能导致这个时间间隔内所有的ES *** 作既没有生成segment磁盘文件,又没有记录到Translog磁盘文件中,导致这期间的所有 *** 作都丢失且无法恢复。

translog的fsync是ES在后台自动执行的,默认是每5秒钟主动进行一次translog fsync,或者当translog文件大小大于512MB主动进行一次fsync,对应的配置是 index.translog.flush_threshold_period 和 index.translog.flush_threshold_size 。

当 Elasticsearch 启动的时候, 它会从磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变更 *** 作。

translog 也被用来提供实时 CRUD 。当你试着通过ID来RUD一个Doc,它会在从相关的段检索之前先检查 translog 中最新的变更。

默认 translog 是每5秒或是每次请求完成后被 fsync 到磁盘(在主分片和副本分片都会)。也就是说,如果你发起一个index, delete, update, bulk请求写入translog并被fsync到主分片和副本分片的磁盘前不会反回200状态。

这样会带来一些性能损失,可以通过设为异步fsync,但是必须接受由此带来的丢失少量数据的风险:

flush 就是执行commit清空、干掉老translog的过程。默认每个分片30分钟或者是translog过于大的时候自动flush一次。可以通过flush API手动触发,但是只会在重启节点或关闭某个索引的时候这样做,因为这可以让未来ES恢复的速度更快(translog文件更小)。

满足下列条件之一就会触发冲刷 *** 作:

整体流程:

删除一个ES文档不会立即从磁盘上移除,它只是被标记成已删除。因为段是不可变的,所以文档既不能从旧的段中移除,旧的段也不能更新以反映文档最新的版本。

ES的做法是,每一个提交点包括一个 .del 文件(还包括新段),包含了段上已经被标记为删除状态的文档。所以,当一个文档被做删除 *** 作,实际上只是在 .del 文件中将该文档标记为删除,依然会在查询时被匹配到,只不过在最终返回结果之前会被从结果中删除。ES将会在用户之后添加更多索引的时候,在后台进行要删除内容的清理。

文档的更新 *** 作和删除是类似的:当一个文档被更新,旧版本的文档被标记为删除,新版本的文档在新的段中索引。

该文档的不同版本都会匹配一个查询,但是较旧的版本会从结果中删除。

通过每秒自动刷新创建新的段,用不了多久段的数量就爆炸了,每个段消费大量文件句柄,内存,cpu资源。更重要的是,每次搜索请求都需要依次检查每个段。段越多,查询越慢。

ES通过后台合并段解决这个问题。ES利用段合并的时机来真正从文件系统删除那些version较老或者是被标记为删除的文档。被删除的文档(或者是version较老的)不会再被合并到新的更大的段中。

可见,段合并主要有两个目的:

ES对一个不断有数据写入的索引处理流程如下:

合并过程如图:

从上图可以看到,段合并之前,旧有的Commit和没Commit的小段皆可被搜索。

段合并后的 *** 作:

合并完成后新的段可被搜索,旧的段被删除,如下图所示:

注意 :段合并过程虽然看起来很爽,但是大段的合并可能会占用大量的IO和CPU,如果不加以控制,可能会大大降低搜索性能。段合并的optimize API 不是非常特殊的情况下千万不要使用,默认策略已经足够好了。不恰当的使用可能会将你机器的资源全部耗尽在段合并上,导致无法搜索、无法响应。

很多使用Elasticsearch的同学会关心数据存储在ES中的存储容量,会有这样的疑问:xxTB的数据入到ES会使用多少存储空间。这个问题其实很难直接回答的,只有数据写入ES后,才能观察到实际的存储空间。比如同样是1TB的数据,写入ES的存储空间可能差距会非常大,可能小到只有300~400GB,也可能多到6-7TB,为什么会造成这么大的差距呢?究其原因,我们来探究下Elasticsearch中的数据是如何存储。文章中我以Elasticsearch 2.3版本为示例,对应的lucene版本是5.5,Elasticsearch现在已经来到了6.5版本,数字类型、列存等存储结构有些变化,但基本的概念变化不多,文章中的内容依然适用。

Elasticsearch对外提供的是index的概念,可以类比为DB,用户查询是在index上完成的,每个index由若干个shard组成,以此来达到分布式可扩展的能力。比如下图是一个由10个shard组成的index。

shard是Elasticsearch数据存储的最小单位,index的存储容量为所有shard的存储容量之和。Elasticsearch集群的存储容量则为所有index存储容量之和。

一个shard就对应了一个lucene的library。对于一个shard,Elasticsearch增加了translog的功能,类似于HBase WAL,是数据写入过程中的中间数据,其余的数据都在lucene库中管理的。

所以Elasticsearch索引使用的存储内容主要取决于lucene中的数据存储。

下面我们主要看下lucene的文件内容,在了解lucene文件内容前,大家先了解些lucene的基本概念。

lucene包的文件是由很多segment文件组成的,segments_xxx文件记录了lucene包下面的segment文件数量。每个segment会包含如下的文件。

下面我们以真实的数据作为示例,看看lucene中各类型数据的容量占比。

写100w数据,有一个uuid字段,写入的是长度为36位的uuid,字符串总为3600w字节,约为35M。

数据使用一个shard,不带副本,使用默认的压缩算法,写入完成后merge成一个segment方便观察。

使用线上默认的配置,uuid存为不分词的字符串类型。创建如下索引:

首先写入100w不同的uuid,使用磁盘容量细节如下:

可以看到正排数据、倒排索引数据,列存数据容量占比几乎相同,正排数据和倒排数据还会存储Elasticsearch的唯一id字段,所以容量会比列存多一些。

35M的uuid存入Elasticsearch后,数据膨胀了3倍,达到了122.7mb。Elasticsearch竟然这么消耗资源,不要着急下结论,接下来看另一个测试结果。

我们写入100w一样的uuid,然后看看Elasticsearch使用的容量。

这回35M的数据Elasticsearch容量只有13.2mb,其中还有主要的占比还是Elasticsearch的唯一id,100w的uuid几乎不占存储容积。

所以在Elasticsearch中建立索引的字段如果基数越大(count distinct),越占用磁盘空间。

我们再看看存100w个不一样的整型会是如何。

从结果可以看到,100w整型数据,Elasticsearch的存储开销为13.6mb。如果以int型计算100w数据的长度的话,为400w字节,大概是3.8mb数据。忽略Elasticsearch唯一id字段的影响,Elasticsearch实际存储容量跟整型数据长度差不多。

我们再看一下开启最佳压缩参数对存储空间的影响:

结果中可以发现,只有正排数据会启动压缩,压缩能力确实强劲,不考虑唯一id字段,存储容量大概压缩到接近50%。

我们还做了一些实验,Elasticsearch默认是开启_all参数的,_all可以让用户传入的整体json数据作为全文检索的字段,可以更方便的检索,但在现实场景中已经使用的不多,相反会增加很多存储容量的开销,可以看下开启_all的磁盘空间使用情况:

开启_all比不开启多了40mb的存储空间,多的数据都在倒排索引上,大约会增加30%多的存储开销。所以线上都直接禁用。

然后我还做了其他几个尝试,为了验证存储容量是否和数据量成正比,写入1000w数据的uuid,发现存储容量基本为100w数据的10倍。我还验证了数据长度是否和数据量成正比,发现把uuid增长2倍、4倍,存储容量也响应的增加了2倍和4倍。在此就不一一列出数据了。

文件名为:segments_xxx

该文件为lucene数据文件的元信息文件,记录所有segment的元数据信息。

该文件主要记录了目前有多少segment,每个segment有一些基本信息,更新这些信息定位到每个segment的元信息文件。

lucene元信息文件还支持记录userData,Elasticsearch可以在此记录translog的一些相关信息。

文件后缀:.si

每个segment都有一个.si文件,记录了该segment的元信息。

segment元信息文件中记录了segment的文档数量,segment对应的文件列表等信息。

文件后缀:.fnm

该文件存储了fields的基本信息。

fields信息中包括field的数量,field的类型,以及IndexOpetions,包括是否存储、是否索引,是否分词,是否需要列存等等。

文件后缀:.fdx, .fdt

索引文件为.fdx,数据文件为.fdt,数据存储文件功能为根据自动的文档id,得到文档的内容,搜索引擎的术语习惯称之为正排数据,即doc_id ->content,es的_source数据就存在这

索引文件记录了快速定位文档数据的索引信息,数据文件记录了所有文档id的具体内容。

索引后缀:.tip,.tim

倒排索引也包含索引文件和数据文件,.tip为索引文件,.tim为数据文件,索引文件包含了每个字段的索引元信息,数据文件有具体的索引内容。

5.5.0版本的倒排索引实现为FST tree,FST tree的最大优势就是内存空间占用非常低 ,具体可以参看下这篇文章: http://www.cnblogs.com/bonelee/p/6226185.html

http://examples.mikemccandless.com/fst.py?terms=&cmd=Build+it 为FST图实例,可以根据输入的数据构造出FST图

生成的 FST 图为:

文件后缀:.doc, .pos, .pay

.doc保存了每个term的doc id列表和term在doc中的词频

全文索引的字段,会有.pos文件,保存了term在doc中的位置

全文索引的字段,使用了一些像payloads的高级特性才会有.pay文件,保存了term在doc中的一些高级特性

文件后缀:.dvm, .dvd

索引文件为.dvm,数据文件为.dvd。

lucene实现的docvalues有如下类型:

其中SORTED_SET 的 SORTED_SINGLE_VALUED类型包括了两类数据 : binary + numeric, binary是按ord排序的term的列表,numeric是doc到ord的映射。


欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/bake/7904784.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023-04-11
下一篇 2023-04-11

发表评论

登录后才能评论

评论列表(0条)

保存