ES:写入原理详解

实时性

Elasticsearch的主要应用场景就是实时,但Elasticsearch本身并非实时而是near-real-time(近实时)。Index的实时性是由refresh控制的,默认是1s,最快可到100ms,那么也就意味着Index doc成功后,需要等待一秒钟后才可以被搜索到。

Elasticsearch中的Get请求也能保证是实时的,因为Get请求会直接读内存中尚未Flush到磁盘的TransLog。但是Get请求只支持通过doc_id进行查询,所以对于条件查询依然无法实现实时。

可靠性
搜索系统对可靠性要求都不高,一般数据的可靠性通过将原始数据存储在另一个存储系统来保证,当搜索系统的数据发生丢失时,再从其他存储系统导一份数据过来重新rebuild就可以了。在Elasticsearch中,通过设置TransLog的Flush频率可以控制可靠性,要么是按请求,每次请求都Flush;要么是按时间,每隔一段时间Flush一次。一般为了性能考虑,会设置为每隔5秒或者1分钟Flush一次,Flush间隔时间越长,可靠性就会越低。

Elasticsearch的写入

Elasticsearch采用多Shard方式,通过配置routing规则将数据分成多个数据子集,每个数据子集提供独立的索引和搜索功能。当写入文档的时候,根据routing规则,将文档发送给特定Shard中建立索引。这样就能实现分布式了。

此外,Elasticsearch整体架构上采用了一主多副的方式:
ES:写入原理详解_第1张图片

每个Index由多个Shard组成(默认是5个),每个Shard有一个主节点和多个副本节点,副本个数可配。但每次写入的时候,写入请求会先根据_routing规则选择发给哪个Shard,Index Request中可以设置使用哪个Filed的值作为路由参数,如果没有设置,则使用Mapping中的配置,如果mapping中也没有配置,则使用_id作为路由参数,然后通过_routing的Hash值选择出Shard(在OperationRouting类中),最后从集群的Meta中找出出该Shard的Primary节点。

请求接着会发送给Primary Shard,在Primary Shard上执行成功后,再从Primary Shard上将请求同时发送给多个Replica Shard,请求在多个Replica Shard上执行成功并返回给Primary Shard后,写入请求执行成功,返回结果给客户端。

在写入时,我们可以在Request自己指定_routing,也可以在Mapping指定文档中的Field值作为_routing。如果没有指定_routing,则会把_id作为_routing进行计算。由于写入时,具有相同_routing的文档一定会分配在同一个分片上,所以如果是自定义的_routing,在查询时,一定要指定_routing进行查询,否则是查询不到文档的。这并不是局限性,恰恰相反,指定_routing的查询,性能上会好很多,因为指定_routing意味着直接去存储数据的shard上搜索,而不会搜索所有shard。

这种模式下,写入操作的延时就等于latency = Latency(Primary Write) + Max(Replicas Write)。只要有副本在,写入延时最小也是两次单Shard的写入时延总和,写入效率会较低,但是这样的好处也很明显,避免写入后,单机或磁盘故障导致数据丢失,在数据重要性和性能方面,一般都是优先选择数据,除非一些允许丢数据的特殊场景。

从上述可以看出,Elasticsearch文档写入主要是写主分片和写副本分片。所以副本分片的个数就直接决定了写入的性能。合理配置副本数,在性能和安全之间取得平衡。

采用多个副本后,避免了单机或磁盘故障发生时,对已经持久化后的数据造成损害,但是Elasticsearch里为了减少磁盘IO保证读写性能,一般是每隔一段时间(比如5分钟)才会把Lucene的Segment写入磁盘持久化,对于写入内存,但还未Flush到磁盘的Lucene数据,如果发生机器宕机或者掉电,那么内存中的数据也会丢失,这时候如何保证?

对于这种问题,Elasticsearch学习了数据库中的处理方式:增加CommitLog模块,Elasticsearch中叫TransLog。
ES:写入原理详解_第2张图片
在每一个Shard中,写入流程分为两部分,先写入Lucene,再写入TransLog。

写入请求到达Shard后,先写Lucene文件,创建好索引,此时索引还在内存里面,接着去写TransLog,写完TransLog后,刷新TransLog数据到磁盘上,写磁盘成功后,请求返回给用户。这里有几个关键点:

  1. 和数据库不同,数据库是先写CommitLog,然后再写内存,而Elasticsearch是先写内存,最后才写TransLog,一种可能的原因是Lucene的内存写入会有很复杂的逻辑,很容易失败,比如分词,字段长度超过限制等,比较重,为了避免TransLog中有大量无效记录,减少recover的复杂度和提高速度,所以就把写Lucene放在了最前面。

  2. 写Lucene内存后,并不是可被搜索的,需要通过Refresh把内存的对象转成完整的Segment后,然后再次reopen后才能被搜索,一般这个时间设置为1秒钟,导致写入Elasticsearch的文档,最快要1秒钟才可被从搜索到,所以Elasticsearch在搜索方面是NRT(Near Real Time)近实时的系统。

  3. 当Elasticsearch作为NoSQL数据库时,查询方式是GetById,这种查询可以直接从TransLog中查询,这时候就成了RT(Real Time)实时系统。

  4. 每隔一段比较长的时间,比如30分钟后,Lucene会把内存中生成的新Segment刷新到磁盘上,刷新后索引文件已经持久化了,历史的TransLog就没用了,会清空掉旧的TransLog。

