1.1. Scaling Lucene
怎样在Lucene之上构建一个分布式、高度伸缩、接近实时的搜索引擎呢?
让我们回顾一下在搜索引擎(基于lucene)伸缩性这条路上都做了那些尝试,并且elasticsearch是如何尝试并去解决这些挑战的。
首先我们了解下最基础的理论知识 building blocks (这些理论基础是构建分布式近实时搜索引擎的基础)。 接着我们研究一下到底哪种才是最佳的分区策略 partitioning (将lucene索引文档分割到多个分布式的分片中去)。 然后我们同样需要决定使用哪种分区复制方式 replication (复制能够保证系统的高可用以及提高搜索的吞吐)。 最后,我们再看一下事务日志 transaction log (事务日志在elasticsearch里面是一个保证数据一致性的非常酷的功能)。
1.1.1. Building Blocks
当我们要构建一个分布式接近实时的搜索引擎,并且要让lucene可伸缩可扩展,必须首先知道lucene的关键概念以及它们与我们要达成目标的一些局限性.
Directory
Lucene Directory 是一个抽象的文件系统的接口,用来允许你读写文件,不管lucene的索引是存放在内存中还是在物理磁盘上,它都是通过lucene的Directory抽象层来访问和维护的。
IndexWriter
IndexWriter 用来添加、删除和更新lucene里面的索引文档。这些操作是在内存中完成以保证更好的性能,但是如果要保证这些操作的持久化,这些操作是需要flush到磁盘的。并且,flush操作或者是显式的commit提交开销都是比较大的,因为这些操作通常需要处理很多的文件,而处理这些文件又涉及到大量的磁盘io。
此外, 每次只能有一个IndexWriter对象来对一个索引目录进行索引操作,并且创建这个对象的开销很大,所以必须尽可能去重用这个对象.
Index Segments
Lucene 索引被分解为很多段(segments)。每个索引段实际上是一个功能完整的lucene索引,一旦一个索引段创建完成,它将是不可变的,并且不能删除段里面的索引文档。commit提交操作用来往索引里面添加一个新段。lucene内部会来对这些段进行合并,所以我们必须要有策略来控制这些合并(MergePolisy, MergeScheuler, … etc)。Because segments need to be kept at bay they are being merged continuously by internal Lucene processes (MergePolisy, MergeScheuler, … etc)。
因为段是不可变的,所以用来做缓存(caching)是一个很好的选择,你可以加载所有的term词条并且创建一个跳跃列表( skip lists ) ,或者用来构造FieldCache,如果段没有变化,你就不需要重新加载。
IndexReader
IndexReader 用来执行搜索索引。这个对象通过IndexWriter来提供,并且创建代价也是比较高。一旦IndexReader打开之后,它就不能够发现打开之后的索引变化,如果要知道这些由IndexWriter产生的索引变化,除非刷新IndexReader对象(当然前提需要flush操作)。搜索操作在内部其实是按段来进行的(每次一个段)。
Near Real-Time Search
获取一个新的IndexReader开销很大,所以也是我们不能每次一有索引操作就真的去获取一个新的IndexReader,你可以隔一段时间去刷新一下,比如每隔一秒钟等等,这也是我们在这里称之为接近实时( near real-time )的原因。
1.1.2. Partitioning
可能用来伸缩Lucene的途径(Possible approach to Scale Lucene):
Distributed Directory
其中一个途径用来伸缩Lucene就是使用分布式文件系统,大文件会被拆分成chunks块并且会保存到分布式存储系统(比如 Coherence, Terracota, GigaSpaces or Infinispan等等)。这样IndexWriter和IndexReader都是工作在一个自定义的Directory分布式实现上,每个操作后面其实是分布了很多个节点,每个节点上面存储了索引文件的一部分.
但是这种方案有一些问题:
首先,这种方案会产生密集的网络流量。尽管可以用一些高级的方法如本地缓存等,但仍然会产生大量的网络请求,因为最主要的原因是因为这种将文件拆分为块的想法与lucene索引文件构建方式和使用方式实在相隔太远,结论就是使用这种方式来做大规模索引和搜索是不切实际的。(ps:所以solandra这种玩意还是不要去考虑了)。
其次,大的索引必然会使IndexReader变的无法分布式。IndexReader是一个很重的对象,并且term词条越多,其消耗的内存也会越多。
最后,索引操作也会变的非常困难,因为只有一个单一的IndexWriter能够写索引。所以,我们把目光投向另一种方式。
Partitioning
有2种通过将数据分区方式来scale搜索引擎: 基于文档(Document based partitioning) and 基于词条(Term based partitioning). Elasticsearch 使用的基于文档的分区方式。
基于文档的分区(Document Based Partitioning)
每一个文档只存一个分区,每个分区持有整个文档集的一个子集,分区是一个功能完整的索引。
优点:
每个分区都可以独立的处理查询。
可以非常简单的添加以文档为单位的索引信息。
网络开销很小,每个节点可以分别执行搜索,执行完了之后只需用返回文档的ID和评分信息就可以了,然后在其中一个我们执行分布式搜索的节点上执行合并就可以了。
缺点:
查询如果需要在所有的分区上执行,那么它将执行 O(K*N) 次磁盘操作(K是词条(Term,或者理解为Field)的数量,N是分区的数量)。
在实用性的角度来看基于文档的分区方式已经被证明是一个构建大型的分布式信息检索系统的一种行之有效的方法, 关于这方面的详细内容,可以看 这里 talk by Jeffrey Dean (Google)。
基于词条的分区(Term Based Partitioning)
每个分区拥有一部分词条,词条里面包含了整个index的文档数据。
一些基于词条分区的系统,如Riak Search (built on top of Riak key-value store engine) 或是 Lucandra/Solandra (on top of Cassandra). 尽管这些系统不是完全一样,但是它们都面临一个相似的挑战,当然也得益于相同的设计理念。
优点:
一般来说,你只需要在很少的部分分区上执行查询就行了,比如,我们有5个term词条的查询,我们将至多命中5个分区,如果这5个term词条都保存同一个分区中,那么我们只需用访问一个分区即可,而不管我们是不是实际上有50个分区。
另外一个优势就是对应K个Term词条的查询,你只需用执行 O(K) 次磁盘查找(假设我们使用的优化过的实现)。
缺点:
最主要的问题是Lucene Segment概念里面固有的很多结构都将失去。
The main problem is that whole notion of Lucene Segment which is inherent to a lot of constructs in Lucene is lost.
对于那些复杂的查询,网络开销将会变得非常高,并且可能使得系统可用性大大降低,尤其是那些会expand出大量的term词条的查询,如fuzzy或者prefix查询。
另外一个问题就是获取每个文档的信息将会变得非常困难,举例来说,如果你想获取文档的一部分数据来做进一步的控制,比如(google的PageRank算法),获取每个文档的这些数据都会变得非常困难,因为这种分区的方式使得文档的数据被分散到了不同的地方,所以实现faceting、评分、自定义评分等等都将变得难以实现。
1.1.3. Replication
分布式系统的另外一方面就是复制(replication)了。通过复制我们可以得到2个主要的好处:
High Availability (HA高可用性)。如果一个节点挂了,另外一个节点能从它趴下的地方应头顶上,如果一个节点上面持有索引分片,而另一个节点持有该分片的副本,那么我们的数据就有了一个备份。
拥有数据多个副本的另一个好处就是 scalability (可伸缩性)。我们没有理由不通过增加副本来提高搜索能力,而我们只需要简单的增加几个副本或从节点(slave nodes)就能提升我们搜索的吞吐,何乐而不为呢。
一般有两种方式来实现复制: Push Replication(推模式) 和 Pull Replication(拉模式)。 Elasticsearch 使用的是Push Replication(推模式)。
Push Replication
工作起来非常简单, 当你往 [master] 主分片上面索引一个文档,该分片会复制该文档(document)到剩下的所有 [replica] 副本分片中,这些分片也会索引这个文档。
缺点:
同一个文档重复索引多次,相比拉模式而言,要传输相对较少的数据(众所周知,Lucene索引速度非常快)。
You index the same document several times, but we transfer much less data compared to Pull replication (and Lucene is known to index very fast)。
这就需要在并发索引的时候进行一些微妙的控制,比如对同一个文档进行非常频繁的索引,在主分片和副本分片上面执行索引操作的时候,你必须保证每次更新是按照正确的顺序,或者通过版本(versioning)来拒绝旧版本的操作,而拉模式就没有这个问题。
优点:
一旦文档索引完毕,那么该文档在所有的分片及副本上都是立即可用的。 索引操作会等待直到确认所有的副本也执行了同样的索引操作(注意: 如果需要,你也可以使用异步复制)。 这意味着索引的实时性。 然后你只需要 refresh 一下 IndexReader 就能搜索到新的数据了。
这样的架构能让你非常方便的在节点之间进行切换,假如包含主分片(primary shard)的节点挂了,我们能够很快的进行切换,因为其它的分片和主分片都是一模一样的。
Pull Replication
拉模式是一种主从方式(master – slave)(Solr 用的就是这种)。 当一个文档在master上面进行索引,并且数据通过commit操作产生了新的段文件(segment),这个时候,从节点(slave)把这些段文件(segments)拉到自己的机器然后再执行相应的刷新操作,并保证lucene能够使用这些新的数据。
缺点:
需要在master上面执行commit操作来产生这些段文件(segment),这样slave才能够执行pull操作。 不知道你还记不记得前面说过,lucene的commit的开销是非常大的,如果可能,commit次数越少越好。
数据的传输会有不必要的冗余。 在分布式系统里面,网络通常来说是非常宝贵的资源(如果你跑在EC2上面,那将更加宝贵) 并且最终要移动的数据会越来越多,举例来说,如果你有2个段文件,里面包含了文档,文档里面的字段都是存储的(stored fields),并且Lucene决定要合并这2个段文件,那么你也必须要传输这部分数据(合并之后的段文件),因为这是一个新的段文件,但是实际上你传输的是一份相同的数据。
这将造成一个这样的局面,所有的slaves确实是在master后面。 也可能是确实没有理由每次都进行commit或者花大量时间来传输一个大的段文件。但是至少意味着你的slave会丢失 high availability,并且不可能当成是一个实时的slave(a real time high available slave)。 实时搜索不可能存在,并且(使用拉模式)也不可能有这种1秒的刷新率,然后lucene就能实时搜索。
1.1.4. Transaction Log
正如前面提到过的,索引提交(commit)的开销实在太大,但是我们又必须通过提交操作来保证数据被可靠的持久化,如果拥有数据的节点突然崩溃的话,那么最后一次提交操作之后产生的数据操作将会丢失。
数据可靠性(Data Persistency)
ElasticSearch通过使用 transaction log (或预写日志(write ahead log)) 来解决这个问题,通过日志记录发生在索引上的各种操作,来保证就算没有调用commit操作也能保证数据的持久化。并且能够很自然的支持推送复制(push replication),因为我们能够让每个不同的shard都拥有 transaction log ,就算某些节点崩溃了,如果有必要,可以很轻松对日志操作进行重放(replay)。
Transaction log 周期性的将数据刷新(flushed)到磁盘,你可以通过 参数 来进行控制。 简单来说就是保存两次提交之间的连续数据操作的记录。
尽管你只运行了一个elasticsearch的服务节点(可能暂时不需要分布式),trasncation log也能够使你的es即使被强制结束进程( “kill -9” )也不会丢失任何数据。
当然,还不止这些!Transaction log还有一个重要的功能就是可以保证当你生成快照( shared gateway snapshot )、分片恢复( peer shard recovery )或是分片热迁移(shard “Hot” relocation)的时候,索引数据不会丢失。
Shared Gateway Snapshot
使用共享gateway时,会周期性的生成数据改变(changes)的快照 ( snapshots ) ,并存储到共享存储中(shared storage),并且transaction log也是持久化数据的一部分。
Peer Shard Reovery
当分片从一个节点迁移到另一个节点或者需要分配更多的分片(比如你 增加 了副本数) 的时候,数据会从某一个节点上取来进行恢复,而不是从gateway。
迁移数据时,首先我们保证不会删除Lucene的段文件(segment files),然后禁用flushing操作,这个时候保证不调用commit操作,然后开始迁移这些段文件,这个时候产生的索引改变,我们存放到transaction log中,一旦这个步骤结束(ie:索引索引文件拷贝完毕),我们开始对transaction log里面的日志在replica分片上进行重放操作(replay),完毕之后,我们就可以进行切换了,数据迁移成功!
迁移操作进行时,你仍然可以进行索引,仍然可以进行搜索,只有索引切换的时候会有一段很短的时间阻塞(blocking),但是直到切换前,迁移对你来说是完全透明的。