额外参考资料:
http://www.ehcache.org/documentation/3.2/expiry.html
F. Cache Aside 模式的问题:缓存过期
有时我们会在上线前给缓存系统来个预热,提前读取一部分用户信息到缓存中。默认情况下,这些缓存项拥有相同的 ttl 设置,会在一个很短的时间段内大批量的过期,导致这段时间后端 SoR 压力过大,可能会导致整个系统崩溃。
如果我们给每个缓存项设计一个随机的过期时间,就可以避免缓存过期的集中爆发。
G. Cache Aside 模式的问题:缓存穿透
当查询无结果时,一般情况下我们不会缓存这次查询(Ehcache3 也不允许缓存 null 值)。但是,有时由于程序缺陷或者恶意攻击,短时间内会有大量异常查询请求到达系统,这些请求全都会透过缓存层到达 SoR,可能导致后端 SoR 崩溃。这就是缓存穿透。
因此,对于查询结果为 null 的请求,我们也需要缓存起来。只不过缓存的过期时间必须很短,防止恶意攻击程序制造出太多无用的缓存项,把整个缓存无效化。
缓存穿透的另一种解决办法是使用布隆过滤器将不存在的 id 提前拦截掉,降低 SoR 层的压力。布隆过滤器不在本次交流范围内,具体细节可以参考以下链接:
https://mp.weixin.qq.com/s/TBCEwLVAXdsTszRVpXhVug
http://blog.csdn.net/dadoneo/article/details/6847481
https://en.wikipedia.org/wiki/Bloom_filter
H. Ehcache3 自定义过期策略
通过实现 Expiry 接口即可以自定义过期策略。
public interface Expiry {
/**
* 当缓存项第一次写入缓存中时,为该缓存项设置过期时间(从当前时间开始,超过指定的 Duration 时间即为过期)。
* 返回值不可以为 null。
* 当该方法抛出异常时,异常会被调用方静默处理,相当于返回了 Duration.ZERO,即立即过期。
*/
Duration getExpiryForCreation(K key, V value);
/**
* 缓存项被命中时,用返回值重置该缓存项的过期时间。
* 返回值为 null 表示不重置过期时间
* 当该方法抛出异常时,异常会被调用方静默处理,相当于返回了 Duration.ZERO,即立即过期。
*/
Duration getExpiryForAccess(K key, ValueSupplier extends V> value);
/**
* 缓存项被更新时,用返回值重置该缓存项的过期时间。
* 返回值为 null 表示不重置过期时间
* 当该方法抛出异常时,异常会被调用方静默处理,相当于返回了 Duration.ZERO,即立即过期。
*/
Duration getExpiryForUpdate(K key, ValueSupplier extends V> oldValue, V newValue);
演示代码如下:
gordon.study.cache.ehcache3.pattern.CustomExpiryCacheAsideUserService.java
private static final UserModel NULL_USER = new NullUser();
private Ehcache cache;
public CustomExpiryCacheAsideUserService() {
cache = (Ehcache) UserManagedCacheBuilder.newUserManagedCacheBuilder(String.class, UserModel.class)
.withExpiry(new CustomUserExpiry(new Duration(100, TimeUnit.SECONDS))).build(true);
}
public UserModel findUser(String id) {
UserModel cached = cache.get(id);
if (cached != null) {
System.out.println("get user from cache: " + id);
return cached;
}
UserModel user = null;
if (!id.equals("0")) {
user = new UserModel(id, "info ..."); // find user
}
System.out.println("get user from db: " + id);
if (user == null) {
user = NULL_USER;
}
cache.put(id, user);
return user;
}
private static class CustomUserExpiry implements Expiry {
private final Duration ttl;
public CustomUserExpiry(Duration ttl) {
this.ttl = ttl;
}
@Override
public Duration getExpiryForCreation(String key, UserModel value) {
if (value.isNull()) {
System.out.println("user is null: " + key);
return new Duration(10, TimeUnit.SECONDS);
}
long length = ttl.getLength();
if (length > 10) {
long max = length / 5;
long random = ThreadLocalRandom.current().nextLong(-max, max);
return new Duration(ttl.getLength() + random, ttl.getTimeUnit());
}
return ttl;
}
@Override
public Duration getExpiryForAccess(String key, ValueSupplier extends UserModel> value) {
return null;
}
@Override
public Duration getExpiryForUpdate(String key, ValueSupplier extends UserModel> oldValue, UserModel newValue) {
return ttl;
}
}
public static void main(String[] args) throws Exception {
final CustomExpiryCacheAsideUserService service = new CustomExpiryCacheAsideUserService();
for (int i = 0; i < 5; i++) {
service.findUser("" + i);
}
}
CustomUserExpiry 类似于 TimeToLiveExpiry,唯一区别是当缓存项被创建时,返回的 Duration 会在原来的基础上随机浮动 20%。考虑到并发性,随机数生成器用的是 ThreadLocalRandom。CustomUserExpiry 类通过代码第7行 withExpiry 方法设置。这用来解决缓存过期问题。
至于缓存穿透问题,首先用 Null Object 模式修改 UserModel 类,增加 isNull 方法用于判断是否为 null object,简单起见,该方法直接返回 false。再定义 NullUser 类,继承自 userModel,其 isNull 方法返回 true。
public class UserModel {
public boolean isNull() {
return false;
}
public static class NullUser extends UserModel {
public NullUser() {
super(null, null);
}
public boolean isNull() {
return true;
}
}
}
代码第21行,当 SoR 返回的查询结果为 null 时,使用第1行预先定义好的 NullUser 实例作为返回值,同时将本次查询结果放入缓存。
代码第38行,如果即将创建的缓存是 null object,则只缓存10秒钟。
I. 过期算法略读
Ehcache3 的回收判定发生在 put 操作时,而过期判定则发生在 get 操作时。
Ehcache 类的 get 方法调用其 Store store 属性的 get 方法,尝试获得缓存的数据。Store 代表缓存的各种存储方式。
本例中,Store 的具体实现类为 OnHeapStore,表示使用堆空间存储缓存项。它的 get 方法调用其 Backend map 属性的 get 方法尝试获得缓存的数据。Backend 通过泛型屏蔽底层 map 的键类型。
本例中,Backend 的具体实现类为 SimpleBackend,它拥有 org.ehcache.impl.internal.concurrent.ConcurrentHashMap
OnHeapStore 从最底层的 ConcurrentHashMap 获取到缓存项后,调用 OnHeapValueHolder 的 isExpired 方法判断缓存项是否过期:
@Override
public boolean isExpired(long expirationTime, TimeUnit unit) {
final long expire = this.expirationTime;
if (expire == NO_EXPIRE) {
return false;
}
return expire <= nativeTimeUnit().convert(expirationTime, unit);
}
OnHeapValueHolder 的 expirationTime 属性用于判断缓存项是否过期。方法参数 long expirationTime 传入的是当前系统时间,如果 OnHeapValueHolder 的 expirationTime 小于当前系统时间,则该缓存项已经过期。对于过期的缓存项,会将之从 ConcurrentHashMap 移除,因此 Ehcache 的 get 方法会返回 null。
Ehcahce3 的这种设计方式导致 Null Object 模式优化的缓存穿透预防方案有点奇怪。一般情况下,null object 不会被再次 get,就不会被过期算法直接移除,另一方面,按照默认回收算法只比较 lastAccessTime 值,因此为 null object 设置的很短的失效时间实际上很可能没有作用。
个人觉得,put 操作引发的回收算法中可以增加过期判断,如果发现过期数据,优先回收这些数据,可以缓解明明缓存空间中有过期数据,却回收尚未过期的数据这种情况。