ES2_1索引原理

基于 Lucene,ES 实现了分布式的索引管理,这篇文档分析单机视角下的索引原理。

[x] ES如何保证搜索的近实时(1秒后被搜到)
[x] 为什么删除文档,不会立刻释放空间

索引策略

在 Elasticsearch 中, 每个字段的所有数据 都是 默认被索引的 。 即每个字段都有为了快速检索设置的专用倒排索引
倒排索引由一些词项组成,每个词项包含了它所有曾出现过的文档的列表。
Term | Doc 1 | Doc 2 | Doc 3 |

brown | X | | X | …
fox | X | X | X | …
quick | X | X | | …
the | X | | X | …

另外,这个倒排索引相比特定词项出现过的文档列表,会包含更多其它信息。它会保存每一个词项出现过的文档总数, 在对应的文档中一个具体词项出现的总次数,词项在文档中的顺序,每个文档的长度,所有文档的平均长度,等等。这些统计信息允许 Elasticsearch 决定哪些词比其它词更重要,哪些文档比其它文档更重要,用于搜索时计算文档的相关性。

倒排索引的不变性

倒排索引被写入磁盘后是 不可改变 的:它永远不会被修改。 不变性有重要的价值:

  • 无需考虑并发写文件问题,不需要锁,因此也避免了锁机制带来的性能问题。
  • 一旦索引被读入内核的文件系统缓存,便会留在哪里,由于其不变性。只要文件系统缓存中还有足够的空间,那么大部分读请求会直接请求内存,而不会命中磁盘。这提供了很大的性能提升。
  • 其它缓存(像 filter 缓存),在索引的生命周期内始终有效。它们不需要在每次数据改变时被重建,因为数据不会变化。
  • 写入单个大的倒排索引允许数据被压缩,减少磁盘 I/O 和 需要被缓存到内存的索引的使用量。

当然,一个不变的索引也有不好的地方:

  • 由于不变性,你不能修改它,如果你需要让一个新的文档 可被搜索,你需要重新索引该文档。这对一个索引能包含的数据量和被更新频率造成很大限制。

Segment(段)和Commit Point(提交点)

一个Lucene索引包含一个提交点和三个段

  • 每一个Segment本身都是一个倒排索引,索引在 Lucene 中表示所有段的集合。

    一个 Lucene 索引在 Elasticsearch 中被称作分片,一个 Elasticsearch 索引是分片的集合,当 Elasticsearch 在索引中搜索的时候,会发送查询请求到每一个属于该索引的分片,然后合并每个分片的结果到一个全局的结果集中。

  • 当有新文档写入时,会生成新Segment,查询时会同时查询所有Segments,并对结果汇总。
  • Commit Point是一个列出了所有已知Segments的文件
    Elasticsearch 在启动或重新打开一个索引的过程中使用这个Commit Point来判断哪些段隶属于当前分片。
  • 删除的文档信息,保存在”.del”文件中

新文档的索引流程

准实时搜索与刷新策略

对一个文档进行更新操作后可能会发现属性还是旧的值,我们称之为准实时现象:更新的数据还存在于内存中、还未刷新到磁盘上。

  • 在索引期新文档会写入Segment,这些Segment是独立的,这意味着查询是可以与索引并行的,只是不时会有新增的索引段被添加到可被搜索的索引段集合之中。
  • Lucene 通过创建后续的(基于索引只写一次的特性)segments_N 文件来实现此功能,且该文件列举了索引中的索引段。这个过程称为提交(Commit),Lucene 以一种安全的方式来执行该操作,能确保索引更改以原子操作方式写入索引,即便有错误发生,也能保证索引数据的一致性。

随着按段搜索(per-segment)的发展,一个新的文档从索引到可被搜索的延迟显著降低,新文档在几分钟内即可被检索,但是这个速度还是不够快。磁盘在这里称为了瓶颈,提交(Commiting)一个新的段到磁盘需要一个 fsync 来确保段被物理性地写入磁盘,这样在断电的时候就不会丢失数据。 但是 fsync 操作代价很大,如果每次索引一个文档都去执行一次的话会造成很大的性能问题。
在 Lucene 中提交后,内存索引缓冲区中的文档会被写入到一个新的段中,但是这里新段会被先写入到文件系统缓存——这一步代价会比较低,稍后再被刷新到磁盘——这一步代价比较高,不过只要文件已经在缓存中就可以像其他文件一样被打开和读取了。

