写入速度优化
在 ES 的默认设置下,是综合考虑数据可靠性、搜索实时性、写 入速度等因素的。当离开默认设置、追求极致的写入速度时,很多是 以牺牲可靠性和搜索实时性为代价的。有时候,业务上对数据可靠性 和搜索实时性要求并不高,反而对写入速度要求很高,此时可以调整 一些策略,最大化写入速度。
接下来的优化基于集群正常运行的前提下,如果是集群首次批量 导入数据,则可以将副本数设置为0,导入完毕再将副本数调整回 去,这样副分片只需要复制,节省了构建索引过程。
综合来说,提升写入速度从以下几方面入手:
- 加大translog flush间隔,目的是降低iops、writeblock。
- 加大index refresh间隔,除了降低I/O,更重要的是降低了segment merge频率。
- 调整bulk请求。
- 优化磁盘间的任务均匀情况,将shard尽量均匀分布到物理主机的各个磁盘。
- 优化节点间的任务分布,将任务尽量均匀地发到各节点。
- 优化Lucene层建立索引的过程,目的是降低CPU占用率及I/O,例如,禁用_all字段。
translog flush间隔调整
从ES 2.x开始,在默认设置下,translog的持久化策略为:每个请求都“flush”。对应配置项如下: index.translog.durability: request
这是影响 ES 写入速度的最大因素。但是只有这样,写操作才有 可能是可靠的。如果系统可以接受一定概率的数据丢失(例如,数据 写入主分片成功,尚未复制到副分片时,主机断电。由于数据既没有 刷到Lucene,translog也没有刷盘,恢复时translog中没有这个数 据,数据丢失),则调整translog持久化策略为周期性和一定大小的 时候“flush”,例如: index.translog.durability: async 设置为async表示translog的刷盘策略按sync_interval配置指 定的时间周期进行index.translog.sync_interval: 120s 加大translog刷盘间隔时间。默认为5s,不可低于100ms。 index.translog.flush_threshold_size: 1024mb 超过这个大 小会导致refresh操作,产生新的Lucene分段。默认值为512MB。
索引刷新间隔refresh_interval
默认情况下索引的refresh_interval为1秒,这意味着数据写1秒 后就可以被搜索到,每次索引的refresh会产生一个新的Lucene段, 这会导致频繁的segment merge行为,如果不需要这么高的搜索实 时性,应该降低索引refresh周期,例如: index.refresh_interval: 120s
段合并优化
segment merge操作对系统I/O和内存占用都比较高,从ES 2.0 开始,merge行为不再由ES控制,而是由Lucene控制,因此以下配 置已被删除: indices.store.throttle.type indices.store.throttle.max_bytes_per_sec index.store.throttle.type index.store.throttle.max_bytes_per_sec 改 为 以 下 调 整 开 关: index.merge.scheduler.max_thread_count index.merge.policy.* 最大线程数max_thread_count的默认值如下: Math.max ( 1, Math.min ( 4, Runtime.getRuntime().availableProcessors() / 2))
以上是一个比较理想的值,如果只有一块硬盘并且非 SSD,则应 该把它设置为1,因为在旋转存储介质上并发写,由于寻址的原因, 只会降低写入速度。
merge策略index.merge.policy有三种:
- tiered(默认策略);
- log_byete_size;
- log_doc。
索引创建时合并策略就已确定,不能更改,但是可以动态更新策 略参数,可以不做此项调整。如果堆栈经常有很多merge,则可以尝 试调整以下策略配置: index.merge.policy.segments_per_tier 该属性指定了每层分 段的数量,取值越小则最终segment越少,因此需要merge的操作 更 多 , 可 以 考 虑 适 当 增 加 此 值 。 默 认 为 10 , 其 应 该 大 于 等 于 index.merge.policy.max_merge_at_once。index.merge.policy.max_merged_segment 指 定 了 单 个 segment的最大容量,默认为5GB,可以考虑适当降低此值。
indexing buffer
indexing buffer在为doc建立索引时使用,当缓冲满时会刷入磁 盘,生成一个新的segment,这是除refresh_interval刷新索引外, 另 一 个 生 成 新 segment 的 机 会 。 每 个 shard 有 自 己 的 indexing buffer,下面的这个buffer大小的配置需要除以这个节点上所有 shard的数量:indices.memory.index_buffer_size默认为整个堆空间的10%。 indices.memory.min_index_buffer_size 默认为48MB。 indices.memory.max_index_buffer_size 默认为无限制。
在 执 行 大 量 的 索 引 操 作 时 , indices.memory.index_buffer_size的默认设置可能不够,这和可 用堆内存、单节点上的shard数量相关,可以考虑适当增大该值。
使用bulk请求
批量写比一个索引请求只写单个文档的效率高得多,但是要注意 bulk请求的整体字节数不要太大,太大的请求可能会给集群带来内存 压力,因此每个请求最好避免超过几十兆字节,即使较大的请求看上 去执行得更好。
bulk线程池和队列
建立索引的过程属于计算密集型任务,应该使用固定大小的线程 池配置,来不及处理的任务放入队列。线程池最大线程数量应配置为 CPU核心数+1,这也是bulk线程池的默认设置,可以避免过多的上 下文切换。队列大小可以适当增加,但一定要严格控制大小,过大的 队列导致较高的GC压力,并可能导致FGC频繁发生。
并发执行bulk请求
bulk写请求是个长任务,为了给系统增加足够的写入压力,写入 过程应该多个客户端、多线程地并行执行,如果要验证系统的极限写 入能力,那么目标就是把CPU压满。磁盘util、内存等一般都不是瓶 颈。如果 CPU 没有压满,则应该提高写入端的并发数量。但是要注 意 bulk线程池队列的reject情况,出现reject代表ES的bulk队列已 满 , 客 户 端 请 求 被 拒 绝 , 此 时 客 户 端 会 收 到 429 错 误(TOO_MANY_REQUESTS),客户端对此的处理策略应该是延迟重 试。不可忽略这个异常,否则写入系统的数据会少于预期。即使客户 端正确处理了429错误,我们仍然应该尽量避免产生reject。因此, 在评估极限的写入能力时,客户端的极限写入并发量应该控制在不产 生reject前提下的最大值为宜。
磁盘间的任务均衡
如果部署方案是为path.data配置多个路径来使用多块磁盘,则 ES在分配shard时,落到各磁盘上的 shard 可能并不均匀,这种不均 匀可能会导致某些磁盘繁忙,利用率在较长时间内持续达到100%。 这种不均匀达到一定程度会对写入性能产生负面影响。 ES在处理多路径时,优先将shard分配到可用空间百分比最多的 磁盘上,因此短时间内创建的shard可能被集中分配到这个磁盘上, 即使可用空间是99%和98%的差别。后来ES在2.x版本中开始解决这 个问题:预估一下 shard 会使用的空间,从磁盘可用空间中减去这部 分,直到现在6.x版也是这种处理方式。但是实现也存在一些问题:
从可用空间减去预估大小
这种机制只存在于一次索引创建的过程中,下一次的索引创建, 磁盘可用空间并不是上次做完减法以后的结果。这也可以理解,毕竟 预估是不准的,一直减下去空间很快就减没了。 但是最终的效果是,这种机制并没有从根本上解决问题,即使没 有完美的解决方案,这种机制的效果也不够好。 如果单一的机制不能解决所有的场景,那么至少应该为不同场景 准备多种选择。
为此,我们为ES增加了两种策略。
- 简单轮询:在系统初始阶段,简单轮询的效果是最均匀的。
- 基于可用空间的动态加权轮询:以可用空间作为权重,在磁盘之间加权轮询
节点间的任务均衡
为了节点间的任务尽量均衡,数据写入客户端应该把bulk请求轮 询发送到各个节点。 当使用Java API或REST API的bulk接口发送数据时,客户端将 会轮询发送到集群节点,节点列表取决于:
- 使用Java API时,当设置client.transport.sniff为true(默认 为false)时,列表为所有数据节点,否则节点列表为构建客户端对象 时传入的节点列表
- 使用REST API时,列表为构建对象时添加进去的节点。
Java API的TransportClient和REST API的RestClient都是线程 安全的,如果写入程序自己创建线程池控制并发,则应该使用同一个 Client对象。在此建议使用REST API,Java API会在未来的版本中废 弃,REST API有良好的版本兼容性好。理论上,Java API在序列化上 有性能优势,但是只有在吞吐量非常大时才值得考虑序列化的开销带 来的影响,通常搜索并不是高吞吐量的业务。 要观察bulk请求在不同节点间的均衡性,可以通过cat接口观察 bulk线程池和队列情况
索引过程调整和优化
自动生成doc ID
通过ES写入流程可以看出,写入doc时如果外部指定了id,则ES 会先尝试读取原来doc的版本号,以判断是否需要更新。这会涉及一 次读取磁盘的操作,通过自动生成doc ID可以避免这个环节。
调整字段Mappings
(1)减少字段数量,对于不需要建立索引的字段,不写入ES。
(2)将不需要建立索引的字段index属性设置为not_analyzed或no。对字段不分词,或者不索引,可以减少很多运算操作,降低CPU占用。尤其是binary类型,默认情况下占用CPU非常高,而这种类型进行分词通常没有什么意义。
(3)减少字段内容长度,如果原始数据的大段内容无须全部建立索引,则可以尽量减少不必要的内容。
(4)使用不同的分析器(analyzer),不同的分析器在索引过程中运算复杂度也有较大的差异。
调整_source字段
_source 字段用于存储 doc 原始数据,对于部分不需要存储的 字段,可以通过 includes excludes过滤,或者将_source禁用,一 般用于索引和数据分离。
这样可以降低 I/O 的压力,不过实际场景中大多不会禁用 _source,而即使过滤掉某些字段,对于写入速度的提升作用也不 大,满负荷写入情况下,基本是 CPU 先跑满了,瓶颈在于CPU。
禁用_all字段
从ES 6.0开始,_all字段默认为不启用,而在此前的版本中,_all 字段默认是开启的。_all字段中包含所有字段分词后的关键词,作用 是可以在搜索的时候不指定特定字段,从所有字段中检索。ES 6.0默 认禁用_all字段主要有以下几点原因:
- 由于需要从其他的全部字段复制所有字段值,导致_all字段占用 非常大的空间。
- _all 字段有自己的分析器,在进行某些查询时(例如,同义 词),结果不符合预期,因为没有匹配同一个分析器。
- 由于数据重复引起的额外建立索引的开销。
- 想要调试时,其内容不容易检查。
- 有些用户甚至不知道存在这个字段,导致了查询混乱。
- 有更好的替代方法。
对Analyzed的字段禁用Norms
Norms用于在搜索时计算doc的评分,如果不需要评分,则可以 将其禁用:
"title": {"type": "string","norms": {"enabled":
false}}
index_options 设置
index_options用于控制在建立倒排索引过程中,哪些内容会被 添加到倒排索引,例如,doc数量、词频、positions、offsets等信 息,优化这些设置可以一定程度降低索引过程中的运算任务,节省 CPU占用率。 不过在实际场景中,通常很难确定业务将来会不会用到这些信 息,除非一开始方案就明确是这样设计的。
最后
- (1)方法比结论重要。一个系统性问题往往是多种因素造成 的,在处理集群的写入性能问题上,先将问题分解,在单台上进行压 测,观察哪种系统资源达到极限,例如,CPU或磁盘利用率、I/O block、线程切换、堆栈状态等。然后分析并调整参数,优化单台上 的能力,先解决局部问题,在此基础上解决整体问题会容易得多。
- (2)可以使用更好的 CPU,或者使用 SSD,对写入性能提升明 显。在我们的测试中,在相同条件下,E5 2650V4比E5 2430 v2的 写入速度高60%左右。
- (3)在我们的压测环境中,写入速度稳定在平均单机每秒3万条 以上,使用的测试数据:每个文档的字段数量为10个左右,文档大小 约100字节,CPU使用E5 2430 v2。