一. 为什么用缓存
- 用户数和访问量越来越大
- 并发量/吞吐量要求越来越高
- 连接数或者文件读写存在瓶颈
- 应用和数据库所做的计算也越来越多
如何能够有效利用有限的资源来提供尽可能大的吞吐量?一个有效的办法就是引入缓存
什么是缓存?
缓存(cache)最初用于CPU和主内存之间,指代访问速度比一般随机存取存储器(RAM)快的一种RAM。如今缓存的概念已被扩充,CPU与内存之间、内存和硬盘之间、硬盘与网络之间,都存在某种意义上的缓存。从广义上来说,凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存。
举例来说,业务程序和数据库通常运行在不同的物理服务器上,并通过网络访问数据库。网络传输的耗时,自然会增加系统的响应时间。为了降低响应时间、提高系统性能,业务程序可以将从数据库中读取到的部分数据,缓存在本地服务器以供后续使用。
缓存分类:
缓存的分类与实现机制多种多样, 根据缓存与应用的藕合度, 分为 local cache (本地缓存/单机缓存)和 remote cache(分布式缓存):
本地缓存:在应用中的缓存组件
分布式缓存:与应用分离的缓存组件或服务
二. 为什么用本地缓存
本地缓存:在应用中的缓存组件
- 应用和 cache是在同一个进程内部,请求缓存非常快速/性能提升,没有过多的网络开销等
- 集群节点扩容简单/快速
- 堆机器就能快速实现吞吐量提升
- 集群各节点都需要维护单独的缓存, 对内存等资源是一种浪费 (用空间换时间)
- 可能存在缓存数据和真实数据不一致
- 可能需要额外的数据同步机制
分布式缓存:与应用分离的缓存组件或服务
- 缓存本身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存(占用资源少)
- 不存在数据一致性问题
- 有网络开销, 响应速度相对本地缓存较慢
- 集群节点扩容数据同步复杂
- 单点故障: 缓存组件挂掉, 整个应用不可用
- 存在连接数和访问上限
为什么选择本地缓存?
- 对响应速度要求比较高
- 需要支撑较高的吞吐量
哪些数据可以存储到本地缓存?
1.访问频繁的数据
2.静态基础数据(长时间内不变的数据)
3.相对静态数据(短时间内不变的数据)
主流的本地缓存框架:
Map
Guava Cache
Caffeine
EhCache
Ohcache
三. 为什么用堆外缓存
本地缓存具体到JVM应用,又可以分为堆内缓存(Heap)和堆外缓存(Heap-Off)
Java程序运行时,由Java虚拟机(JVM)管理的内存区域称为堆(heap)。
由于GC时需要扫描堆,并且在扫描时需要暂停应用线程(stop-the-world,STW),因此,缓存数据过多必然导致GC开销增大,从而影响应用程序性能。
与堆内空间不同,堆外空间不影响GC,由应用程序自身负责分配与释放内存。因此,当缓存数据量较大(达到G以上级别)时,可以使用堆外缓存来提升性能。
那么缓存数据进入老年代,出现堆积,为何会导致YGC时间过长呢?
- 在YGC阶段,涉及到垃圾标记的过程,从GCRoot开始标记。
- 因为YGC不涉及到老年代的回收,一旦从GCRoot扫描到引用了老年代对象时,就中断本次扫描。这样做可以减少扫描范围,加速YGC。
- 存在被老年代对象引用的年轻代对象,它们没有被GCRoot直接或者间接引用。
- YGC阶段中的old-gen scanning即用于扫描被老年代引用的年轻代对象。
- old-gen scanning扫描时间与老年代内存占用大小成正比。
- 得到结论,老年代内存占用增大会导致YGC时间变长。
总的来说,将缓存数据在JVM内存会对垃圾回收造成一定影响:
- 缓存数据最初缓存到年轻代,会增加YGC的频率。
- 缓存数据被提升到老年代,会增加FGC的频率。
- 老年代的缓存数据增长后,会延长old-gen scanning时间,从而增加YGC耗时。
堆内缓存: 是指将数据缓存在JVM进程堆内的机制
- 优点是直接在 heap区内读写,速度快
- 缺点是缓存的数据量非常有限
- 同时缓存时间受 GC影响
- 数据过多会导致GC开销增大,从而影响应用程序性能
堆外缓存: 是指将数据缓存在JVM进程堆外的机制
- 读写比堆内相对要慢
- 优点是堆外空间不受GC影响
- 缓存数据量较大(G以上级别)时, 且仍有较高的性能
主流堆内缓存:
- LinkedHashMap:Java自带类,内置LRU驱逐策略的实现(access-order);多线程访问需要自己实现同步。
- Guava Cache:Google Guava工具包中的缓存实现,支持LRU驱逐策略;支持多线程并发访问,支持按时间过期,但只有在访问时才清除过期数据。
- Ehcache:支持多种驱逐策略:LFU、LRU、FIFO,支持持久化和集群。性能跟Guava Cache比相当。
- Caffeine:支持W-TinyLFU驱逐策略,Benchmark测试读写性能是Guava Cache的6倍左右。
主流堆外缓存:
- OHCache:支持缓存驱逐和过期(Cassandra/HugeGraph使用的缓存库)
- ChronicleMap:支持Hash结构,性能好,不支持缓存驱逐
- MapDB:支持Tree结构,可顺序扫描,不支持缓存驱逐
- Ehcache3:BigMemory收费
四. 为什么选择OHCache
目前在市面上, 有诸多的堆外缓存框架, 比如mapdb,ohc,ehcache3等,
但是由于ehcache3收费,所以这里不做讨论,主要讨论mapdb和ohc这两个。
我们先通过benchmark来筛选一下二者的性能差异
从上面的结果可以看出,ohc性能性能十倍于mapdb。而且由于ohc本身支持entry过期,但是mapdb不支持。
所以这里综合一下,选择ohc作为我们的堆外缓存组件
- 性能卓越
- 支持容量大, GB级别
- 不影响GC
- API简单, 学习成本低, 能快速上手
- 适合于离线数据,更新周期比较长
五. OHCache怎么用
1. OHCache介绍
OHC全称为off-heap-cache,即堆外缓存,是一款基于Java的key-value堆外缓存框架。OHC是2015年针对Apache Cassandra开发的缓存框架,后来从Cassandra项目中独立出来,成为单独的类库,其项目地址为 https://github.com/snazy/ohc
Cassandra是一套开源分布式NoSQL数据库系统。它最初由Facebook开发,用于储存收件箱等简单格式数据,集GoogleBigTable的数据模型与Amazon Dynamo的完全分布式的架构于一身Facebook于2008将 Cassandra 开源,此后,由于Cassandra良好的可扩展性,被Digg、Twitter等知名[Web 2.0](https://baike.baidu.com/item/Web 2.0)网站所采纳,成为了一种流行的分布式结构化数据存储方案。[百度百科]
2. OHCache特性
相对于持久化数据库,可用的内存空间更少、速度也更快,因此通常将访问频繁的数据放入堆外内存进行缓存,并保证缓存的时效性。OHC主要具有以下特性来满足需求:
1、数据存储在堆外,不影响GC 2、支持为每个缓存项设置过期时间 3、支持配置LRU、W-TinyLFU逐出策略 4、能够维护大量的缓存条目(百万量级以上) 5、支持异步加载缓存 6、读写速度在微秒级别
3. OHC使用示例
OHC以键值对的形式缓存数据,这里以key和value都是String类型为例,
- 首先需要在项目pom中加入OHC依赖。
<dependency>
<groupId>org.caffinitas.ohc</groupId>
<artifactId>ohc-core</artifactId>
<version>0.7.4</version>
</dependency>
- OHC是将Java对象序列化后存储在堆外,因此用户需要实现 org.caffinitas.ohc.CacheSerializer 类,OHC会运用其实现类来序列化和反序列化对象。例如,以下例子是对 string 进行的序列化实现
public class StringSerializer implements CacheSerializer<String> {
@Override
public int serializedSize(String value) {
byte[] bytes = value.getBytes(Charsets.UTF_8);
if (bytes.length > 65536)
throw new RuntimeException("encoded string too long: " + bytes.length + " bytes");
return bytes.length + 2;
}
@Override
public void serialize(String value, ByteBuffer buf) {
byte[] bytes = value.getBytes(Charsets.UTF_8);
buf.put((byte) ((bytes.length >>> 8) & 0xFF));
buf.put((byte) ((bytes.length) & 0xFF));
buf.put(bytes);
}
@Override
public String deserialize(ByteBuffer buf) {
int length = (((buf.get() & 0xff) << 8) + ((buf.get() & 0xff)));
byte[] bytes = new byte[length];
buf.get(bytes);
return new String(bytes, Charsets.UTF_8);
}
}
- 将CacheSerializer的实现类作为参数,传递给OHCache的构造函数来创建OHCache
import org.caffinitas.ohc.Eviction;
import org.caffinitas.ohc.OHCache;
import org.caffinitas.ohc.OHCacheBuilder;
public class OffHeapCacheExample {
public static void main(String[] args) {
OHCache<String, String> ohCache = OHCacheBuilder.<String, String>newBuilder()
.keySerializer(new StringSerializer())
.valueSerializer(new StringSerializer())
.eviction(Eviction.LRU)
.build();
ohCache.put("hello", "world");
System.out.println(ohCache.get("hello"));
}
}
4. OHCache原理
4.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 实现为例,其整体架构及内存分布如下图所示,下文将分别介绍其功能。
4.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[],并写入指针所指向的堆外内存
5、对于 remove 操作,直接释放内存
4.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
同时,所有分桶第一个元素的地址,会存在一个连续的内存空间。这里我们假设该空间从12000出开始,那么12000出将存储1024(分桶1首元素的地址)和2048(分桶2首元素的地址)。上述示例的数据在堆外分布如下图所示。需要注意的是,上述数据均保存在堆外,在堆内只需要保存一个地址指针(12000)即可。当我们要查找color对应的值时,
- 先计算color的hash值
- 根据hash值计算桶号,这里是2号分桶
- 从堆外12000出,获取2号分桶对应的起始地址,这里是2048
- 访问2048,发现key是animal, 和 color不匹配,得到下一个地址4096
- 访问4096,发现命中color,返回
- 确定segmentIndex/tableIndex
OHC中包含多个段,每个段又包含多个桶,在读取和写入时,OHC会根据hash值自动计算段号和桶号
int i = Util.bitNum((long)segments) - 1;
this.segmentShift = 64 - i;
this.segmentMask = (long)segments - 1L << this.segmentShift;
private CheckSegment segment(long hash){
int seg = (int) ((hash & segmentMask) >>> segmentShift);
return maps[seg];
}
this.mask = hashTableSize - 1;
private int bucketIndexForHash(long hash) {
return (int)(hash & (long)this.mask);
}
-
先计算并分配内存, 如果分配失败则remove -
写入key和value到堆外内存, 计算hash, 初始化header -
计算seg索引(hash & this.segmentMask) >>> this.segmentShift -
计算bucket索引(mask&hash)拿到首节点, 遍历链表 4.1 如果比较key是否已存在, 如果存在且未过期, 则将value进行替换, 如果过期先将老的移除(包括从TimeOut保证有enoughCapaticy(不够先回收expire的, 再不够按evict策略回收) -
检查是否需要rehash (rehash后直接替换整个table) -
直接add到table对应的bucket的head节点, 如果expireAt>0添加到timeOut中, 更新LRU链表 -
更新freeCapacity
-
计算hash和segmentIndex和tableIndex -
遍历table的bucket判断该node是否存在 2.1 如果存在且过期先删除老的node, 如果未过期 2.2 如果存在未过期更新LRU和refrenceCount++, 返回addr -
根据offset获取value序列化后返回
同步删除, 访问时先检查若过期先删除
- 计算hash和segmentIndex和tableIndex
- 遍历table对应bucket判断该node是否存在, 如果不存在直接返回
- 存在则重新链接bucket中链表, 在timeout中删除并更新, 从LRU删除并更新
实际测试各操作耗时:
add/put 操作约为<=100us
remove/query 操作约为20~30us
4.4 Entry空间分布
OHC 的 linked 实现为每个键值对分别分配堆外内存,因此键值对实际是零散地分布在堆外。
OHC提供了JNANativeAllocator 和 UnsafeAllocator 这两个分配器,分别使用 Native.malloc(size) 和 Unsafe.allocateMemory(size) 分配堆外内存,用户可以通过配置来使用其中一种。
OHC 会把 key 和 value 序列化成 byte[] 存储到堆外,用户需要通过实现 CacheSerializer 来自定义类完成 序列化 和 反序列化。因此,占用的空间实际取决于用户自定义的序列化方法。
除了 key 和 value 本身占用的空间,OHC 还会对 key 进行 8位 对齐。比如用户计算出 key 占用 3个字节,OHC会将其对齐到8个字节。另外,对于每个键值对,OHC需要额外的64个字节来维护偏移量等元数据。因此,对于每个键值对占用的堆外空间为:
每个条目占用堆外内存 = 64字节 + key占用内存(8位对齐) + value占用内存
其中64个字节的元数据及偏移量如下:
static final long ENTRY_OFF_LRU_NEXT = 0;
static final long ENTRY_OFF_LRU_PREV = 8;
static final long ENTRY_OFF_NEXT = 16;
static final long ENTRY_OFF_REFCOUNT = 24;
static final long ENTRY_OFF_SENTINEL = 28;
static final long ENTRY_OFF_EXPIRE_AT = 32;
static final long ENTRY_OFF_GENERATION = 40;
static final long ENTRY_OFF_HASH = 48;
static final long ENTRY_OFF_VALUE_LENGTH = 56;
static final long ENTRY_OFF_KEY_LENGTH = 60;
static final long ENTRY_OFF_DATA = 64;
4.5 过期时间实现
OHC保留了64个字节存储键值对的元数据,其中包含用户设置的过期时间。
采用类似hashTable的结构, 根据expireAt计算slot, slot结构位于堆外连续内存空间, 单个entry占用16个字节(hashEntryAdr+expireAt)
final class Timeouts
{
private final long slotBitmask;
private final int precisionShift;
private final int slotCount;
private final Slot[] slots;
private final Ticker ticker;
private final class Slot {
private static final int MIN_LEN = 16;
private static final int ENTRY_SIZE = 8 + 8;
private long addr;
private int allocLen;
private int len;
private int used;
private int min0;
}
builde时设置timeout, 且defaultTTLmillis>0即可
如果不用默认的timeoutsSlots和timeoutsPrecision, 需显式设置timeoutsSlots/timeoutsPrecision
private static OHCache<String, String> cache = OHCacheBuilder.<String,String>newBuilder()
.keySerializer(StringCacheSerializer.INSTANCE)
.valueSerializer(StringCacheSerializer.INSTANCE)
.eviction(Eviction.LRU)
.capacity(2*1024*1024)
.hashTableSize(16)
.segmentCount(2)
.timeouts(true)
.defaultTTLmillis(1000L)
.build();
builder的构建的ttl是统一的默认的超时时间, 如果想实现随机的超时时间
调用Cache对应的重载方法
public boolean put(K k, V v, long expireAt);
boolean addOrReplace(K key, V old, V value, long expireAt);
boolean putIfAbsent(K key, V value, long expireAt);
OHC过期键删除使用的是“惰性删除”的策略, 即操作Key前先检查是否过期, 过期则删除
可能存在的问题, 如果遇到瓶颈可做优化:
- 大量失效键, 内存利用率不高(当内存不够时, 会evict)
- 过期键同步删除: 可以添加到队列异步删除
- 若不需要过期功能, 则不要配置从而节省空间&提高性能
4.6 异步回源实现
如果回源比较耗时, 可以选择异步回源的方式.
此时CacheBuilder构建时必须通过executorService显式配置线程池
其原理是先在堆外创建一个没有value中间状态的Entry, 将load的操作提交到配置的executorService, 等value获取load成功后替换成完整的Entry
public interface OHCache<K, V> extends Closeable{
Future<V> getWithLoaderAsync(K key, CacheLoader<K, V> loader);
Future<V> getWithLoaderAsync(K key, CacheLoader<K, V> loader, long expireAt);
}
public interface CacheLoader<K, V>{
V load(K key) throws PermanentLoadException, Exception;
}
4.7 OHCacheBuilder配置参数
OHCache构建配置参数
Field | Meaning | Default |
---|
keySerializer | Serializer implementation used for keys | Must be configured | valueSerializer | Serializer implementation used for values | Must be configured | executorService | Executor service required for get operations using a cache loader. E.g. OHCache.getWithLoaderAsync(Object, CacheLoader) | (Not configured by default meaning get operations with cache loader not supported by default) | segmentCount | Number of segments | 2 * number of CPUs (java.lang.Runtime.availableProcessors() ) | hashTableSize | Initial size of each segment’s hash table | 8192 | loadFactor | Hash table load factor. I.e. determines when rehashing occurs. | .75f | capacity | Capacity of the cache in bytes | 16 MB * number of CPUs (java.lang.Runtime.availableProcessors() ), minimum 64 MB | chunkSize | If set and positive, the chunked implementation will be used and each segment will be divided into this amount of chunks. | 0 - i.e. linked implementation will be used | fixedEntrySize | If set and positive, the chunked implementation with fixed sized entries will be used. The parameter chunkSize must be set for fixed-sized entries. | 0 - i.e. linked implementation will be used, if chunkSize is also 0 | maxEntrySize | Maximum size of a hash entry (including header, serialized key + serialized value) | (not set, defaults to capacity divided by number of segments) | throwOOME | Throw OutOfMemoryError if off-heap allocation fails | false | hashAlgorighm | Hash algorithm to use internally. Valid options are: XX for xx-hash, MURMUR3 or CRC32 Note: this setting does may only help to improve throughput in rare situations - i.e. if the key is very long and you’ve proven that it really improves performace | MURMUR3 | unlocked | If set to true , implementations will not perform any locking. The calling code has to take care of synchronized access. In order to create an instance for a thread-per-core implementation, set segmentCount=1 , too. | false | defaultTTLmillis | If set to a value > 0 , implementations supporting TTLs will tag all entries with the given TTL in milliseconds. | 0 | timeoutsSlots | The number of timeouts slots for each segment - compare with hashed wheel timer. | 64 | timeoutsPrecision | The amount of time in milliseconds for each timeouts-slot. | 128 | ticker | Indirection for current time - used for unit tests. | Default ticker using System.nanoTime() and System.currentTimeMillis() | eviction | Choose the eviction algorithm to use. Available are:LRU : Plain LRU - least used entry is subject to evictionW-WinyLFU : Enable use of Window Tiny-LFU. The size of the frequency sketch (“admission filter”) is set to the value of hashTableSize . See this article for a description.None : No entries will be evicted - this effectively provides a capacity-bounded off-heap map. | LRU | frequencySketchSize | Size of the frequency sketch used by W-WinyLFU | Defaults to hashTableSize . | edenSize | Size of the eden generation used by W-WinyLFU relative to a segment’s size | 0.2 |
几个重要的配置参数
配置参数 | 注意点 |
---|
capacity | 根据机器实际内存和实际业务量 | keySerializer | 尽量选用占用空间小的序列化方法, 如Kryo | valueSerializer | 尽量选用占用空间小的序列化方法, 如Kryo | segmentCount | OHC使用了分段锁, 多个线程访问同一个段时会导致竞争,所以段数量不宜设置过小。同时,当段内条目数量达到一定负载时OHC会自动rehash,段数量过小则会允许段内存储的条目数量增加,从而可能导致段内频繁进行rehash,影响性能。另一方面,段的元数据是存储在堆内的,过大的段数量会占用堆内空间。因此,应该在尽量减少rehash的次数的前提下,结合业务的QPS等参数,将段数量设置为较小的值 | hashTableSize | 结合segmentCount和实际数据量(热数据量)评估, 尽量避免rehash(同步)影响性能 | eviction | 如果有周期性或偶发性的批量操作可以选择Tiny-LFU,一般选用LRU即可 | hashAlgorighm | 区别不大, 如果使用JDK11及以上使用CRC32C ( CPU使用率相对较低) |
5. 实践优化
5.1 序列化的选择
如上文所说,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 真正进行序列化。因此,务必在这两个方法中使用相同的序列化方法。
public interface CacheSerializer<T> {
void serialize(T var1, ByteBuffer var2);
T deserialize(ByteBuffer var1);
int serializedSize(T var1);
}
基本类型的包装类型length是确定, 实现比较简单. String类型可以将length和value一起写入.
public class LongCacheSerializer implements CacheSerializer<Long> {
public static final LongCacheSerializer INSTANCE = new LongCacheSerializer();
private LongCacheSerializer(){}
@Override
public void serialize(Long value, ByteBuffer buf) {
buf.putLong(value);
}
@Override
public Long deserialize(ByteBuffer buf) {
return buf.getLong();
}
@Override
public int serializedSize(Long value) {
return Longs.toByteArray(value).length;
}
}
public class StringCacheSerializer implements CacheSerializer<String> {
public static final StringCacheSerializer INSTANCE = new StringCacheSerializer();
private StringCacheSerializer(){}
@Override
public int serializedSize(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length > 65536)
throw new RuntimeException("encoded string too long: " + bytes.length + " bytes");
return bytes.length + 2;
}
@Override
public void serialize(String value, ByteBuffer buf) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
buf.put((byte) ((bytes.length >>> 8) & 0xFF));
buf.put((byte) ((bytes.length) & 0xFF));
buf.put(bytes);
}
@Override
public String deserialize(ByteBuffer buf) {
int length = (((buf.get() & 0xff) << 8) + ((buf.get() & 0xff)));
byte[] bytes = new byte[length];
buf.get(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
}
public class OHCCacheSerializer<T> implements CacheSerializer<T> {
private final ThreadLocal<ByteBufferOutput> outputLocal = new ThreadLocal<>();
private final ThreadLocal<Input> inputLocal = new ThreadLocal<>();
private static final ThreadLocal<Kryo> kryoLocal = ThreadLocal.withInitial(() -> {
Kryo kryo = new Kryo();
kryo.setReferences(true);
kryo.setRegistrationRequired(false);
((DefaultInstantiatorStrategy) kryo.getInstantiatorStrategy())
.setFallbackInstantiatorStrategy(new StdInstantiatorStrategy());
return kryo;
});
public OHCCacheSerializer(Class<T> clazz){
this.clazz = clazz;
}
private Class<T> clazz;
@Override
public void serialize(T value, ByteBuffer buf) {
Kryo kryo = getKryo();
Output output = getOutput(buf);
kryo.writeObjectOrNull(output, value, this.clazz);
output.flush();
}
@Override
public T deserialize(ByteBuffer buf) {
Kryo kryo = getKryo();
Input input = getInput(buf);
return kryo.readObjectOrNull(input,this.clazz);
}
@Override
public int serializedSize(T value) {
Kryo kryo = getKryo();
return getKryoSerializerLength(value, kryo);
}
private int getKryoSerializerLength(Object value, Kryo kryo) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
Output output = new Output(outputStream);
kryo.writeClassAndObject(output, value);
output.flush();
output.close();
return outputStream.size();
}
private Kryo getKryo() {
return kryoLocal.get();
}
private Output getOutput(ByteBuffer buffer) {
ByteBufferOutput output;
if ((output = outputLocal.get()) == null) {
output = new ByteBufferOutput();
outputLocal.set(output);
}
if (buffer != null) {
output.setBuffer(buffer);
}
return output;
}
private Input getInput(ByteBuffer buffer) {
Input input;
if ((input = inputLocal.get()) == null) {
input = new Input();
inputLocal.set(input);
}
if (buffer != null) {
byte[] bytes = new byte[buffer.capacity()];
buffer.get(bytes);
input.setBuffer(bytes);
}
return input;
}
}
5.2 监控或配置告警
定时打印缓存使用统计信息或配置告警, 统计信息包含:
public final class OHCacheStats
{
private final long hitCount;
private final long missCount;
private final long evictionCount;
private final long expireCount;
private final long[] segmentSizes;
private final long capacity;
private final long free;
private final long size;
private final long rehashCount;
private final long putAddCount;
private final long putReplaceCount;
private final long putFailCount;
private final long removeCount;
private final long totalAllocated;
private final long lruCompactions;
}
如果需要严格控制堆外内存大小, 可以在启动参数里添加 -XX:MaxDirectMemorySize=size 用于设置 New I/O(java.nio ) direct-buffer allocations 的最大大小,size的单位可以使用 k/K、m/M、g/G;
如果没有设置该参数则默认值为0,意味着JVM自己自动给NIO direct-buffer allocations选择最大大小;
从代码java.base/jdk/internal/misc/VM.java中可以看到默认是取的Runtime.getRuntime().maxMemory(
注意: 不要忽略其他使用堆外内存的场景
5.3 缓存预热
实例刚启动时, 为了避免大量请求导致缓存穿透&响应较慢
同时上云后有CPU抑制现象, 首次请求耗时明显
可以在实例启动时, 提前将部分数据或全部数据加载到缓存里
5.4 减少缓存延迟
对准确性/实时性敏感的场景, 如何最大程度保证数据的实时性
- expire设置较小 => 接受该级别的数据延迟, 但回源频繁
- expire设置较大 => 数据延迟明显,需手动维护数据的一致性
数据更新时, 主动更新缓存
基于zk的watch/通知
基于RPC广播调用(注意漏机器问题)
基于mq广播消息
5.5 避免存放大对象
需要注意的是,get 和 put 的速度 和 缓存的键值对大小呈正相关趋势,因此不建议缓存过大的内容。可以通过maxEntrySize 配置项,来限制存储的最大键值对,OHC发现单个条目超过该值时不会将其放入堆外缓存。
5.6 线上表现
数据量: 10W个item* 2个限时折扣活动*30天 = 600W
需根据itemId和offsetMin来查询最近可参加的限时折扣信息
原来用的caffine, 常驻内存大造成GC频繁, 尖刺比较多, 响应时间增长明显,偶发超时
用OHC后, 全量数据不过期, 占用内存 <= 600M
单机压到多少3K, 查询TP99 <10ms, 批量查询TP99<20ms
六. 参考文档
https://www.cnblogs.com/liang1101/p/13499781.html
https://cloud.tencent.com/developer/article/1903413
https://my.oschina.net/bianxin/blog/4917416
https://zhuanlan.zhihu.com/p/345071202
https://www.cnblogs.com/scy251147/p/9634766.html
https://blog.csdn.net/qq_16046891/article/details/109194240
https://blog.csdn.net/javeme/article/details/104488028/
https://javajgs.com/archives/8310
https://github.com/snazy/ohc
https://gitee.com/mirrors_snazy/ohc
https://javadoc.io/doc/org.caffinitas.ohc/ohc-core/latest/index.html
|