目录
背景
公司使用es存储全链路追踪的日志数据,生产环境数据量比较大,上线之后集群频繁卡死。领导让解决,一开始为了图省事,开发团队提出了加服务器的要求,运维团队表示让开发先评估需要新增服务器的数量,领导表示这个项目预算有限,如果性能优化能解决问题就不要再额外申请服务器。作为这个项目的开发代表,外加想挑战下自己,这个性能优化的任务落在了我头上。
现状
- 生产环境一共7台服务器,3台用来部署master node,3台用来部署data node,1台用来部署client ndoe。配置都一样,4核、8GB、机械硬盘。
- 索引较多,数据量最大的索引是用来存储调用链的索引,目前是按照星期来拆分索引,即使如此,单个索引的数据量还是太大。
- 每个索引3个主分片,3个复制分片。
- 写请求非常频繁,读请求比较少。
优化措施
写性能优化
一直以为搜索缓慢是数据量大导致的,当然这没错,但是数据量大到哪个级别才会明显影响搜索体验,可能你并没有真正了解。比如说,一个正在写入数据的索引里有一千万个文档,搜索耗时3秒,你认为es的性能本就如此,直到一次偶然机会,你发现在一个同样数据量但很久没有数据写入的索引里搜索文档耗时才100毫秒。也就是说,同样数据量的两个索引,如果A索引正在频繁写入数据,B索引已经很久没有数据写入了,B的搜索速度会远快于A。
频繁写入数据不仅仅对当前索引有影响,在同一个节点上,资源是共享的,如果你消耗大量的CPU来合并段,或者频繁提交事务日志导致线程阻塞,都会对这个节点的所有请求造成影响。
一般来说,es的默认配置是基于各方面考虑的最优解,但我们可以根据自己的实际情况进行调整,这完全取决于你的关注点,例如我们的情况是资源有限,可适当牺牲可靠性,优先关注性能和可用性。具体措施下面详细来说。
事务日志异步刷盘,适当延长调系统fsync的间隔,比如将sync_interval从默认的5s调整为20s,在创建索引或者索引模板中配置如下:
"settings":{"index":{"translog":{"durability":"async","sync_interval":"20s"}}}
In-memory buffer的文档每秒refresh到Filesystem cache,refresh太频繁会产生大量的segment,从而导致segment merge占用大量CPU和IO. 将refresh_interval从1s调整为10s:
"settings":{"index":{"refresh_interval":"10s"}}
按天拆分索引
如果服务器数量足够,可以通过增加主分片数量来减少每个分片的数据量,从而提升性能,但是如果服务器数量是固定的,增加分片数量就没什么意义了。
日志数据,某种意义上是一种时序数据。基于我们目前的数据量考虑,按天拆分索引,能让每个索引的数据量控制在一个合理的水平。搜索的时候,时间作为一个必须的搜索条件(这其实跟时序数据库类似),根据时间跨度解析出涉及到的索引,这样能缩小搜索的范围,如果可以,我们可以对时间跨度进行限制,比如规定时间跨度不能超过3天,这样最多只会搜索4个索引。
冷热分离
es通过将索引结构放在内存来提升查询性能,这是一种空间换时间的做法,内存足够时,es查询速度非常快,内存不够时,查询性能将会急剧下降。然而内存有限,不可能将所有数据放在内存,为了解决这个问题,我们可以定义索引的生命周期,根据生命周期来合理分配资源。
es官方定义的索引生命周期有以下四种:
- Hot,索引正在频繁更新和查询。
- Warm,索引不再更新,但仍被频繁的查询。
- Cold,索引不再更新,也很少被查询。这些信息仍然会被搜索,但查询速度慢一些也没关系。
- Delete,索引不再需要,可以安全删除。
关于索引的生命周期管理,es官方有一套完整的解决方案,详情请参考官方文档。这里我选择自己来实现生命周期管理。
在我的项目里,我只定义hot、cold和delete这三个生命周期。热点数据可以全部缓存在内存里,冷数据只能缓存一小部分在内存,大部分还是要走磁盘IO,反正只是偶尔查询,慢就慢点,也是能够接受的。
像日志这种时序数据随着时间流逝价值会降低,我们只会关注当前或者最近的日志,几天前的日志往往不太关注。所以我将当天的索引定义为热数据索引,其他索引全部是冷数据索引,超过30天的索引会被删除。
决定要做冷热分离后,我就着手对集群重新规划和改造。上文提到过,我们有3个data node,我决定再增加一个data node,一共4个,2个是存放热索引的热数据节点,2个是存放冷索引的冷数据节点,一开始数据写入到热节点,一天之后,从热节点迁移到冷节点。
我们在热节点的配置文件elasticsearch.yml中定义一个lifecycle属性,值为hot:
node.attr.lifecycle: hot
冷节点:
node.attr.lifecycle: cold
通过如下命令可以查看每个节点的属性:
GET _cat/nodeattrs?v&h=node,attr,value&s=attr:desc
然后在创建索引时或者索引模板中配置:
"settings":{"index.routing.allocation.require.lifecycle":"hot"}
这样,新写入的数据就会存储到热节点。
接着,在自己的程序中写一个定时任务,每天凌晨触发一次,将索引的lifecycle属性值改为cold:
PUT/index_name/_settings
{"index.routing.allocation.require.lifecycle":"cold"}
java代码:
@OverridepublicvoidhotToCold(String... indices){Map<String,String> properties =newHashMap<>();
properties.put("index.routing.allocation.require.lifecycle","cold");for(String index : indices){
elasticsearchDAO.updateSettingsAsync(index, properties,newActionListener<AcknowledgedResponse>(){@OverridepublicvoidonResponse(AcknowledgedResponse acknowledgedResponse){EsIndexManageServiceImpl.log.info("Move {} to cold node return {}", index,
acknowledgedResponse.isAcknowledged());}@OverridepublicvoidonFailure(Exception ex){EsIndexManageServiceImpl.log.error("Move "+ index +" to cold node", ex);}});}}
改完属性后,es集群会自动进行数据迁移。
另外,定时任务还需要删除那些处于delete生命周期的索引。
服务器配置调整
通过zabbix观察发现master node的负载并不高,4核8GB的配置有点浪费,于是将配置降低至2核4GB,相当于节省了6核12GB出来了。data node还是上文提到的4台(原来3台,增加了一台),不过将热节点的机械硬盘换成读写速度更快的固态硬盘。client node保持不变。这样一核算,服务器成本其实基本没变。
内存分配
内存如何分配,取决于你利用es的方式。如果你把es当做搜索引擎来用,涉及到对text类型字段的聚合和排序,那你就用到了fielddata这种数据结构,fielddata主要是基于jvm堆,并且非常容易造成堆内存溢出,另外分页功能也会占用JVM堆(无论如何,你都不应该支持深度分页);如果你把es当做存储文档的NoSQL数据库来用,不涉及到对text类型字段的聚合和排序,那es主要利用的是文件系统缓存,文件系统缓存由系统内核管理,不会造成内存溢出。
像我主要是把es当做NoSQL数据库来用,所以jvm堆不需要分配很大的内存,1GB就够了。修改jvm.options:
-Xms1g-Xmx1g
对于单台8GB内存的服务器来说,减去jvm堆的1GB,还剩7GB,这7GB保留20%给其他进程,还剩5.6GB给文件系统缓存。那么两台热节点就有5.6 × 2 = 11.2GB的缓存空间了。我们不考虑数据膨胀等各种其他因素的影响,只做一个近似的估算,那么放在热节点的分片总大小不能超过11.2GB,才能得到最佳的搜索性能。超过11.2GB,就需要将索引往冷节点迁移。
去掉复制分片
复制分片可用于查询,所以可以通过增加复制分片的数量来提高索引的查询并发(当然,服务器数量也要增加,最好一个分片位于一台独立的服务器)。更重要的是,在主分片挂掉之后,复制分片可以取而代之,也就是说复制分片可用于实现索引的高可用。
而对于我的使用场景来说,查询并发很低,也没有高可用的要求,再说服务器突然挂掉这种情况也非常少见,3个复制分片实在太浪费,不仅没带来任何帮助,数据复制的开销却很大,所以这次我直接将复制分片数量设置为0:
"settings":{"index":{"number_of_shards":2,"number_of_replicas":0}}
字段设计
我相信大家在刚开始使用es时,文档的字段根本就没有设计过,全部是text一把梭。为了减少不必要的性能开销,我们对字段精心设计。
- 对于不可能作为搜索条件的字段,可以将index设置为false,这样就不用创建倒排索引。
- 对于不会用来聚合和排序的字段,将doc_values设置为false,这样就不用创建列式存储结构。
- 字符串类型,如果不用来做全文检索,将type设置为keyword,这样就不用走分析的流程(字符过滤、分词、标记过滤),提高写入性能。
- 整型,在对应的取值范围选择合适的类型,能用byte就绝对不用short,能用integer就绝对不用long,节省存储空间。
- 日志中有但用不到的字段,将store设置为false,同时在_source里排除掉。
用于参考的mappings如下:
"mappings":{"_source":{"enabled":true,"excludes":["recordType"]},"properties":{"traceId":{"type":"keyword","doc_values":false},"traceType":{"type":"keyword"},"startTime":{"type":"long"},"endTime":{"type":"long","doc_values":false,"index":false},"recordType":{"type":"keyword","store":false,"doc_values":false,"index":false}}}
查询小技巧
- 如果不关心相关性评分,请使用过滤语句代替查询语句。
- 只获取自己关心的字段,而不是整个文档。
- 如果水平拆分索引,当查询不涉及全量数据时,只查询自己需要的索引。不要使用索引别名,因为使用索引别名会查询该别名对应的所有索引。
索引垂直拆分
通过梳理需求发现,主调用日志和子调用日志不会在一张表格里展示,也就是说它们是分开展现的。当前是把它们放在同一个索引,于是我把它们拆分到两个不同的索引,又进一步减小了单个索引的大小。这其实是一种垂直拆分的思路,上文提到的按天分索引是水平拆分。
成果
通过以上优化措施,es集群再也没有出现卡死的现象,如果是走热数据节点,数据量最大的索引(300万左右个文档)查询耗时也不过十几毫秒。可以说优化效果非常的好。
版权归原作者 咦940 所有, 如有侵权,请联系我们删除。