接上文In-memory cache设计总结 - 1,我们首先来分析caffeine的设计特点。
基于过往对于guava的使用经验,一个优秀的in-memory cache的基本功能应该包括如下几点:
- LFU/LRU保证缓存命中率
- eviction policy
- statistics
- 高性能读写
下面我们就分析caffeine的设计及其优化思路,本文使用的caffeine版本为2.8.6。
缓存命中率保证
基于缓存局部性假设:
假如该数据近期被访问较多,那么很可能再次被访问。
caffeine设计中采用了Window TinyLfu算法(论文名称"TinyLFU: A Highly Efficient Cache Admission Policy")提供最高的命中率。
具体实现中,采用long数组记录访问频率,元素在long数组中的下标由hash算法确定。
为了降低hash碰撞对于频率记录的影响,caffeine采用Count-Min Sketch算法,思路如下:
- 采用4种hash算法计算元素的hash值,获取不同的数组下标
- 每一次访问,将不同数组下标对应的值加1
- 统计频率时,获取所有4个频率值,取其中的最小值作为实际频率
为了提高空间使用效率,caffeine将每个long变量划分为16份,每一份对应一条访问频率的记录(由此可知频率的最高值为15),单个long的bit划分如下:
频率统计相关源码如下:
public int frequency(@NonNull E e) {
//计算hash code用于确认segment
int hash = spread(e.hashCode());
int start = (hash & 3) << 2;
int frequency = Integer.MAX_VALUE;
for (int i = 0; i < 4; i++) {
//计算当前hash算法的index
int index = indexOf(hash, i);
//start*4+i*4代表频率bits的起始下标
int count = (int) ((table[index] >>> ((start + i) << 2)) & 0xfL);
//所有频率的最小值为实际频率
frequency = Math.min(frequency, count);
}
return frequency;
}
频率变更相关源码如下:
public void increment(@NonNull E e) {
int hash = spread(e.hashCode());
int start = (hash & 3) << 2;
// Loop unrolling improves throughput by 5m ops/s
int index0 = indexOf(hash, 0);
int index1 = indexOf(hash, 1);
int index2 = indexOf(hash, 2);
int index3 = indexOf(hash, 3);
//更新4个counter
boolean added = incrementAt(index0, start);
added |= incrementAt(index1, start + 1);
added |= incrementAt(index2, start + 2);
added |= incrementAt(index3, start + 3);
//全局更新一定次数后衰减,全体减半
if (added && (++size == sampleSize)) {
reset();
}
}
boolean incrementAt(int i, int j) {
int offset = j << 2;
long mask = (0xfL << offset);
if ((table[i] & mask) != mask) {
table[i] += (1L << offset);
return true;
}
return false;
}
//全局频率减半
void reset() {
int count = 0;
for (int i = 0; i < table.length; i++) {
count += Long.bitCount(table[i] & ONE_MASK);
table[i] = (table[i] >>> 1) & RESET_MASK;
}
size = (size >>> 1) - (count >>> 2);
}
数据淘汰策略
相比于简单的基于容量和访问频率淘汰,caffeine设计了多级缓存机制以保证在突发流量等情况下缓存依旧有着较高的命中率。
依据java doc中的描述,我们可以得知数据淘汰策略的设计:
- 新entry首先进入admission window,在该window中保持高的hit rate,当hit rate下降后,最终进入main space
- 如果main space已满,通过frequency filter确定将要淘汰的entry
- window用于保证burst类型的访问,main space用于保证popular item的hit rate
- window基于LRU,main space基于segment LRU,大小比例动态调节
main space中存在probation queue和protected queue,3个区域间的关系如下
- window满了,淘汰进入Probation
- 如果在probation中entry被访问,则升级为protected
- 如果protected满了,继续降级为probation
expireAfterWrite/expireAfterAccess/expireAfter基于timer-wheel构建过期机制。
高性能读写设计
对于一个cache的read/writer操作,除了对于K-V结构本身的操作外,还有如下动作:
- 调整frequency
- 确认是否需要淘汰数据
为了执行上述的附属动作,我们需要更长的操作时间和锁保护粒度,导致cache的吞吐降低。
因此在caffeine中,将上述的附属动作分离,在主要操作结束后,封装上述附属动作作为异步任务执行。
对于读操作,每一个线程都对应一个ringbuffer,在提交任务时可能失败。
对于写操作,共用一个MPSC队列,在消费端保证无锁操作。