速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

Lucene 中的 Stored Fields 存储优化

  • 2021-01-09
  • 本文字数:4749 字

    阅读完需:约 16 分钟

Lucene 中的 Stored Fields 存储优化

1 背景


Qunar 酒店的搜索和 suggest 是基于 Lucene 构建的,在我们的使用场景中,由于召回和排序是作为两个单独的应用,当召回的文档数量比较多的时候,响应速度较慢,Young GC 也比较严重,导致并发量很难上去。经过分析我们发现,主要的问题是因为需要获取大量文档的存储字段,造成反序列化比较多,所以影响速度,GC 也比较多。


Lucene 正常的使用场景是不期望返回这么多文档的,一般是排序完成后只返回其中一页的结果,所以问题不明显,尽管也可以通过一些方法(比如粗排序)减少返回文档的数量,但问题还是存在的。所以针对这个问题,我们希望能够找到一个比较彻底的解决方案。


为什么获取存储字段会有速度和 GC 的问题呢?


我们知道,Lucene 的 Stored Fields 在存储的时候,会把文档的字段按照某种形式编码后存储,并且会按块进行压缩。所以获取存储字段的时候,先会对字段所在的块解压缩,然后将对应的字段值反序列化为 Java 对象,放到 StoredField 对象中,文档的所有字段组装成一个 Document 对象。


这里头对时间影响比较大的是解压缩和反序列化,对 GC 影响大的是两部分,一部分是反序列化会产生很多小的 Java 对象,另外是每个字段都会创建一个 StoredField Java 对象。


压缩的问题,可以通过选项禁用压缩解决,其他的在现有的实现上就不好避免了。


那么有没有其他的选项呢?Doc Values 提供了另外一种存储字段的方法,它采用列式存储,但其目的并不是为了替代 Stored Fields,Doc Values 适用于获取大量文档的少数字段的情况,而 Stored Fields 适用于获取少数文档的大量字段的情况,Doc Values 通常用于排序、算分或者 Facet 聚合计算等场景。


尽管用 Doc Values 来存储是比较接近我们的优化目标,但当字段比较多的时候不太合适,而且 String 类型的数值需要以 binary 的形式存储,编解码次数多了也比较耗时,所以我们想,能不能自己实现字段的存储,把字段 cache 到内存里头,每次访问的时候,直接根据文档 ID 去获取相应的字段,这样就基本上没有序列化的开销,也少创建很多对象,对于我们这种数据量不是特别大的情况来说,效果应该更好。基于这个想法,我们调研了一下 Lucene 提供的相关机制,证明这么做是可行的,下面我们说一下 Lucene 提供的机制,以及我们怎么利用这种机制去实现我们想要的功能。


2 Lucene 自定义 Codec 机制


Lucene 内部通过 codec API 来读写索引文件,codec 是 Lucene 的一个非常重要的抽象:它把索引数据结构的存储和上层的建索引和搜索的复杂逻辑隔离了开来,访问索引的时候都是通过 codec API 来操作,这样就允许我们实验各种不同的索引存储格式,而不会影响上层的搜索和建索引的逻辑。


Codec 针对不同类型的索引数据定义了 10 种 Format,每种类型的 Format 又定义了读写的 API,其中读的 API 在搜索时使用,写的 API 在建索引的时候使用,每个 Segment 可以设置自己单独的 Codec。


Lucene 中的抽象类 org.apache.lucene.codec.Codec 定义了 Codec 的接口:



每个 codec 必须有一个唯一的名字,比如"Lucene80",codec 通过 Java 的 SPI(Service Provider Interface)机制进行注册,所以只要知道了名字,就可以找到对应的 codec 实例,同时在建索引的过程中 codec 的名字也会写入到每个 Segment 对应的索引元数据 SegmentInfos 中,所以 Lucene 能够根据索引中的信息找到对应的 codec。


