背景
刚接触 Lucene 时,只求能用就行,至于背后复杂的设计和原理,我看得晕头转向,云里雾里。
比如,作为搜索引擎核心的倒排索引概念,原理上其实不难理解,但怎么一到了具体的实现,就多出了这么多复杂的设计!
- 倒排索引本质上不就是个哈希表吗,为什么在 Lucene 里要分段(Segment)?
- 为什么每个段都是不变的,从而带来了段合并(segment merge)的问题?
- …
等后来闲下来,终于有功夫耐心地看别人的回答、查文档,慢慢把这些问题搞明白了。
搜索引擎的核心:倒排索引
这里简单解释一下倒排索引,如果已经熟悉这个概念大可以跳过这一节。
倒排索引是个很简单的概念,其实就像书后面的术语表索引。你很可能读过这样的书(大概率是教材):在正文之后的索引中,会按字母或者拼音顺序列出书中出现的重要术语,以及该术语在书中每次出现的页码。
这样,如果你希望快速定位书中出现某个术语的地方,就不需要把书从头到尾读一遍了,只需要执行下面两个简单的步骤:
- 在术语表中找到该索引。由于术语表是按字母或拼音排序的,这一步不会很难,和查字典差不多;
- 根据术语表中记录的页码,直接跳转到书中相应的那些位置。这一步同样也会很快;
倒排索引和这个很像,可以理解为一个哈希表:
- 哈希表中的每个键是一个词项(term),相当于术语表中的术语;
- 每个词项的对应值则是出现该词项的所有文档的编号,相当于术语表中出现该术语的所有页码位置;
利用倒排索引,如果希望找到出现某个词项的所有文档(例如网页),那么同样也只需要两个简单的步骤:
- 在倒排索引中找到该词项;
- 根据该词项对应的文档编号 ,找到相应的文档;
这样一来,倒排索引就可以大大降低搜索文档的时间成本。
Lucene 的索引实现
基本原理很简单,但具体实现就比较麻烦了。
Lucene 中的索引是分段的,每个段(segment)对应不同的文件。
为什么 Lucene 要对索引分段?为什么段是不可变的 ?
既然倒排索引本质上就是个哈希表,存成一个文件不就好了?为什么要分段呢?
其实这也是一种无可奈何。在理想情况下,如果只需要建一次索引,那么只存为一个文件当然没问题,完全不需要分段。
但现实情况往往会更复杂,比如你已经对现有的网页建了索引,落成了索引文件,但每天都会有新的网页冒出来,旧的网页也会修改,这时候就需要更新索引。
如果索引不分段,那么就意味着你唯一的这个索引文件,在每次更新索引的时候都会被修改。
如果索引更新非常频繁(比如在 Elasticsearch 中默认是每秒),那么也就意味着你唯一的这个索引文件,每秒都在被更新。
这样会带来非常不好的影响。比如:
- 单个文件不断被频繁写入和读取,容易产生性能瓶颈,也容易导致数据损坏;
- 对该文件的缓存将变得毫无意义;
第二点尤为关键。例如在 Elasticsearch 集群内,为了提供更高的查询性能、保证更高的可用性,每份 Lucene 索引一般都会有几个副本,每次索引更新都会同步给所有副本。问题是,如果索引每秒都在更新,每秒钟都需要重写所有副本,效率也太低了吧!
这些问题都可以通过分段解决。分段之后,每次更新索引时会写入新的段,而之前的段则不会被修改。
比如,如果你有 10 个段,那么实际上相当于有 10 份倒排索引,每次查询某个词项时去这 10 个倒排索引中分别查询,再对结果进行汇总,这样和查一个单独的倒排索引的效果是一样的。
这样的好处是,由于之前的段都是不变的,缓存效率会很高;每个段只会被写入一次,也避免了频繁写入的问题。
可以发现,分段策略和“段的不可变性”是紧密关联的。如果段不是不可变的,那么分段的好处也就所剩无几了。
分段会带来什么问题?
分段策略非常好,但也会带来一些问题。
最大的问题是:由于之前的段是不可变的,如果希望删除之前段中的某些内容(比如,删除一些文档),那么只能过通过“标记删除”的方式实现,即在新增的段中说明哪些段被删除了,然后在结果汇总阶段把相关结果剔除掉。
这样带来的影响就是,本来应该已经删除的内容,实际上还保留在倒排索引中,一方面会影响查询效率,另一方面也占用着存储空间。
文档更新也是同样的道理。如果某个文档的内容变化了,那么 Lucene 实际上会先把该文档删除掉(通过标记删除的方法),然后再新增一份最新版本的文档。
换句话说,文档更新会带来和文档删除一样的问题:查询效率降低、存储空间浪费。
如果你的索引更新主要是文档新增,那么还好;如果有频繁的文档删除和文档更新,那么索引就会逐渐变得非常低效。
这个时候就需要“段合并”(segment merge)了。段合并操作会删除旧有段,写入合并之后的新段。
从搜索结果来看,段合并之前和之后没有任何变化。不过,段合并期间会剔除已经被删除的文档,提高查询效率、减少储存空间浪费。
小结
本文简单介绍了倒排索引、Lucene 中的索引分段。至于段合并的具体细节,就放到下一篇来写吧!