因为本文篇幅有限,在这里我只会着重介绍:实时性、高可用性在我们产品中的一些技术实践。
实时解决方案
在介绍我们产品方案之前,首先介绍下业内常见的实时解决方案,见图1-1实时架构图:
图1-1
该方案一般是由:
内存索引(Ram-IndexA)负责数据更新。
内存索引(Ram-IndexA)达到阀值,角色转换成待合并内存索引(Ram-IndexB),同时重新开辟一块新的内存索引(Ram-IndexA)负责新的更新写入,老的内存索引(Ram-IndexB)合并且优化到主索引中。
内存索引(Ram-IndexA)+磁盘索引(Full-IndexA)提供检索服务。
基于该方案可以带来的最大的优势是内存索引可以合并到主索引,避免了索引碎片,从而可以屏蔽全量索引重新构建和保障搜索服务的性能稳定性。但是这种方案仍然会存在以下问题:
生产者(写入量)远远大于消费能力(内存索引构建),影响上游系统(数据生成方)的稳定性。
内存索引合并磁盘主索引并执行优化过程时间较长,如果该过程出现宕机,重启机器后存在丢失数据的可能性。
内存索引和主磁盘索引合并后,主索引是需要重新打开才能让更新可见,而对于大的磁盘索引重新打开一次耗时是比较长,因为需要重新预热数据到内存中。那么对实时性要求很高的需要明显是不合理的。
另外为了保证重新打开主索引视图期间查询是不中断,也就意味着一份大的磁盘索引的资源视图需要被同时打开2份。那么就意味着承载该主索引的机器资源是需要实际承载主索引2倍以上资源才能满足。
大索引的合并并优化的过程对机器IO资源占用较大,而本身搜索服务本身就是IO密集型的应用,所以合并主索引并优化必定对搜索服务稳定性带来影响。
因为要数据更新可见,所以需要频繁的重新打开合并后的磁盘大索引,这样会导致大索引对应的优化Cache出现频繁清空,重新加载的问题。从而使得为性能优化而设置的Cache访问命中率将非常低,这样对于一些复杂的统计查询带来性能上极大不稳定性,同时Cache如果本身所占内存过大,还会带来JVM频繁FullGC的影响。
所以为了单纯追求系统某个指标值(如永远不需要做全量),而牺牲掉系统稳定性是得不偿失的。当然上述某些原因可能在一些好的硬件配置机器下并不会暴露的特别明显,但是从技术架构的机器成本上考虑的话,上述设计方案就不是一种特别合适的方案。那么我们产品平台便采取了一种更低成本更稳定的实时架构方案来解决上述问题,其主要思路:
采用WAL机制保证上游系统写入磁盘的源数据不丢失,机器宕机重启保证让机器数据快速恢复到宕机前一致。
所有的更新操作只会发生在内存索引,但内存索引不会无限扩大,满足系统设置阀值后就会刷入磁盘,一旦刷入磁盘的索引将不会发生重新打开(只存在标记删除操作),通过这种屏蔽重新打开磁盘索引的操作,也就解决了前面提到由于主索引频繁重新打开导致的查询实时性、资源占用峰值、Cache的命中率、FullGC的问题。
内存索引直接刷磁盘生成子索引,不再去合并主索引,避免大的主索引需要重新打开,同时子索引的大小可控,基本在100MB之内,避免重新打开子索引非常慢。
子索引数目增多,也就意味着冗余数据变多、查询遍历更多的文件,那么性能必定会造成影响,所以子索引必须通过一种合并策略进行合并优化。我们产品采取的策略由合并因子和阀值影响,例如:当相同大小的100MB子索引达到10个的时候,触发合并。虽然触发合并后,仍然会带来IO争用,但因为子索引体积小,所以合并优化时间较快,那么对搜索服务的影响基本不存在。另外当子索引合并生成的新子索引达到一定大小时候,合并策略将不会将其纳入下次待合并列表中,即永远不会再参与合并。
基于上述的思路,我将着重在实时更新处理、实时索引体系两个方面来跟大家介绍下我们产品。
实时更新处理
WAL日志
大型分布式系统中故障很常见,设想一下,如果内存索引没有刷写,服务器就宕机了。内存中没有写于硬盘的数据就会丢失。所以我们的分布式实时搜索产品应对的办法是在写内存索引之前先写入WAL(Write-Ahead Logging,预写式日志)。其写入流程如下:
(1) 将WAL日志以追加写的方式写入磁盘日志文件中
(2) 将WAL日志的修改操作作用到内存索引中
(3) 返回操作成功或者失败
如上所示,在修改内存索引元素之前,要确保与这一个修改相关的操作日志必须要刷入磁盘中。如果检索服务器宕机,没有从RamIndex刷写入Disk的数据将可以通过回放WAL来恢复。而这个过程并不需要人为参与,检索节点内部机制中有恢复流程来处理。
批提交
一般而言搜索系统是需要将WAL日志刷入磁盘才可以构建内存索引的,但是如果每个事务都要求将日志立即刷入磁盘,系统的吞吐量将会很差。因此,对一致性要求很高的应用,需要立即刷入;相应地,对一致性要求不高的应用,可以考虑不要求立即刷入,首先将WAL日志缓存到内存缓存区中,定期刷入磁盘。但是这种做法有一个问题,如果搜索应用系统意外故障,可能丢失最后一部分更新操作。
批提交(Group Commit,如图1-2批处理流程图)技术是一种有效的优化手段。WAL日志首先写入到系统内容缓存区中:
(1) 日志缓存区的数据量超过一定大小,比如128KB;
(2) 距离上次刷入磁盘超过一定时间,比如10ms。
当满足以上两个条件中的某一个时,将日志缓存区中多个事务的操作一次性刷入磁盘,接着一次性将多个事务的修改操作逐个返回客户端操作结果。批提交技术保证了WAL日志成功刷入磁盘后,才返回操作结果保障数据的不丢失,虽然牺牲了写事务延时,但大大提高了系统吞吐量。
图1-2
CheckPoint检查点
考虑数据写入需要实时可查,那么更新的数据都是在内存索引中,那么可能出现一些问题:
故障恢复时需要回放所有WAL,效率较低。如果WAL超过100GB,那么,故障恢复时间根本无法接受。另外内存有限,内存索引需要达到阀值后转储到磁盘。所以,我们需要在内存索引转储到磁盘的时候,记录checkpoint时刻的日志回放点,以后故障恢复只需要回放checkpoint时刻日志之后的WAL日志,如图1-3检查点方案流程图所示:
图1-3
当机器发送重启,只需要重新加载subindexA、subindexB、subindexC的索引,并重放checkpointC之后的WAL日志,变可让数据恢复到宕机前一致。
实时索引体系
图1-4
根据图1-4实时方案架构图我们详细说明下实时模式实现流程:
(1) 更新操作都会在服务端以WAL落地磁盘
(2) 服务端异步线程顺序消费WAL构建成内存索引(Ram-IndexA)
(3) 内存索引(Ram-IndexA)大小达到内存阀值将转换角色为Ram-IndexB
(4) 重新开辟新的内存索引(Ram-IndexA)负责当前WAL的消费
(5) Ram-IndexB内存索引直接刷入磁盘生成以Index前缀的子索引,如:Index_0,Index_1,Index_2….。
(6) 防止索引碎片(Index_0,Index_1…)会越来越多从而影响性能,我们采取一种合并策略可以通过合并因子和索引大小来选出可以合并的小索引进行合并。
(7) 达到阀值的子索引将不会在参与合并,那么系统运行一段较长时间后,索引碎片(子索引)也将会越来越多,那么系统可以通过重新做一次全量的方式来消除索引碎片带来的影响。
子索引合并策略
前面我们说到内存索引一旦达到阀值,将被刷入到磁盘,那么磁盘将会存在很多类似index_0、index_1、index2,index_3的索引碎片。如果不对这些索引碎片进行合并,那么随着这些索引碎片的增加,会导致搜索服务性能降低。所以我们的产品对索引碎片采取了一种合并策略对其进行定期合并。
图1-5
如图1-5子索引合并流程图所示,内存索引刷入磁盘,将会依次递增的生成index_0,index_1,index_2的磁盘索引碎片。假设当前的合并因子是2,当合并管理器发现存在2个大小一致索引index_0,index_1的时候,变会触发合并操作:index_0和index_1合并成index_3,合并过程中index_0、index_1依然提供正常服务,当合并操作成功完成,即index_3生成完毕,并对外提供服务。接下来将index_0和index_1的资源引用计数减1,即当基于index_0、index1的查询访问线程结束时,index_0,index_1的资源引用计数为0、索引将正常关闭,这样一个索引碎片合并操作正常结束。但是如果合并过程出现宕机或者异常情况,即当前合并事务未正常结束,那么整个合并过程将会回滚,即index_3被清理,index_0,index_1正常提供服务。当然如果内存索引继续刷到磁盘生成了index_4、index_5,通过合并策略生成了index_6,这个时候发现index_3和index_6又满足了合并条件,那么index_3和index_6又会合并生成index_7。所以通过这种合并策略,小索引碎片逐步会被合并成大索引碎片,但是如果索引碎片越大,那么带来的合并代价也越大,我们需要设置一个合并阀值,凡是索引碎片达到指定文件大小阀值后,将不会进一步再参与合并,这样就很好的屏蔽了大索引碎片合并代价过大的问题。
全量索引构建
因为前面说到我们产生索引碎片,而这些索引碎片即使进行了碎片合并而减少碎片数,但是一旦当碎片达到一定大小后就不适合继续进行合并,否则合并代价很大,所以我们无法避免的会因为碎片问题而导致更新实时性和查询QPS性能损耗问题。所以我们的解决的办法就是通过一段时间对具体业务全部源数据进行一次构建全量索引DUMP工作,用构建好的新的全量主索引去替换原来老的主索引和磁盘索引,从而让实时更新、搜索服务性能恢复到最佳。
阿里的业务数据规模都很庞大,动辄就上10亿到百亿,那么我们如果使用Solr原生的基于检索服务节点的索引构建模式会带来2个很大问题:
构建索引就是一个IO密集型的任务,而搜索服务也是IO密集型,那么两个任务如果在一台机器上并存,将会导致双方服务变得都不稳定。
搜索业务数据规模大,导致传统原生构建索引的方式在几十亿数据量规模下所需要的时间特别长,即使深夜访问低峰期开始全量任务,也需要延续到白天甚至是访问高峰期还未结束,从而使得搜索服务出现频繁超时现象。
所以基于上述原因我们的搜索平台实现一个分布式全量索引任务调度框架来解决搜索业务全量索引构建的问题。
图1-6
如图1-6 DUMP中心架构图所示简单描述下一个业务全量索引构建的流程:
将一个具体业务相关的上下文信息以全量索引构建任务的形式提交给JobNode
JobNode根据TaskNode空闲程度,选择好若干个TaskNode并将全量任务下发到具体的TaskNode。JobNode根据了这些上下文信息把一个全量任务分解成若干个TaskNode进行,这样有效的用到分布式并行任务的优势来加速索引构建。
被选择出来的若干个TaskNode根据授予任务的上下文信息,获取业务数据的来源类型和存储地址,(如数据库、Hadoop云梯),然后通过流的方式消费源数据内容并构建成索引。
一个索引任务执行完毕后,将完成的全量索引根据指定的目标存储源进行回流(一般还是HDFS)。
那么通过这种离线的分布式索引构建中心具体为搜索业务带来什么呢,主要在以下几个方面体现:
彻底隔离全量构建索引和搜索服务的耦合性,杜绝资源抢用情况。
实现与业务细节无关的全量构建任务集群,能为接入搜索平台的所有业务进行全量索引构建服务,意味着极大提高机器综合利用率,不用为具体业务搭建具体的全量DUMP集群。
索引构建和检索服务隔离后,DUMP任务节点(TaskNode)可以在最大化利用机器资源,即对底层索引构建细节深入优化,所以极大提升了海量数据索引构建速度。
DUMP中心快速构建海量数据索引并回流索引文件到存储中心的过程,为在线搜索服务无缝扩容和线上故障快速恢复提供了数据来源基础。
其他优化
我们产品在solr和Lucene上做了很多优化来适应一些业务的需求,本文篇幅有限,所以在这里我主要挑出一个比较有代表性的优化实践:Cache改造。
Cache改造
用过solr的同学们都知道所有的Cache都是由SolrIndexSearcher来管理的,如图1-7 Searcher结构图所示:
图1-7
而在我们的实时模式下需要让更新的数据实时可见,那么必须近实时的用新的SolrIndexSearcher-new去替换SolrIndexSearcher-old。(如图1-7)而这样实时的替换 也就引发如下问题:
solrIndexSearcher的替换,意味着基于solrIndexSearcher层的Cache(如图1-7所示的4种Cache)全部失效,那么意味着毫秒级别会频繁有大空间的内存需要被垃圾回收,最终会触发频繁的FullGC。
如果Cache配置还打开了预热功能(warm),那么新的SolrIndexSearcher在替换之前需要将其管理的新Cache进行预热。数据量如果较大,那么预热时间会较长,从而引发数据实时性可见问题。
所以基于如上的问题,终搜产品重新设计了一些Cache,将Cache的管理由SolrIndexSearcher迁移到IndexReader层中来,如图1-8 Cache结构图所示:
图1-8
首先,先阐述下我们这种优化思路的前置条件,前文中提到我们内存索引会直接刷磁盘而不用合并到主索引中,这样在磁盘存在的主索引、子索引对应的内存视图对象IndexReader在任何时候都不需要重新打开,而以IndexReader管理的Cache一旦创建后将不会被失效,而需要涉及到预加载Cache的过程只是在刷入磁盘或者系统重新启动过程中一次将配置涉及到的Cache都预加载到内存中,那么之前存在的频繁失效导致GC、预加载慢引起实时性的若干问题都将不复存在。
所以通过将Cache从Searcher层迁移到IndexReader层的设计使得实时模式下的引擎在复杂的统计查询下性能也能得到很好的保证。
总结与展望
本文中我们深入的探讨了一种高稳定性实时搜索引擎系统实践,这些实践内容也依托于我们的产品服务于阿里众多业务线。而目前我们的产品搜索服务集群已经1100台,接入业务范围也涵盖整个阿里集团。而这些业务特别是在数据量和访问量的成倍增长的情况下,我们产品更加需要关注
不再需要为数据规模和访问规模增长而提心吊胆。
更加合理利用机器资源,搜索服务集群吞吐量可以根据业务实际情况来动态调整。
归根结底其实这些要求是对搜索服务系统的易扩展提出了更高的要求,即如何提供一种无缝的在线扩容方案达到搜索服务吞吐量无上限的目标,而这个目标也正是我们产品目前正在重点关注的方向,而关于这块的内容希望有机会在新的文章中跟大家做深入探讨。