Elasticsearch通过TransLog和副本来保障segment的安全性

Lucene缓存中的数据默认1秒之后才生成segment文件,即使是生成了segment文件,这个segment是写到页面缓存中的,并不是实时的写到磁盘,只有达到一定时间或者达到一定的量才会强制flush磁盘。如果这期间机器宕掉,内存中的数据就丢了。如果发生这种情况,内存中的数据是可以从TransLog中进行恢复的,TransLog默认是每5秒都会刷新一次磁盘。但这依然不能保证数据安全,因为仍然有可能最多丢失TransLog中5秒的数据。这里可以通过配置增加TransLog刷磁盘的频率来增加数据可靠性,最小可配置100ms,但不建议这么做,因为这会对性能有非常大的影响。一般情况下,Elasticsearch是通过副本机制来解决这一问题的。即使主分片所在节点宕机,丢失了5秒数据,依然是可以通过副本来进行恢复的。

Elasticsearch的更新

ES:写入原理详解_第3张图片

Lucene中不支持部分字段的Update,所以需要在Elasticsearch中实现该功能,具体流程如下:

  1. 收到Update请求后,从Segment或者TransLog中读取同id的完整Doc,记录版本号为V1。
  2. 将版本V1的全量Doc和请求中的部分字段Doc合并为一个完整的Doc,同时更新内存中的VersionMap。获取到完整Doc后,Update请求就变成了Index请求。
  3. 加锁。
  4. 再次从versionMap中读取该id的最大版本号V2,如果versionMap中没有,则从Segment或者TransLog中读取,这里基本都会从versionMap中获取到。
  5. 检查版本是否冲突(V1==V2),如果冲突,则回退到开始的“Update doc”阶段,重新执行。如果不冲突,则执行最新的Add请求。
  6. 在Index Doc阶段,首先将Version + 1得到V3,再将Doc加入到Lucene中去,Lucene中会先删同id下的已存在doc id,然后再增加新Doc。写入Lucene成功后,将当前V3更新到versionMap中。
  7. 释放锁,部分更新的流程就结束了。

Elasticsearch写入请求流程

Elasticsearch的写入请求主要包括:index、create、update、delete、bulk。bulk是实现对前四种的批量操作。在6.x版本以后实际上走的都是bulk接口了。

ES:写入原理详解_第4张图片

  • 红色:Client Node(客户端节点)
  • 绿色:Primary Node(主分片节点)
  • 蓝色:Replica Node(副本分片节点)

注册Action

在Elasticsearch中,所有action的入口处理方法都是注册在ActionModule.java中,比如Bulk Request有两个注册入口,分别是Rest和Transport入口:
在这里插入图片描述
在这里插入图片描述

对于Rest请求,会在RestBulkAction中解析请求,并最终转成TransportAction处理。
TransportShardBulkAction的基类TransportReplicationAction中注册了对Primary,Replica等的不同处理入口:
ES:写入原理详解_第5张图片
这里对原始请求,Primary Node请求和Replica Node请求各自注册了一个handler处理入口。

由源码可知我们通过Rest请求Elasticsearch最终还是会转换成Transport方式,但官方并不建议直接采用Transport去与Elasticsearch交互,在新版本和未来版本中也是大力推荐使用Rest请求。

Client Node
客户端节点,用来接受请求和解析请求。

  1. Ingest Pipeline
    在这一步可以对原始文档做一些处理,比如HTML解析,自定义的处理,具体处理逻辑可以通过插件来实现。在Elasticsearch中,由于Ingest Pipeline会比较耗费CPU等资源,可以设置专门的Ingest Node,专门用来处理Ingest Pipeline逻辑。

    如果当前Node不能执行Ingest Pipeline,则会将请求发给另一台可以执行Ingest Pipeline的Node。

  2. Auto Create Index
    判断当前Index是否存在,如果不存在,则需要自动创建Index,这里需要和Master交互。也可以通过配置关闭自动创建Index的功能。

  3. Set Routing
    设置路由条件,如果Request中指定了路由条件,则直接使用Request中的Routing,否则使用Mapping中配置的,如果Mapping中无配置,则使用默认的_id字段值。

    在这一步中,如果没有指定id字段,则会自动生成一个唯一的_id字段,目前使用的是UUID。

  4. Construct BulkShardRequest
    由于Bulk Request中会包括多个(Index/Update/Delete)请求,这些请求根据routing可能会落在多个Shard上执行,这一步会按Shard挑拣Single Write Request,同一个Shard中的请求聚集在一起,构建BulkShardRequest,每个BulkShardRequest对应一个Shard。

  5. Send Request To Primary
    这一步会将每一个BulkShardRequest请求发送给相应Shard的Primary Node。

