本文主要有下面几个内容:
- 文档索引创建
- ES里的translog
- 并发下索引update操作
- 并发下的索引检索
- 查询三阶段
文档索引步骤
- 客户端向node1发送新建,查询或删除请求。
- 节点使用文档的_id确定文档属于分片0,请求会被转发到node3,因为分片0的主分片目前被分配在node3上
- node3在主分片上面执行请求,如果成功了,它会将请求并行转化到node1与node2的副本分片上,一旦所有的副本分片都报告成功,node3将向协调节点报告成功,协调节点向客户端报告成功。
上面是按单个文档操作的,多个文档在使用bulk操作时和上面流程差不多,这里不再多说。
文档索引过程详解
整体流程图。
协调节点默认使用文档ID参与计算(也支持通过routing), 以便为路由提供合适的分片。
shard = hash(document_id) % (num_of_primary_shards)
- 当分片所在的节点接收到来自协调节点的请求后,会将请求写入到memory buffer,然后定时(默认1秒)写入到filesystem cache(操作系统文件缓存,这里不由JVM管理),从memory buffer到filesystem cache的过程就叫refresh。需要注意的是数据写入memory buffer后并不以马上被检索,而只有经过refresh写入到filesystem caceh后也就是写入到segment之后,此时文档才可以被检索到。
- 因为memory buffer与filesystem cache都还未写入磁盘所以会有丢失的可能。ES是通过translog机制来保证数据的可靠性的。在接收到请求后,同时也会写入到translog,当filesystem cache中的数据写入到磁盘后才会清除translog里的数据,这个过程称flush。flush是定时触发(默认30分钟)或translog变得太大(默认为512MS)。
ES里的translog
ES为了减少磁盘IO保证读写性能,一般是每隔一段时间(比如5分钟)才会将segment写入磁盘持久化,对于还未flush到磁盘的数据,如果发生宕机或掉电,那么内存里的数据是会丢失的,ES是如何保证数据的可靠性的呢?这里我们来说下translog。
在每个shard中,写入流程分两部分,先写入lucene,再写入到translog。写完lucene文件创建好索引后,此时索引还在内存里,接着去写translog,写完translog后,默认5S(可配置)将translog数据fsync到磁盘上,请求返回用户。这里有几个关键点:
- 一是和数据库不同,数据库是先写commitlog,然后再写内存,而ES是先写内存再写translog,一种可能原因是lucene内存写入有很复杂的逻辑,容易失败,比如分词,字段长度超限等,为了避免translog里有大量无效记录,就将写内存放到了前面
- 二是写入内存后,并不是可搜索的,需要通过refresh将内存的对象转换成完整的segment后,然后再次reopen后才能被搜索,一般这个时间设置为1秒,这也是ES被称为NRT(near real time)的原因
- 三是当ES作为nosql数据库时,查询方式是getDocById,这种查询可以直接从translog中查询(translog是以key/value形式写入的,key是_id,value是Doc内容),这时就成了RT实时系统了。
- 四是每隔一段较长时间,比如30分钟后,lucene会将内存中生成的segment刷新到磁盘上,刷新后的索引文件已经持久化了,历史的translog会被清掉
- flush是把内存中的数据(包括translog和segments)都刷到磁盘,而fsync只是把translog刷新的磁盘,也就是说系统掉电的情况下es最多会丢失5秒钟的数据。
并发下的update流程
ES使用版本号这种乐观锁的机制处理并发修改问题,ES保证了一个老版本的数据永远无法重写或覆盖更新版本的数据,如果因版本号冲突修改失败可以使用retry_on_conflict参数设定重试次数。流程如下:
- 收到update请求后,从segment或者translog中读取同id的Doc,并获取此时版本号。
- 将第1步版本号对应的全量Doc和请求中的部分字段合并为一个完整的Doc,同时更新内存中的versionMap。这个时候update请求相当于一个index请求了。
- 加锁。
- 再次从versionMap中读取该id最大版本号,如果versionMap没有,则从segment或translog里读。
- 检查版本号是否冲突(检查第1步与第4步的版本号是否相同,相同为不冲突,不相同为冲突),如果冲突则回退到开始的update doc阶段重新执行;如果没冲突则执行最新的add请求。
- 在index doc阶段,首先将version+1,再将doc加入到lucene中,lucene会先删除同id下已存在的doc id,然后再增加新doc,写入lucene成功后,将更新后的版本号更新到versionMap。
- 释放锁,部分更新流程结束。
并发下的读
ES通过分区实现分布式,数据写入的时候根据routing规则将数据写入某一个shard中,这样就能将海量数据分布在多个shard及多台机器上,从而达到分布式的目标。所以查询的时候需要将查询请求分发给相差shard,各shard将查询结果汇总到client node上,由client node通过优先级队列进行二次排序,最终确定最后结果返回给用户。
- 在初始查询阶段时,查询会广播到索引中每一个分片,每个分片在本地执行搜索并构建一个匹配文档大小为from+size的优先队列
- 每个分片返回各自优先队列中所有文档的ID和排序值给协调节点,协调节点合并这些值到自己的优先级队列来产生一个全局排序后的结果列表
- 接下来就是取回阶段,协调节点辨别出哪些文档需要被取回并向相差的分片提交多个GET请求,每个分片加载并丰富文档然后返回文档给协调节点,一旦所有的文档都被取回,协调节点返回结果给客户端。
查询三阶段
大部分搜索系统一般都是两阶段查询,第一阶段查询到匹配的DocId,第二阶段再查询DocId对应的完整文档,这种在ES中称为query_then_fetch,还有一种是一阶段查询的时候就返回完整Doc,这种ES叫query_and_fetch,一般第二种适用于只需要查询一个shared的请求。
下图是一个二阶段查询示意图。
除了上面说的两种,还有一种三阶段查询的情况,搜索里面有一种算分逻辑是根据TF(term frequency)和DF(document frequency)计算基础分,但是ES中查询的时候,是在每个shared中独立查询的,每个shared中的TF和DF也是独立的,虽然在写入的时候通过routing保证Doc分布均匀,但是没汉保证TF和DF均匀,就会有局部的TF和DF不准的情况出现,这个时候基于TF,DF的算分就不准,为了应对这个问题,ES引入了DFS查询,比如DFS_query_the_fetch会先收集所有shared中的TF和DF值,然后将这些请求带入请求中,再次执行query_then_fetch,这样算分的时候TF和DF就是准确的。
特性总结
- 可靠性:由于lucene的设计不考虑可靠性,在ES中通过replica和translog两套机制保证数据的可靠性
- 一致性:lucence中的flush锁只保证update接口里的delete和add中间不会flush,但是add完成后仍然有可能立即发生flush,导致segment可读,这样就没法保证primary和其他replica可以同一时间flush,进而出现查询不稳定的情况,这里只能实现最终一致性。
- 原子性:add与delete都是直接调用lucene的接口,是原子的。当部分更新时,使用version和锁保证更新是原子的。
- 隔离性:仍然采用version和局部锁来保住更新的是特定版本的数据
- 实时性:使用定期refresh segment到内存,并且reopen segment方式保证搜索可以在较短时间(比如1秒)内被搜索到。通过将未刷新到磁盘数据记入translog,保证对未提交数据可以通过ID实时访问到
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。