很多大数据组件在快速原型时期都是Java实现,后来因为GC不可控、内存或者向量化等等各种各样的问题换到了C++,比如zookeeper->nuraft(https://www.yuque.com/treblez/qksu6c/hu1fuu71hgwanq8o?singleDoc# 《olap/clickhouse keeper 一致性协调服务》),kafka->redpanda(https://www.yuque.com/treblez/qksu6c/ugig8y358fyyg5lp?singleDoc# 《Clickhouse blob阅读笔记(一)》)之类的。
但是nuraft和redpanda估计大部分人都没听说过,因为绝大多数情况下,zk和kafka这种也就足够了。数据量越大,Java和C++的性能差别也就越小。
Elasticsearch这里也是一样,它也有C++的替代品manticoresearch,可以看看官方的测试结果(https://github.com/manticoresoftware/manticoresearch),提升还是挺大的。
同样,大多数情况下选型还得是Elasticsearch,因为这东西生态好,省去了大量踩坑的成本,最关键的是性能也足够了,百万数据几十毫秒延迟,换C++意义也不大。所以我们今天不看manticoresearch,还是学一下Elasticsearch。
ES接近三百万行代码,那肯定不能直接看,本文主要还是结合《Elasticsearch源码解析与优化实战》梳理下它在分布式、数据结构、搜索方面用到的一些核心的东西。ES其实是对lucene的分布式改造,在上面加了一些共识、主备、选举、高可用之类的东西。
顺带说一下,它和CK的场景是不同的,ES主要面对非结构化数据查询,CK面对的是OLAP场景。
索引
es由_index、_type和_id唯一标识一个文档。
_index(索引)指向一个或多个物理分片的逻辑命名空间,_type类型用于区分同一个集合中的不同细分,在不同的细分中,数据的整体模式是相同或相似的,不适合完全不同类型的数据。多个_type可以在相同的索引中存在,只要它们的字段不冲突即可(对于整个索引,映射在本质上被“扁平化”成一个单一的、全局的模式)。_id文档标记符由系统自动生成或使用者提供。
索引和分片的关系:
一个ES Index(索引)包含很多shard(分片),一个shard是一个Lucene索引,Lucene索引由很多分段组成,每一个分段都是一个倒排索引。Lucene索引可以独立执行建立索引和搜索的任务。ES每次“refresh”都会生成一个新的分段,其中包含若干文档的数据。在每个分段内部,文档的不同字段被单独建立索引。每个字段的值由若干词(Term)组成,Term是原文本内容经过分词器处理和语言处理后的最终结果(例如,去除标点符号和转换为词根)。
ES通过文件页缓存的flush机制(可以看https://www.yuque.com/treblez/qksu6c/yxl59pkvczqot9us?singleDoc# 《ptmalloc:从内存虚拟化说起》)实现了近实时查询。每秒产生一个新分段,新段先写入文件系统缓存,但稍后再执行flush刷盘操作,写操作很快会执行完,一旦写成功,就可以像其他文件一样被打开和读取了。
近实时查询其实就是最终一致,最终一致要保证没有易失性,ES通过事务日志做到了这一点。
段合并
ES段合并
在ES中,每秒清空一次写缓冲,将这些数据写入文件,这个过程称为refresh,每次refresh会创建一个新的Lucene 段。但是分段数量太多会带来较大的麻烦,**每个段都会消耗文件句柄、内存。每个搜索请求都需要轮流检查每个段,查询完再对结果进行合并;所以段越多,搜索也就越慢。**因此需要通过一定的策略将这些较小的段合并为大的段,常用的方案是选择大小相似的分段进行合并。在合并过程中,标记为删除的数据不会写入新分段,当合并过程结束,旧的分段数据被删除,标记删除的数据才从磁盘删除。
HBase、Cassandra等系统都有类似的分段机制,写过程中先在内存缓冲一批数据,不时地将这些数据写入文件作为一个分段,分段具有不变性,再通过一些策略合并分段。分段合并过程中,新段的产生需要一定的磁盘空间,我们要保证系统有足够的剩余可用空间。Cassandra系统在段合并过程中的一个问题就是,**当持续地向一个表中写入数据,如果段文件大小没有上限,当巨大的段达到磁盘空间的一半时,剩余空间不足以进行新的段合并过程。**如果段文件设置一定上限不再合并,则对表中部分数据无法实现真正的物理删除。ES存在同样的问题。
LSM-Tree段合并
我们都知道,LSM-Tree脱胎于BigTable,LevelDB,RocksDB,HBase,Cassandra等都是基于LSM结构。这里既然提到了HBase、Cassandra,那就不得不看下LSM-Tree的段合并了。
我们回顾下LSM-Tree的读写过程:
- 写入时,数据会被添加到内存中的平衡树数据结构(例如,红黑树)。这个内存树被称为内存表(memtable)。 当内存表大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。这可以 高效地完成,因为树已经维护了按键排序的键值对。新的SSTable文件成为数据库的最新 部分。当SSTable被写入磁盘时,写入可以继续到一个新的内存表实例。
- 读取时首先尝试在内存表中找到关键字,然后在最近的磁盘段中,然后在 下一个较旧的段中找到该关键字。
(下面来自https://zhuanlan.zhihu.com/p/462850000)
sst 是不可修改的,数据的更新和删除都是以写入新记录的形式呈现。数据在文件中是按 key 有序组织的,利于高效地查询和后续合并。(可以参考数据密集型应用系统设计第三章)
随着数据的不断写入和更新,sst 的数量会不断增加,进而会出现两个问题:
- sst 中可能存在修改前的老数据和已经删除的数据,这些无用数据会占用存储空间,造成资源浪费;
- 由于 sst 越来越多,数据分散在多个文件,读取时,可能会访问多个文件,导致读性能下降。对于上述问题,需要一些机制去解决,这种机制就称为 compaction,compaction 的目的是将多个 sst 合并成一个,在合并的同时将无用的数据清理掉,合并成的新文件也是按 key 排序的。可以看到,通过 compaction,很好地解决上述问题。
**Size-Tiered Compaction Strategy (STCS) **
memtable 逐步刷入到磁盘 sst,刚开始 sst 都是小文件,随着小文件越来越多,当数据量达到一定阈值时,STCS 策略会将这些小文件 compaction 成一个中等大小的新文件。同样的道理,当中等文件数量达到一定阈值,这些文件将被 compaction 成大文件,这种方式不断递归,会持续生成越来越大的文件。总的来说,STCS 就是将 sst 按大小分类,相似大小的 sst 分在同一类,然后将多个同类的 sst 合并到下一个类别。通过这种方式,可以有效减少 sst 的数量。
由于 STCS 策略比较简单,同一份数据在 compaction 期间拷贝的次数相对较少,即写入放大相对小,很多基于 LSM-Tree 的系统将其作为默认的 compaction 策略,如 Lucene、Cassandra、Scylla 等。STCS 逻辑简单、写入放大低,但是它也有很大的缺陷 – 空间放大。其实也存在较大的读放大,
为什么会产生空间放大呢?compaction 的过程中,参与 compaction 的 sst 不能立马删除,直到新生成的 sst 写入完毕,这里其实还有一个原因,如果老的 sst 有读操作,由于文件还被引用,也是不能立即删除的。因此,在 compaction 的过程中,磁盘上新老文件共存,产生临时空间放大。即使这种空间放大是临时的,但是对于系统来说,不得不使用比实际数据量更大的磁盘空间,以保证 compaction 正常执行,这产生的代价很昂贵。
这也就是上面一节提到的那个问题。
**Leveled Compaction Strategy (LCS) **
- sst 的大小可控,默认每个 sst 的大小一致(STCS 在经过多次合并后,层级越深,产生的 sst 文件就越大,最终会形成超大文件)
- LCS 在合并时,会保证除 Level 0(L0)之外的其他 Level 有序且无覆盖
- 除 L0 外,每层文件的总大小呈指数增长,假如 L1 最多 10 个,则 L2 为 100 个,L3 为 1000 个…
首先是内存中的 memtable 刷到 L0,当 L0 中的文件数达到一定阈值后,会将 L0 的所有文件及与 L1 有覆盖的文件做合并,然后生成新文件(如果文件大小超过阈值,会切成多个)到 L1,L1 中的文件时全局有序的,不会出现重叠的情况;
当 L1 的文件数量达到阈值时,会选取 L1 中的一个 sst 与 L2 中的多个文件做合并,假设 L1 有 10 个文件,那么一个文件便占 L1 数据量的 1/10,假设每层包含的 key 范围相同,那么 L1 中的一个文件理论上会覆盖 L2 层的 10 个文件,因此会选取 L1 中的一个文件与 L2 中的 10 个文件一起 compaction,将生成的新文件放到 L2;
LCS 不会有超大文件,而且在层与层之间合并时,大体上只选取 11 个 sst 进行 compaction,而这 11 个文件的大小只占整个系统的小部分,因此临时空间放大很小。
LCS 最好的情况是,最后一层数据已经被填满。假设最后一层为 L3,一共有 1000 个 sst。那么 L1 和 L2 一共最多 110 个文件。由于每个 sst 基本大小相同,因此,几乎 90% 的数据在 L3。我们直到每层内的数据不会重复,因此最多,L1 和 L2 的数据包含在 L3 中,重复数据导致的空间放大为 1/0.9=1.11,
相比STCS 8 倍的空间放大好得多。然后,也存在比较差的情况,即当最后一层没有填满时,假设 L3 的文件个数为 100 个,L2 中的文件数也是 100 个,最坏情况下,如果 L2 和 L3 的数据相同,则会产生 2 倍的空间放大。但即使是这样,相对 STCS 还是好得多。
LCS存在写放大问题,写放大指的是IO写带宽的放大
对于 STCS 来说,写入放大和层高强相关,而每层的数据量又是呈指数增长的(即第一层最多 400MB,第二层 1600MB,第三层6400MB,一次类推),很明显层高和数据量的关系为对数关系,而写入放大和层高一致,因此写入放大随数据量增长为 O(logN),N 为数据量大小。
但是对于 LCS 来说,情况会更糟糕,我们来分析下原因:假设需要将 L1 层的数据量 X compaction 到下一层,STCS 的写如放大为 1。而 LCS 需要将这 X 的数据量和 L1 层 10倍 X 数据量,一共 11 * X 一起 compaction,写入放大为 11,是 STCS 的 11 倍。
如果大部分都是写新数据建议stcs 如果更新频繁那么选lcs
比较奇怪的是Lucene这种读取频繁的场景 为什么选择STCS作为默认策略,读放大应该是个重要的因素才对。
ES集群
节点和模块
节点分类:
- 主节点:负责管理集群变更
- 数据节点:负责保存数据/执行CRUD
- 预处理节点:进行数据转换/预处理
- 协调节点:处理客户端请求的节点
- 部落节点:在多个集群之间充当客户端
集群的健康状态:
- Green:所有的主副分片都正常运行
- Yellow:所有的主分片都正常运行,但是不是所有的副分片都正常运行
- Red:有主分片没有正常运行
模块:
- cluster:负责管理集群状态、配置,进行节点分片的决策、分片迁移等
- allocation:封装了分片分配相关的功能和策略
- discovery:进行集群节点的发现和主节点的选举
- gateway:进行对master广播的集群状态的存储,并负责集群重启时的恢复
- indices:全局索引配置管理,索引数据恢复
- http:异步的json over http访问es的api,解决C10k问题
- transport:集群节点之间的相互通信
- engine:封装了对Lucene的操作及translog的调用,它是对一个分片读写操作的最终提供者。
集群启动流程
选主
为什么使用主从模式?
这个问题我在https://www.yuque.com/treblez/qksu6c/ahgvn94c2nh1y34w中也提过:除主从(Leader/Follower)模式外,另一种选择是分布式哈希表(DHT),可以支持每小时数千个节点的离开和加入,其可以在不了解底层网络拓扑的异构网络中工作,查询响应时间大约为4到10跳(中转次数),例如,Cassandra就使用这种方案。但是在相对稳定的对等网络中,主从模式会更好。
ES的典型场景中的另一个简化是集群中没有那么多节点。通常,节点的数量远远小于单个节点能够维护的连接数,并且网络环境不必经常处理节点的加入和离开。这就是为什么主从模式更适合ES。
现在在普遍的上云的环境下,对等网络更多,所以无主的方案用的就很少了。
ES的选主算法是基于Bully算法的改进,主要思路是对节点ID排序,取ID值最大的节点作为Master,每个节点都运行这个流程。
(1)参选人数需要过半,达到 quorum(多数)后就选出了临时的主。为什么是临时的?每个节点运行排序取最大值的算法,结果不一定相同。举个例子,集群有5台主机,节点ID分别是1、2、3、4、5。当产生网络分区或节点启动速度差异较大时,节点1看到的节点列表是1、2、3、4,选出4;节点2看到的节点列表是2、3、4、5,选出5。结果就不一致了,由此产生下面的第二条限制。
(2)得票数需过半。某节点被选为主节点,必须判断加入它的节点数过半,才确认Master身份。解决第一个问题。
(3)当探测到节点离开事件时,必须判断当前节点数是否过半。如果达不到quorum,则放弃Master身份,重新加入集群。如果不这么做,则设想以下情况:假设5台机器组成的集群产生网络分区,2台一组,3台一组,产生分区前,Master位于2台中的一个,此时3台一组的节点会重新并成功选取Master,产生双主,俗称脑裂。
这里我们很容易想到paxos或者raft,为什么它们不采用这种选主的方式呢?又或者说,为什么es采用如此简单原始的bully算法呢?
https://stackoverflow.com/questions/27558708/whats-the-benefit-of-advanced-master-election-algorithms-over-bully-algorithm
bully算法的缺点在于:
- 通信成本高 最坏可能需要n^2次通信
- 节点不均等,排名低的节点可能永远无法被选为主节点
- 节点之间需要准确的进程排名
选举集群元信息
被选出的 Master 和集群元信息的新旧程度没有关系。因此它的第一个任务是选举元信息,让各节点把各自存储的元信息发过来,根据版本号确定最新的元信息,然后把这个信息广播下去,这样集群的所有节点都有了最新的元信息。
集群元信息的选举包括两个级别:集群级和索引级。(回顾一下,一个索引包含多个shard)不包含哪个shard存于哪个节点这种信息。这种信息以节点磁盘存储的为准,需要上报。为什么呢?因为读写流程是不经过Master的,Master 不知道各 shard 副本直接的数据差异。HDFS 也有类似的机制,block 信息依赖于DataNode的上报。
为了集群一致性,参与选举的元信息数量需要过半,Master发布集群状态成功的规则也是等待发布成功的节点数过半。在选举过程中,不接受新节点的加入请求。集群元信息选举完毕后,Master发布首次集群状态,然后开始选举shard级元信息。
allocation过程
选举shard级元信息,构建内容路由表,是在allocation模块完成的。在初始阶段,所有的shard都处于UNASSIGNED(未分配)状态。ES中通过分配过程决定哪个分片位于哪个节点,重构内容路由表。此时,首先要做的是分配主分片。
主分片负责处理所有的读和写请求,因此它们负担了最大的负载。副本分片主要用于复制数据以提供高可用性和容错性,它们不直接处理请求,但可以在主分片不可用时接管请求。
ES根据集群级别元信息中记录的最新主分片的列表来记录主分片,从上一个过程汇总的 shard 信息中选择一个副本作为副分片。
index recovery
之前我在https://www.yuque.com/treblez/qksu6c/ahgvn94c2nh1y34w#pxq1W中解读过Redis的主从复值的问题:没有日志保证主从一致。ES这里就给出了一种解决方案:translog+recovery。recovery一个是可以解决主分片中存在没刷盘的内存数据的问题,另一个是可以解决主从不一致的问题。
这里注意translog会受到commit操作的影响(清空translog),所以这里额外引入快照机制解决这个问题。
副分片的allocation和主分片的recovery是独立的过程,副分片的recovery需要等主分片的recovery结束之后才开始。
- 主分片recovery
由于每次写操作都会记录事务日志(translog),事务日志中记录了哪种操作,以及相关的数据。因此将最后一次提交(Lucene 的一次提交就是一次 fsync 刷盘的过程)之后的 translog中进行重放,建立Lucene索引,如此完成主分片的recovery。
- 副分片的recovery
- phase1:在主分片所在节点,获取translog保留锁,从获取保留锁开始,会保留translog不受其刷盘清空的影响。然后调用Lucene接口把shard做快照,这是已经刷磁盘中的分片数据。把这些shard数据复制到副本节点。在phase1完毕前,会向副分片节点发送告知对方启动engine,在phase2开始之前,副分片就可以正常处理写请求了。
- phase2:对translog做快照,这个快照里包含从phase1开始,到执行translog快照期间的新增索引。将这些translog发送到副分片所在节点进行重放。
当一个索引的主分片分配成功后,到此分片的写操作就是允许的。当一个索引所有的主分片都分配成功后,该索引变为Yellow。当全部索引的主分片都分配成功后,整个集群变为Yellow。当一个索引全部分片分配成功后,该索引变为 Green。当全部索引的索引分片分配成功后,整个集群变为Green。
索引数据恢复是最漫长的过程。当shard总量达到十万级的时候,6.x之前的版本集群从Red变为Green的时间可能需要小时级。ES 6.x中的副本允许从本地translog恢复是一次重大的改进,避免了从主分片所在节点拉取全量数据,为恢复过程节约了大量时间。
数据模型
这里主要是讲数据节点的架构和处理流程。
PacificA算法
概念:
- Replica Group:一个互为副本的数据集合称为副本组。其中只有一个副本是主数据(Primary),其他为从数据(Secondary)。
- Configuration:配置信息中描述了一个副本组都有哪些副本,Primary是谁,以及它们位于哪个节点。
- Configuration Version:配置信息的版本号,每次发生变更时递增。
- Serial Number:代表每个写操作的顺序,每次写操作时递增,简称SN。每个主副本维护自己的递增SN。
- Prepared List:写操作的准备序列。存储来自外部请求的列表,将请求按照 SN 排序,向列表中插入的序列号必须大于列表中最大的SN。每个副本上有自己的Prepared List。
- Committed List:写操作的提交序列
设计假设:
- 节点可以失效,对消息延迟的上限不做假设。
- 消息可以丢失、乱序,但不能被篡改,即不存在拜占庭问题。
- 网络分区可以发生,系统时钟可以不同步,但漂移是有限度的。
整个系统框架主要由两部分组成:存储管理和配置管理。
- 存储管理:负责数据的读取和更新,使用多副本方式保证数据的可靠性和可用性;
- 配置管理:对配置信息进行管理,维护所有配置信息的一致性。
数据写入流程:
(1)写请求进入主副本节点,节点为该操作分配SN,使用该SN创建UpdateRequest结构。然后将该UpdateRequest插入自己的prepare list。
(2)主副本节点将携带 SN 的 UpdateRequest 发往从副本节点,从节点收到后同样插入prepare list,完成后给主副本节点回复一个ACK。
(3)一旦主副本节点收到所有从副本节点的响应,确定该数据已经被正确写入所有的从副本节点,此时认为可以提交了,将此UpdateRequest放入committed list,committed list向前移动。
(4)主副本节点回复客户端更新成功完成。对每一个Prepare消息,主副本节点向从副本节点发送一个commit通知,告诉它们自己的committed point位置,从副本节点收到通知后根据指示移动committed point到相同的位置。
错误检测:
分布式系统经常存在网络分区、节点离线等异常。全局的配置管理器维护权威配置信息,但其他各节点上的配置信息不一定同步,我们必须处理旧的主副本和新的主副本同时存在的情况—旧的主副本可能没有意识到重新分配了一个新的主副本,从而违反了强一致性。PacificA使用了租约(lease)机制来解决这个问题。主副本定期向其他从副本获取租约。
这个过程中可能产生两种情况:
- 如果主副本节点在一定时间内(lease period)未收到从副本节点的租约回复,则主副本节点认为从副本节点异常,向配置管理器汇报,将该异常从副本从副本组中移除,同时,它也将自己降级,不再作为主副本节点。
- 如果从副本节点在一定时间内(grace period)未收到主副本节点的租约请求,则认为主副本异常,向配置管理器汇报,将主副本从副本组中移除,同时将自己提升为新的主。如果存在多个从副本,则哪个从副本先执行成功,哪个从副本就被提升为新主。
假设没有时钟漂移,只要grace period≥lease period,则租约机制就可以保证主副本会比任意从副本先感知到租约失效。同时任何一个从副本只有在它租约失效时才会争取去当新的主副本,因此保证了新主副本产生之前,旧的主分片已降级,不会产生两个主副本。
其他系统也经常将租约机制作为故障检测手段,如GFS、Bigtable。
写入模型
每个索引操作首先会使用routing参数解析到副本组,通常基于文档ID。一旦确定副本组,就会内部转发该操作到分片组的主分片中。主分片负责验证操作和转发它到其他副分片。ES维护一个可以接收该操作的分片的副本列表。这个列表叫作同步副本列表(in-sync copies),并由Master节点维护。正如它的名字,这个“好”分片副本列表中的分片,都会保证已成功处理所有的索引和删除操作,并给用户返回ACK。主分片负责维护不变性(各个副本保持一致),因此必须复制这些操作到这个列表中的每个副本。
写入过程遵循以下基本流程:
(1)请求到达协调节点,协调节点先验证操作,如果有错就拒绝该操作。然后根据当前集群状态,请求被路由到主分片所在节点。
(2)该操作在主分片上本地执行,例如,索引、更新或删除文档。这也会验证字段的内容,如果未通过就拒绝操作(例如,字段串的长度超出Lucene定义的长度)。
(3)操作成功执行后,转发该操作到当前in-sync 副本组的所有副分片。如果有多个副分片,则会并行转发。(4)一旦所有的副分片成功执行操作并回复主分片,主分片会把请求执行成功的信息返回给协调节点,协调节点返回给客户端。
写故障处理:
对于主分片自身错误的情况,它所在的节点会发送一个消息到Master节点。这个索引操作会等待(默认为最多一分钟)Master节点提升一个副分片为主分片。这个操作会被转发给新的主分片。注意,Master同样会监控节点的健康,并且可能会主动降级主分片。这通常发生在主分片所在的节点离线的时候。
在主分片上执行的操作成功后,该主分片必须处理在副分片上潜在发生的错误。错误发生的原因可能是在副分片上执行操作时发生的错误,也可能是因为网络阻塞,导致主分片无法转发操作到副分片,或者副分片无法返回结果给主分片。这些错误都会导致相同的结果:in-sync replica set中的一个分片丢失一个即将要向用户确认的操作。为了避免出现不一致,主分片会发送一条消息到Master节点,要求它把有问题的分片从in-sync replica set中移除。一旦Master确认移除了该分片,主分片就会确认这次操作。注意,Master也会指导另一个节点建立一个新的分片副本,以便把系统恢复成健康状态。
在转发请求到副分片时,主分片会使用副分片来验证它是否仍是一个活跃的主分片。如果主分片因为网络原因(或很长时间的 GC)被隔离,则在它意识到被降级之前可能会继续处理传入的索引操作。来自陈旧的主分片的操作将会被副分片拒绝。当它接收来自副分片的拒绝其请求的响应时,它将会访问一下主节点,然后就会知道自己已被替换。最后将操作路由到新的主分片。
主分片首先在本地进行索引,然后转发请求,由于主分片已经写成功,因此在并行的读请求中,有可能在写请求返回成功之前就可以读取更新的内容。
读取模型
基本流程如下:
(1)把读请求转发到相关分片。注意,因为大多数搜索都会发送到一个或多个索引,通常需要从多个分片中读取,每个分片都保存这些数据的一部分。
(2)从副本组中选择一个相关分片的活跃副本。它可以是主分片或副分片。默认情况下, ES会简单地循环遍历这些分片。
(3)发送分片级的读请求到被选中的副本。
(4)合并结果并给客户端返回响应。注意,针对通过ID查找的get请求,会跳过这个步骤,因为只有一个相关的分片。
Allocation ID 、Sequence ID和_version
Allocation ID用于标记分片副本的陈旧情况,用来决策主分片分配。
SequenceID、SequenceNums、全局检查点、本地检查点用来保障脑裂时主分片唯一,跟Raft中Term的概念相似。
具体就不详细介绍了,可以看https://weread.qq.com/web/reader/f9c32dc07184876ef9cdeb6ka1d32a6022aa1d0c6e83eb4?
每个文档都有一个版本号(_version),当文档被修改时版本号递增。ES 使用这个_version来确保变更以正确顺序执行。如果旧版本的文档在新版本之后到达,则它可以被简单地忽略。例如,索引recovery阶段就利用了这个特性。
版本号由主分片生成,在将请求转发给副本片时将携带此版本号。
版本号的另一个作用是实现乐观锁,如同其他数据库的乐观锁一样。我们在写请求中指定文档的版本号,如果文档的当前版本与请求中指定的版本号不同,则请求会失败。
写流程
ES中,写入单个文档的请求称为Index请求,批量写入的请求称为Bulk请求。写单个和多个文档使用相同的处理逻辑,请求被统一封装为BulkRequest。
可用的操作如下:
· INDEX:向索引中“put”一个文档的操作称为“索引”一个文档。此处“索引”为动词。
· CREATE:put 请求可以通过 op_type 参数设置操作类型为 create,在这种操作下,如果文档已存在,则请求将失败。
· UPDATE:默认情况下,“put”一个文档时,如果文档已存在,则更新它。
· DELETE:删除文档。
基本的写流程如下:
(1)客户端向NODE1发送写请求。
(2)NODE1使用文档ID来确定文档属于分片0,通过集群状态中的内容路由表信息获知分片0的主分片位于NODE3,因此请求被转发到NODE3上。
(3)NODE3上的主分片执行写操作。如果写入成功,则它将请求并行转发到NODE1和NODE2的副分片上,等待返回结果。当所有的副分片都报告成功,NODE3将向协调节点报告成功,协调节点再向客户端报告成功。
写一致性的默认策略是quorum,即多数的分片(其中分片副本可以是主分片或副分片)在写入操作时处于可用状态。
quorum = int( (primary + number_of_replicas) / 2 ) + 1
从不同节点视角来看(具体见https://weread.qq.com/web/reader/f9c32dc07184876ef9cdeb6k64232b60230642e92efb54c?):
异常处理
(1)如果请求在协调节点的路由阶段失败,则会等待集群状态更新,拿到更新后,进行重试,如果再次失败,则仍旧等集群状态更新,直到超时1分钟为止。超时后仍失败则进行整体请求失败处理。
(2)在主分片写入过程中,写入是阻塞的。只有写入成功,才会发起写副本请求。如果主shard写失败,则整个请求被认为处理失败。如果有部分副本写失败,则整个请求被认为处理成功。
(3)无论主分片还是副分片,当写一个doc失败时,集群不会重试,而是关闭本地shard,然后向Master汇报,删除是以shard为单位的。
数据特性
· 数据可靠性:通过分片副本和事务日志机制保障数据安全。
· 服务可用性:在可用性和一致性的取舍方面,默认情况下 ES 更倾向于可用性,只要主分片可用即可执行写入操作。
· 一致性:笔者认为是弱一致性。只要主分片写成功,数据就可能被读取。因此读取操作在主分片和副分片上可能会得到不同结果。
· 原子性:索引的读写、别名更新是原子操作,不会出现中间状态。但bulk不是原子操作,不能用来实现事务。
· 扩展性:主副分片都可以承担读请求,分担系统负载。
· 写入较慢:副分片写入过程需要重新生成索引,不能单纯复制数据,浪费计算能力,影响入库速度。
· 磁盘管理能力较差:对坏盘检查和容忍性比HDFS差不少。例如,在配置多磁盘路径的情况下,有一块坏盘就无法启动节点。
读流程
get
(1)客户端向NODE1发送读请求。
(2)NODE1使用文档ID来确定文档属于分片0,通过集群状态中的内容路由表信息获知分片0有三个副本数据,位于所有的三个节点中,此时它可以将请求发送到任意节点,这里它将请求转发到NODE2。
(3)NODE2将文档返回给 NODE1,NODE1将文档返回给客户端。NODE1作为协调节点,会将客户端请求轮询发送到集群的所有副本来实现负载均衡。
search
索引的建立流程
ES中的数据可以分为两类:精确值和全文。
· 精确值,比如日期和用户id、IP地址等。
· 全文,指文本内容,比如一条日志,或者邮件的内容。
这两种类型的数据在查询时是不同的:对精确值的比较是二进制的,查询要么匹配,要么不匹配;全文内容的查询无法给出“有”还是“没有”的结果,它只能找到结果是“看起来像”你要查询的东西,因此把查询结果按相似度排序,评分越高,相似度越大。
建立索引和执行搜索的过程如下所示:
全文数据
如果是全文数据,则对文本内容进行分析,这项工作在 ES 中由分析器实现。
分析器实现如下功能:
· 字符过滤器。主要是对字符串进行预处理,例如,去掉HTML,将&转换成and等。
· 分词器(Tokenizer)。将字符串分割为单个词条,例如,根据空格和标点符号分割,输出的词条称为词元(Token)。
· Token过滤器。根据停止词(Stop word)删除词元,例如,and、the等无用词,或者根据同义词表增加词条,例如,jump和leap。
· 语言处理。对上一步得到的Token做一些和语言相关的处理,例如,转为小写,以及将单词转换为词根的形式。语言处理组件输出的结果称为词(Term)。
分析完毕后,将分析器输出的词(Term)传递给索引组件,生成倒排和正排索引,再存储到文件系统中。
执行搜索
执行搜索
搜索调用Lucene完成,如果是全文检索,则:
· 对检索字段使用建立索引时相同的分析器进行分析,产生Token列表;
· 根据查询语句的语法规则转换成一棵语法树;
· 查找符合语法树的文档;
· 对匹配到的文档列表进行相关性评分,评分策略一般使用TF/IDF;
· 根据评分结果进行排序。
分布式搜索过程
搜索过程分为query和fetch两步:
cluster视角:
(1)客户端发送search请求到NODE 3。
(2)Node 3将查询请求转发到索引的每个主分片或副分片中。
(3)每个分片在本地执行查询,并使用本地的Term/Document Frequency信息进行打分,添加结果到大小为from + size的本地有序优先队列中。
(4)每个分片返回各自优先队列中所有文档的ID和排序值给协调节点,协调节点合并这些值到自己的优先队列中,产生一个全局排序后的列表。
总结
· 聚合是在ES中实现的,而非Lucene。
· Query和Fetch请求之间是无状态的,除非是scroll方式。
· 分页搜索不会单独“cache”,cache和分页没有关系。
· 每次分页的请求都是一次重新搜索的过程,而不是从第一次搜索的结果中获取。看上去不太符合常规的做法,事实上互联网的搜索引擎都是重新执行了搜索过程:人们基本只看前几页,很少深度分页;重新执行一次搜索很快;如果缓存第一次搜索结果等待翻页命中,则这种缓存的代价较大,意义却不大,因此不如重新执行一次搜索。
· 搜索需要遍历分片所有的Lucene分段,因此合并Lucene分段对搜索性能有好处。
线程池
我们跳过对于索引恢复、gateway、allocation、snapshot、cluster、transport几个模块的分析,来看线程池。这几个模块的流程比较符合直觉,跟平常的分布式系统也没什么区别。篇幅原因,就不在这里展开了。
一般说来,线程池的大小可以参考如下设置,其中N为CPU的个数:
· 对于CPU密集型任务,线程池大小设置为N+1;
· 对于I/O密集型任务,线程池大小设置为2N+1。
对于计算密集型任务,线程池的线程数量一般不应该超过N+1。如果线程数量太多,则会导致更高的线程间上下文切换的代价。加1是为了当计算线程出现偶尔的故障,或者偶尔的I/O、发送数据、写日志等情况时,这个额外的线程可以保证CPU时钟周期不被浪费。
I/O密集型任务的线程数可以稍大一些,因为I/O密集型任务大部分时间阻塞在I/O过程,适当增加线程数可以增加并发处理能力。而上下文切换的代价相对来说已经不那么敏感。但是线程数量不一定设置为2N+1,具体需要看I/O等待时间有多长。等待时间越长,需要越多的线程,等待时间越少,需要越少的线程。因此也可以参考下面的公式:
最佳线程数=((线程等待时间+线程CPU时间)/线程CPU时间)×CPU数
(nginx: 呵呵,一群菜鸡)
nginx这种出神入化的水平一般库确实达不到,属于是超出三界外不在五行中了。
线程池的分类
fixed
fixed线程池拥有固定数量的线程来处理请求,当线程空闲时不会销毁,当所有线程都繁忙时,请求被添加到队列中。
size参数用来控制线程的数量。
queue_size参数用来控制线程池相关的任务队列大小。设置为 -1表示无限制。当请求到达时,如果队列已满,则请求将被拒绝。
scaling
scaling线程池的线程数量是动态的,介于core和max参数之间变化。线程池的最小线程数为配置的core大小,随着请求的增加,当core数量的线程全都繁忙时,线程数逐渐增大到max数量。max是线程池可拥有的线程数上限。当线程空闲时,线程数从max大小逐渐降低到core大小。
direct
这种线程池对用户并不可见,当某个任务不需要在独立的线程执行,又想被线程池管理时,于是诞生了这种特殊类型的线程池:在调用者线程中执行任务。
fixed_auto_queue_size
与fixed类型的线程池相似,该线程池的线程数量为固定值,但是队列类型不一样。其队列大小根据利特尔法则(Little's Law)自动调整大小。该法则的详细信息可以参考https://en.wikipedia.org/wiki/Little%27s_law。
这个线程池加入了target_response_time,用来指示任务的平均响应时间值设置,如果任务经常高于这个时间,则线程池队列将被调小,以便拒绝任务。
线程池模块分析
generic
用于通用的操作(例如,节点发现),线程池类型为scaling。
index
用于index/delete操作,线程池类型为fixed,大小为处理器的数量,队列大小为200,允许设置的最大线程数为1+处理器数量。
search
用于count/search/suggest操作。线程池类型为fixed,大小为int((处理器数量3)/2)+1,队列大小为1000。
get
用于get操作。线程池类型为fixed,大小为处理器的数量,队列大小为1000。
bulk
用于bulk操作,线程池类型为fixed,大小为处理器的数量,队列大小为200,该线程池允许设置的最大线程数为1+处理器数量。
snapshot
用于snaphost/restore操作。线程池类型为scaling,线程保持存活时间为5min,最大线程数为min(5, (处理器数量)/2)。
warmer
用于segment warm-up操作。线程池类型为scaling,线程保持存活时间为5min,最大线程数为min(5, (处理器数量)/2)。
refresh
用于 refresh 操作。线程池类型为 scaling,线程空闲保持存活时间为5min,最大线程数为min(10, (处理器数量)/2)。
listener
主要用于Java客户端线程监听器被设置为true时执行动作。线程池类型为scaling,最大线程数为min(10, (处理器数量)/2)。
same
在调用者线程执行,不转移到新的线程池。
management
管理工作的线程池,例如,Node info、Node tats、List tasks等。flush用于索引数据的flush操作。
其实juc把线程池定的很清楚了:fixed pool(固定线程数的普通任务)、single pool(单线程)、cached pool(线程数动态扩缩,适合于短时间内的大量异步任务)、fork join pool(多队列任务窃取,适合cpu密集场景),根据场景选就可以了。C++就没这么好用的内部库。
shrink
索引分片数量一般在模板中统一定义,在数据规模比较大的索引中,索引分片数一般也大一些,在笔者的集群中设置为24。同时按天生成新的索引,使用别名关联。但是,并非每天的索引数据量都很大,小数据量的索引同样有较大的分片数。在ES 中,主节点管理分片是很大的工作量,降低集群整体分片数量可以减少recovery时间,减小集群状态的大小。因此,可以使用Shrink API缩小索引分片数。当索引缩小完成后,源索引可以删除。
Shrink API是ES 5.0之后提供的新功能,其可以缩小主分片数量。但其并不对源索引直接进行缩小操作,而是使用与源索引相同的配置创建一个新索引,仅降低分片数。由于添加新文档时使用对分片数量取余获取目的分片的关系,新索引的主分片数必须是源索引主分片数的因数。例如,8个分片可以缩小到4、2、1个分片。如果源索引的分片数为素数,则目标索引的分片数只能为1。
过程
引用官方手册对Shrink工作过程的描述:
· 以相同配置创建目标索引,但是降低主分片数量。所有副本都迁移到同一个节点。创建硬链接时,源文件和目标文件必须在同一台主机。
· 从源索引的Lucene分段创建硬链接到目的索引。如果系统不支持硬链接,那么索引的所有分段都将复制到新索引,将会花费大量时间。
· 对目标索引执行恢复操作,就像一个关闭的索引重新打开时一样。
为什么要使用硬链接而不是软链接?
Linux的文件系统由两部分组成(实际上任何文件系统的基本概念都相似):inode和block。
block用于存储用户数据,inode用于记录元数据,系统通过inode定位唯一的文件。
· 硬链接:文件有相同的inode和block。
· 软链接:文件有独立的inode和block,block内容为目的文件路径名。
那么为什么一定要硬链接过去呢?从本质上来说,我们需要保证Shrink之后,源索引和目的索引是完全独立的,读写和删除都不应该互相影响。如果软链接过去,删除源索引,则目的索引的数据也会被删除,硬链接则不会。满足下面条件时操作系统才真正删除文件:
文件被打开的fd数量为0且硬链接数量为0。
使用硬链接,删除源索引,只是将文件的硬链接数量减1,删除源索引和目的索引中的任何一个,都不影响另一个正常读写。
由于使用了硬链接,也因为硬链接的特性带来一些限制:不能交叉文件系统或分区进行硬链接的创建,因为不同分区和文件系统有自己的inode。
优化写入速度
综合来说,提升写入速度从以下几方面入手:
· 加大translog flush间隔,目的是降低iops、writeblock。
· 加大index refresh间隔,除了降低I/O,更重要的是降低了segment merge频率。
· 调整bulk请求。
· 优化磁盘间的任务均匀情况,将shard尽量均匀分布到物理主机的各个磁盘。
· 优化节点间的任务分布,将任务尽量均匀地发到各节点。
· 优化Lucene层建立索引的过程,目的是降低CPU占用率及I/O,例如,禁用_all字段。
优化搜索速度
- cache预留空间
- 使用更快的硬件
- 合理建模文档
- 预索引数据
- 合理字段映射
- 避免使用脚本
- 日期搜索 增添模糊范围
- 对只读索引进行force-merge
- 每天建立新的索引而不是只写一个索引
- 预热cache
- 限制分片数(减少协调节点的压力)
- 使用自适应副本选择提升响应速度(思路来自Cassandra:https://www.usenix.org/conference/nsdi15/technical-sessions/presentation/suresh)