ToB企服应用市场:ToB评测及商务社交产业平台

标题: 一招MAX降低10倍,现在它是我的了 [打印本页]

作者: 北冰洋以北    时间: 2024-2-18 03:50
标题: 一招MAX降低10倍,现在它是我的了
一.背景

性能优化是一场永无止境的旅程。
到家门店系统,作为到家核心基础服务之一,门店C端接口有着调用量高,性能要求高的特点。
C端服务经过演进,核心接口先查询本地缓存,如果本地缓存没有命中,再查询Redis。本地缓存命中率99%,服务性能比较平稳。

随着门店数据越来越多,本地缓存容量逐渐增大到3G左右。虽然对垃圾回收器和JVM参数都进行调整,由于本地缓存数据量越来越大,本地缓存数据对于应用GC的影响越来越明显,YGC平均耗时****100ms特别是大促期间调用方接口毛刺感知也越来越明显
由于本地缓存在每台机器上容量是固定的,即便是将机器扩容,对与GC毛刺也没有明显效果。


二.初识此物心已惊-OHC初识

本地缓存位于应用程序的内存中,读取和写入速度非常快,可以快速响应请求,无需额外的网络通信,但是一般本地缓存存在JVM内,数据量过多会影响GC,造成GC频率、耗时增加;如果用Redis的话有网络通信的开销。
框架          | 简介                                                                                                          | 特点                                                                                                                                                               | 堆外缓存 | 性能(一般情况) |
| ----------- | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | -------- |
| Guava Cache | Guava Cache是Google的本地缓存库,提供了基本的缓存功能。它简单易用、轻量级,并支持基本的缓存操作。                                                   | ·支持最大容量限制 ·支持两种过期删除策略(插入时间和访问时间) ·支持简单的统计功能 ·基于LRU算法实现                                                                                                           | 不支持  | 性能中等     |
| Caffeine    | Caffeine是一个高性能的本地缓存库,提供了丰富的功能和配置选项。它支持高并发性能、低延迟和一些高级功能,如缓存过期、异步刷新和缓存统计等。                                    | ·提供了丰富的功能和配置选项;高并发性能和低延迟;支持缓存过期、异步刷新和缓存统计等功能; ·基于java8实现的新一代缓存工具,缓存性能接近理论最优。 ·可以看作是Guava Cache的增强版,功能上两者类似,不同的是Caffeine采用了一种结合LRU、LFU优点的算法:W-TinyLFU,在性能上有明显的优越性 | 不支持  | 性能出色     |
| Ehcache     | Encache是一个纯Java的进程内缓存框架,具有快速、精干等特点,是Hibernate中默认的CacheProvider。同Caffeine和Guava Cache相比,Encache的功能更加丰富,扩展性更强 | ·支持多种缓存淘汰算法,包括LRU、LFU和FIFO ·缓存支持堆内存储、堆外存储、磁盘存储(支持持久化)三种 ·支持多种集群方案,解决数据共享问题                                                                                       | 支持   | 性能一般     |
| **OHC**     | OHC(Off-Heap Cache)是一个高性能的堆外缓存库,专为高并发和低延迟而设计。它使用堆外内存和自定义的数据结构来提供出色的性能                                       | ·针对高并发和低延迟进行了优化;使用自定义数据结构和无锁并发控制;较低的GC开销; ·在高并发和低延迟的缓存访问场景下表现出色                                                                                              | 支持   | 性能最佳     |
通过对本地缓存的调研,堆外缓存可以很好兼顾上面的问题。堆外缓存把数据放在JVM堆外的,缓存数据对GC影响较小,同时它是在机器内存中的,相对与Redis也没有网络开销,最终选择OHC。
三.习得技能心自安-OHC使用

talk is cheap, show me the code! OCH是骡子是马我们遛一遛。
1.引入POM

OHC 存储的是二进制数组,需要实现OHC序列化接口,将缓存数据与二进制数组之间序列化和反序列化
这里使用的是Protostuff,当然也可以使用kryo、Hession等,通过压测验证选择适合的序列化框架。
  1. <dependency>
  2.         <groupId>org.caffinitas.ohc</groupId>
  3.         <artifactId>ohc-core</artifactId>
  4.         <version>0.7.4</version>
  5. </dependency>
  6. <dependency>
  7.         <groupId>io.protostuff</groupId>
  8.         <artifactId>protostuff-core</artifactId>
  9.         <version>1.6.0</version>
  10. </dependency>
  11. <dependency>
  12.         <groupId>io.protostuff</groupId>
  13.         <artifactId>protostuff-runtime</artifactId>
  14.         <version>1.6.0</version>
  15. </dependency>
复制代码
2.创建OHC缓存