Refresh - 动态更新索引

新文档被添加到缓存
ES中新建的文档会先被写入到Index Buffer。
缓冲区被写入段但未完成提交
将Index Buffer写入Segment的过程叫Refresh,注意Refresh不执行fsync操作,此时还未被刷到磁盘。

  • Refresh默认1次/秒,可通过index.refresh_interval配置。
  • Index Buffer被占满时,也会触发Refresh,默认值是JVM的10%。
  • Refresh后,数据就可以被搜索到了,这也是为什么Elasticsearch被称为近实时搜索
  • 如果系统有大量的数据写入,就会产生很多的Segment。

Searcher

Lucene 使用了一个叫作Searcher的抽象类来执行索引的读取,如果索引更新提交了,但 Searcher 实例并没有重新打开,那么它觉察不到新索引段的加入。写入和 Searcher 重新打开新段的过程叫作刷新(Refresh)。出于性能考虑,Lucene 推迟了耗时的刷新,因此它不会在每次新增一个文档(或批量增加文档)的时候刷新,但 Searcher 会默认每秒刷新一次。这就是为什么我们说 Elasticsearch 是 近 实时搜索: 文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。
因此新索引的数据找不到可能有以下两个原因:

  1. 可能还未执行提交 commit 操作
  2. Searcher 未重新打开执行刷新

强制刷新

如果有必要执行强制刷新,可以使用下面的命令:

1
2
3
4
# 刷新所有索引
POST /_refresh
# 只刷新一个索引
POST /my_index/_refresh

配置刷新策略

可以更改 ElasticSearch 配置文件中的 index.refresh_interval,,或者使用下面的命令来修改自动刷新时间:

1
2
3
4
5
6
7
8
9
10
11
12
PUT /my_index/_settings
{
"index": {
"refresh_interval": "5m"
}
}
PUT /my_index
{
"settings": {
"refresh_interval": "30s"
}
}

刷新操作是很耗资源的,因此刷新间隔时间越长,索引速度越快。如果需要长时间高速建索引、或建一个比较大的新索引,并且在建索引结束之前暂不执行查询,那么可以考虑将 index.refresh_interval 参数值设置为-1,然后在建索引结束以后再将该参数恢复为初始值。

1
2
3
4
5
6
7
8
9
10
# 关闭自动刷新
PUT /my_logs/_settings
{
"refresh_interval": -1
}
# 每秒自动刷新
PUT /my_logs/_settings
{
"refresh_interval": "1s"
}

注意 refresh_interval 的单位,设置为 1 实际上表示的是 1 毫秒,这显然会导致集群陷入瘫痪。
尽管刷新是比提交轻量很多的操作,它还是会有性能开销。 当写测试的时候, 手动刷新很有用,但是不要在生产环境下每次索引一个文档都去手动刷新。 相反,你的应用需要意识到 Elasticsearch 的近实时的性质,并接受它的不足。

Transaction Log(事务日志) - Refresh保证准实时搜索的同时保证数据不丢

Lucene 不能保证索引数据不丢失

Lucene 能保证索引的一致性,但是这并不能保证当往索引中写数据(fsync)失败时不会损失数据(如磁盘空间不足、设备损坏,或没有足够的文件句柄供索引文件使用)。
另外,频繁提交操作会导致严重的性能问题(因为每提交一次就会触发一个索引段的创建操作,同时也可能触发索引段的合并)。
即使通过每秒刷新(Refresh)实现了近实时搜索,我们仍然需要经常进行完整提交来确保能从失败中恢复。但在两次提交之间发生变化的文档怎么办?我们也不希望丢失掉这些数据。

Transaction Log写入流程