Primary Node
主分片所在节点,Primary 请求的入口是在PrimaryOperationTransportHandler的messageReceived。
ES:写入原理详解_第6张图片

  1. Index or Update or Delete
    循环执行每个Single Write Request,对于每个Request,根据操作类型(CREATE/INDEX/UPDATE/DELETE)选择不同的处理逻辑。

    其中,Create/Index是直接新增Doc,Delete是直接根据_id删除Doc,Update会稍微复杂些,我们下面就以Update为例来介绍。

  2. Translate Update To Index or Delete
    这一步是Update操作的特有步骤,在这里,会将Update请求转换为Index或者Delete请求。首先,会通过GetRequest查询到已经存在的同_id Doc(如果有)的完整字段和值(依赖_source字段),然后和请求中的Doc合并。同时,这里会获取到读到的Doc版本号,记做V1。

  3. Parse Doc
    这里会解析Doc中各个字段。生成ParsedDocument对象,同时会生成uid Term。在Elasticsearch中,_uid = type # _id,对用户,_Id可见,而Elasticsearch中存储的是_uid。这一部分生成的ParsedDocument中也有Elasticsearch的系统字段,大部分会根据当前内容填充,部分未知的会在后面继续填充ParsedDocument。

  4. Update Mapping
    Elasticsearch中有个自动更新Mapping的功能,就在这一步生效。会先挑选出Mapping中未包含的新Field,然后判断是否运行自动更新Mapping,如果允许,则更新Mapping。

  5. Get Sequence Id and Version
    由于当前是Primary Shard,则会从SequenceNumber Service获取一个sequenceID和Version。SequenceID在Shard级别每次递增1,SequenceID在写入Doc成功后,会用来初始化LocalCheckpoint。Version则是根据当前Doc的最大Version递增1。

  6. Add Doc To Lucene
    这一步开始的时候会给特定_uid加锁,然后判断该_uid对应的Version是否等于之前Translate Update To Index步骤里获取到的Version,如果不相等,则说明刚才读取Doc后,该Doc发生了变化,出现了版本冲突,这时候会抛出一个VersionConflict的异常,该异常会在Primary Node最开始处捕获,重新从“Translate Update To Index or Delete”开始执行。

    如果Version相等,则继续执行,如果已经存在同id的Doc,则会调用Lucene的UpdateDocument(uid, doc)接口,先根据uid删除Doc,然后再Index新Doc。如果是首次写入,则直接调用Lucene的AddDocument接口完成Doc的Index,AddDocument也是通过UpdateDocument实现。

    这一步中有个问题是,如何保证Delete-Then-Add的原子性,怎么避免中间状态时被Refresh?答案是在开始Delete之前,会加一个Refresh Lock,禁止被Refresh,只有等Add完后释放了Refresh Lock后才能被Refresh,这样就保证了Delete-Then-Add的原子性。

    Lucene的UpdateDocument接口中就只是处理多个Field,会遍历每个Field逐个处理,处理顺序是invert index,store field,doc values,point dimension,后续会有文章专门介绍Lucene中的写入。

  7. Write Translog
    写完Lucene的Segment后,会以keyvalue的形式写TransLog,Key是_id,Value是Doc内容。当查询的时候,如果请求是GetDocByID,则可以直接根据_id从TransLog中读取到,满足NoSQL场景下的实时性要去。

    需要注意的是,这里只是写入到内存的TransLog,是否Sync到磁盘的逻辑还在后面。

    这一步的最后,会标记当前SequenceID已经成功执行,接着会更新当前Shard的LocalCheckPoint。

  8. Renew Bulk Request
    这里会重新构造Bulk Request,原因是前面已经将UpdateRequest翻译成了Index或Delete请求,则后续所有Replica中只需要执行Index或Delete请求就可以了,不需要再执行Update逻辑,一是保证Replica中逻辑更简单,性能更好,二是保证同一个请求在Primary和Replica中的执行结果一样。

  9. Flush Translog
    这里会根据TransLog的策略,选择不同的执行方式,要么是立即Flush到磁盘,要么是等到以后再Flush。Flush的频率越高,可靠性越高,对写入性能影响越大。

  10. Send Requests To Replicas
    这里会将刚才构造的新的Bulk Request并行发送给多个Replica,然后等待Replica的返回,这里需要等待所有Replica返回后(可能有成功,也有可能失败),Primary Node才会返回用户。如果某个Replica失败了,则Primary会给Master发送一个Remove Shard请求,要求Master将该Replica Shard从可用节点中移除。

    这里,同时会将SequenceID,PrimaryTerm,GlobalCheckPoint等传递给Replica。

    发送给Replica的请求中,Action Name等于原始ActionName + [R],这里的R表示Replica。通过这个[R]的不同,可以找到处理Replica请求的Handler。

  11. Receive Response From Replicas
    Replica中请求都处理完后,会更新Primary Node的LocalCheckPoint。

