工作中组内遇到的 elasticsearch 使用上的踩坑总结
嵌套索引的坑
场景: 一个spu doc下有多个内嵌的csu,csu内有上下架状态,前台操作某csu上下架,在商城界面看起来未生效。
坑1: mysql binlog消息监控组件dbus 通知服务端B多台机器消息变更时,未考虑spu下csu消息的消费顺序性,导致同一spu的多个csu上下架变更消息被多个后端服务乱序消费
方案: 重新定制dbus消息通知 的分发逻辑,采用spu的唯一标志分发,进而保证同一spu的消息定向到某台机器上,最终确保 顺序下发到服务端
坑2: dbus监控商品业务端A的某一从库,因为上下架对于业务端A的写操作肯定会写主库,导致多个从库同步落地时间不一致,到这里还没问题,关键点后端服务B为了填充整个doc,又去业务端A做了读操作。导致后端服务B消费 消息的上下架状态字段时可能拿到某一还未被同步的从库数据,导致状态变更丢失
方案:
- B应该相信dbus的消息通知中的上下架状态,而不应该依赖另一个数据源(A)
- 服务端B如果非得去查A,可以控制只能查询A的主库
坑3: 同事们踩过之前的坑后,状态更新丢失频度大幅削减,但还是被运营端发现有部分csu上下架失败。经同事排查,老代码中用的BulkProcessor
API,且用业务代码采用异步方式使用的这个API,业务代码并未有任何异步回调告诉开发者是否更新ES成功,这就导致了即使更新失败也难以察觉(为啥没有API底层内部异常日志?)
方案: 同事深入代码底层发现问题后,决定捕获更新时的错误,并采取消息重试机制。上线后很多的异常堆栈出现在了日志中,真正的问题才得以暴露,异常显示ES 的 version conflict,事实上是多个请求线程同时更新同一个doc, 并且嵌套索引(一个spu下多个csu)放大了更新同一个spu的频度,因为更新一个doc相当于加锁的粒度是多个csu,超级像老版本的ConcurrentHashMap
分段锁,后来被优化后的ConcurrentHashMap
,锁的粒度就变成了map中的单个key。
备注: 内嵌结构更像是外层doc的一个字段,亲测过如果尝试更新内嵌结构的单个字段,其它字段内容将会丢失!
ES2.x升级到ES5.x的坑
场景: ES2.x集群 版本升级到5.x,上线后,线上大量查询ES请求超时。
坑: ES2.x集群 版本升级到5.x时,未将某long
或int
等用于过滤的数值类型字段改为keyword,比如可能是表示状态的字段(会匹配召回超大量doc)。lucene6.x之前的版本 lucene对数值型数据都会建立倒排索引,而对数值型数据的rang query
支持的很垃圾,广为诟病后,lucene于 6.x之后,对数值型数据采用多维空间索引树Block k-d tree
,来优化范围查询。
- 首先,用户范例查询里还有其他更加结果集更小的TermQuery,cost更低,因此迭代器从选择从这个低代价的Query作为起点开始执行;
- 其次,因为数值型字段在5.x里没有采用倒排表索引, 而是以value为序,将docid切分到不同的block里面。对应的,数值型字段的TermQuery被转换为了PointRangeQuery。这个Query利用Block k-d tree进行范围查找速度非常快,但是满足查询条件的docid集合在磁盘上并非向Postlings list那样按照docid顺序存放,也就无法实现postings list上借助跳表做蛙跳的操作。 要实现对docid集合的快速advance操作,只能将docid集合拿出来,做一些再处理。 这个处理过程在
org.apache.lucene.search.PointRangeQuery#createWeight
这个方法里可以读取到。 这里就不贴冗长的代码了,主要逻辑就是在创建scorer对象的时候,顺带先将满足查询条件的docid都选出来,然后构造成一个代表docid集合的bitset,这个过程和构造Query cache的过程非常类似。 之后advance操作,就是在这个bitset上完成的。
方案: 修改某些用于过滤数值字段为keyword
深入分析:
那对于数值的RangeQuery
也会在k-d tree
上查找docId,类似上面数值型的TermQuery
为啥前者就那么快呢?以下内容摘自文末参考文档1
Block k-d tree的基本概念和Lucene实现
基本思想就是将一个N维的数值空间,不断选定包含值最多的维度做2分切割,反复迭代,直到切分出来的空间单元(cell
)包含的值数量小于某个数值。 对于单维度的数据,实际上就是简单的对所有值做一个排序,然后反复从中间做切分,生成一个类似于B-tree这样的结构。和传统的B-tree不同的是,他的叶子结点存储的不是单值,而是一组值的集合,也就是是所谓的一个Block。每个Block内部包含的值数量控制在512- 1024个,保证值的数量在block之间尽量均匀分布。 其数据结构大致看起来是这样的:
Lucene将这颗B-tree的非叶子结点部分放在内存里,而叶子结点紧紧相邻存放在磁盘上。当作range查询的时候,内存里的B-tree可以帮助快速定位到满足查询条件的叶子结点块在磁盘上的位置,之后对叶子结点块的读取几乎都是顺序的。
要注意一点,不是简单的将拿到的所有块合并就可以得到想要的docID结果集,因为查询的上下边界不一定刚好落在两端block的上下边界上。 所以如果需要拿到range filter的结果集,就要对于两端的block内的docid做扫描,将他们的值和range的上下边界做比较,挑选出match的docid集合。
从上面数值型字段的Block k-d tree的特性可以看出,rangeQuery的结果集比较小的时候,其构造bitset的代价很低,不管是从他开始迭代做nextdoc()
,或者从其他结果集开始迭代,对其做advance
,都会比较快。 但是如果rangeQuery的结果集非常巨大,则构造bitset的过程会大大延缓scorer对象的构造过程,造成结果合并过程缓慢。
这个问题官方其实早已经意识到了,所以从ES5.4开始,引入了indexOrDocValuesQuery
作为对RangeQuery的优化。(参考: better-query-planning-for-range-queries-in-elasticsearch)。 这个Query包装了上面的PointRangeQuery
和SortedSetDocValuesRangeQuery
,并且会根据Rang查询的数据集大小,以及要做的合并操作类型,决定用哪种Query。 如果Range的代价小,可以用来引领合并过程,就走PointRangeQuery
,直接构造bitset来进行迭代。 而如果range的代价高,构造bitset太慢,就使用SortedSetDocValuesRangeQuery
。 这个Query利用了DocValues这种全局docID序,并包含每个docid对应value的数据结构来做文档的匹配。 当给定一个docid的时候,一次随机磁盘访问就可以定位到该id对应的value,从而可以判断该doc是否match。 因此它非常适合从其他查询条件得到的一个小结果集作为迭代起点,对于每个docid依次调用其内部的matches()
函数判断匹配与否。也就是说, 5.4新增的indexOrDocValuesQuery
将Range查询过程中的顺序访问任务扔给Block k-d Tree索引,将随机访任务交给doc values。 值得注意的是目前这个优化只针对RangeQuery!对于TermQuery,因为实际的复杂性,还未做类似的优化,也就导致对于数值型字段,Term和Range Query的性能差异极大。
小结:
- 在ES5.x里,一定要注意数值类型是否需要做范围查询,看似数值,但其实只用于Term或者Terms这类精确匹配的,应该定义为keyword类型。典型的例子就是索引web日志时常见的HTTP Status code。
- 如果RangeQuery的结果集很大,并且还需要和其他结果集更小的查询条件做AND的,应该升级到ES5.4+,该版本在底层引入的
indexOrDocValuesQuery
,可以极大提升该场景下RangeQuery的查询速度。
参考文档
[1] https://elasticsearch.cn/article/446