共计 9303 个字符,预计需要花费 24 分钟才能阅读完成。
一、对于 Caffeine Cache
在举荐服务中,尽管容许大量申请因计算超时等起因返回默认列表。但从经营指标来说, 越高的“完算率”意味着越残缺的算法成果出现,也意味着越高的商业收益。(完算率类比视频的完播率,胜利实现整个举荐线上流程计算的申请次数 / 总申请次数)
为了可能尽可能快地实现计算,多级缓存计划曾经成为举荐线上服务的标配。其中本地缓存显得尤为重要,而 Caffeine Cache 就是近几年怀才不遇的高性能本地缓存库。Caffeine Cache 曾经在 Spring Boot 2.0 中取代了 Google Guava 成为默认缓存框架,足见其成熟和牢靠。
对于 Caffeine 的介绍文章有很多,不再累述,可浏览文末的参考资料理解 Caffeine 的简述、性能基准测试后果、根本 API 用法和 Window-TinyLFU 缓存算法原理等。尽管接触 Caffeine 的工夫不长,但其简洁的 API 和如丝般顺滑的异步加载能力几乎不要太好用。而本菜鸟在应用的过程中也踩了一些坑,使用不当甚至缓存也能卡得和磁盘 IO 一样慢。
通过一番学习尝试,总算理解到 Caffeine Cache 如丝般顺滑的神秘,总结下来分享一下。
二、Caffeine Cache 配置套路
应用 Caffeine Cache,除了 Spring 中常见的 @EnableCache、@Cacheable 等注解外,间接应用 Caffeine.newBuilder().build() 办法创立 LoadingCache 也是举荐服务罕用的形式。
咱们先来看看 Caffeine#builder 都有哪些配置套路:
2.1 诘问三连
2.1.1 ObjectPool
当然能够,光脚的不怕穿鞋的,上线后别走……
2.1.2 expireAfterWrite、expireAfterAccess 都配置?
尽管 expireAfterWrite 和 expireAfterAccess 同时配置不报错,但 access 蕴含了 write,所以选一个就好了亲。
2.1.3 reference-based 驱赶有啥特点?
只有配置上都会应用 == 来比拟对象相等,而不是 equals;还有一个十分重要的配置,也是决定缓存如丝般顺滑的秘诀:刷新策略 refreshAfterWrite。该配置使得 Caffeine 能够在数据加载后超过给定工夫时刷新数据。下文详解。
机智如我在 Builder 上也能踩坑
和 lombok 的 builder 不同,Caffeine#builder 的策略调用两次将会导致运行时异样!这是因为 Caffeine 构建时每个策略都保留了已设置的标记位,所以反复设置并不是笼罩而是间接抛异样:
public Caffeine<K, V> maximumWeight(@NonNegative long maximumWeight) {
requireState(this.maximumWeight == UNSET_INT,
"maximum weight was already set to %s", this.maximumWeight);
requireState(this.maximumSize == UNSET_INT,
"maximum size was already set to %s", this.maximumSize);
this.maximumWeight = maximumWeight;
requireArgument(maximumWeight >= 0, "maximum weight must not be negative");
return this;
}
比方上述代码,maximumWeight() 调用两次的话就会抛出异样并提醒 maximum weight was already set to xxx。
三、Caffeine Cache 精髓
3.1 get 办法都做了什么?
首先在实现类 LocalLoadingCache<K, V> 中能够看到;
default @Nullable V get(K key) {return cache().computeIfAbsent(key, mappingFunction());
}
但忽然发现这个 get 办法没有实现类!Why?咱们跟踪 cache() 办法就能够发现端倪:
public BoundedLocalCache<K, V> cache() {return cache;}
public UnboundedLocalCache<K, V> cache() {return cache;}
依据调用 Caffeine.newBuilder().build() 的过程,决定了具体生成的是 BoundedLocalCache 还是 UnboundedLocalCache;
断定 BoundedLocalCache 的条件如下:
public <K1 extends K, V1 extends V> LoadingCache<K1, V1> build(@NonNull CacheLoader<? super K1, V1> loader) {requireWeightWithWeigher();
@SuppressWarnings("unchecked")
Caffeine<K1, V1> self = (Caffeine<K1, V1>) this;
return isBounded() || refreshes()
? new BoundedLocalCache.BoundedLocalLoadingCache<>(self, loader)
: new UnboundedLocalCache.UnboundedLocalLoadingCache<>(self, loader);
}
其中的 isBounded()、refreshes() 办法别离如下:
boolean isBounded() {return (maximumSize != UNSET_INT)
|| (maximumWeight != UNSET_INT)
|| (expireAfterAccessNanos != UNSET_INT)
|| (expireAfterWriteNanos != UNSET_INT)
|| (expiry != null)
|| (keyStrength != null)
|| (valueStrength != null);
}
boolean refreshes() {
// 调用了 refreshAfter 就会返回 false
return refreshNanos != UNSET_INT;
}
能够看到个别状况下惯例的配置都是 BoundedLocalCache。所以咱们以它为例持续看 BoundedLocalCache#computeIfAbsent 办法吧:
public @Nullable V computeIfAbsent(K key,
Function<? super K, ? extends V> mappingFunction,
boolean recordStats, boolean recordLoad) {
// 罕用的 LoadingCache#get 办法 recordStats、recordLoad 都为 true
// mappingFunction 即 builder 中传入的 CacheLoader 实例包装
requireNonNull(key);
requireNonNull(mappingFunction);
// 默认的 ticker read 返回的是 System.nanoTime();
// 对于其余的 ticker 见文末参考文献,能够让使用者自定义超时的计时形式
long now = expirationTicker().read();
// data 是 ConcurrentHashMap<Object, Node<K, V>>
// key 依据代码目前都是 LookupKeyReference 对象
// 能够发现 LookupKeyReference 保留的是 System.identityHashCode(key) 后果
// 对于 identityHashCode 和 hashCode 的区别可浏览文末参考资料
Node<K, V> node = data.get(nodeFactory.newLookupKey(key));
if (node != null) {V value = node.getValue();
if ((value != null) && !hasExpired(node, now)) {
// isComputingAsync 中将会判断以后是否为异步类的缓存实例
// 是的话再判断 node.getValue 是否实现。BoundedLocaCache 总是返回 false
if (!isComputingAsync(node)) {
// 此处在 BoundedLocaCache 中也是间接 return 不会执行
tryExpireAfterRead(node, key, value, expiry(), now);
setAccessTime(node, now);
}
// 异步驱赶工作提交、异步刷新操作
// CacheLoader#asyncReload 就在其中的 refreshIfNeeded 办法被调用
afterRead(node, now, recordStats);
return value;
}
}
if (recordStats) {
// 记录缓存的加载胜利、失败等统计信息
mappingFunction = statsAware(mappingFunction, recordLoad);
}
// 这里 2.8.0 版本不同实现类生成的都是 WeakKeyReference
Object keyRef = nodeFactory.newReferenceKey(key, keyReferenceQueue());
// 本地缓存没有,应用加载函数读取到缓存
return doComputeIfAbsent(key, keyRef, mappingFunction,
new long[] { now}, recordStats);
}
上文中 hasExpired 判断数据是否过期,看代码就很明确了:是通过 builder 的配置 + 工夫计算来判断的。
boolean hasExpired(Node<K, V> node, long now) {
return
(expiresAfterAccess() &&
(now - node.getAccessTime() >= expiresAfterAccessNanos()))
| (expiresAfterWrite() &&
(now - node.getWriteTime() >= expiresAfterWriteNanos()))
| (expiresVariable() &&
(now - node.getVariableTime() >= 0));
}
持续看代码,doComputeIfAbsent 办法次要内容如下:
@Nullable V doComputeIfAbsent(K key, Object keyRef,
Function<? super K, ? extends V> mappingFunction,
long[] now, boolean recordStats) {@SuppressWarnings("unchecked")
V[] oldValue = (V[]) new Object[1];
@SuppressWarnings("unchecked")
V[] newValue = (V[]) new Object[1];
@SuppressWarnings("unchecked")
K[] nodeKey = (K[]) new Object[1];
@SuppressWarnings({"unchecked", "rawtypes"})
Node<K, V>[] removed = new Node[1];
int[] weight = new int[2]; // old, new
RemovalCause[] cause = new RemovalCause[1];
// 对 data 这个 ConcurrentHashMap 调用 compute 办法,计算 key 对应的值
// compute 办法的执行是原子的,并且会对 key 加锁
// JDK 正文阐明 compute 应该短而快并且不要在其中更新其余的 key-value
Node<K, V> node = data.compute(keyRef, (k, n) -> {if (n == null) {
// 没有值的时候调用 builder 传入的 CacheLoader#load 办法
// mappingFunction 是在 LocalLoadingCache#newMappingFunction 中创立的
newValue[0] = mappingFunction.apply(key);
if (newValue[0] == null) {return null;}
now[0] = expirationTicker().read();
// builder 没有指定 weigher 时,这里默认为 SingletonWeigher,总是返回 1
weight[1] = weigher.weigh(key, newValue[0]);
n = nodeFactory.newNode(key, keyReferenceQueue(),
newValue[0], valueReferenceQueue(), weight[1], now[0]);
setVariableTime(n, expireAfterCreate(key, newValue[0], expiry(), now[0]));
return n;
}
// 有值的时候对 node 实例加同步块
synchronized (n) {nodeKey[0] = n.getKey();
weight[0] = n.getWeight();
oldValue[0] = n.getValue();
// 设置驱赶起因,如果数据无效间接返回
if ((nodeKey[0] == null) || (oldValue[0] == null)) {cause[0] = RemovalCause.COLLECTED;
} else if (hasExpired(n, now[0])) {cause[0] = RemovalCause.EXPIRED;
} else {return n;}
// 默认的配置 writer 是 CacheWriter.disabledWriter(),无操作;// 本人定义的 CacheWriter 个别用于驱赶数据时失去回调进行内部数据源操作
// 详情能够参考文末的材料
writer.delete(nodeKey[0], oldValue[0], cause[0]);
newValue[0] = mappingFunction.apply(key);
if (newValue[0] == null) {removed[0] = n;
n.retire();
return null;
}
weight[1] = weigher.weigh(key, newValue[0]);
n.setValue(newValue[0], valueReferenceQueue());
n.setWeight(weight[1]);
now[0] = expirationTicker().read();
setVariableTime(n, expireAfterCreate(key, newValue[0], expiry(), now[0]));
setAccessTime(n, now[0]);
setWriteTime(n, now[0]);
return n;
}
});
// 剩下的代码次要是调用 afterWrite、notifyRemoval 等办法
// 进行后置操作,后置操作中将会再次尝试缓存驱赶
// ...
return newValue[0];
}
看完下面的代码,遇到这些问题也就心里有数了。
3.2 缓存的数据什么时候淘汰?
显式调用 invalid 办法时;弱援用、软援用可回收时;get 办法老值存在且已实现异步加载后调用 afterRead。
get 办法老值不存在,调用 doComputeIfAbsent 加载完数据后调用 afterWrite。
3.3 CacheLoader#load 和 CacheLoader#asyncReload 有什么区别?
首先 CacheLoader#load 办法是必须提供的,缓存调用时将是同步操作(回顾上文 data.compute 办法),会阻塞以后线程。
而 CacheLoader#asyncReload 须要配合 builder#refreshAfterWrite 应用这样将在 computeIfAbsent->afterRead->refreshIfNeeded 中调用,并异步更新到 data 对象上;并且,load 办法没有传入 oldValue,而 asyncReload 办法提供了 oldValue,这意味着如果触发 load 操作时,缓存是不能保障 oldValue 是否存在的(可能是首次,也可能是已生效)。
3.4 加载数据耗时较长,对性能的影响是什么?
CacheLoader#load 耗时长,将会导致缓存运行过程中查问数据时阻塞期待加载,当多个线程同时查问同一个 key 时,业务申请可能阻塞,甚至超时失败;
CacheLoader#asyncReload 耗时长,在工夫周期满足的状况下,即便耗时长,对业务的影响也较小
3.5 说好的如丝般顺滑呢?
首要前提是内部数据查问能保障单次查问的性能(一次查问山高水长那加本地缓存也于事无补);而后,咱们在构建 LoadingCache 时,配置 refreshAfterWrite 并在 CacheLoader 实例上定义 asyncReload 办法;
灵魂诘问:只有以上两步就够了吗?
机智的我忽然感觉事件并不简略。还有一个工夫设置的问题,咱们来看看:
如果 expireAfterWrite 周期 < refreshAfterWrite 周期会如何?此时查问生效数据时总是会调用 load 办法,refreshAfterWrite 基本没用!
如果 CacheLoader#asyncReload 有额定操作,导致它本身理论执行查问耗时超过 expireAfterWrite 又会如何?还是 CacheLoader#load 失效,refreshAfterWrite 还是没用!
所以丝滑的正确打开方式,是 refreshAfterWrite 周期显著小于 expireAfterWrite 周期,并且 CacheLoader#asyncReload 自身也有较好的性能,能力如丝般顺滑地加载数据。此时就会发现业务一直进行 get 操作,基本感知不到数据加载时的卡顿!
3.6 用本地缓存会不会呈现缓存穿透?怎么避免?
computeIfAbsent 和 doComputeIfAbsent 办法能够看出如果加载后果是 null,那么每次从缓存查问,都会触发 mappingFunction.apply,进一步调用 CacheLoader#load。从而流量会间接打到后端数据库,造成缓存穿透。
避免的办法也比较简单,在业务可承受的状况下,如果未能查问到后果,则返回一个非 null 的“假对象”到本地缓存中。
灵魂诘问:如果查不到,new 一个对象返回行不行?
key 范畴不大时能够,builder 设置了 size-based 驱赶策略时能够,但都存在耗费较多内存的危险,能够定义一个默认的 PLACE_HOLDER 动态对象作为援用。
灵魂诘问:都用同一个假对象援用真的大丈夫(没问题)?
这么大的坑本菜鸟怎么能错过!缓存中存的是对象援用,如果业务 get 后批改了对象的内容,那么其余线程再次获取到这个对象时,将会失去批改后的值!鬼晓得那个深夜定位出这个问题的我有多兴奋(苍蝇搓手)。
过后缓存中保留的是 List<Item>,而不同线程中对这些 item 的 score 进行了不同的 set 操作,导致同一个 item 排序后的分数和程序变幻莫测。本菜鸟一度认为是举荐之神来临,冥冥中加持 CTR 所以把 score 变来变去。
灵魂诘问:那怎么解决缓存被意外批改的问题呢?怎么 copy 一个对象呢?
So easy,就在 get 的时候 copy 一下对象就好了。
灵魂诘问 4:怎么 copy 一个对象?……停!咱们当前有机会再来说说这个浅拷贝和深拷贝,以及常见的拷贝工具吧,聚焦聚焦……
3.7 某次加载数据失败怎么办,还能用之前的缓存值吗?
依据 CacheLoader#load 和 CacheLoader#asyncReload 的参数区别,咱们能够发现:
应该在 asyncReload 中来解决,如果查询数据库异样,则能够返回 oldValue 来持续应用之前的缓存;否则只能通过 load 办法中返回预留空对象来解决。应用哪一种办法须要依据具体的业务场景来决定。
【踩坑】返回 null 将导致 Caffeine 认为该值不须要缓存,下次查问还会持续调用 load 办法,缓存并没失效。
3.8 多个线程同时 get 一个本地缓存不存在的值,会如何?
依据代码能够晓得,曾经进入 doComputeIfAbsent 的线程将阻塞在 data.compute 办法上;
比方短时间内有 N 个线程同时 get 雷同的 key 并且 key 不存在,则这 N 个线程最终都会重复执行 compute 办法。但只有 data 中该 key 的值更新胜利,其余进入 computeIfAbsent 的线程都可间接取得后果返回,不会呈现阻塞期待加载;
所以,如果一开始就有大量申请进入 doComputeIfAbsent 阻塞期待数据,就会造成短时间申请挂起、超时的问题。由此在大流量场景下降级服务时,须要思考在接入流量前对缓存进行预热(我查我本人,嗯),避免刹时申请太多导致大量申请挂起或超时。
灵魂诘问:如果一次 load 耗时 100ms,一开始有 10 个线程冷启动,最终等待时间会是 1s 左右吗?
其实……要看状况,回顾一下 data.compute 外面的代码:
if (n == null) {// 这部分代码其余后续线程进入后曾经有值,不再执行}
synchronized (n) {
// ...
if ((nodeKey[0] == null) || (oldValue[0] == null)) {cause[0] = RemovalCause.COLLECTED;
} else if (hasExpired(n, now[0])) {cause[0] = RemovalCause.EXPIRED;
} else {
// 未生效时在这里返回,不会触发 load 函数
return n;
}
// ...
}
所以,如果 load 后果不是 null,那么只第一个线程花了 100ms,后续线程会尽快返回,最终时长应该只比 100ms 多一点。但如果 load 后果返回 null(缓存穿透),相当于没有查到数据,于是后续线程还会再次执行 load,最终工夫就是 1s 左右。
以上就是本菜鸟目前总结的内容,如有疏漏欢送指出。在学习源码的过程中,Caffeine Cache 还应用了其余编码小技巧,咱们下次有空接着聊。
三、参考资料
1.Caffeine 应用及原理
2.Caffeine Cache- 高性能 Java 本地缓存组件
3.Eviction 和 Ticker 相干介绍
4.Efficiency
5.CacheWriter
6.System.identityHashCode(obj) 与 obj.hashcode
作者:vivo 互联网服务器团队 -Li Haoxuan