写点什么

解密 Elasticsearch:深入探究这款搜索和分析引擎 | 京东云技术团队

  • 2023-05-05
    北京
  • 本文字数:3878 字

    阅读完需:约 13 分钟

解密Elasticsearch:深入探究这款搜索和分析引擎 | 京东云技术团队

作者:京东保险 管顺利

开篇

最近使用 Elasticsearch 实现画像系统,实现的 dmp 的数据中台能力。同时调研了竞品的架构选型。以及重温了 redis 原理等。特此做一次 es 的总结和回顾。网上没看到有人用 Elasticsearch 来完成画像的。我来做第一次尝试。


背景说完,我们先思考一件事,使用内存系统做数据库。他的优点是什么?他的痛点是什么?

一、原理

这里不在阐述全貌。只聊聊通讯、内存、持久化三部分。

通讯

es 集群最小单元是三个节点。两个从节点搭配保证其高可用也是集群化的基础。那么节点之间 RPC 通讯用的是什么?必然是 netty,es 基于 netty 实现了 Netty4Transport 的通讯包。初始化 Transport 后建立 Bootstrap,通过 MessageChannelHandler 完成接收和转发。es 里区分 server 和 client,如图 1。序列化使用的 json。es 在 rpc 设计上偏向于易用、通用、易理解。而不是单追求性能。



图 1


有了 netty 的保驾护航使得 es 放心是使用 json 序列化。

内存

图 2


es 内存分为两部分【on heap】和【off heap】。on heap 这部分由 es 的 jvm 管理。off heap 则是由 lucene 管理。on heap 被分为两部分,一部分可以回收,一部分不能回收。


能回收的部分 index buffer 存储新的索引文档。当被填满时,缓冲区的文档会被写入到磁盘 segment 上。node 上共享所有 shards。


不能被回收的有 node query cache、shard request cache、file data cache、segments cache


node query cache 是 node 级缓存,过滤后保存在每个 node 上,被所有 shards 共享,使用 bitset 数据结构(布隆优化版)关掉了评分。使用的 LRU 淘汰策略。GC 无法回收。


shard request cache 是 shard 级缓存,每个 shard 都有。默认情况下该缓存只存储 request 结果 size 等于 0 的查询。所以该缓存不会被 hits,但却缓存 hits.total,aggregations,suggestions。可以通过 clear cache api 清除。使用的 LRU 淘汰策略。GC 无法回收。


file data cache 是把聚合、排序后的 data 缓存起来。初期 es 是没有 doc values 的,所以聚合、排序后需要有一个 file data 来缓存,避免磁盘 IO。如果没有足够内存存储 file data,es 会不断地从磁盘加载数据到内存,并删除旧的数据。这些会造成磁盘 IO 和引发 GC。所以 2.x 之后版本引入 doc values 特性,把文档构建在 indextime 上,存储到磁盘,通过 memory mapped file 方式访问。甚至如果只关心 hits.total,只返回 doc id,关掉 doc values。doc values 支持 keyword 和数值类型。text 类型还是会创建 file data。


segments cache 是为了加速查询,FST 永驻堆内内存。FST 可以理解为前缀树,加速查询。but!!es 7.3 版本开始把 FST 交给了堆外内存,可以让节点支持更多的数据。FST 在磁盘上也有对应的持久化文件。


off heap 即 Segments Memory,堆外内存是给 Lucene 使用的。 所以建议至少留一半的内存给 lucene。


es 7.3 版本开始把 tip(terms index)通过 mmp 方式加载,交由系统的 pagecache 管理。除了 tip,nvd(norms),dvd(doc values), tim(term dictionary),cfs(compound)类型的文件都是由 mmp 方式加载传输,其余都是 nio 方式。tip off heap 后的效果 jvm 占用量下降了 78%左右。可以使用_cat/segments API 查看 segments.memory 内存占用量。


由于对外内存是由操作系统 pagecache 管理内存的。如果发生回收时,FST 的查询会牵扯到磁盘 IO 上,对查询效率影响比较大。可以参考 linux pagecache 的回收策略使用双链策略。

持久化

es 的持久化分为两部分,一部分类似快照,把文件缓存中的 segments 刷新(fsync)磁盘。另一部分是 translog 日志,它每秒都会追加操作日志,默认 30 分钟刷到磁盘上。es 持久化和 redis 的 RDB+AOF 模式很像。如下图


图 3


上图是一个完整写入流程。磁盘也是分 segment 记录数据。这里濡染跟 redis 很像。但是内部机制没有采用 COW(copy-on-write)。这也是查询和写入并行时 load 被打满的原因所在。

图 4

小结

es 内存和磁盘的设计上非常巧妙。零拷贝上采用 mmap 方式,磁盘数据映射到 off heap,也就是 lucene。为了加速数据的访问,es 每个 segment 都有会一些索引数据驻留在 off heap 里;因此 segment 越多,瓜分掉的 off heap 也越多,这部分是无法被 GC 回收!


结合以上两点可以清楚知道为什么 es 非常吃内存了。

二、应用

用户画像系统中有以下难点需要解决。


1.人群预估:根据标签选出一类人群,如 20-25 岁的喜欢电商社交的男性。20-25 岁∩电商社交∩男性。通过与或非的运算选出符合特征的 clientId 的个数。这是一组。


我们组与组之前也是可以在做交并差的运算。如既是 20-25 岁的喜欢电商社交的男性,又是北京市喜欢撸铁的男性。(20-25 岁∩电商社交∩男性)∩(20-25 岁∩撸铁∩男性)。对于这样的递归要求在 17 亿多的画像库中,秒级返回预估人数。


