最近一直在忙着重构网站的全文搜索系统,搞了接近三个周,现已经成功上线,头一次做搜索,并且整个系统的设计没有参考任何第三方,因此想把架构设计的方案总结一下,做个纪念,更希望大家能给这个架构提出宝贵的改进、优化建议。
这个难点在于实现RPC的通信,如果要自己实现的话会非常麻烦,好在现在有很多非常优秀的RPC通信框架,我们选用了Facebook捐赠给Apache的thrift,对thrift和RPC通信不是很了解的同学可以参考这篇文章:http://www.ibm.com/developerworks/cn/java/j-lo-apachethrift/ 这里将不再详细赘述。
第二个阶段的搜索架构如图所示:
第二个阶段的设计完全废弃了NFS的使用,需要Lucene搜索的组件只需要依赖一个thrift client包即可,连Lucene都不需要依赖,顿时感觉世界清爽了很多。
第三个阶段是目前的架构方案,也是花了近三周时间搞出来的。第二个阶段虽然使用了thrift来取代NFS 的方式,但是索引的创建仍然是使用了Copy on write的方式。对于数据量不大的时候,Copy On Write方式是一种安全的数据读写方式,但是当数据量增大,每次都需要去创建一份新的索引副本是一件很不现实的事情。这时我们的搜索要满足这两个条件才可以:
1. 分布式,可水平扩展。提供搜索服务的节点要随时可以水平添加,也就是达到最理想的状态,通过直接加机器的方式就可以缓解线上的压力。
2. 近实时,构建增量索引。线上触发事件之后,比如用户写了博客,要在用户可接受的时间之内搜索到最新的内容。
开始看到这两点的时候,就想到了MySQL的Master-Slave架构,其适应情景跟这个相类似。如果要参考MySQL的方案,那么就需要先来搞清在搜索当中Master的角色应该是什么,Slave应该是什么。
1. Master
在搜索服务中的Master应该是接受需要创建索引的线上事件,比如新用户注册,用户发布了新帖子等等,这些操作都需要向索引中添加新的数据,每当用户进行这些搜索的时候就会触发一个修改索引的事件,Master监听这个事件,然后对事件进行积攒,当积攒的量或者是积攒的时间超过设置的阈值的时候就创建一个子索引目录,创建子索引结束之后会向Slave发送一个通知,要求slave更新。
Master最核心的部分是接受事件的消息队列。消息队列的选择有很多,有JavaEE中最为传统的JMS技术、新浪微博开源的MemcacheQ、还有Linkedin的Kafka、Redis也有内置的阻塞队列支持。因为公司在消息队列的选择上统一采用了JMS,于是也就跟着用JMS了,感觉有时间还是要再去对比一下这几种消息队列各自的优缺点。
向Slave发送通知还是使用的原先的网络广播框架,但是此处有个问题,我要是把任何一台Slave机器停掉,隔一段时间再去重启这上面的slave服务,那么需要把在关闭服务期间master产生的所有搜索改动全部合并过来。网络广播框架没有消息持久化的功能,如果slave在消息广播的期间关闭,那么就会造成消息的丢失。因此这里光靠消息不行,还要在数据库中创建一个表来记录索引的变动,并且每次变动都带有一个时间戳记录,slave这边也会维持一个上次合并索引的时间戳记录,重启slave之后它就会把自己的时间戳同表中的时间戳做比较,读出没有合并的索引记录,完成合并。
增量索引的结构问题也需要考虑,master所接收的线上事件并不只是增加索引,还有可能是修改和删除,怎么做呢?这就需要除了维护每次所产生的子索引之外还要记录所有产生事件对象的业务id,子索引当中包含新创建和修改的document,slave在进行合并的时候会先读取业务对象的id列表,根据这些id把主索引中的文档全部删掉,然后再合并子索引。这样要删除的自然已经被删除了,而对于修改的内容旧的已经被删除了,新的会被合并到主索引。
索引的版本控制问题,假设我在master创建子索引的时候修改了索引的结构或者更改了Lucene的版本,如果此时slave再去合并那么就悲剧了。因此在master和slave之间需要一个简单的版本控制机制。master在创建子索引,然后把创建信息写到记录索引变动表的时候会带一个版本号,slave也维护一个自己支持的索引版本号,slave再去合并索引之前回去检查master的版本号与自己的版本号是否一致,如果不一致就不会合并,以免造成错误,当slave这边重新更新完毕(比如根据新的索引结构重新跑一遍索引)之后,把版本号调整到跟master一致才会去重新更新自己的索引。
生产者消费者效率的权衡问题:master本质上是一个生产者-消费者的模式,通过队列的方式来解决生产者和消费者的问题容易因为两者工作的效率有差别而造成错误,比如当生产者生产过快,而消费者又消费的太慢,那么大量的事件充满在队列中就会容易发生OOM造成整个系统的崩溃,因此必须对生产者和消费者采取阻塞策略来防止问题的发生。
2. Slave
Slave接受Master发送来的通知,每个Slave所在的机器上有一个本地的主索引,并且跟master产生的子索引目录通过NFS来挂载。当收到master通知的时候,Slave会去将未合并的子索引合并到主索引上。同时Slave还要向客户端提供搜索的服务。提供搜索服务还是通过thrift框架来完成。关于slave的设计,我是把搜索服务和合并操作通过不同的进程来分开的方式,分开进程的最大的好处是增加维护的灵活性,比如我要在节点初始化索引的时候不能向外提供服务,那么就可以先启动索引创建/合并的进程,然后创建完毕之后再去启搜索服务;当索引发生变更的时候,我也可以停掉索引合并进程来维护,而搜索进程仍然可以通过旧的索引来提供服务,不影响线上的用户,只是没有索引更新罢了。因为进程分开,不属于同一个JVM进程,那么就不能使用Lucene内置的近实时搜索支持,因此为了要做到近实时的效果就在搜索进程中设置一个守护线程,每隔一定的时间去扫描slave机器上主索引的变动,如果有变动,那么就重新打开新的IndexReader,这个过程没必要自己去做,Lucene已经实现了内置的支持,通过IndexReader.openIfChanged方法即可实现。
第三个搜索架构的图示