Elasticsearch-索引写入过程
一、Lucene的写操作及问题
-
没有并发设计: lucene只是一个搜索引擎库,并没有涉及到分布式相关的设计,因此要想使用Lucene来处理海量数据,并利用分布式的能力,就必须在其之上进行分布式的相关设计;
-
非实时: 将文件写入lucence后并不能立即被检索,需要等待lucene生成一个完整的segment才能被检索;
-
数据存储不可靠: 写入lucene的数据不会立即被持久化到磁盘,如果服务器宕机,那存储在内存中的数据将会丢失;
-
不支持部分更新: lucene中提供的updateDocuments仅支持对文档的全量更新,对部分更新不支持。
二、ES写入方案
1. 分布式设计
-
为了支持对海量数据的存储和查询,ES引入分片的概念,一个索引被分为多个分片,每个分片可以有一个主分片和多个副分片;
-
每个分片副本都是一个完整的Lucene实例;
-
分片number计算公式: shard_num = hash(\routing)%num_primary_shards。
2. 近实时性-refresh操作
当一个文档写入Lucene后不能被立即查询到的, ES提供了一个refresh操作,会定时地调用Lucene的reopen(新版的为openChanged)为内存中新写入的数据生成一个新的segment,此时被处理的 文档均可以被检索到。refresh的操作的间隔时间由 refresh_interval来设置,默认为 1s。
3. 数据存储可靠性
-
引入transLog
-
背景:首先一个文档写入Lucene后是存储在内存中的,即使执行了refresh操作,仍然是在文件系统缓存着,如果此时宕机,那么这部分数据就会丢失
-
方案:为此ES增加了transLog,当进行文档写操作时,先写入Lucene中,然后写入一份到transLog,写入transLog是落盘的;(如果可靠性要求不高,
-
可异步落盘,由配置 index.translog.durability和 index.translog.sync_interval控制)。transLog是追加,因此性能比随机写入要好。
-
写入顺序是先写入Lucene,再写入transLog中。
-
-
flush操作
- 另外每30分钟或者transLog达到一定大小(index.translog.flush_threshold_size控制,默认为512m),ES触发一次flush操作,此时ES会先执行 refresh操作将buffer中的数据生成segment,然后调用Lucene的commit方法将所有内存中的segment fsync到磁盘中,此时Lucene中的数据就完成 持久化了,会清空transLog中的数据(6.x不删除)
-
merge操作(额外的)
-
背景:由于refresh默认是 1s刷新一次,所以会参数很多小的segment
-
方案:因此ES会运行一个任务检测当前内存中的segment,对符合条件的segment进行合并,减少segment个数,提高查询速度,降低负载。还可清理已删除和更新的文档。
-
-
多副本机制(额外的)
4. 部分更新
-
背景:Lucene仅支持对文档的整体更新
-
方案:ES为了支持局部更新,在Lucene的store索引中存储了一个_source字段,该字段的key值为文档ID,内容为文档的原文。 当进行更新操作时,先从_source中获取原文,与更新部分进行合并之后,再调用Lucene API进行全量更新。
-
对于写入ES,还没有refresh的数据,从transLog中获取。
-
为了防止多线程修改数据,采用乐观锁,增加了文档_version字段。
三、ES写入流程
1. 基本流程
-
ES的任意节点都可以作为协调节点(coordination node)接收请求,经过一系列后处理
-
然后通过_routing确定primary shard,并将请求发送到primary shard上
-
primary shard完成写入后,将写入数据并发发送给replica
-
replica写入完成之后,告诉primary shard
-
primary shard再将请求返回协调节点。
2. coordinating 节点
ES集群中的任何节点,都可作为协调节点,接收和转发请求。当接收请求后,具体操作如下:
- ingest pipeline:
* 判断请求是否符合某个ingest pipeline的pattern;
* 数据预处理,格式调整、添加字段等;
* 如果当前角色没有ingest角色,发送给有ingest的节点
-
自动创建索引:判断索引是否存在,没有就创建索引
-
设置routing:获取请求URL或mapping中的_routing,如果没有则使用_id,如果没有指定_id,则ES自动生成一个全局唯一ID
-
构建shardBulkRequest:
由于Bulk Request中包含多种(Index/Update/Delete)请求,这些请求分别需要到不同的shard上执行,因此协调节点, 会将请求按照shard分开,同一个shard上的聚合在一起,构建BulkRequest
-
将请求发送给primary shard(写操作)
-
等待primary shard(返回信息)
3. primary shard
Primary的请求入口是PrimaryOperationTransportHandler的MessageReceived,当接收到请求后,具体操作如下:
-
判断请求类型:
- 遍历Bulk请求中的各个子请求,根据不同的操作类型跳转不同的处理逻辑
-
将Update操作转换成Index/Delete请求:
- 先获取当前文档内容,与Update请求内容合并生成新的文档,然后将Update请求变成Index请求,此处文档设置Version v1
-
Parse Doc:解析文档的各个字段,并添加如_uid等ES相关的系统字段
-
更新mapping:
- 对于新增字段会根据dynamic mapping或dynamic template生成对应的mapping, 如果mapping中有dynamic mapping相关设置则按设置处理,如忽略或抛出异常
-
获取sequence ID和Version:
从SequcenceNumberService获取一个sequenceID和Version。SequcenID用于初始化LocalCheckPoint, version是根据当前Versoin+1用于防止并发写导致数据不一致。
- 写入Lucene:
这一步开始会对文档uid加锁,然后判断uid对应的version v2和之前update转换时的version v1是否一致, 不一致则返回第二步重新执行。 如果version一致,如果同id的doc已经存在,则调用lucene的updateDocument接口,
如果是新文档则调用lucene的addDoucument. 这里有个问题,如何保证Delete-Then-Add的原子性,
ES是通过在Delete之前加上已refresh锁,禁止被refresh,只有等待Add完成后释放了Refresh Lock, 这样就保证了这个操作的原子性。
写入transLog:
写入Lucene的Segment后,会以key value的形式写Translog, Key是Id, Value是Doc的内容。当查询的时候,如果请求的是GetDocById则可以直接根据_id从translog中获取。 满足nosql场景的实时性。
flush translog:
默认情况下,translog要在此处落盘完成,如果对可靠性要求不高,可以设置translog异步, 那么translog的fsync将会异步执行,但是落盘前的数据有丢失风险。
-
发送请求给replicas:
- 将构造好的bulkrequest并发发送给各replicas,等待replica返回,这里需要等待所有的replicas返回,响应请求给协调节点。如果某个shard执行失败, 则primary会给master发请求remove该shard。这里会同时把sequenceID, primaryTerm, GlobalCheckPoint等传递给replica。
-
等待replicas响应:
- 当所有的replica返回请求时,更新primary shard的LocalCheckPoint。
4. replicas shard
-
Replica 请求的入口是在ReplicaOperationTransportHandler的messageReceived,当replica shard接收到请求时执行如下流程:
-
判断操作类型 replica收到的写入请求只会有add和delete,因update在primary shard上已经转换为add或delete了。根据不同的操作类型执行对应的操作
-
Parse Doc
-
更新mapping
-
获取sequenceId和Version 直接使用primary shard发送过来的请求中的内容即可
-
写如lucene
-
write Translog
-
Flush translog
五、client客户端中BulkProcessor详细使用
- BulkProcessor设计模式
BulkProcessor将创建bulkRequest对象的过程和时机以及批量执行请求的过程和时机封装了起来, 我们不必手动去调用client.bulk()来执行批量请求,只需要将请求add到BulkProcessor中(BulkProcessor中维护一个bulkRequest), BulkProcessor“满了”就自动执行请求然后重新创建一个bulkRequest,以此循环往复,最后手动调用awaitClose执行所有请求并释放资源。
整个执行请求的过程和时机对于用户来说是完全透明的,我们不必关心什么时候执行请求以及具体怎么执行。 BulkProcessor.builder使用了构建者模式,将consumer和listener作为“原料”投入后调用build来定制一个BulkProcessor。
BulkRequest使用了类似于模板方法的模式理念,将创建BulkRequest和客户端执行请求这两步封装了起来, 用户只需要将请求add到BulkProcessor中即可。
BulkRequest,BulkProcessor对比总结
BulkRequest整个使用过程“循规蹈矩”,从创建BulkRequest到add请求到客户端执行请求按顺序走。
BulkProcessor中维护了一个BulkRequest对象。总的来说,BulkProcessor将创建BulkRequest和客户端执行请求这两步封装了起来,
用户只需要将请求add到BulkProcessor中即可,最后手动调用关闭完成整个请求。