Lucene 8 中有 10 种 Format,具体每种 Format 处理什么类型的索引,我们这里就不一一详细列举了,简单说下其中几个:


  • PostingsFormat 支持倒排索引的读写,倒排索引我们知道,是从 Term→{docId List}的一个索引,其中 docId List 就叫做 posting list。

  • StoredFieldsFormat 支持存储字段的读写,Stored Fields Index 可以算作是一种正排索引(forward index)的存储方式,通过 docId 可以直接获取,Stored Fields 采用行式存储,为了节省存储,做了压缩编码。在建索引时,针对某个字段如果指定 stored=true,会存储到 StoredFields 索引文件中。

  • DocValuesFormat 支持 Doc Values 的读写,Doc Values 也是一种正排的存储方式,是为了解决排序、算分、Facet 聚合等场景引入的一种列式存储方式,当需要访问大量文档的同一字段时的性能提升比较明显。


我们要优化的就是 StoredFields 的访问,其他部分不做修改,所以并不需要自定义所有的 Format,Lucene 提供了 FilterCodec 类,允许我们选择性地改写某个 Format 的实现,其他则 delegate 给默认的实现:



所以我们只需要选择性地覆盖 StoredFieldsFormat 的实现,其他的使用 Lucene80 Codec 默认的实现:



Lucene 提供了完善的单元测试,可以用来验证缩写的 Codec 功能是否正常,具体可以参考:build-your-own-lucene-codec

https://dzone.com/articles/build-your-own-lucene-codec


3 自定义 StoredFieldsFormat 实现


我们希望将 Stored Fields 数据全加载到内存,尽量减少序列化和创建对象的开销。要达成这个目标,实际上我们并不需要完全从头开始定义自己的 Stored Fields 存储格式,我们可以利用原来的索引存储格式,只需要改写读索引的 StoredFieldsReader,将数据缓存到内存中,建索引时使用的 StoredFieldsWriter 和磁盘存储格式都可以保持不变,这样是最简单的。因为我们的整个架构是基于 Lucene NRT replication 构建的一个主从式的架构,所以我们在 Primary(master)建索引的时候,可以按照正常的方式建,在 Replica(slave)使用索引的时候,可以通过开关打开 cache,整个的过程大概是这样的:



  • Primary 节点在建索引的时候配 IndexWriterConfig,通过 IndexWriterConfig.setCodec 设置我们自定义的 codec,codec 的信息会写入索引的元数据中。Primary 端按正常方式建索引。

  • Replica 节点加载 segment 数据的时候,会调用自定义的 codec,进而调用我们自定义的 StoredFieldsReader,自定义的 StoredFieldsReader 通过原有的 Lucene80Codec 的 Reader 读入数据,缓存到内存中(多个列式存储的向量),后续所有访问操作直接读取内存中的数据。


自定义的 Codec,StoredFieldsFormat 和 StoredFieldsReader 之间的关系如下图所示:



其中 StoredFieldsFormat 的接口定义如下:



我们只需要在覆盖 fieldsReader 方法,在其中初始化自定义的 MemoryStoredFieldsReader,传入的参数有 Segment 和字段相关的信息,所以可以通过 delegate 的原始 StoredFieldsReader 读取存储字段的数据(通过 visitDocument 方法访问),并存储到内存数据结构(内存数据结构我们下一节说明)中,因为 Lucene 中的 Segment 数据是不变的,所以一次性读入就可以。


数据放到内存数据结构中之后,可以通过 StoredFieldsReader 的 visitDocument 接口访问:



标准的 StoredFieldsVisitor 实现(比如 DocumentStoredFieldVisitor)有个问题,创建了太多的中间对象,比如每个字段会建一个 StoredField 对象,String 类型的字段需要先转成 byte[],然后再转成 String 等等,产生了很多不必要的中间对象,为了充分利用缓存和减少中间转化的代价,除支持标准接口外,我们自定义了 StoredFieldsVisitor,直接在内存数据结构的基础上包装了一个文档访问的接口,并通过 StoredFieldsVistor 对外提供。


伪代码示例如下:



visitDocument 接口最终是被 IndexSearcher.doc(int docId, StoredFieldVisitor storedFieldsVistor)接口使用的,搜索的时候返回 docId,获取存储字段通过 Searcher 的 doc 接口。


4 内存存储结构


将数据 cache 到内存里头,一是为了解决序列化的速度问题,二是为了减少过多的中间对象,但是我们又不希望存储过度膨胀,那样我们就没法在单个机器存储所有的数据,因此,选择合适的存储结构非常重要。