如Refresh流程所述,Refresh过程并不会立刻将Segment刷新到磁盘,而是先写入缓存并开放查询。
为了保证数据不丢,在Index文档时,Lucene会同时写Transaction Log

  • 高版本开始,Transaction Log默认落盘;
  • 每个分片有一个Transaction Log;
  • 在ES Refresh时,Index Buffer会被清空,但是Transaction Log不会清空
  • 如果发生断电等情况,未落盘的Segment数据会被清空,ES会使用TransactionLog中的数据恢复。

使用事务日志记录未提交事务

Elasticsearch 增加了一个 translog ,或者叫事务日志,在每一次对 Elasticsearch 进行操作时均进行了日志记录。
ElasticSearch 通过使用translog保存所有的未提交的事务,而 ElasticSearch 会不时创建一个新的日志文件用于记录每个事务的后续操作。当有错误发生时,就会检查事务日志,必要时会再次执行某些操作,以确保没有丢失任何更改信息。而且,事务日志的相关操作都是自动完成的,用户并不会意识到某个特定时刻触发的更新提交。事务日志中的信息与存储介质之间的同步(同时清空事务日志)称为事务日志刷新(Flush),Flush 操作会截断 translog。
注意事务日志刷新与 Searcher 刷新的区别。大多数情况下,Searcher 刷新是你所期望的,即搜索到最新的文档。而事务日志刷新用来确保数据正确写入了索引并清空了事务日志。

通过translog,整个流程看起来是下面这样:

  1. 一个文档被索引之后,就会被添加到内存缓冲区,并且 追加到了 translog;
    新的文档被添加到内存缓冲区并且被追加到了事务日志
  2. 分片每秒被刷新(refresh)一次:
    • 这些在内存缓冲区的文档被写入到一个新的段中,且没有进行 fsync 操作。
    • 这个段被打开,使其可被搜索。
    • 内存缓冲区被清空。
      Refresh完成后缓存被清空但是事务日志不会
  3. 这个进程继续工作,更多的文档被添加到内存缓冲区和追加到事务日志;
    事务日志不断积累文档
  4. 每隔一段时间,索引会被刷新(Flush),一个新的 translog 被创建,并且一个全量提交被执行。
    • 所有在内存缓冲区的文档都被写入一个新的段。
    • 缓冲区被清空。
    • 一个提交点被写入硬盘。
    • 文件系统缓存通过 fsync 被刷新(flush)。
    • 老的 translog 被删除。
      translog 提供所有还没有被刷到磁盘的操作的一个持久化纪录。当 Elasticsearch 启动的时候, 它会从磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变更操作。
      translog 也被用来提供实时 CRUD 。当你试着通过 ID 查询、更新、删除一个文档,它会在尝试从相应的段中检索之前, 首先检查 translog 任何最近的变更。这意味着它总是能够实时地获取到文档的最新版本。
      Flush之后段被全量提交并且事务日志被清空

手动执行事务日志刷新

分片每 30 分钟被自动刷新(flush),或者在 translog 太大的时候也会刷新。

1
2
3
4
5
6
POST /_flush
POST /my_index/_flush
# Flush所有的索引并且并且等待所有刷新在返回前完成。
POST /_flush?wait_for_ongoing
# 在事务日志刷新之后,调用Searcher刷新操作,打开一个新的Searcher实例
POST /my_index/_refresh

一般不需要自己手动执行Flush操作,自动刷新就足够了。一般重启节点或关闭索引之前都需要执行一次Flush
当 Elasticsearch 尝试恢复或重新打开一个索引, 它需要重放 translog 中所有的操作,如果日志越短,恢复越快。

异步 fsync

默认 translog 是每 5 秒被 fsync 刷新到硬盘,或者在每次写请求(index, delete, update, bulk)完成之后执行。这个过程在主分片和复制分片都会发生,这意味着在整个请求被 fsync 到主分片和复制分片的 translog 之前,客户端不会得到一个 200 OK 响应。
对于一些大容量的偶尔丢失几秒数据问题也不严重的集群,使用异步的 fsync 相对来说更好,比如,写入的数据被缓存到内存中,再每 5 秒执行一次 fsync ,可以使用如下命令配置:

1
2
3
4
5
PUT /my_index/_settings
{
"index.translog.durability": "async",
"index.translog.sync_interval": "5s"
}

当然,如果不确定丢失几秒数据的后果能否接受,最好还是使用默认的参数:"index.translog.durability": "request"

配置

以下参数既可以通过修改 elasticsearch.yml 文件来配置,也可以通过索引配置更新 API 来更改。

  • index.translog.flush_threshold_period:该参数的默认值为 30 分钟,它控制了强制自动事务日志刷新的时间间隔,即便是没有新数据写入。强制进行事务日志刷新通常会导致大量的 I/O 操作,因此当事务日志涉及少量数据时,才更适合进行这项操作。
  • index.translog.flush_threshold_ops:该参数确定了一个最大操作数,即在上次事务日志刷新以后,当索引更改操作次数超过该参数值时,强制进行事务日志刷新操作,默认值为 5000。
  • index.translog.flush_threshold_size:该参数确定了事务日志的最大容量,当容量大于等于该参数值,就强制进行事务日志刷新操作,默认值为 200MB。
  • index.translog.disable_flush:禁用事务日志刷新。尽管默认情况下事务日志刷新是可用的,但对它临时性地禁用能带来其他方面的便利。例如,向索引中导入大量文档的时候。

或者调用 API 动态修改配置:

1
2
3
4
5
6
PUT /my_index/_settings
{
"index": {
"translog.disable_flush": true
}
}

前述命令在向索引导入大量数据之前执行、可以大幅提高索引的速度。但是请记住,当数据导入完毕之后,要重新设置事务日志刷新相关参数。

Flush - Commit

提交后生成新段且缓存被清空
Flush是ES的持久化操作,对应Lucene的Commit操作,流程如下:

  1. 调用Refresh,此时清空Index Buffer,将文档保存到缓存的Segments中
  2. 调用fsync,将缓存中Segments写入磁盘
  3. 清空(删除)Transaction Log

Flush是一个非常重的操作,需要将文档数据刷新到磁盘,因此其执行间隔也是非常的长:

  • Flush操作默认30分钟调用一次;
  • Transaction Log满(默认512MB)时强制执行一次。

Merge - 段合并

由于自动刷新流程每秒会创建一个新的段,这样会导致短时间内的段数量暴增,而段数目太多会带来较大的麻烦:

  • 每一个段都会消耗文件句柄、内存和 cpu 运行周期;
  • 更重要的是,每个搜索请求都必须轮流检查每个段,所以段越多,搜索也就越慢。
  • 段中已经被删除的文档占用了大量空间,需要清除

Elasticsearch 通过在后台进行段合并来解决这个问题:

  • 小的段被合并到大的段,然后这些大的段再被合并到更大的段。
  • 段合并的时候会将那些旧的已删除文档 从文件系统中清除。 被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。

Merge的触发方式有2种:

  • ES和Lucene会自动进行Merge操作
  • 手动执行POST my_index/_forcemerge

段合并流程

进行索引和搜索时会自动进行段合并:

  1. 当索引的时候,刷新(refresh)操作会创建新的段并将段打开以供搜索使用。
  2. 合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。这并不会中断索引和搜索。
    两个提交了的段和一个未提交的段被合并到一个更大的段
  3. 合并完成后:
    • 新的段被刷新(flush)到了磁盘。写入一个包含新段且排除旧的和较小的段的新提交点。
    • 新的段被打开用来搜索。
    • 老的段被删除。
      合并结束后老的段被删除

optimize API

optimize API 用于手动触发段合并。
将一个分片强制合并到 max_num_segments 参数指定大小的段数目。 这样做的意图是减少段的数量(通常减少到一个),来提升搜索性能。
optimize API 不应该被用在一个活跃的索引上,Elasticsearch 后台会自动触发合并。
在特定情况下,使用 optimize API 颇有益处。例如在日志这种用例下,每天、每周、每月的日志被存储在一个索引中。 老的索引实质上是只读的,它们也并不太可能会发生变化,将历史段合并成一个单独的段就很有用了。

