嵌套索引的坑
场景: 一个 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
- 本文地址:工作中组内遇到的 elasticsearch 使用上的踩坑总结
- 本文版权归作者和AIQ共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出
深入分析:
那对于数值的 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
注意:本文归作者所有,未经作者允许,不得转载