一般来说,有两种存储的方法,一种是行式存储,一种是列式存储,Lucene 里头默认的 StoredFields 存储是行式存储,DocValues 是列式存储。假设我们用行式存储的方式,如果将文档序列化之后再存储,从空间、时间和产生的中间对象上来看相较原始的存储方式并没有什么优势,如果以 Java Bean 的方式来存储,速度上是最快的,产生的中间对象也比较少,但是存储空间消耗非常大,主要是因为 Java 在存储方面并不是很经济:


  • 因为很多字段是允许多值的,所以我们需要采用数组来存储,数组在 Java 里头,64 位系统下光对象头就要占用 24 个字节(启用指针压缩的情况下也得占用 16 个字节,如果超过堆内存大小超过 32G,虽然也能对指针进行压缩,但是会有额外的对齐的开销);

  • 空值字段也会消耗空间,比如一个 null 引用也会占用 64 位,空的原生类型字段也会占用空间;

  • 对象对齐的开销;

  • 复合对象引用的开销。


所以采用 Java Bean 的方式在存储上代价有点高,不太能满足要求。


而列式存储的方式,将同一个字段的放到连续的存储中,可以减少数组对象头的开销,访问的时候,也只是增加了一些偏移量计算的开销,在空间和时间上相对来说更适合。


我们通过一个例子来说明列式存储怎么实现,假设有四个文档,有一个别名字段 hotelAliases:



其中 ID 为 5 的文档有两个别名,ID 为 2 和 6 的文档没有别名,采用列式存储的方式可以用两个数组来表示,一个 value 数组用来存储别名,一个 offset 数组用来指示文档值的起始和结束位置:



其中 offset 的下标为文档 ID,offset[docId+1] - offset[docId]表示值的个数,如果不为 0,表示有值,值在 value 数组中的起始位置为 offset[docId]。


value 数组如果是 String 类型的对象,我们可以通过对 String 做 intern 操作来去除重复,考虑到 intern 操作本身会使用一个 Map 类型的索引来做去重,如果维护一个全局的索引的话,需要一直留着不能释放,占用内存较多,所以我们只在一个 Segment 内做 intern,因为 Segment 的数据是不变的,做完了之后,我们可以将 intern 使用的 Map 释放掉,经过测试,这样做可以节省空间,原因猜测是因为我们的数据重复的值比较集中,大都是一些低 cardinality(基数)的数据,而高 cardinality 的值则很少重复,保存去重的索引反而占用空间较多。


通过列式存储的方式,可以将存储消耗降低为 Java Bean 方式的 65%,访问速度上,损失大概百分之十几。


上面的编码方式,空值是不占 value 数组存储空间的,但是会占用 offset 数组的存储空间,虽然看起来单个文档只占用一个 int,但当存在很多不同类型的文档时,有些类型可能根本就不存在某个字段,这样就会存在大量空值,加起来浪费也比较严重,所以我们后来又在这个基础的列式存储上进一步做了优化,通过采用 succinct data structure 中的 rank/select 操作,用两个 bit 数组代替 int 数组,这个优化能够将存储空间消耗进一步减少将近 20%(12G->10G)。关于这一块,我们在将来的文章中再做介绍。


不同类型的数据,内存占用会有区别,除了提供通用的 Object 类型的实现,我们也针对 Primitive Type 提供单独的实现。


5 写在最后


本文所述的 Lucene Stored Fields 存储优化,主要是对我们的特殊应用场景:数据量不是特别大,每次查询返回文档数较多,做了针对性的优化,降低了生成的中间对象的数量,从我们的线上监控看,Young GC 频次从原来的每秒 2-3 次,变成 9-10 秒发生一次,响应时间也降低了 80%多,存储空间上面,通过采用紧凑的内存存储格式,也较好地解决了空间消耗的问题,使得我们能够将全量的存储字段数据加载到内存里头。


未来,我们还计划在这个基础上进一步做一些优化,比如:


  • 尝试堆外存储,减少堆空间占用,更好地利用指针压缩(不过这样会有字符串编码开销,需要测试下);

  • 实现 Per-field 的存储字段 cache,只对必要的字段做内存缓存,减少总的内存占用;



头图:Unsplash

作者:王名悠

原文:https://mp.weixin.qq.com/s/zsAjhhoOy__UlSf6i-ZMSA

原文:Lucene 中的 Stored Fields 存储优化

