陋室铭
永远也不要停下学习的脚步(大道至简至易)

海量数据的索引,第一个要解决的是数据存储的问题,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,他们在巨量数据检索时可以带来极高的性能体验。

posted on 2023-08-09 12:34  宏宇  阅读(215)  评论(0编辑  收藏  举报