OHC缓存创建
  1. OHCache<String, XxxxInfo> basicStoreInfoCache = OHCacheBuilder.<String, XxxxInfo>newBuilder()
  2.                     .keySerializer(new OhcStringSerializer()) //key的序列化器
  3.                     .valueSerializer(new OhcProtostuffXxxxInfoSerializer()) //value的序列化器
  4.                     .segmentCount(512) // 分段数量 默认=2*CPU核数
  5.                     .hashTableSize(100000)// 哈希表大小 默认=8192
  6.                     .capacity(1024 * 1024 * 1024) //缓存容量 单位B 默认64MB
  7.                     .eviction(Eviction.LRU) // 淘汰策略 可选LRU\W_TINY_LFU\NONE
  8.                     .timeouts(false) //不使用过期时间,根据业务自己选择
  9.                     .build();
复制代码
自定义序列化器,这里key-String 序列化器,这里直接复用OCH源码中测试用例的String序列化器
value-自定义对象序列化器,这里用Protostuff实现,也可以自己选择使用kryo、Hession等实现;
  1. //key-String 序列化器,这里直接复用OCH源码中测试用例的String序列化器
  2. public class OhcStringSerializer implements CacheSerializer<String> {
  3.     @Override
  4.     public int serializedSize(String value) {
  5.         return writeUTFLen(value);
  6.     }
  7.     @Override
  8.     public void serialize(String value, ByteBuffer buf) {
  9.         // 得到字符串对象UTF-8编码的字节数组
  10.         byte[] bytes = value.getBytes(Charsets.UTF_8);
  11.         buf.put((byte) ((bytes.length >>> 8) & 0xFF));
  12.         buf.put((byte) ((bytes.length >>> 0) & 0xFF));
  13.         buf.put(bytes);
  14.     }
  15.     @Override
  16.     public String deserialize(ByteBuffer buf) {
  17.         int length = (((buf.get() & 0xff) << 8) + ((buf.get() & 0xff) << 0));
  18.         byte[] bytes = new byte[length];
  19.         buf.get(bytes);
  20.         return new String(bytes, Charsets.UTF_8);
  21.     }
  22.     static int writeUTFLen(String str) {
  23.         int strlen = str.length();
  24.         int utflen = 0;
  25.         int c;
  26.         for (int i = 0; i < strlen; i++) {
  27.             c = str.charAt(i);
  28.             if ((c >= 0x0001) && (c <= 0x007F)){
  29.                 utflen++;}
  30.             else if (c > 0x07FF){
  31.                 utflen += 3;}
  32.             else{
  33.                 utflen += 2;
  34.             }
  35.         }
  36.         if (utflen > 65535) {
  37.             throw new RuntimeException("encoded string too long: " + utflen + " bytes");
  38.         }
  39.         return utflen + 2;
  40.     }
  41. }
  42. //value-自定义对象序列化器,这里用Protostuff实现,可以自己选择使用kryo、Hession等实现
  43. public class OhcProtostuffXxxxInfoSerializer implements CacheSerializer<XxxxInfo> {
  44.     /**
  45.      * 将缓存数据序列化到 ByteBuffer 中,ByteBuffer是OHC管理的堆外内存区域的映射。
  46.      */
  47.     @Override
  48.     public void serialize(XxxxInfo t, ByteBuffer byteBuffer) {
  49.         byteBuffer.put(ProtostuffUtils.serialize(t));
  50.     }
  51.     /**
  52.      * 对堆外缓存的数据进行反序列化
  53.      */
  54.     @Override
  55.     public XxxxInfo deserialize(ByteBuffer byteBuffer) {
  56.         byte[] bytes = new byte[byteBuffer.remaining()];
  57.         byteBuffer.get(bytes);
  58.         return ProtostuffUtils.deserialize(bytes, XxxxInfo.class);
  59.     }
  60.     /**
  61.      * 计算字序列化后占用的空间
  62.      */
  63.     @Override
  64.     public int serializedSize(XxxxInfo t) {
  65.         return ProtostuffUtils.serialize(t).length;
  66.     }
  67. }
