Java堆外缓存OHC在马蜂窝推荐引擎的应用

ddc7b14f7d4f24a072883e1185a24fc4.gif

点击上方“马蜂窝技术”,关注订阅更多优质内容

在推荐系统中,通常由推荐引擎提供线上推荐服务。推荐引擎的工作流程主要包括召回、排序等阶段,每个阶段都需要大量的数据支撑,快速读取这些数据对提升推荐引擎的性能起着重要的作用。

缓存在企业级Web系统中使用非常广泛,举例来说,业务程序和数据库通常运行在不同的物理服务器上,并通过网络访问数据库。网络传输的耗时,自然会增加系统的响应时间。为了降低响应时间,业务程序可以将从数据库中读取到的部分数据,缓存在本地服务器以供后续使用。

缓存框架OHC基于Java语言实现,并以类库的形式供其他Java程序调用,是一种以单机模式运行的堆外缓存。在马蜂窝推荐引擎中,使用了OHC作为主要的本地缓存工具,节省了获取召回、排序数据的时间成本,从而提升了系统性能。本文将从OHC的基本技术引入马蜂窝推荐引擎中的实践。

Part.1

OHC简介

缓存的分类与实现机制多种多样,包括单机缓存与分布式缓存等等。具体到JVM应用,又可以分为堆内缓存和堆外缓存。OHC全称为off-heap-cache,即堆外缓存,是一款基于Java的key-value堆外缓存框架。OHC是2015年针对Apache Cassandra开发的缓存框架,后来从Cassandra项目中独立出来,成为单独的类库,其项目地址为 https://github.com/snazy/ohc。

1.堆内与堆外

Java程序运行时,由Java虚拟机(JVM)管理的内存区域称为堆(heap)。垃圾收集器会扫描堆内空间,识别应用程序已经不再使用的对象,并释放其空间,这个过程称为GC。堆内缓存,顾名思义,是指将数据缓存在堆内的机制,比如HashMap就可以用作简单的堆内缓存。由于垃圾收集器需要扫描堆,并且在扫描时需要暂停应用线程(stop-the-world,STW),因此,缓存数据过多会导致GC开销增大,从而影响应用程序性能。

与堆内空间不同,堆外空间不影响GC,由应用程序自身负责分配与释放内存。因此,当缓存数据量较大(达到G以上级别)时,可以使用堆外缓存来提升性能。

2.OHC的特性

相对于持久化数据库,可用的内存空间更少、速度也更快,因此通常将访问频繁的数据放入堆外内存进行缓存,并保证缓存的时效性。OHC主要具有以下特性来满足需求:

1、数据存储在堆外,不影响GC

2、支持为每个缓存项设置过期时间

3、支持配置LRU、W-TinyLFU逐出策略

4、能够维护大量的缓存条目(百万量级以上)

5、支持异步加载缓存

6、读写速度在微秒级别

如上文所说,推荐引擎需要大量数据支撑,并且要求在短时间内完成处理。OHC具有低延迟、容量大、不影响GC的特性,并且支持使用方根据自身业务需求进行灵活配置,因此我们选用OHC作为推荐引擎的主要缓存框架。

3.使用示例

在Java项目中使用OHC,主要包括以下步骤:

1、在项目中引入OHC。如果使用Maven来管理依赖,可以将OHC的坐标添加到到项目的POM文件中。

2、OHC是将Java对象序列化后存储在堆外,因此用户需要实现org.caffinitas.ohc.CacheSerializer类,OHC会运用其实现类来序列化和反序列化对象。

3、将CacheSerializer的实现类作为参数,传递给OHCache的构造函数来创建OHCache。

4、使用OHCache的相关方法(get、put)来读写缓存。

