一
海量数据的索引,第一个要解决的是数据存储的问题,solr提供数据存储平台有两种,第一个是本地磁盘,另一个是HDFS,我们可以通过solrhome的配置来实现。在本次实践中,我们选择的是本地磁盘,因为采用的solrcloud部署模式,本身就是多节点多机器,在存储上不会有问题,还有另一个重要的原因后面会讲到。下面讲讲具体从哪些方面做了实践。
solr版本:solr6.0.0;主机:red hat; 网络:千兆网
性能的具体要求是:
1、在一台机器上普通硬盘的情况下,索引单个文档大小为200字节左右,需要达到的效率为7WTPS。
2、具有良好的水平扩展性。
3、对数据备份和数据丢失情况,要求不严格。
在对索引速度进行优化之前,有必要先对建立索引的整个流程做个大致的了解,如下图所示:
说明:
1、 客户端发送HTTP的POST请求到Solr服务器,报文格式一般有xml、json、javabin(只有java才支持,二进制结构)。
2、 Web服务器将请求派发到Solr的Web应用程序(Servlet)。
3、 Solr根据请求的URI中的Collection名字在solrConfig.xml找到注册的/update消息处理器;这是单个副本的情况下,如果多个副本的情况下,如果需要判断此副本是否为Leader,如果是非Leader,则需要将此文档发送给此副本的Leader,如果是非直接路由模式,Solr则会根据文档ID进行hash路由,路由到特定的Leader上。
4、 按照solrConfig.xml配置的请求处理链来处理索引,比如分词处理器等。
5、 写事务日志,当发送提交后正式将数据写入到存储中,事务日志可以保证在用户未提交的情况下数据不丢失。
6、 返回写索引的结果。
提交方式
solr提供了两种提交方式:硬提交和软提交。硬提交(hard commit)指的是在客户端每提交一批数据都通知服务器把数据刷新到硬盘上,检索的时候数据可以立马可见;软提交(soft commit)是让服务器自己控制在一定的时间范围之内刷新数据。很明显,硬提交是非常损耗性能的操作,并且它还会阻塞其他数据的提交,所以我们选择软提交,具体配置方式如下所示:
1
2 3 4 5 6 7 8 |
<autoCommit>
<maxTime>${solr.autoCommit.maxTime:30000}</maxTime> <openSearcher>false</openSearcher> </autoCommit> <autoSoftCommit> <maxTime>${solr.autoSoftCommit.maxTime:60000}</maxTime> </autoSoftCommit> |
?
在这份配置文件中,我们指定了服务器每隔60秒对数据做一次软提交,另外推荐opensearch设置为false,否则每次提交都会开启一个opensearch,这个也会损耗性能。
关闭副本
solr一个collection会有多个分片(shard),多个shard分别位于不同的节点上,每个节点上可能会有shard的多个复制,其中一个为leader shard,在追求极限速度的情况下,可以将副本数设置为1,这样减去了副本间的数据同步等资源消耗。不过这样做带来的弊端就是数据容灾性降低,和检索性能急剧下降。
取消分词器
很显然使用分词器的话会对数据做进一步处理,也是会使得性能大幅度降低的,再不使用全文检索的情况下可以不使用分词器来提高速度。
增加分片
在一台物理机上,我们可以部署多个solr实例,在每个实例上可以设置多个shard,这样在索引数据的时候,一个collection会有多个shard在同时入数据,显然速度是可以大幅提升的。不过shard数也不能太多,机器资源是有限的,当太多shard同时写数据,会导致内存和IO压力很大,效果适得其反,应该根据自己的硬件情况进行合理设置。另外,如果是采用的SSD硬盘,设置多个solrhome也会有不错的效果。
划分collection
当数据积累的越来越多的时候,哪怕多个shard,每个shard的数量也是巨量的,这个时候,不仅查询性能急剧下降,由于lucene倒排序索引的原理建索引速度也会越来越慢。所以我们尝试控制每个shard数据量不会太大,进行了按业务划分索引库,或者按天按小时建立索引库,这样数据分布到多个collection中,均衡了每个索引库的压力。
二
通过上一篇的几个优化方案,我们的索引速度其实已经能得到很大的提升了,从最初的平均每台机器7000TPS/S,大概能到2.5WTPS/S。但是这个速度远远还达不到我们的需求,最关键的时候随着节点数增加速度并不能线性增加,然后又做了许多其他方面的尝试,其中路由方式是比较大的一个方向,本篇将重点介绍这一方案。
前面介绍了solr在创建索引库的时候可以指定多个shard来同时索引数据,那么问题来了,文档是通过何种规则将数据负载均衡到每个分片之上的呢,solr提供了两种路由方式:哈希路由(composite),指定路由(implict)。其中哈希路由,创建collection的时候需要明确指定shard数量,后期shard可以进行分裂(split)操作,但是不能增加或者删除shard。solr默认的就是采用这种路由模式,利用计算文档ID的哈希值,来判断将此文档索引到哪个分片之上,这样做的好处是可以使得每个shard上数据负载均衡,但在追求极限速度之下,会浪费掉不少时间。第一,计算hash值需要耗时;第二,数据在各个分片之间迁徙分发需要消耗网络以及内存资源。所以我们选择了直接路由模式。工作模式如下图,透过collection,直接指定分片载入数据:
直接路由模式顾名思义,指定索引具体落在路由到哪个shard,该模式同样在创建collection时需要具体指定shard数量,后期可以动态追加分片以及删除分片,但是如果一个分片过大时不能进行分裂的。需要注意的是:
1)索引要特殊方式通过以下URL新建
1
|
http://xxxx.xxxx.xxx.xxx:port/solr/admin/collections?action=CREATE&name=implicit1&shards=shard1,shard2,shard3&router.name=implicit
|
?
2)在solr4.x版本中通过更改schemal.xml在5.0以上更改managed-schema文件添加以下字段定义:
1
|
<field name="_route_" type="string"/>
|
3)利用SolrJ添加文档的时候需要加入以下字段:
1
|
doc.addField("_route_","shard_x")
|
通过直接路由的模式,我们可以每个线程操作一个shard,如果物理机上有多块硬盘,就可以每个硬盘上部署一个solr节点,这样多块硬盘之间的索引性能是互不影响的,我们就可以随着节点数的增加而线性增长。带来的问题就是可能会导致数据分布不均,而使某个分片过载,不过我们系统中数据中间件使用的是apache kafka,一个topic设置了多个partiton,在这一层报文已经做了一次均衡路由,每个partiton消费出来的数据负责写一个solr的shard,所以不用担心这个问题,后面有机会介绍下kafka消息中间件。
通过具体测试,指定路由速度相比于哈希路由,在单节点下速度并没有提升,但是在多节点下,集群的吞吐量有明显的提升,可以真正做到水平拓展,对于物理机数量充足的情况下,可以满足每天海量索引的更新。下一篇将继续介绍如何利用solrj客户端来提高单节点的速度,这样整个集群的吞吐量又能进一步提高。
三
本篇文章主要介绍下如何从客户端solrJ以及服务端参数配置的角度来提升索引速度。
solrJ6.0提供的Java客户端主要有下面几种接口:HttpSolrClient,ConcurrentUpdateSolrClient,CloudSolrClient。下面分别对这三种接口做一个简单的比较。HttpSolrClient在定义的时候需要明确指定一个solr节点路径,他在提交数据的时候也只能提交到这个节点上;ConcurrentUpdateSolrClient接口在同样是指定具体solr节点路径的,但不一样的事,这是个异步提交的模式,即我们在对客户端添加数据的时候,客户端会将文档缓存到内存队列中,让队列中的数据达到一定数量时,客户端会自动一次性向solr服务器发起一个http请求;CloudSolrClient就比较简单了,这个在定义时只需要指定zookeeper地址就好了,因为solr节点注册时,会将节点信息同步到zookeeper中,在提交数据时,CloudSolrClient会和zookeeper进行通信,发现solr云中的索引库,然后利用LBHttpSolrClient来提交数据。通过对比发现,ConcurrentUpdateSolrClient和CloudSolrClient这两个接口比较优秀,后者比较适用于哈希路由的模式,而前者则比较适合指定路由的模式。
CloudSolrClient
1
2 3 4 5 6 7 8 9 10 |
public SolrClient CreateCloudSolrClient() {
CloudSolrClient csClient = new CloudSolrClient(zkUrl); csClient.setZkConnectTimeout(zkConnectTimeOut); csClient.setZkClientTimeout(zkClientTimeOut); csClient.setDefaultCollection(collectionName); csClient.setParallelUpdates(true); csClient.setRequestWriter(new BinaryRequestWriter()); csClient.connect(); return csClient; } |
?
ConcurrentUpdateSolrClient
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public SolrClient CreateHttpClient() {
ModifiableSolrParams params = new ModifiableSolrParams(); params.set(HttpClientUtil.PROP_MAX_CONNECTIONS, 128); params.set(HttpClientUtil.PROP_MAX_CONNECTIONS_PER_HOST, 32); params.set(HttpClientUtil.PROP_FOLLOW_REDIRECTS, false); params.set(HttpClientUtil.PROP_ALLOW_COMPRESSION, true); params.set(HttpClientUtil.PROP_USE_RETRY, true); HttpClient httpClient = HttpClientUtil.createClient(params); httpLists.add(httpClient); ConcurrentUpdateSolrClient client = new ConcurrentUpdateSolrClient(solrUrl + "/" + collectionName, 50, 1); client.setConnectionTimeout(zkClientTimeOut); client.setSoTimeout(zkConnectTimeOut); client.setPollQueueTime(500); client.setParser(new BinaryResponseParser()); client.setRequestWriter(new BinaryRequestWriter()); return client; } |
?
上篇文章介绍过,在多线程配合指定路由的模式下,集群的性能能够水平拓展,这个时候再配合ConcurrentUpdateSolrClient客户端,单个节点速度也会有比较大的提升。需要注意的是,有两个参数需要慎重设置,第一个是队列大小,这个指的是队列中最多存储多少个请求之后向服务器进行提交,另一个是线程数,表示内部开几个线程来提交数据。这两个参数需要根据自己应用程序的JVM来设置,如果设置的过大,会导致内存溢出。
由于是异步请求的方式,所以如果在建立索引的过程中出现了异常,异常信息是不会抛给应用程序的,后来通过调试源码发现,solrJ自己在内部处理了这个异常(其实什么都没做,预留了一个空方法),我们可以在选择修改源码来捕捉异常数据,或者将此异常抛出,由应用程序来捕捉异常,持久化异常数据。
在服务器端,我们也可以通过一些优化,来提高建立索引速度。
1、段合并
solr的索引是由段组成,更新索引的时候是写入一个段的信息,几个段共同组成一个索引,在solr优化索引的时候或其他的时候,solr的段是会合并的。所以我们可以对相关参数进行控制,对段的大小以及合并的频率进行调整,来提交系统资源利用效率。
mergeFactor这个参数是合并因子,当内存中有N个文档时则合并成一个段,当存在N个段文件时则合并成一个新的段文件。
minMergeSize,指定最小的合并段大小,如果段的大小小于这个值,则可以参加合并。
maxMergeSize,当一个段的大小大于这个值的时候就不参与合并了。
maxMergeDocs,当文档数据量大于这个的时候,这个段就不参与合并了。
在实际场景中,应该根据物理机的资源,来配置这些参数,适量加大mergeFactor参数,来降低合并频率,频繁的段合并会消耗大量系统资源。
2、自动生成ID
在Solr中,每一个索引,都要有一个唯一的ID,类似于关系型数据库表中的主键。我们在创建文档的时候,需要自己生成一串UUID来标示文档,但是由于ID比较长,在索引过程中,会占用一些额外的网速和内存等资源,我们可以控制在服务器端让solr自己生成UUID,具体配置步骤如下:
1)修改schema.xml文件,将ID字段修改为UUID类型
1
|
<field
name="id" type="uuid" indexed="true" stored="true" required="true"
multiValued="false" /> <fieldType name="uuid"
class="solr.UUIDField" indexed="true" />
|
2)配置solrconfig.xml文件,添加更新策略配置,生成UUID
1
2 3 4 5 6 7 8 |
<updateRequestProcessorChain name="uuid">
<processor class="solr.UUIDUpdateProcessorFactory"> <str name="fieldName">id</str> </processor> <processor class="solr.LogUpdateProcessorFactory" /> <processor class="solr.DistributedUpdateProcessorFactory" /> <processor class="solr.RunUpdateProcessorFactory" /> </updateRequestProcessorChain> |
?
3)配置requestHandler
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
<requestHandler name="/dataimport" class="solr.DataImportHandler">
<lst name="defaults"> <str name="config">tika-data-config.xml</str> <str name="update.chain">uuid</str> </lst> </requestHandler> <requestHandler name="/update" class="solr.UpdateRequestHandler"> <lst name="defaults"> <str name="update.chain">uuid</str> </lst> </requestHandler> <!-- for back compat with clients using /update/json and /update/csv --> <requestHandler name="/update/json" class="solr.JsonUpdateRequestHandler"> <lst name="defaults"> <str name="stream.contentType">application/json</str> <str name="update.chain">uuid</str> </lst> </requestHandler> <requestHandler name="/update/csv" class="solr.CSVRequestHandler"> <lst name="defaults"> <str name="stream.contentType">application/csv</str> <str name="update.chain">uuid</str> </lst> </requestHandler> <requestHandler name="/update/extract" startup="lazy" class="solr.extraction.ExtractingRequestHandler" > <lst name="defaults"> <str name="xpath">/xhtml:html/xhtml:body/descendant:node()</str> <str name="capture">content</str> <str name="fmap.meta">attr_meta_</str> <str name="uprefix">attr_</str> <str name="lowernames">true</str> <str name="update.chain">uuid</str> </lst> </requestHandler> |
?
这样solr即可自动生成UUID,不需要在客户端额外生成。
四
本篇是这个系类的最后一篇,但优化方案不仅于此,需要后续的研究与学习,本篇主要从schema设计的角度来做一些实践。
schema.xml 这个文件的作用是定义索引数据中的域的,包括域名称,域类型,域是否索引,是否分词,是否存储,是否标准化,是否存储项向量等等。在solr6中这个文件是存放在zookeeper的/configs节点之下的,在创建新的collection时,solr会根据此节点下的信息生成相应的索引库,其相关的配置信息会同步到solrhome/core目录下的core.properties文件中。同步schema文件的指令语句样例为:
1
|
bin/solr zk -upconfig -z 127.0.01:2181 -n conf -d /solrhome/configsets/sample_techproducts_configs/conf
|
为了改进性能,可以从以下几个方面来着手:
1、对于field元素,我们将所有只用于搜索的,而不需要作为查询结果的field(特别是一些比较大的field)的stored设置为false,这样这个字段的值将不会被存储,但可以被检索,会减少不小的IO开销。我们设计了一个利用solr来做hbase的二级索引架构,可以利用hbase来存储字段信息,充分利用hadoop的大数据特性。
2、能不用copyfield这个元素就不用,这个属性会对字段做双倍存储,显然非常耗性能,好处就是在查询的时候,想要对多个字段进行检索只需要检索一个字段。
3、将一些不需要被检索的字段的index属性,设置成false,这样solr就不会对这个字段进行索引。
4、不使用中文分词器或者使用高亮功能。termPositions termOffsets的值全都设置成false。
5、在测试中发现solr在处理小报文(1K以下)的情况下吞吐量并不理想,当适当增大报文,发现速度可以得到大幅度提高,可以从之前的每个节点10M/S暴涨到30M/S。
但是在很多情况下,我们并不能人为控制报文长度,这个时候,可以通过solr的字段多值来达到目的,即将多条消息的每个字段的值放到一起,在schema中配置multValued为true。存储到solr中是这个样子的:
这样速度是可以单台节点达到30M/S甚至更高,但是带来的问题就是查询会变得很复杂,在命中多值中任意一条记录,结果集会带出所有值,在solr中认为这一组数据是一个文档,我们想了很多方案来解决这个问题,比较简单的方法是,在生成文档的时候控制多值字段中,没有重复的值,这样检索结果则会变得精确,缺点就是灵活性太低。另一个方案,是通过对数据做预聚合,管理快照,由于其实现比较复杂,效果也不是很理想,在此就不做过多描述了。
在不追求检索精确度,或者对数据可控的情况下,对于索引速度真的可以带来很大的惊喜。
尾言:solr由于是利用lucene为底层,lucene本身是单机的无法分布式,solr的核心就是引入了分片的机制,在数据规模变得特别庞大的时候各种弊端就显示出来了,无论是建立索引还是查询性能都不尽人意。但通过各种方法的优化与舍弃之后,差不多可以做到水平拓展,线性增长,能够满足大多数的业务场景。但是如果是要对历史数据进行检索的时候,这个历史数据规模又是极其巨量时,solr恐怕是无法承受的。现在兴起了很多列式存储结构以及时间序列的数据库以及仓库,比如driud和tsdb,他们在巨量数据检索时可以带来极高的性能体验。