2.人群包圈选:上述圈选出的人群包。 要求分钟级构建。


3.人包判定:判断一个 clientId 是否存在若干个人群包中。要求 10 毫秒返回结果。


我们先尝试用 es 来解决以上所有问题。


人群预估,最容易想到方案是在服务端的内存中做逻辑运算。但是圈选出千万级的人群包人数秒级返回的话在服务端做代价非常大。这时候可以吧计算压力抛给 es 存储端,像查询数据库一样。使用一条语句查出我们想要的数据来。


例如 mysql


select a.age from a where a.tel in (select b.age from b);
复制代码


对应的 es 的 dsl 类似于


{"query":{"bool":{"must":[{"bool":{"must":[{"term":{"a9aa8uk0":{"value":"age18-24","boost":1.0}}},{"term":{"a9ajq480":{"value":"male","boost":1.0}}}],"adjust_pure_negative":true,"boost":1.0}},{"bool":{"adjust_pure_negative":true,"boost":1.0}}],"adjust_pure_negative":true,"boost":1.0}}}
复制代码


这样使用 es 的高检索性能来满足业务需求。无论所少组,组内多少的标签。都打成一条 dsl 语句。来保证秒级返回结果。


使用官方推荐的 RestHighLevelClient,实现方式有三种,一种是拼 json 字符串,第二种调用 api 去拼字符串。我使用第三种方式 BoolQueryBuilder 来实现,比较优雅。它提供了 filter、must、should 和 mustNot 方法。如


     /**     * Adds a query that <b>must not</b> appear in the matching documents.     * No {@code null} value allowed.     */    public BoolQueryBuilder mustNot(QueryBuilder queryBuilder) {        if (queryBuilder == null) {            throw new IllegalArgumentException("inner bool query clause cannot be null");        }        mustNotClauses.add(queryBuilder);        return this;    }
/** * Gets the queries that <b>must not</b> appear in the matching documents. */ public List<QueryBuilder> mustNot() { return this.mustNotClauses; }
复制代码


使用 api 的可以大大的 show 下编代码的能力。


构建人群包。目前我们圈出最大的包有 7 千多万的 clientId。想要分钟级别构建完(7 千万数据在条件限制下 35 分钟构建完)需要注意两个地方,一个是 es 深度查询,另一个是批量写入。


es 分页有三种方式,深度分页有两种,后两种都是利用游标(scroll 和 search_after)滚动的方式检索。


scroll 需要维护游标状态,每一个线程都会创建一个 32 位唯一 scroll id,每次查询都要带上唯一的 scroll id。如果多个线程就要维护多个游标状态。search_after 与 scroll 方式相似。但是它的参数是无状态的,始终会针对对新版本的搜索器进行解析。它的排序顺序会在滚动中更改。scroll 原理是将 doc id 结果集保留在协调节点的上下文里,每次滚动分批获取。只需要根据 size 在每个 shard 内部按照顺序取回结果即可。


写入时使用线程池来做,注意使用的阻塞队列的大小,还要选择适的拒绝策略(这里不需要抛异常的策略)。批量如果还是写到 es 中(比如做了读写分离)写入时除了要多线程外,还有优化写入时的 refresh policy。


人包判定接口,由于整条业务链路非常长,这块检索,上游服务设置的熔断时间是 10ms。所以优化要优化 es 的查询(也可以 redis)毕竟没负责逻辑处理。使用线程池解决 IO 密集型优化后可以达到 1ms。tp99 高峰在 4ms。

三、优化、瓶颈与解决方案

以上是针对业务需求使用 es 的解题方式。还需要做响应的优化。同时也遇到 es 的瓶颈。


1.首先是 mapping 的优化。画像的 mapping 中 fields 中的 type 是 keyword,index 要关掉。人包中的 fields 中的 doc value 关掉。画像是要精确匹配;人包判定只需要结果而不需要取值。es api 上人包计算使用 filter 去掉评分,filter 内部使用 bitset 的布隆数据结构,但是需要对数据预热。写入时线程不易过多,和核心数相同即可;调整 refresh policy 等级。手动刷盘,构建时 index.refresh_interval 调整-1,需要注意的是停止刷盘会加大堆内存,需要结合业务调整刷盘频率。构建大的人群包可以将 index 拆分成若干个。分散存储可以提高响应。目前几十个人群包还是能支撑。如果日后成长到几百个的时候。就需要使用 bitmap 来构建存储人群包。es 对检索性能很卓越。但是如遇到写操作和查操作并行时,就不是他擅长的。比如人群包的数据是每天都在变化的。这个时候 es 的内存和磁盘 io 会非常高。上百个包时我们可以用 redis 来存。也可以选择使用 MongoDB 来存人包数据。

四、总结

以上是我们使用 Elasticsearch 来解决业务上的难点。同时发现他的持久化没有使用 COW(copy-on-write)方式。导致在实时写的时候检索性能降低。


使用内存系统做数据源有点非常明显,就是检索块!尤其再实时场景下堪称利器。同时痛点也很明显,实时写会拉低检索性能。当然我们可以做读写分离,拆分 index 等方案。


除了 Elasticsearch,我们还可以选用 ClickHouse,ck 也是支持 bitmap 数据结构。甚至可以上 Pilosa,pilosa 本就是 BitMap Database。

参考

贝壳DMP平台建设实践


Mapping parameters | Elasticsearch Reference [7.10] | Elastic


Elasticsearch 7.3 的 offheap 原理

发布于: 5 小时前阅读数: 2
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
解密Elasticsearch:深入探究这款搜索和分析引擎 | 京东云技术团队_elasticsearch_京东科技开发者_InfoQ写作社区