来源:Qunar 技术沙龙 - 微信公众号 [ID:QunarTL]

转载:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

2021-01-09 23:282052

评论

发布
暂无评论
发现更多内容

来啊!跟我一起“单排”推荐系统!

博文视点Broadview

【直播预告】今晚7点,来HarmonyOS极客松直播间与技术专家聊聊新技术!

HarmonyOS开发者

HarmonyOS

带你彻底掌握Bean的生命周期

华为云开发者联盟

后端 开发 华为云 华为云开发者联盟 企业号 6 月 PK 榜

火山引擎数智平台最新直播活动:ByteHouse技术架构与最佳实践分享

字节跳动数据平台

数据 活动 直播

分享6个SQL小技巧

EquatorCoco

sql 开发语言

软件设计原则与设计模式

源字节1号

开源 软件开发 前端开发 后端开发 小程序开发

DevOps|中式土味OKR与绩效考核落地与实践

laofo

DevOps OKR 研发效能 绩效考核

基于ChatGPT函数调用来实现C#本地函数逻辑链式调用助力大模型落地

EquatorCoco

函数 C++ ChatGPT

规则引擎调研及初步使用 | 京东云技术团队

京东科技开发者

算法 规则引擎 企业号 6 月 PK 榜 匹配算法 rete

直播源码搭建技术弹幕消息功能的实现

山东布谷科技

软件开发 直播 源码搭建 直播源码

软件测试/测试开发丨Pytest结合数据驱动-JSON

测试人

json 程序员 软件测试 数据驱动 pytest

如何设计一个高效的分布式日志服务平台

百度Geek说

分布式 企业号 6 月 PK 榜 服务平台 大模型结合 6 月 优质更文活动

龙蜥操作系统完成与高通 Cloud AI 100 兼容认证

OpenAnolis小助手

开源 操作系统 龙蜥社区 兼容适配 高通

Flutter状态管理新的实践 | 京东云技术团队

京东科技开发者

flutter ios 企业号 6 月 PK 榜 声明式UI

从0到1构造自定义限流组件 | 京东云技术团队

京东科技开发者

限流算法 令牌桶算法 企业号 6 月 PK 榜 接口限流

杭州市等级保护测评机构名录-2023年

行云管家

等保 等级保护 等保测评 杭州

堡垒机免费版在哪里下载?是否安全可靠?

行云管家

网络安全 堡垒机 免费堡垒机

趋势分享 | 多云时代数据安全面临的挑战

原点安全

强化学习从基础到进阶-案例与实践[2]:马尔科夫决策、贝尔曼方程、动态规划、策略价值迭代

汀丶人工智能

人工智能 深度学习 强化学习 马尔科夫决策 6 月 优质更文活动

ChatGPT小型平替之ChatGLM-6B本地化部署、接入本地知识库体验 | 京东云技术团队

京东科技开发者

知识库 企业号 6 月 PK 榜 ChatGLM-6B LLM模型

2024深圳电博会

AIOTE智博会

电子信息展

强化学习从基础到进阶-常见问题和面试必知必答[2]:马尔科夫决策、贝尔曼方程、动态规划、策略价值迭代

汀丶人工智能

人工智能 深度学习 强化学习 马尔科夫决策 6 月 优质更文活动

CSR格式如何更新? GES图计算引擎HyG揭秘之数据更新

华为云开发者联盟

大数据 华为云 华为云开发者联盟 企业号 6 月 PK 榜

2023上半年Java高频面试题库总结(600+java面试真题含答案解析)

小小怪下士

Java 程序员 面试

龙蜥白皮书精选:Ancert——硬件兼容性验证与守护

OpenAnolis小助手

开源 龙蜥社区 龙蜥操作系统 Ancert 硬件兼容性

移动端浏览器性能优化探索

阿里技术

性能优化 移动端开发

英特尔锐炫:驱动持续进步,尽展硬件潜力

E科讯

PWA和小程序的比较与优势

没有用户名丶

一场专属开发者的技术盛宴——华为开发者联创日首站登陆深圳

华为云PaaS服务小智

云计算 AI 华为云 华为开发者大会2023

Lucene 中的 Stored Fields 存储优化_语言 & 开发_Qunar技术沙龙_InfoQ精选文章