1
2
# 合并索引中的每个分片为一个单独的段 
POST /logstash-2014-10/_optimize?max_num_segments=1

使用 optimize API 触发段合并的操作不会受到任何资源上的限制。这可能会消耗掉你节点上全部的 I/O 资源, 使其没有余裕来处理搜索请求,从而有可能使集群失去响应。 如果你想要对索引执行 optimize,你需要先使用分片分配把索引移到一个安全的节点,再执行。

删除和更新索引

段是不可改变的,所以既不能从把文档从旧的段中移除,也不能修改旧的段来进行反映文档的更新。 取而代之的是,每个提交点会包含一个 .del 文件,文件中会列出这些被删除文档的段信息。
当一个文档被 删除 时,它实际上只是在 .del 文件中被标记删除。一个被标记删除的文档仍然可以被查询匹配到,但它会在最终结果被返回前从结果集中移除。
文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。 可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。

重建索引

重建索引一般是索引的元数据发生变更了,但是文档还没更新,此时需要重建索引,让新数据结构可以被搜索到。

需要重建索引的情况:

  • 索引的Mappings发生变更,字段类型更改,分词器及字典更新
  • 索引的Settings发生变更:索引的主分片数发生改变
  • 集群内,汲取间需要做数据迁移

ES内部有2种方法重建索引:

  1. Update By Query,在现有索引上重建
  2. Reindex,在其他索引上重建索引

Update By Query

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
PUT blogs/doc_update/1
{
"content":"Hadoop is cool",
"keyword":"hadoop"
}

# 修改 Mapping,增加子字段,使用英文分词器
PUT blogs/_mapping/doc_update
{
"properties" : {
"content" : {
"type" : "text",
"fields" : {
"english" : {
"type" : "text",
"analyzer":"english"
}
}
}
}
}

POST blogs/_search
{
"query": {
"match": {
"content.english": "Hadoop"
}
}
}
  • 改变Mapping,增加子字段,使用英文分词器
  • 虽然数据存在,无法查到结果
  • 此时重新插入一条数据,是可以被查到的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Update所有文档
POST blogs/_update_by_query
{
}

# 查询之前写入的文档
POST blogs/_search
{
"query": {
"match": {
"content.english": "Hadoop"
}
}
}

Reindex

Reindex将老索引数据重建到新索引

  • 新索引是可以新增、修改字段声明的,而ES本身是不支持对mapping中的字段进行修改的,这也是Reindex的主要意义
  • Reindex要求_source字段是enabled的
  • 重建索引后,可以通过Index Alias在不停机的情况下取代原来的索引
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
# 创建新的索引并且设定新的Mapping
PUT blogs_fix
{
"mappings": {
"doc": {
"properties" : {
"content" : {
"type" : "text",
"fields" : {
"english" : {
"type" : "text",
"analyzer" : "english"
}
}
},
"a": {
"type": "keyword"
}
}
}
}
}

# 利用Reindx API,将老索引
POST _reindex
{
"source": {
"index": "blogs"
},
"dest": {
"index": "blogs_fix"
}
}

GET blogs_fix/doc_update/1
  • 上面的Reindex将老的blogs索引下的文档重建到新的blogs_fix
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
# 重建索引时使用内部版本号
POST _reindex
{
"source": {
"index": "blogs"
},
"dest": {
"index": "blogs_fix",
"version_type": "internal"
}
}

# 重建索引时使用外部版本号
POST _reindex
{
"source": {
"index": "blogs"
},
"dest": {
"index": "blogs_fix",
"version_type": "external"
}
}

# 只创建不存在的文档,文档已存在的情况下,会导致版本冲突
POST _reindex
{
"source": {
"index": "blogs"
},
"dest": {
"index": "blogs_fix",
"op_type": "create"
}
}

查看Reindex执行进度:

1
GET _tasks?detailed=true&actions=*reindex

异步操作,执行只返回Task ID:

1
POST _reindex?wait_for_completion=false