我经常被问到这样的问题:ES最大能撑多少QPS ?
确实这比证明我爸是我爸的问题更难解释清楚,不过我通常会给出两个选择;1)你是否希望尽量高QPS而不需要管最大latency甚至是总体latency升的很高? 2)你是否希望最大的latency 尽量的低,甚至不允许发生大于XXX的latency?因此这个回答需要你对你需要实现的业务熟悉。
大促在即,最近几个索引被盯上了,因为出现了类似99.9% 百分位延迟很大的问题,大家都知道,在电商大并发的系统里,任何的延迟抖动最后可能都会导致非常恐怖的崩塌效应,因此最近两周都在仔细地琢磨这个问题,想尽力去解决。
话不多说,出正题,当前我们的索引遇到了下面的问题:
- 索引A,百万级,用terms + Time range + aggs, 偶尔延迟抖动很厉害,最大延迟去到1s
- 索引B,亿级别,只对id 做terms 查询,平均延迟几ms,但是99.9% 百分位延迟有500ms+,重点调优对象
- 这几个索引的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 还不清楚的再次翻一下上面的这个连接,下面我们从性能的角度再看这个问题。
这张图是从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
如果你把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集合
Notice:
这里忘了说经一下就是我们之前的业务的id字段都是用long值和类型来保存,但是根据经验来看,如果不是id用一些非常复杂的逻辑拼凑的话应该还是不用去太多顾虑这个的加载问题,(我不过我们确实碰到过一些业务方,比如财务,由于是hive库表,确实唯一id字段,都会用非常复杂的拼凑逻辑来拼凑一个字符串来作为id,这种的话id 的查找确实比较蛋疼),由于一般的id都是有序增加,就算是用number保存查询也是比较快的
- 对这些id集合逐个读取sell_time的 doc Value 表 (不常驻 HEAP, 如果很大可能会有OS cache miss)
- 最后拿到最终的id结果集,去正向 .fdt .fnm .fdx表中去捞数据 (不常住HEAP,如果很大可能会有OS cache miss)
接下来逐个去分析,先看FST表,Lucene的FST表大致是一下类似下面这样的结构图
这里简单介绍两句,Lucene对有terms 倒排表,是分开前缀表和后缀表两部分组成,为了不会撑爆HEAP,Lucene会智能推算出一个前缀表来常驻HEAP,比如我们做一个 term :abcd, 那么其实会先查找FST表,找到ab,或者abc, 然后再从后缀表找到abcd,进而在doc 里查到倒排索引表,如果从数据结构来看,大致入下图
根据热数据特性,计算是tim文件不是常驻 HEAP,那么其实原则上它会失效的可能也是很低的,就是说根据一个前缀,一下找到一堆的id的可能性是非常大的,因此这部分不太可能造成大延迟。
那么在往下看,doc values 保存在dvd 中,并且 id 都是顺序保存的,保存就是一堆的key/value 结构,只不过 key 都是通过Lucene压缩的数据结构,如果最后压缩的文件很少,那么对于一堆的id集合来说,page cache missing 应该会存在,但是频繁missing应该不至于。所以这个地方也是个关注点,cache missing应该还是会有延迟增长的,特别是在大segment merge完后特别明显。
最后就是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