复制代码
为了方便调用和序列化封装为工具类,同时对代码通过FastThreadLocal进行优化,提升性能
  1. public class ProtostuffUtils {
  2.     /**
  3.      * 避免每次序列化都重新申请Buffer空间,提升性能
  4.      */
  5.     private static final FastThreadLocal<LinkedBuffer> bufferPool = new FastThreadLocal<LinkedBuffer>() {
  6.         @Override
  7.         protected LinkedBuffer initialValue() throws Exception {
  8.             return LinkedBuffer.allocate(4 * 2 * LinkedBuffer.DEFAULT_BUFFER_SIZE);
  9.         }
  10.     };
  11.     /**
  12.      * 缓存Schema
  13.      */
  14.     private static Map<Class<?>, Schema<?>> schemaCache = new ConcurrentHashMap<>();
  15.     /**
  16.      * 序列化方法,把指定对象序列化成字节数组
  17.      */
  18.     @SuppressWarnings("unchecked")
  19.     public static <T> byte[] serialize(T obj) {
  20.         Class<T> clazz = (Class<T>) obj.getClass();
  21.         Schema<T> schema = getSchema(clazz);
  22.         byte[] data;
  23.         LinkedBuffer linkedBuffer = null;
  24.         try {
  25.             linkedBuffer = bufferPool.get();
  26.             data = ProtostuffIOUtil.toByteArray(obj, schema, linkedBuffer);
  27.         } finally {
  28.             if (Objects.nonNull(linkedBuffer)) {
  29.                 linkedBuffer.clear();
  30.             }
  31.         }
  32.         return data;
  33.     }
  34.     /**
  35.      * 反序列化方法,将字节数组反序列化成指定Class类型
  36.      */
  37.     public static <T> T deserialize(byte[] data, Class<T> clazz) {
  38.         Schema<T> schema = getSchema(clazz);
  39.         T obj = schema.newMessage();
  40.         ProtostuffIOUtil.mergeFrom(data, obj, schema);
  41.         return obj;
  42.     }
  43.     @SuppressWarnings("unchecked")
  44.     private static <T> Schema<T> getSchema(Class<T> clazz) {
  45.         Schema<T> schema = (Schema<T>) schemaCache.get(clazz);
  46.         if (Objects.isNull(schema)) {
  47.             schema = RuntimeSchema.getSchema(clazz);
  48.             if (Objects.nonNull(schema)) {
  49.                 schemaCache.put(clazz, schema);
  50.             }
  51.         }
  52.         return schema;
  53.     }
  54. }
复制代码
3.压测及参数调整