为了便于读者快速了解OHC,我们在GitHub上提供了一个Demo程序(https://github.com/chebacca/ohc-example),感兴趣的读者可以下载运行。

Part.2

OHC的实现

1.整体架构

OHC以API的方式供其他Java程序调用,其org.caffinitas.ohc.OHCache接口定义了可调用的方法。对于缓存来说,最常用的是get和put方法。针对不同的使用场景,OHC提供了两种OHCache的实现:

org.caffinitas.ohc.chunked.OHCacheChunkedImpl

org.caffinitas.ohc.linked.OHCacheLinkedImpl

以上两种实现均把所有条目缓存在堆外,堆内通过指向堆外的地址指针对缓存条目进行管理。其中,linked实现为每个键值对分别分配堆外内存,适合中大型键值对。chunked实现为每个段分配堆外内存,适用于存储小型键值对。由于chunked实现仍然处于实验阶段,所以我们选择linked实现在线上使用,后续介绍也以linked实现为例,其整体架构及内存分布如下图所示,下文将分别介绍其功能。

1ef5f1c7451d176607607684a175411c.png

2. OHCacheLinkedImpl

OHCacheLinkedImpl是堆外缓存的具体实现类,其主要成员包括:

段数组:OffHeapLinkedMap[]

序列化器与反序列化器:CacheSerializer

OHCacheLinkedImpl中包含多个段,每个段用OffHeapLinkedMap来表示。同时,OHCacheLinkedImpl将Java对象序列化成字节数组存储在堆外,在该过程中需要使用用户自定义的CacheSerializer。OHCacheLinkedImpl的主要工作流程如下:

1、计算key的hash值,根据hash值计算段号,确定其所处的OffHeapLinkedMap

2、从OffHeapLinkedMap中获取该键值对的堆外内存指针

3、对于get操作,从指针所指向的堆外内存读取byte[],把byte[]反序列化成对象

4、对于put操作,把对象序列化成byte[],并写入指针所指向的堆外内存

3.段的实现:OffHeapLinkedMap

在OHC中,每个段用OffHeapLinkedMap来表示,段中包含多个分桶,每个桶是一个链表,链表中的元素即是缓存条目的堆外地址指针。OffHeapLinkedMap的主要作用是根据hash值找到键值对的堆外地址指针。在查找指针时,OffHeapLinkedMap先根据hash值计算出桶号,然后找到该桶的第一个元素,然后沿着第一个元素按顺序线性查找。

举个例子,OffHeapLinkedMap中包含两个分桶,分桶1中有两个键值对:

元素1:name:Jack,堆外地址为1024

元素2:age:20,堆外地址为8192

分桶2中也有两个键值对:

元素1:animal:cat,堆外地址为2048

元素2:color:black,堆外地址为4096

OffHeapLinkedMap将每个分桶中第一个元素的地址存储在一个连续的内存空间,这里我们假设该空间从12000处开始,那么12000将存储1024(分桶1首元素的地址)。由于使用64位寻址,每个地址占用8个字节,所以12008处会存储分桶2首元素的地址,即2048。其堆外内存分布如下图所示:

8404cae6e19f3ece29768260422f5bb2.png

需要注意的是,上述数据均保存在堆外,在堆内只需要保存一个地址指针(12000)即可。当我们要查找color对应的值时,

1、先计算color的hash值

2、根据hash值计算桶号,这里是2号分桶

3、1号分桶存储在12000处,每个地址占用8个字节,则2号分桶应该存储在12008处。从堆外12008处读取到2048

4、访问2048,发现key是animal, 和 color不匹配,得到下一个地址4096

5、访问4096,发现命中color,返回

4.空间分配

OHC的linked实现为每个键值对分别分配堆外内存,因此键值对实际是零散地分布在堆外。OHC提供了JNANativeAllocator和UnsafeAllocator这两个分配器,分别使用Native.malloc(size)和Unsafe.allocateMemory(size)分配堆外内存,用户可以通过配置来使用其中一种。

OHC会把key和value序列化成byte[]存储到堆外,如2.2所述,用户需要通过实现CacheSerializer来自定义类完成序列化和反序列化。因此,占用的空间实际取决于用户自定义的序列化方法。除了key和value本身占用的空间,OHC还会对key进行8位对齐。比如用户计算出key占用3个字节,OHC会将其对齐到8个字节。另外,对于每个键值对,OHC需要额外的64个字节来维护偏移量等元数据。因此,对于每个键值对占用的堆外空间为:

每个条目占用堆外内存 = key占用内存(8位对齐)+ value 占用内存 + 64字节

Part.3

OHC在马蜂窝推荐引擎中的实践

1.马蜂窝推荐引擎的工作流程

推荐引擎的工作流程一般包括三个主要步骤:召回、排序和重排,每个步骤都依赖大量的数据支撑。在马蜂窝推荐引擎中,对个每个用户请求,需要进行几十种召回得到上千个item,每个item又需要读取数百个特征来进行排序。如果每次都通过网络请求来读取相应数据,则会导致响应速度太慢,直接影响用户体验。因此,我们需要在服务器本地对数据进行缓存,降低处理时延。

43c3cb6070a34a01d042963444eff11b.png

2.适用于OHC存储的数据

马蜂窝推荐引擎主要使用Redis集群、OHC和Guava来进行线上数据存储,这三种存储方式的特性分别如下:

4676def3ea42ceacc1eb9d1af44d4e05.png

因为不同存储方式的特性差别较大,我们会根据具体场景来从中选择。马蜂窝推荐引擎所需的召回和排序数据主要分为“离线数据”和“实时数据”,均使用Redis作为主库。离线数据由定时算法任务生成后写入HDFS,一般按照小时级或者天级进行更新,并通过XXL Job和DataX定时从HDFS同步到Redis供推荐引擎使用。实时数据则根据用户行为进行在线更新,通常使用Flink任务实时计算后直接写入Redis。

a0ea0a39c93b7f174bc0126a359241ed.png

对于离线数据,其更新周期比较长,非常适合使用OHC缓存到推荐引擎本地。比如,在进行排序时,item的历史点击率是非常重要的特征数据,特别是其最近几天的点击率。这种以天为单位更新的离线特征,如果使用OHC缓存到本地,则可以避免读取Redis的网络开销,节省排序阶段耗时。

对于实时数据,其更新受用户实时行为影响,下次更新时间是不确定的。比如用户对某个目的地的偏好程度,这种数据随着用户在App端不断进行点击而更新。在马蜂窝推荐引擎中,这种实时数据不会使用OHC缓存到本地,否则可能会导致本地缓存和主库的数据不一致。即使进行缓存,也应该设置较小的过期时间(比如秒级或者分钟级),尽量保证数据的实时性和准确性。

其他数据,比如站内的高热数据和兜底数据,其数据量较小且可能频繁使用,这种数据我们使用Guava缓存到堆内,以便于快速读取。

3.序列化工具的选择

如上文所说,OHC是一款key-value形式的缓存框架,并且对key和value都提供了泛型支持。因此,使用方在创建OHC对象时就需要确定key和value的类型。

在马蜂窝推荐引擎中,使用OHC时key设置为String类型,value则设置为Object类型,从而可以存储各种类型的对象。由于OHC需要把key和value序列化成字节数组存储到堆外,因此需要选择合适的序列化工具。对于String类型的key,其序列化过程比较简单,可以直接转换成UTF-8格式的字节数组来表示。对于Object类型的value,则选用了开源的Kyro作为序列化工具。需要注意的是,由于Kyro不是线程安全的,可以搭配ThreadLocal一起使用。

在使用OHC时,通常有两个地方用到序列化。在存储每个键值对时,会调用CacheSerializer#serializedSize计算序列化后的内存空间占用,从而申请堆外内存。另外,在真正写入堆外时,会调用CacheSerializer#serialize真正进行序列化。因此,务必在这两个方法中使用相同的序列化方法,也就是说,申请的堆外内存(CacheSerializer#serializedSize计算所得)和实际占用的堆外内存(CacheSerializer#serialize)要保持一致。我们开始使用OHC时,在CacheSerializer#serializedSize方法中使用com.twitter.common.objectsize.ObjectSizeCalculator计算序列化后的空间占用,而在CacheSerializer#serialize中则使用了Kryo。结果发现ObjectSizeCalculator计算的内存远远大于Kyro计算出来的,导致为每个键值对申请了大量堆外内存却没有充分使用。

4.生产环境的配置

OHC支持大量配置选项,供使用方根据自身业务场景进行选择,这里介绍下在我们业务中相关参数的配置。

aa5d9d7114cba6f9783f1e0ed2869324.png

总容量:最开始使用OHC时,我们设置的上限为4G左右。随着业务的发展和数据量的增长,逐渐增大到10G,基本可以覆盖热点数据。

段数量:一方面,OHC使用了分段锁,多个线程访问同一个段时会导致竞争,所以段数量不宜设置过小。同时,当段内条目数量达到一定负载时OHC会自动rehash,段数量过小则会允许段内存储的条目数量增加,从而可能导致段内频繁进行rehash,影响性能。另一方面,段的元数据是存储在堆内的,过大的段数量会占用堆内空间。因此,应该在尽量减少rehash的次数的前提下,结合业务的QPS等参数,将段数量设置为较小的值。

哈希算法:通过压测,我们发现使用CRC32、CRC32C和MURMUR3时,键值对的分布都比较均匀,而CRC32C的CPU使用率相对较低,因此使用CRC32C作为哈希算法。

逐出算法:选用10G的总容量,基本已经覆盖了大部分热点数据,并且很少出现偶发性或者周期性的批量操作,因此选用了LRU。

5.线上表现

在马蜂窝推荐引擎中,使用OHC管理的单机堆外内存在10G左右,可以缓存的条目为百万量级。我们主要关注命中率、读取和写入速度这几个指标。OHC#stats方法会返回OHCacheStats对象,其中包含了命中率等指标。当内存配置为10G时,在我们的业务场景下,缓存命中率可以稳定在95%以上。同时,我们在调用get和put方法时,进行了日志记录,get的平均耗时稳定在20微妙左右,put则需要100微妙。需要注意的是,get和put的速度和缓存的键值对大小呈正相关趋势,因此不建议缓存过大的内容。可以通过org.caffinitas.ohc.maxEntrySize配置项,来限制存储的最大键值对,OHC发现单个条目超过该值时不会将其放入堆外缓存。

a19001b92064c2eba7f7146cc53cda25.png

6.在实践中优化

(1)异步移除过期数据

在OffHeapLinkedMap的原始实现中,读取键值对时会判断其是否过期,如果过期则立即将其移除。移除键值对是相对比较“昂贵”的操作,可能会阻塞当前读取线程,因此我们对其进行了异步改造。读取键值对时,如果发现其已经过期,则会将其存入一个队列。同时,在后台加入了一个清理线程,定期从队列里面读取过期内容并进行移除。

(2)加锁方式优化

OHC本身是线程安全的,因为每个段都有自己的锁,在读取和写入时都会加锁。其源代码中使用的是TAS锁(test-and-set),在更新失败时尝试挂起线程并重试:

faf36e598b0a9bfaa13773e82506b3a5.png

每个线程都有自己的缓存,当变量标记为脏时线程会更新缓存。但是,无论是否成功设置该值,TAS锁在每次调用变量时都会将其标记为脏数据,这会导致在线程竞争激烈时性能下降。使用TTAS(test-test-and-set)锁可以尽量减少TAS的次数,从而提高性能:

e59ea30f5c561a026b7a34af42486d47.png

Part.4

总结

本文介绍了OHC的实现原理及其在马蜂窝的实践。OHC是一款Java实现的堆外缓存框架,具有低时延、不影响GC的特点,适合存储大量缓存条目,同时支持配置过期时间、逐出算法等多个配置项。同时我们注意到,相对于另一款开源的缓存框架ehcache,OHC的中文资料相对较少。我们在框架选型时也对两者进行了压测,OHC在我们业务场景下性能表现更好,因此选择OHC作为马蜂窝推荐引擎的主要缓存实现。特别地,对于推荐引擎这种依赖大量离线数据和实时数据的应用,OHC适合将离线数据进行本地缓存,从而节省访问远程数据库的时间。

本文作者:孙兴斌,智能中台-引擎平台研发工程师

(题图来源:网络)

End

1f431cdca54d63c58dbfd2d7940eb6f5.png

你「在看」我吗?

bb81d4ba3beb9a55def2106a81aba64f.png

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值