Replica Node
Replica 请求的入口是在ReplicaOperationTransportHandler的messageReceived,我们来看一下相关的逻辑流程。
ES:写入原理详解_第7张图片

  1. Index or Delete
    根据请求类型是Index还是Delete,选择不同的执行逻辑。这里没有Update,是因为在Primary Node中已经将Update转换成了Index或Delete请求了。

  2. Parse Doc
    同上。

  3. Update Mapping
    以上都和Primary Node中逻辑一致。

  4. Get Sequence Id and Version
    Primary Node中会生成Sequence ID和Version,然后放入ReplicaRequest中,这里只需要从Request中获取到就行。

  5. Add Doc To Lucene
    由于已经在Primary Node中将部分Update请求转换成了Index或Delete请求,这里只需要处理Index和Delete两种请求,不再需要处理Update请求了。比Primary Node会更简单一些。

  6. Write Translog
    同上

  7. Flush Translog
    以上都和Primary Node中逻辑一致。

document写入原理(buffer,segment,commit)

ES:写入原理详解_第8张图片

  • segment file: 存储逆向索引的文件,每个segment本质上就是一个逆向索引,每秒都会生成一个segment文件,当文件过多时es会自动进行segment merge(合并文件),合并时会同时将已经标注删除的文档物理删除
  • commit point(重点理解): 记录当前所有可用的segment,每个commit point都会维护一个.del文件(es删除数据本质是不属于物理删除),当es做删改操作时首先会在.del文件中声明某个document已经被删除,文件内记录了在某个segment内某个文档已经被删除,当查询请求过来时在segment中被删除的文件是能够查出来的,但是当返回结果时会根据commit point维护的那个.del文件把已经删除的文档过滤掉
  • translog日志文件: 为了防止elasticsearch宕机造成数据丢失保证可靠存储,es会将每次写入数据同时写到translog日志中(图中会有详解)。

ES:写入原理详解_第9张图片

  1. 数据写入buffer缓冲和translog日志文件
  2. 每隔一秒钟,buffer中的数据被写入新的segment file,并进入os cache,此时segment被打开并供search使用
  3. buffer被清空
  4. 重复1~3,新的segment不断添加,buffer不断被清空,而translog中的数据不断累加
  5. 当translog长度达到一定程度的时候,commit操作发生
    • buffer中的所有数据写入一个新的segment,并写入os cache,打开供使用
    • buffer被清空
    • 一个commit ponit被写入磁盘,标明了所有的index segment
    • filesystem cache中的所有index segment file缓存数据,被fsync强行刷到磁盘上
    • 现有的translog被清空,创建一个新的translog

Elasticsearch的写入流程主要可分为几大特性。

  1. 可靠性,通过Replica和TransLog两套机制保证数据的可靠性
  2. 一致性,Lucene中的Flush锁只保证Update接口里面Delete和Add中间不会Flush,但无法保证主分片与副本分片一致。因为如果add之后立即flush,这个时候segment是主分片可见的,但副本分片要落后于主分片。不过最终都会一致。
  3. 原子性,Add和Delete具有原子性。当部分更新时,使用Version和锁保证更新是原子的
  4. 实时性,Flush之后的segment对用户可见,最快可配置100ms,可实现near-real-time。特定的查询,直接查TransLog,可实现real-time。
  5. 隔离性:仍然采用Version和局部锁来保证更新的是特定版本的数据。
  6. 性能,在很多地方的设计都考虑到了性能
    • 不需要所有Replica都返回后才能返回给用户,只需要返回特定数目的就行.
    • 生成的Segment现在内存中提供服务,等一段时间后才刷新到磁盘,Segment在内存这段时间的可靠性由TransLog保证.
    • TransLog可以配置为周期性的Flush,但这个会给可靠性带来伤害.
    • 每个线程持有一个Segment,多线程时相互不影响,相互独立,性能更好.
    • 系统的写入流程对版本依赖较重,读取频率较高,因此采用了versionMap,减少热点数据的多次磁盘IO开销。

你可能感兴趣的:(Elasticsearch)