通过压测并逐步调整OHC配置常见参数(segmentCount、hashTableSize、eviction,参数含义见附录
MAX对比降低10倍

GC时间对比降低10倍
优化前

优化后

4.OHC缓存状态监控

OHC缓存的命中次数、内存使用状态等存储在OHCacheStats中,可以通过OHCache.stats()获取
OHCacheStates信息:
hitCount :缓存命中次数,表示从缓存中成功获取数据的次数。 missCount :缓存未命中次数,表示尝试从缓存中获取数据但未找到的次数。 evictionCount :缓存驱逐次数,表示因为缓存空间不足而从缓存中移除的数据项数量。 expireCount :缓存过期次数,表示因为缓存数据过期而从缓存中移除的数据项数量。 size :缓存当前存储的数据项数量。 capacity :缓存的最大容量,表示缓存可以存储的最大数据项数量。 free :缓存剩余空闲容量,表示缓存中未使用的可用空间。 rehashCount :重新哈希次数,表示进行哈希表重新分配的次数。 put(add/replace/fail) :数据项添加/替换/失败的次数。 removeCount :缓存移除次数,表示从缓存中移除数据项的次数。 segmentSizes(#/min/max/avg) :段大小统计信息,包括段的数量、最小大小、最大大小和平均大小。 totalAllocated :已分配的总内存大小,表示为负数时表示未知。 lruCompactions :LRU 压缩次数,表示进行 LRU 压缩的次数。
通过定期采集OHCacheStates信息,来监控本地缓存数据、命中率=[命中次数 / (命中次数 + 未命中次数)]等,并添加相关报警。同时通过缓存状态信息,来判断过期策略、段数、容量等设置是否合理,命中率是否符合预期等。
四.剖析根源见真谛-OHC原理

堆外缓存框架(Off-Heap Cache)是将缓存数据存储在 JVM 堆外的内存区域,而不是存储在 JVM 堆中。在 OHC(Off-Heap Cache)中,数据也是存储在堆外的内存区域。
具体来说,OHC 使用 DirectByteBuffer 来分配堆外内存,并将缓存数据存储在这些 DirectByteBuffer 中。
DirectByteBuffer 在 JVM 堆外的内存区域中分配一块连续的内存空间,缓存数据被存储在这个内存区域中。这使得 OHC 在处理大量数据时具有更高的性能和效率,因为它可以避免 JVM 堆的垃圾回收和堆内存限制
OHC 核心OHCache接口提供了两种实现:

可以看到OHCacheLinkedImpl中包含多个段,每个段用OffHeapLinkedMap来表示。同时,OHCacheLinkedImpl将Java对象序列化成字节数组存储在堆外,在该过程中需要使用用户自定义的CacheSerializer。
OHCacheLinkedImpl的主要工作流程如下:
可以将OHC理解为一个key-value结果的map,只不过这个map数据存储是指向堆外内存的内存指针。
指针在堆内,指针指向的缓存数据存储在堆外。那么OHC最核心的其实就是对堆外内存的地址引用的put和get以及发生在其中内存空间的操作了。
对OHCacheLinkedImpl的put、get本地调试
1.put


put核心操作就是
1.申请堆外内存
2.将申请地址存入map;
3.异常时释放内存
第2步其实就是map数据更新、扩容等的一些实现这里不在关注,我们重点关注怎么申请和释放内存的
1.申请内存

通过深入代码发现是调用的IAllocator接口的JNANativeAllocator实现类,最后调用的是com.sun.jna.Native#malloc实现



2.释放内存

通过上面可知释放内存操作的代码

2.get


3.Q&A

在put操作时,上面看到IAllocator有两个实现类,JNANativeAllocator和UnsafeAllocator两个实现类,他们有什么区别?为什么使用JNANativeAllocator?
区别:UnsafeAllocator对内存操作使用的是Unsafe

为什么使用JNANativeAllocator:Native比Unsafe性能更好,差3倍左右,OHC默认使用JNANativeAllocator
在日常我们知道通过ByteBuffer#allocateDirect(int capacity)也可以直接申请堆外内存,通过ByteBuffer源码可以看到内部使用的就是Unsafe类


可以看到,同时DirectByteBuffer内部会调用 Bits.reserveMemory(size, cap);

Bits.reserveMemory方法中,当内存不足时可能会触发fullgc,多个申请内存的线程同时遇到这种情况时,对于服务来说是不能接受的,所以这也是OHC自己进行堆外内存管理的原因。
如果自己进行实现堆外缓存框架,要考虑上面这种情况。
五.总结

1.OHC使用建议

2.缓存优化建议

对象:{"paramID":1,"paramName":"John Doe"} 正常JSON字符串:{"paramID":1,"paramName":"John Doe"} 压缩字段名JSON字符串:
Hold hold , One more thing....

在使用Guava时,存储25w个缓存对象数据占用空间485M
使用OHCache时,储存60w个缓存对象数据占用数据387M
为什么存储空间差别那么多吶?
Guava 存储的对象是在堆内存中的,对象在 JVM 堆中存储时,它们会占用一定的内存空间,并且会包含对象头、实例数据和对齐填充等信息。对象的大小取决于其成员变量的类型和数量,以及可能存在的对齐需求。同时当对象被频繁创建和销毁时,可能会产生内存碎片。
而 OHC 它将对象存储在 JVM 堆外的直接内存中。由于堆外内存不受 Java 堆内存大小限制,OHC 可以更有效地管理和利用内存。此外,OHC 底层存储字节数组,存储字节数组相对于直接存储对象,可以减少对象的创建和销毁,在一些场景下,直接操作字节数组可能比操作对象更高效,因为它避免了对象的额外开销,如对象头和引用,减少额外的开销。同时将对象序列化为二进制数组存储,内存更加紧凑,减少内存碎片的产生
综上所述,OHC 在存储大量对象时能够更有效地利用内存空间,相对于 Guava 在内存占用方面具有优势。
另外一个原因,不同序列化框架性能不同,将对象序列化后的占用空间的大小也不同

参考及附录

1.OHC常见参数
name默认值描述keySerializer需要开发者实现Key序列化实现valueSerializer需要开发者实现Value序列化实现capacity64MB缓存容量单位BsegmentCount2倍CPU核心数分段数量hashTableSize8192哈希表的大小loadFactor0.75负载因子maxEntrySizecapacity/segmentCount缓存项最大字节限制throwOOMEfalse内存不足是否抛出OOMhashAlgorighmMURMUR3hash算法,可选性MURMUR3、 CRC32, CRC32C (Jdk9以上支持)unlockedfalse读写数据是否加锁,默认是加锁evictionLRU驱逐策略,可选项:LRU、W_TINY_LFU、NONEfrequencySketchSizehashTableSize数量W_TINY_ LFU frequency sketch 的大小edenSize0.2W_TINY_LFU 驱逐策略下使用2.JNI faster than Unsafe?
https://mail.openjdk.org/pipermail/hotspot-dev/2015-February/017089.html
3.OHC源码
https://github.com/snazy/ohc
4.参考文档
序列化框架对比
Java堆外缓存OHC在马蜂窝推荐引擎的应用
“堆外缓存”这玩意是真不错,我要写进简历了。
如何使用OHC提升系统吞吐量
作者:京东零售 赵雪召
来源:京东云开发者社区 转载请注明来源

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4