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(提交点)
- 每一个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 是 近 实时搜索: 文档的变化并不是立即对搜索可见,但会在一秒之内变为可见。
因此新索引的数据找不到可能有以下两个原因:
- 可能还未执行提交 commit 操作
- Searcher 未重新打开执行刷新
强制刷新
如果有必要执行强制刷新,可以使用下面的命令:
1 | # 刷新所有索引 |
配置刷新策略
可以更改 ElasticSearch 配置文件中的 index.refresh_interval,,或者使用下面的命令来修改自动刷新时间:
1 | PUT /my_index/_settings |
刷新操作是很耗资源的,因此刷新间隔时间越长,索引速度越快。如果需要长时间高速建索引、或建一个比较大的新索引,并且在建索引结束之前暂不执行查询,那么可以考虑将 index.refresh_interval 参数值设置为-1,然后在建索引结束以后再将该参数恢复为初始值。
1 | # 关闭自动刷新 |
注意 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
,整个流程看起来是下面这样:
- 一个文档被索引之后,就会被添加到内存缓冲区,并且 追加到了 translog;
- 分片每秒被刷新(refresh)一次:
- 这些在内存缓冲区的文档被写入到一个新的段中,且没有进行 fsync 操作。
- 这个段被打开,使其可被搜索。
- 内存缓冲区被清空。
- 这个进程继续工作,更多的文档被添加到内存缓冲区和追加到事务日志;
- 每隔一段时间,索引会被刷新(Flush),一个新的 translog 被创建,并且一个全量提交被执行。
- 所有在内存缓冲区的文档都被写入一个新的段。
- 缓冲区被清空。
- 一个提交点被写入硬盘。
- 文件系统缓存通过 fsync 被刷新(flush)。
- 老的 translog 被删除。
translog 提供所有还没有被刷到磁盘的操作的一个持久化纪录。当 Elasticsearch 启动的时候, 它会从磁盘中使用最后一个提交点去恢复已知的段,并且会重放 translog 中所有在最后一次提交后发生的变更操作。
translog 也被用来提供实时 CRUD 。当你试着通过 ID 查询、更新、删除一个文档,它会在尝试从相应的段中检索之前, 首先检查 translog 任何最近的变更。这意味着它总是能够实时地获取到文档的最新版本。
手动执行事务日志刷新
分片每 30 分钟被自动刷新(flush),或者在 translog 太大的时候也会刷新。
1 | POST /_flush |
一般不需要自己手动执行Flush
操作,自动刷新就足够了。一般重启节点或关闭索引之前都需要执行一次Flush
。
当 Elasticsearch 尝试恢复或重新打开一个索引, 它需要重放 translog 中所有的操作,如果日志越短,恢复越快。
异步 fsync
默认 translog 是每 5 秒被 fsync 刷新到硬盘,或者在每次写请求(index, delete, update, bulk)完成之后执行。这个过程在主分片和复制分片都会发生,这意味着在整个请求被 fsync 到主分片和复制分片的 translog 之前,客户端不会得到一个 200 OK 响应。
对于一些大容量的偶尔丢失几秒数据问题也不严重的集群,使用异步的 fsync 相对来说更好,比如,写入的数据被缓存到内存中,再每 5 秒执行一次 fsync ,可以使用如下命令配置:
1 | PUT /my_index/_settings |
当然,如果不确定丢失几秒数据的后果能否接受,最好还是使用默认的参数:"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 | PUT /my_index/_settings |
前述命令在向索引导入大量数据之前执行、可以大幅提高索引的速度。但是请记住,当数据导入完毕之后,要重新设置事务日志刷新相关参数。
Flush - Commit
Flush是ES的持久化操作,对应Lucene的Commit操作,流程如下:
- 调用Refresh,此时清空Index Buffer,将文档保存到缓存的Segments中
- 调用fsync,将缓存中Segments写入磁盘
- 清空(删除)Transaction Log
Flush是一个非常重的操作,需要将文档数据刷新到磁盘,因此其执行间隔也是非常的长:
- Flush操作默认30分钟调用一次;
- Transaction Log满(默认512MB)时强制执行一次。
Merge - 段合并
由于自动刷新流程每秒会创建一个新的段,这样会导致短时间内的段数量暴增,而段数目太多会带来较大的麻烦:
- 每一个段都会消耗文件句柄、内存和 cpu 运行周期;
- 更重要的是,每个搜索请求都必须轮流检查每个段,所以段越多,搜索也就越慢。
- 段中已经被删除的文档占用了大量空间,需要清除
Elasticsearch 通过在后台进行段合并来解决这个问题:
- 小的段被合并到大的段,然后这些大的段再被合并到更大的段。
- 段合并的时候会将那些旧的已删除文档 从文件系统中清除。 被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。
Merge的触发方式有2种:
- ES和Lucene会自动进行Merge操作
- 手动执行
POST my_index/_forcemerge
段合并流程
进行索引和搜索时会自动进行段合并:
- 当索引的时候,刷新(refresh)操作会创建新的段并将段打开以供搜索使用。
- 合并进程选择一小部分大小相似的段,并且在后台将它们合并到更大的段中。这并不会中断索引和搜索。
- 合并完成后:
- 新的段被刷新(flush)到了磁盘。写入一个包含新段且排除旧的和较小的段的新提交点。
- 新的段被打开用来搜索。
- 老的段被删除。
optimize API
optimize API 用于手动触发段合并。
将一个分片强制合并到 max_num_segments 参数指定大小的段数目。 这样做的意图是减少段的数量(通常减少到一个),来提升搜索性能。
optimize API 不应该被用在一个活跃的索引上,Elasticsearch 后台会自动触发合并。
在特定情况下,使用 optimize API 颇有益处。例如在日志这种用例下,每天、每周、每月的日志被存储在一个索引中。 老的索引实质上是只读的,它们也并不太可能会发生变化,将历史段合并成一个单独的段就很有用了。
1 | # 合并索引中的每个分片为一个单独的段 |
使用 optimize API 触发段合并的操作不会受到任何资源上的限制。这可能会消耗掉你节点上全部的 I/O 资源, 使其没有余裕来处理搜索请求,从而有可能使集群失去响应。 如果你想要对索引执行
optimize
,你需要先使用分片分配把索引移到一个安全的节点,再执行。
删除和更新索引
段是不可改变的,所以既不能从把文档从旧的段中移除,也不能修改旧的段来进行反映文档的更新。 取而代之的是,每个提交点会包含一个 .del
文件,文件中会列出这些被删除文档的段信息。
当一个文档被 删除 时,它实际上只是在 .del 文件中被标记删除。一个被标记删除的文档仍然可以被查询匹配到,但它会在最终结果被返回前从结果集中移除。
文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。 可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。
重建索引
重建索引一般是索引的元数据发生变更了,但是文档还没更新,此时需要重建索引,让新数据结构可以被搜索到。
需要重建索引的情况:
- 索引的Mappings发生变更,字段类型更改,分词器及字典更新
- 索引的Settings发生变更:索引的主分片数发生改变
- 集群内,汲取间需要做数据迁移
ES内部有2种方法重建索引:
- Update By Query,在现有索引上重建
- Reindex,在其他索引上重建索引
Update By Query
1 | PUT blogs/doc_update/1 |
- 改变Mapping,增加子字段,使用英文分词器
- 虽然数据存在,无法查到结果
- 此时重新插入一条数据,是可以被查到的
1 | # Update所有文档 |
Reindex
Reindex将老索引数据重建到新索引
- 新索引是可以新增、修改字段声明的,而ES本身是不支持对mapping中的字段进行修改的,这也是Reindex的主要意义
- Reindex要求_source字段是enabled的
- 重建索引后,可以通过Index Alias在不停机的情况下取代原来的索引
1 | # 创建新的索引并且设定新的Mapping |
- 上面的Reindex将老的blogs索引下的文档重建到新的blogs_fix
1 | # 重建索引时使用内部版本号 |
查看Reindex执行进度:
1 | GET _tasks?detailed=true&actions=*reindex |
异步操作,执行只返回Task ID:
1 | POST _reindex?wait_for_completion=false |