背景
本文是上周去技术沙龙听了一下爱奇艺的 Java 缓存之路有感写出来的。先简单介绍一下爱奇艺的 Java 缓存道路的发展吧。
可以看见图中分为几个阶段:
第一阶段:数据同步加 Redis
通过消息队列进行数据同步至 Redis,然后 Java 应用直接去取缓存。这个阶段的优点是:由于是使用的分布式缓存,所以数据更新快。缺点也比较明显:依赖 Redis 的稳定性,一旦 Redis 挂了,整个缓存系统不可用,造成缓存雪崩,所有请求打到 DB。
第二,三阶段:JavaMap 到 Guava Cache
这个阶段使用进程内缓存作为一级缓存,Redis 作为二级。优点:不受外部系统影响,其他系统挂了,依然能使用。缺点:进程内缓存无法像分布式缓存那样做到实时更新。由于 Java 内存有限,必定缓存得设置大小,然后有些缓存会被淘汰,就会有命中率的问题。
第四阶段: Guava Cache 刷新
为了解决上面的问题,利用 Guava Cache 可以设置写后刷新时间,进行刷新。解决了一直不更新的问题,但是依然没有解决实时刷新。
第五阶段:外部缓存异步刷新
这个阶段扩展了 Guava Cache,利用 Redis 作为消息队列通知机制,通知其他 Java 应用程序进行刷新。
这里简单介绍一下爱奇艺缓存发展的五个阶段,当然还有一些其他的优化,比如 GC 调优,缓存穿透,缓存覆盖的一些优化等等。
原始社会 - 查库
上面说的是爱奇艺的一个进化线路,但是在大家的一般开发过程中,第一步一般都没有 Redis,而是直接查库。
在流量不大的时候,查数据库或者读取文件最为方便,也能完全满足我们的业务要求。
古代社会 - HashMap
当我们应用有一定流量之后或者查询数据库特别频繁,这个时候就可以祭出我们 Java 中自带的 HashMap 或者 ConcurrentHashMap。我们可以在代码中这么写:
public class CustomerService {
private HashMap hashMap = new HashMap<>();
private CustomerMapper customerMapper;
public String getCustomer(String name){
String customer = hashMap.get(name);
if ( customer ==null){
customer = customerMapper.get(name);
hashMap.put(name,customer);
}
return customer;
}
}
但是这样做就有个问题 HashMap 无法进行数据淘汰,内存会无限制的增长,所以 HashMap 很快也被淘汰了。
当然并不是说它完全就没用,就像我们古代社会也不是所有的东西都是过时的,比如我们中华名族的传统美德是永不过时的,就像这个 HashMap 一样的可以在某些场景下作为缓存,当不需要淘汰机制的时候,比如我们利用反射,如果我们每次都通过反射去搜索 Method,field,性能必定低效,这时我们用 HashMap 将其缓存起来,性能能提升很多。
近代社会 - LRUHashMap
在古代社会中难住我们的问题是无法进行数据淘汰,这样会导致我们内存无限膨胀,显然我们是不可以接受的。
有人就说我把一些数据给淘汰掉呗,这样不就对了,但是怎么淘汰呢?随机淘汰吗?当然不行,试想一下你刚把 A 装载进缓存,下一次要访问的时候就被淘汰了,那又会访问我们的数据库了,那我们要缓存干嘛呢?
所以聪明的人们就发明了几种淘汰算法,下面列举下常见的三种 FIFO,LRU,LFU(还有一些 ARC,MRU 感兴趣的可以自行搜索):
FIFO:先进先出,在这种淘汰算法中,先进入缓存的会先被淘汰。
这种可谓是最简单的了,但是会导致我们命中率很低。试想一下我们如果有个访问频率很高的数据是所有数据第一个访问的,而那些不是很高的是后面再访问的,那这样就会把我们的首个数据但是他的访问频率很高给挤出。
LRU:最近最少使用算法。
在这种算法中避免了上面的问题,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可。
但是这个依然有个问题,如果有个数据在 1 个小时的前 59 分钟访问了 1 万次(可见这是个热点数据),再后 1 分钟没有访问这个数据,但是有其他的数据访问,就导致了我们这个热点数据被淘汰。
LFU:最近最少频率使用。
在这种算法中又对上面进行了优化,利用额外的空间记录每个数据的使用频率,然后选出频率最低进行淘汰。这样就避免了 LRU 不能处理时间段的问题。
上面列举了三种淘汰策略,对于这三种,实现成本是一个比一个高,同样的命中率也是一个比一个好。
而我们一般来说选择的方案居中即可,即实现成本不是太高,而命中率也还行的 LRU,如何实现一个 LRUMap 呢?我们可以通过继承 LinkedHashMap,重写 removeEldestEntry 方法,即可完成一个简单的 LRUMap。
class LRUMap extends LinkedHashMap {
private finalint max;
private Object lock;
public LRUMap(int max, Object lock) {
//无需扩容
super((int) (max * 1.4f), 0.75f, true);
this.max = max;
this.lock = lock;
}
/**
* 重写LinkedHashMap的removeEldestEntry方法即可
* 在Put的时候判断,如果为true,就会删除最老的
* @param eldest
* @return
*/
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > max;
}
public Object getValue(Object key) {
synchronized (lock) {
return get(key);
}
}
public void putValue(Object key, Object value) {
synchronized (lock) {
put(key, value);
}
}
public boolean removeValue(Object key) {
synchronized (lock) {
return remove(key) != null;
}
}
public boolean removeAll(){
clear();
return true;
}
}
在 LinkedHashMap 中维护了一个 entry(用来放 key 和 value 的对象)链表。在每一次 get 或者 put 的时候都会把插入的新 entry,或查询到的老 entry 放在我们链表末尾。
可以注意到我们在构造方法中,设置的大小特意设置到 max*1.4,在下面的 removeEldestEntry 方法中只需要 size>max 就淘汰,这样我们这个 map 永远也走不到扩容的逻辑了,通过重写 LinkedHashMap,几个简单的方法我们实现了我们的 LruMap。
现代社会 - Guava Cache
在近代社会中已经发明出来了 LRUMap,用来进行缓存数据的淘汰,但是有几个问题:
锁竞争严重,可以看见我的代码中,Lock 是全局锁,在方法级别上面的,当调用量较大时,性能必然会比较低。
不支持过期时间
不支持自动刷新
所以谷歌的大佬们对于这些问题,按捺不住了,发明了 Guava Cache,在 Guava Cache 中你可以如下面的代码一样,轻松使用:
public static void main(String[] args) throws ExecutionException {
LoadingCache cache = CacheBuilder.newBuilder()
.maximumSize(100)
//写之后30ms过期
.expireAfterWrite(30L, TimeUnit.MILLISECONDS)
//访问之后30ms过期
.expireAfterAccess(30L, TimeUnit.MILLISECONDS)
//20ms之后刷新
.refreshAfterWrite(20L, TimeUnit.MILLISECONDS)
//开启weakKeykey 当启动垃圾回收时,该缓存也被回收
.weakKeys()
.build(createCacheLoader());
System.out.println(cache.get("hello"));
cache.put("hello1", "我是hello1");
System.out.println(cache.get("hello1"));
cache.put("hello1", "我是hello2");
System.out.println(cache.get("hello1"));
}
public static com.google.common.cache.CacheLoader createCacheLoader() {
return new com.google.common.cache.CacheLoader() {
@Override
public String load(String key) throws Exception {
return key;
}
};
}
我将会从 Guava Cache 原理中,解释 Guava Cache 是如何解决 LRUMap 的几个问题的。
锁竞争
Guava Cache 采用了类似 ConcurrentHashMap 的思想,分段加锁,在每个段里面各自负责自己的淘汰的事情。
在 Guava 根据一定的算法进行分段,这里要说明的是,如果段太少那竞争依然很严重,如果段太多容易出现随机淘汰,比如大小为 100 的,给他分 100 个段,那也就是让每个数据都独占一个段,而每个段会自己处理淘汰的过程,所以会出现随机淘汰。在 Guava Cache 中通过如下代码,计算出应该如何分段。
int segmentShift = 0;
int segmentCount = 1;
while (segmentCount < concurrencyLevel && (!evictsBySize() || segmentCount * 20 <= maxWeight)) {
++segmentShift;
segmentCount <<= 1;
}
上面 segmentCount 就是我们最后的分段数,其保证了每个段至少 10 个 entry。如果没有设置 concurrencyLevel 这个参数,那么默认就会是 4,最后分段数也最多为 4,例如我们 size 为 100,会分为 4 段,每段最大的 size 是 25。
在 Guava Cache 中对于写操作直接加锁,对于读操作,如果读取的数据没有过期,且已经加载就绪,不需要进行加锁,如果没有读到会再次加锁进行二次读,如果还没有需要进行缓存加载,也就是通过我们配置的 CacheLoader,我这里配置的是直接返回 Key,在业务中通常配置从数据库中查询。 如下图所示:
过期时间
相比于 LRUMap 多了两种过期时间,一个是写后多久过期 expireAfterWrite,一个是读后多久过期 expireAfterAccess。
很有意思的事情是,在 Guava Cache 中对于过期的 entry 并没有马上过期(也就是并没有后台线程一直在扫),而是通过进行读写操作的时候进行过期处理,这样做的好处是避免后台线程扫描的时候进行全局加锁。看下面的代码:
public static void main(String[] args) throws ExecutionException, InterruptedException {
Cache cache = CacheBuilder.newBuilder()
.maximumSize(100)
//写之后5s过期
.expireAfterWrite(5, TimeUnit.MILLISECONDS)
.concurrencyLevel(1)
.build();
cache.put("hello1", "我是hello1");
cache.put("hello2", "我是hello2");
cache.put("hello3", "我是hello3");
cache.put("hello4", "我是hello4");
//至少睡眠5ms
Thread.sleep(5);
System.out.println(cache.size());
cache.put("hello5", "我是hello5");
System.out.println(cache.size());
}
输出:
4
1
从这个结果中我们知道,在 put 的时候才进行的过期处理。特别注意的是我上面 concurrencyLevel(1)这里将分段最大设置为 1,不然不会出现这个实验效果的,在上面一节中已经说过,我们是以段位单位进行过期处理。在每个 Segment 中维护了两个队列:
final Queue> writeQueue;
final Queue> accessQueue;
writeQueue 维护了写队列,队头代表着写得早的数据,队尾代表写得晚的数据。accessQueue 维护了访问队列,和 LRU 一样,用来进行访问时间的淘汰。如果当这个 Segment 超过最大容量,比如我们上面所说的 25,超过之后,就会把 accessQueue 这个队列的第一个元素进行淘汰。
void expireEntries(long now) {
drainRecencyQueue();
ReferenceEntry e;
while ((e = writeQueue.peek()) !=null && map.isExpired(e, now)) {
if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
throw new AssertionError();
}
}
while ((e = accessQueue.peek()) !=null && map.isExpired(e, now)) {
if (!removeEntry(e, e.getHash(), RemovalCause.EXPIRED)) {
throw new AssertionError();
}
}
}
上面就是 Guava Cache 处理过期 entries 的过程,会对两个队列一次进行 peek 操作,如果过期就进行删除。
一般处理过期 entries 可以在我们的 put 操作的前后,或者读取数据时发现过期了,然后进行整个 segment 的过期处理,又或者进行二次读 lockedGetOrLoad 操作的时候调用。
void evictEntries(ReferenceEntry newest) {
///... 省略无用代码
while (totalWeight > maxSegmentWeight) {
ReferenceEntry e = getNextEvictable();
if (!removeEntry(e, e.getHash(), RemovalCause.SIZE)) {
throw new AssertionError();
}
}
}
/**
**返回accessQueue的entry
**/
ReferenceEntry getNextEvictable() {
for (ReferenceEntry e : accessQueue) {
int weight = e.getValueReference().getWeight();
if (weight > 0) {
return e;
}
}
throw new AssertionError();
}
上面是我们驱逐 entry 的时候的代码,可以看见访问的是 accessQueue 对其队头进行驱逐。而驱逐策略一般是在对 segment 中的元素发生变化时进行调用,比如插入操作,更新操作,加载数据操作。
自动刷新
自动刷新操作,在 Guava Cache 中实现相对比较简单,直接通过查询,判断其是否满足刷新条件,进行刷新。
其他特性
在 Guava Cache 中还有一些其他特性:
虚引用
在 Guava Cache 中,key 和 value 都能进行虚引用的设定,在 segment 中有两个引用队列:
final @Nullable ReferenceQueue keyReferenceQueue;
final @Nullable ReferenceQueue valueReferenceQueue;
这两个队列用来记录被回收的引用,其中每个队列记录了每个被回收的 entry 的 hash,这样回收了之后通过这个队列中的 hash 值就能把以前的 entry 进行删除。
删除监听器
在 Guava Cache 中,当有数据被淘汰时,但是你不知道他到底是过期,还是被驱逐,还是因为虚引用的对象被回收?
这个时候你可以调用这个方法 removalListener(RemovalListener listener)添加监听器进行数据淘汰的监听,可以打日志或者一些其他处理,可以用来进行数据淘汰分析。
在 RemovalCause 记录了所有被淘汰的原因:被用户删除,被用户替代,过期,驱逐收集,由于大小淘汰。
Guava Cache 的总结
细细品读 Guava Cache 的源码总结下来,其实就是一个性能不错的,api 丰富的 LRU Map。爱奇艺的缓存的发展也是基于此之上,通过对 Guava Cache 的二次开发,让其可以进行 Java 应用服务之间的缓存更新。
走向未来-caffeine
Guava Cache 的功能的确是很强大,满足了绝大多数人的需求,但是其本质上还是 LRU 的一层封装,所以在众多其他较为优良的淘汰算法中就相形见绌了。而 Caffeine Cache 实现了 W-TinyLFU(LFU+LRU 算法的变种)。下面是不同算法的命中率的比较:
其中 Optimal 是最理想的命中率,LRU 和其他算法相比的确是个弟弟。而我们的 W-TinyLFU 是最接近理想命中率的。当然不仅仅是命中率 Caffeine 优于了 Guava Cache,在读写吞吐量上面也是完爆 Guava Cache。
作者:Java高级架构
链接:https://www.jianshu.com/p/500049e41e6f