工作中组内遇到的 elasticsearch 使用上的踩坑总结



转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com

AIQ 机器学习大数据 知乎专栏 点击关注

嵌套索引的坑

场景: 一个 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 消费 消息的上下架状态字段时可能拿到某一还未被同步的从库数据,导致状态变更丢失

方案:

  1. B 应该相信 dbus 的消息通知中的上下架状态,而不应该依赖另一个数据源(A)
  2. 服务端 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 时,未将某longint等用于过滤的数值类型字段改为 keyword,比如可能是表示状态的字段(会匹配召回超大量 doc)。lucene6.x 之前的版本 lucene 对数值型数据都会建立倒排索引,而对数值型数据的rang query 支持的很垃圾,广为诟病后,lucene 于 6.x 之后,对数值型数据采用多维空间索引树Block k-d tree,来优化范围查询。

  1. 首先,用户范例查询里还有其他更加结果集更小的 TermQuery,cost 更低,因此迭代器从选择从这个低代价的 Query 作为起点开始执行;
  2. 其次,因为数值型字段在 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 包装了上面的PointRangeQuerySortedSetDocValuesRangeQuery,并且会根据 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 的性能差异极大。

小结:

  1. 在 ES5.x 里,一定要注意数值类型是否需要做范围查询,看似数值,但其实只用于 Term 或者 Terms 这类精确匹配的,应该定义为 keyword 类型。典型的例子就是索引 web 日志时常见的 HTTP Status code。
  2. 如果 RangeQuery 的结果集很大,并且还需要和其他结果集更小的查询条件做 AND 的,应该升级到 ES5.4+,该版本在底层引入的indexOrDocValuesQuery,可以极大提升该场景下 RangeQuery 的查询速度。

参考文档

[1] https://elasticsearch.cn/article/446


更多高质资源 尽在AIQ 机器学习大数据 知乎专栏 点击关注

转载请注明 AIQ - 最专业的机器学习大数据社区  http://www.6aiq.com