ES索引的最后一公里,减少最后的延迟

96
华安火车迷
0.4 2018.06.17 15:32 字数 2937

我经常被问到这样的问题:ES最大能撑多少QPS ?
确实这比证明我爸是我爸的问题更难解释清楚,不过我通常会给出两个选择;1)你是否希望尽量高QPS而不需要管最大latency甚至是总体latency升的很高? 2)你是否希望最大的latency 尽量的低,甚至不允许发生大于XXX的latency?因此这个回答需要你对你需要实现的业务熟悉。

大促在即,最近几个索引被盯上了,因为出现了类似99.9% 百分位延迟很大的问题,大家都知道,在电商大并发的系统里,任何的延迟抖动最后可能都会导致非常恐怖的崩塌效应,因此最近两周都在仔细地琢磨这个问题,想尽力去解决。

话不多说,出正题,当前我们的索引遇到了下面的问题:

  1. 索引A,百万级,用terms + Time range + aggs, 偶尔延迟抖动很厉害,最大延迟去到1s
  2. 索引B,亿级别,只对id 做terms 查询,平均延迟几ms,但是99.9% 百分位延迟有500ms+,重点调优对象
  3. 这几个索引的indxing rate 在几百上千不等,在优化这几个索引期间,用定时任务做force merge,但是发现做force merge时总体延迟非常高,需要分析为什么会这样。

问题一:range, range 还是range 惹的祸

第一个问题,由于terms 只会过滤出非常少量的结果,因此我猜测aggs 是非常稳定的,首先排除,最大嫌疑肯定就是这个Time range 了,因此解决办法也非常迅速,因为terms 结果集很少,那么就直接把Time 的range 放一个script在内存算好了, 结果当然是,延迟抖动没有了!
之前的文章也曾经介绍过,对于不做数学运算,不做聚合的数字类型应该用keyword 来建索引,但是像类似时间这种类型,其实本质上还是用了long来存,所以对它做range时,之前说的那些Elasticsearch 5.x 源码分析(12)对类似枚举数据的搜索异常慢的一种猜测 问题都还是会有,本质都是用了Lucene 新的 Block K-d Tree 的数据结构引起的,因此如果你做的range结果集非常大的话就还是有问题,避免的方式无非就是减少这些bucket的数量,比如采用秒级时间戳落盘,而不是毫秒,这样查询速度提升还是很明显的。

那你应该会问了,新版本ES不是修了这个issue了么,是的,在新的Lucene 里这个查询被一个IndexOrDocValuesQuery ("Points + Doc values") 包了一层,如果Lucene的新solution 还不清楚的再次翻一下上面的这个连接,下面我们从性能的角度再看这个问题。


https://www.elastic.co/blog/better-query-planning-for-range-queries-in-elasticsearch

这张图是从ES的一篇博客中截取,绿色的线指的是如果你的terms查询总是召回0.1% 的结果集,那么这个查询的延迟 一般是很稳定的。紫色的线就代表这个range 查询随着range 的结果集增大而延迟增大,这个很容易理解,那么再看这个蓝色的线,也就是Lucene的这个solution,它的意思就是在range 的结果集在0.1%以内时,则还是走了这个field的索引,并且把取得的id集合和terms的id集合做conjunction处理,但是如果range 的结果集大于0.1%时,怎放弃走range field的索引,而是直接在terms的结果集基础上逐个对doc Value 进行判断。

上面这个图当然是个极度理想化的图,因为这个IndexOrDocValuesQuery永远都是得到一个最低延迟的查询,因此实际情况很可能是下面这个图


首先我们不可能每次的结果集占比都是非常稳定,IndexOrDocValuesQuery是个只智能分配的过程,比如如果这个阈值取1%,而range在这个1%结果集的延迟还是低于对terms的1%结果集做doc Value查询时,那么从蓝线得出,我们这次的tradeoff 是亏了的。但我们仍然觉得这个交易还是划得来的,因为我们只损失了毫秒级而已。

这个问题我在ES5.5 发现已经修复了,那么为什么还是会带来这么厉害的延迟抖动呢,问题就在于,range 的索引不是常驻内存的。也就是说上面的紫线的结果,在高并发或者甚至地并发随着时间的推移,它总会被GC掉!因此重新取这个cache是无法避免的。
所以如果你想尽量减少这个开销,那么你只能把Query cache调大来缓解,而最致命的是下面这个Lucene的issue

Cache costly subqueries asynchronously

如果你把range查询放在filter里,那么Lucene总是希望尝试去cache这个查询,因此,如果cache丢失了,它下次又会尝试去查询并且cache住。这个问题至少在Lucene 7.2 还是没有很好解决。

问题一解决办法:
对于你能判断的做完terms 后的结果集是恒定并且很少的话,尽量避免掉对大结果集的字段做range查询,放在内存做是个非常不错的选择。


问题二:如果想低延迟,尽量把整个index cache住

这个问题我们从头到尾再捋一捋

  • 机器 24C/64G/800G, HEAP 30G
  • index 130G+/3shards/ 50G一个分片
  • Query: id terms + time field doc values

下面是其中的一个查询例子:

{
    "size": 50,
    "query": {
      "bool": {
        "filter": [
          {
            "terms": {
              "_id": [
                11111111111,
                22222222222,
                33333333333,
                 ...
              ]
            }
          },
          {
            "script": {
              "script": {
                "lang": "painless",
                "params": {
                  "now": 1523849100000
                },
                "inline": "def now = params.now; if (now == null) { def d = new Date(); now = d.getTime(); } now+=28800000; return doc['sell_time'].value > now"
              }
            }
          }
        ]
      }
    }
}

乍一看大家都傻眼了,可以说这就是一个简单的get id的操作,老实说Redis可能轻松就上几W QPS了,这个ES最长延迟竟然去到500ms+ 甚至有1s的,都不太好意思去交代。

但是只要仔细去分析,还是挺容易发现问题的,我们用Lucene的语言去解读这个查询:

  • _id terms , 我们的id字段,为了尽量避免对long 做term,我们尝试把 id改成 _id,这个过程会读取 Lucene FST 前缀表 (常驻HEAP),后缀表(不常驻HEAP,但是理论应该常驻OS cache),找到一堆id集合
  • 对这些id集合逐个读取sell_time的 doc Value 表 (不常驻 HEAP, 如果很大可能会有OS cache miss)
  • 最后拿到最终的id结果集,去正向 .fdt .fnm .fdx表中去捞数据 (不常住HEAP,如果很大可能会有OS cache miss)

接下来逐个去分析,先看FST表,Lucene的FST表大致是一下类似下面这样的结构图


摘自http://blog.csdn.net/ronalod

这里简单介绍两句,Lucene对有terms 倒排表,是分开前缀表和后缀表两部分组成,为了不会撑爆HEAP,Lucene会智能推算出一个前缀表来常驻HEAP,比如我们做一个 term :abcd, 那么其实会先查找FST表,找到ab,或者abc, 然后再从后缀表找到abcd,进而在doc 里查到倒排索引表,如果从数据结构来看,大致入下图

摘自http://blog.csdn.net/ronalod

根据热数据特性,计算是tim文件不是常驻 HEAP,那么其实原则上它会失效的可能也是很低的,就是说根据一个前缀,一下找到一堆的id的可能性是非常大的,因此这部分不太可能造成大延迟。

那么在往下看,doc values 保存在dvd 中,并且 id 都是顺序保存的,保存就是一堆的key/value 结构,只不过 key 都是通过Lucene压缩的数据结构,如果最后压缩的文件很少,那么对于一堆的id集合来说,page cache missing 应该会存在,但是频繁missing应该不至于。所以这个地方也是个关注点,cache missing应该还是会有延迟增长的,特别是在大segment merge完后特别明显。

摘自http://blog.csdn.net/ronalod

最后就是fetch 的过程了,这个过程就没什么好说了,通过id get doc,最最最坏的情况应该就是在大segments 做完merge,所有文件都没cache, 然后突然来一批稀疏id的时候去load 文件,这种造成最大的延迟的可能性是最大的。

那再回顾这个索引, 单机占用空间50G+, 而我们只有64G内存,30G分配到HEAP,也就是 OS cache 其实30G不到,那么要缓存50G+ 的文件,应该cache 频繁切换的可能还是有的,这里还没算上 1K TPS 的indexing,还有后台的merge 线程所造成的大文件切换。

问题二解决办法:
那么分析到这里的话,99.9% 百分位延迟的问题似乎就讲得通了,因此我们的措施就是增大内存到96G,减少索引容量,清理掉没用的数据,并且要把refresh 的时间把握好,尽量让Lucene来生成一些不大不小的segments,一方面避免后台频繁merge,一方面也使得切换大文件时OS 能尽快地cache segments文件。


问题三:做force merge 延迟非常高

由于我们show hand买中问题二的root cause,那么问题三自然就很容易推断了:
首先,force merge 会强行merge一些比较大的segments,例如 2G + 2G -> 4G ,这些在切指针时就会造成这4G的文件全部missing,OS加载需要一点时间。
其次,FST 表需要重新算,doc values需要重新算,元数据表需要重新算,这些都是额外常驻在HEAP的,因此大索引的话做force merge 其实 HEAP的老生代很容易沾满并且频繁触发GC, 甚至最坏的时候会有full GC,在第三个问题排查的时候我们的节点发生了最高超过6s的GC。
所以最后的总结就是在生产的环境谨慎对待force merge。


结论:

  • 谨慎对待数字类型的查询,long, date 等,date字段应该尽可能round up到秒,分钟更佳,long值不做运算的统一改到keyword,这样FST表可以常驻HEAP
  • 如果对最大延迟有要求,合理分配索引大小和机器内存,如果OS cache能够完全cover index size则基本可以消除掉page cache missing带来的大延迟
  • 合理规划好索引segments 的merge ,大白天的谨慎操作force merge

参考文献:

Lucene底层原理和优化经验分享(1)-Lucene简介和索引原理
Lucene底层原理和优化经验分享(2)-Lucene优化经验总结
Frame of Reference and Roaring Bitmaps
Better Query Planning for Range Queries in Elasticsearch
Latency spike after big merge
Cache costly subqueries asynchronously
Solr Wiki

elasticsearch