聊聊缓存回收策略跟缓存更新策略

Posted 心灵震撼ya

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了聊聊缓存回收策略跟缓存更新策略相关的知识,希望对你有一定的参考价值。

互联网时代的飞速发展,用户的体验度是判断一个软件好坏的重要原因,所以缓存就是必不可少的一个神器。缓存的种类有很多,需要根据不同的应用场景来需要选择不同的cache,比如分布式缓存如redis跟本地缓存如Caffeine。今天简单聊聊缓存的回收策略跟更新策略。由于笔者自身水平有限,如果有不对或者任何建议欢迎批评和指正~


缓存回收策略

回收策略

1 基于空间:  

即设置缓存的【存储空间】,如设置为10MB,当达到存储空间时,按照一定的策略移除数据。

2 基于容量: 

指缓存设置了最大大小,当缓存的条目超过最大大小时,按照一定的策略移除数据。如Caffeine Cache可以通过 maximumSize 参数设置缓存容量,当超出 maximumSize 时,按照算法进行缓存回收。

 public static void maximumSizeTest() { Cache<String, String> maximumSizeCaffeineCache = Caffeine.newBuilder() .maximumSize(1) .build();
maximumSizeCaffeineCache.put("A", "A");
String value1 = maximumSizeCaffeineCache.getIfPresent("A"); System.out.println("key:key1" + " value:" + value1);
maximumSizeCaffeineCache.put("B", "B");
String value1AfterExpired = maximumSizeCaffeineCache.getIfPresent("A"); //输出null System.out.println("key:key1" + " value:" + value1AfterExpired); String value2 = maximumSizeCaffeineCache.getIfPresent("B"); //输出B System.out.println("key:key2" + " value:" + value2);
}

3 基于时间

TTL(Time To Live):存活期,即缓存数据从创建开始直到到期的一个时间段(不管在这个时间段内有没有被访问,缓存数据都将过期)。

TTI(Time To Idle):空闲期,即缓存数据多久没被访问后移除缓存的时间。

Caffeine Cache可以通过 expireAfterWrite跟expireAfterAccess参数设置过期时间。

 public static void ttiTest() { Cache<String, String> ttiCaffeineCache = Caffeine.newBuilder() .maximumSize(100).expireAfterAccess(1, TimeUnit.SECONDS) .build(); ttiCaffeineCache.put("A", "A"); //输出A System.out.println(ttiCaffeineCache.getIfPresent("A")); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } //输出null System.out.println(ttiCaffeineCache.getIfPresent("A")); }

回收算法

1 FIFO: 先进先出算法,即先放入缓存的先被移除。

流程:

优点: 最简单、最公平的一种数据淘汰算法,逻辑简单清晰,易于实现

缺点: 这种算法逻辑设计所实现的缓存的命中率是比较低的,因为没有任何额外逻辑能够尽可能的保证常用数据不被淘汰掉

2 LRU

流程:

聊聊缓存回收策略跟缓存更新策略

优点: LRU可以有效的对访问比较频繁的数据进行保护,也就是针对热点数据的命中率提高有明显的效果。

缺点: 对于周期性、偶发性的访问数据,有大概率可能造成缓存污染,也就是置换出去了热点数据,把这些偶发性数据留下了,从而导致LRU的数据命中率急剧下降。

3 LFU

流程:

聊聊缓存回收策略跟缓存更新策略

优点: LFU也可以有效的保护缓存,相对场景来讲,比LRU有更好的缓存命中率。因为是以次数为基准,所以更加准确,自然能有效的保证和提高命中率

缺点: 因为LFU需要记录数据的访问频率,因此需要额外的空间;当访问模式改变的时候,算法命中率会急剧下降,这也是他最大弊端。

4 W-TinyLFU

流程:

聊聊缓存回收策略跟缓存更新策略

优点: 使用Count-Min Sketch算法存储访问频率,极大的节省空间;定期衰减操作,应对访问模式变化;并且使用window-lru机制能够尽可能避免缓存污染的发生,在过滤器内部会进行筛选处理,避免低频数据置换高频数据。

缺点:暂未发现


缓存更新策略

Cache-Aside

1 什么是Cache Aside Pattern

旁路缓存方案的经验实践,这个实践又分读实践,写实践。该模式对缓存的关注点主要在于业务代码,即缓存的更新,删除与数据库的操作,以及他们之间的先后顺序在业务代码中实现。

读操作:

  1. 先读缓存,缓存命中,则直接返回

  2. 缓存未命中,则回源到数据库获取源数据

  3. 将数据重新放入缓存,下次即可从缓存中获取数据

聊聊缓存回收策略跟缓存更新策略

写操作:

  1. 淘汰缓存,而不是更新缓存

  2. 先操作数据库,再淘汰缓存

2 Cache Aside 为什么建议淘汰缓存而不是更新缓存

如下图所示在1和2两个并发写发生时,由于无法保证时序,此时不管先操作缓存还是先操作数据库,都可能出现:

(1)请求1先操作数据库,请求2后操作数据库

(2)请求2先set了缓存,请求1后set了缓存


3 Cache Aside 经典缓存处理切面

缓存切面抽象类

/** * @Author: yangjie * @Date: 2019-05-21 16:43 * @Description: */
@Componentpublic abstract class RedisCacheAbstractAspect { protected static final String PREFIX = "AOP:"; protected static final String EL_PATTERN = "#\\{([$?\\w\\.]+?)\\}"; protected static final String EL_REPLACE_PATTERN = "[#\\{\\}]"; @Resource RedisUtil redisUtil;
/** * 公用获取锁方法,使用setnx对key进行赋值,过期时间seconds * * @return true加锁成功,false锁已存在 */ protected boolean getLock(String key, int seconds) { return redisUtil.setNx(getLockKey(key), "1", seconds); }
/** * 释放key对应额锁 * * @param keys */ protected void releaseLock(String... keys) { redisUtil.del(this.getLockKey(keys)); }
/** * 获取锁的key,如果以冒号结尾,添加“LOCK”,否则末尾添加“:LOCK” * * @param key * @return */ protected String getLockKey(String key) { return key.endsWith(RedisConstants.REDIS_SEPARATE) ? key + "LOCK" : key + ":LOCK"; }
protected String[] getLockKey(String... keys) { if (keys == null || keys.length < 1) { return new String[0]; } else { String[] result = new String[keys.length]; for (int i = 0; i < keys.length; i++) { result[i] = this.getLockKey(keys[i]); } return result; } }

/** * 通过ProceedingJoinPoint获取方法的包名 */ protected String getPackageName(ProceedingJoinPoint pjp) { return pjp.getTarget().getClass().getName(); }
/** * 根据包名+ 类名 + 方法名 + 参数(多个) 生成Key(String存储使用) * * @Param fullKey 值为true时,不使用md5压缩包名 */ protected String getCacheKey(ProceedingJoinPoint pjp, String cacheField, boolean fullKey) {
// 取到方法名 StringBuffer function = new StringBuffer() .append(pjp.getTarget().getClass().getName()) .append(".") .append(pjp.getSignature().getName());
// 取到参数字符串 StringBuffer args = new StringBuffer().append(cacheField); StringBuffer key = new StringBuffer(); if (!fullKey) { key.append(DigestUtils.md5DigestAsHex(function.toString().getBytes())); key.append(":").append(args); } else { key.append(function).append(":").append(args); } return PREFIX + key.toString(); }
/** * 根据【包名+ 类名 + 方法名 + 参数(多个) 】生成Key(Hash存储使用) * * @param pjp * @param fullKey * @return */ protected String getHashCacheKey(ProceedingJoinPoint pjp, boolean fullKey) {
// 取到方法名 StringBuffer function = new StringBuffer() .append(pjp.getTarget().getClass().getName()) .append(".") .append(pjp.getSignature().getName());
StringBuffer key = new StringBuffer(); if (!fullKey) { key.append(DigestUtils.md5DigestAsHex(function.toString().getBytes())); } else { key.append(":").append(JSON.toJSONString(pjp.getArgs())); } return PREFIX + key.toString(); }

/** * 判断字符串是否为EL表达式 */ protected boolean isEl(String elStr) { return !StringUtils.isEmpty(elStr) && elStr.matches(EL_PATTERN); }
/** * 根据EL表达式从obj中取值 * * @param obj 从该实体中取值 * @param name #{obj.ooo.ooo} * @param isRoot 当前是否为顶节点,为true时可以使用“#{param}”取非封装的obj值 * @return * @throws InvocationTargetException * @throws IllegalAccessException */ protected static Object getterMethod(Object obj, String name, boolean isRoot) throws InvocationTargetException, IllegalAccessException { String thisField; String nextField = null; int pointIndex = name.indexOf(".");
// 如果是顶层,切掉第一节 #{obj.oooo.oooo} if (isRoot && pointIndex < 0) { return obj; } else { name = name.substring(pointIndex + 1); pointIndex = name.indexOf("."); }
if (pointIndex <= 0) { thisField = name; } else { // thisField = xxx thisField = name.substring(0, pointIndex); // nextField = ooo.ooo nextField = name.substring(pointIndex + 1); }
Class clazz = obj.getClass(); Object returnValue = null; String getMethodName = "get" + thisField.substring(0, 1).toUpperCase() + thisField.substring(1); Method[] methods = clazz.getDeclaredMethods(); for (Method method : methods) { if (getMethodName.equals(method.getName())) { returnValue = method.invoke(obj, null); } } if (StringUtils.isEmpty(nextField)) { return returnValue; } else { return getterMethod(returnValue, nextField, false); } }

}

读切面

/** * @Author: yangjie * @Date: 2019-10-12 15:18 * @Description: */@Slf4j@Aspect@Component@Order(2)public class RedisCacheAspect extends RedisCacheAbstractAspect { /** * 配置redis 切面环绕方法 * * @param point * @param redisCache * @return * @throws Throwable */ @Around("@annotation(redisCache)") public Object doAround(ProceedingJoinPoint point, RedisCache redisCache) throws Throwable { ////获得切面当中方法签名 MethodSignature methodSignature = (MethodSignature) point.getSignature(); Method method = methodSignature.getMethod(); // 所有参数的值 Object[] params = point.getArgs(); // 所有参数的名字 String[] paramNames = methodSignature.getParameterNames(); // 缓存个性化字段 String cacheField = redisCache.field(); String cacheKey = redisCache.key(); // 缓存个性化字段的EL值 String fieldName = cacheField.replaceAll(EL_REPLACE_PATTERN, ""); List<String> keyElList = new ArrayList<>();
Pattern p = Pattern.compile(EL_PATTERN); Matcher m = p.matcher(redisCache.key()); while (m.find()) { keyElList.add(m.group()); }
// 循环所有的参数名,为EL动态取值的属性赋值 for (int i = 0; i < paramNames.length; i++) { String name = paramNames[i]; // 替换field里的el表达式的值 if (isEl(cacheField) && name.equals(fieldName.split("\\.")[0])) { cacheField = String.valueOf(getterMethod(params[i], fieldName, true)); } else { cacheField = redisCache.field(); }
// 替换key里的el表达式的值 for (String keyEl : keyElList) { String elStr = keyEl.replaceAll(EL_REPLACE_PATTERN, ""); if (!name.equals(elStr.split("\\.")[0])) { continue; } cacheKey = cacheKey.replace(keyEl, getterMethod(params[i], elStr, true) + ""); } }
// 根据需要的类型进行处理 try { if (RedisDataType.STRING == redisCache.redisDataType()) { // Redis数据结构是String类型的逻辑 return handleString(point, method, redisCache, cacheField, cacheKey); } else if (RedisDataType.HASH == redisCache.redisDataType()) { // Redis数据结构是Hash类型的逻辑 return handleHash(point, method, redisCache, cacheField, cacheKey); } } catch (Exception e) { log.error("【redis aop handler error】", e); return point.proceed(); } return point.proceed(); }
/** * handler string * * @param point * @param method * @param redisCache * @param cacheField * @param cacheKey * @return * @throws Throwable */ private Object handleString(ProceedingJoinPoint point, Method method, RedisCache redisCache, String cacheField, String cacheKey) throws Throwable { Object result; String key = StringUtils.isEmpty(cacheKey) ? getCacheKey(point, cacheField, redisCache.fullKey()) : cacheKey + (StringUtils.isEmpty(cacheField) ? "" : RedisConstants.REDIS_SEPARATE + cacheField); if (getLock(key, redisCache.refreshTime())) { // 抢到锁更新缓存数据 result = point.proceed(); if (result == null) { return result; } // 放入缓存 String data = encodeObject(result); log.info(MessageFormat.format("【redis key miss,key->{0},data->{1}】 ", key, data)); // -1 不过期 if (redisCache.expire() == -1) { redisUtil.set(key, data); } else if (redisCache.expire() >= 0) { SetParams setParams = SetParams.setParams().ex(redisCache.expire()); redisUtil.set(key, data, setParams); } return result; } else { return decodeObject(redisUtil.get(key), method, redisCache); } }
private Object handleHash(ProceedingJoinPoint point, Method method, RedisCache redisCache, String cacheField, String cacheKey) throws Throwable { Object result; String key = StringUtils.isEmpty(cacheKey) ? getHashCacheKey(point, redisCache.fullKey()) : cacheKey; String field = cacheField; String lockTargetKey = key + RedisConstants.REDIS_SEPARATE + field; if (getLock(lockTargetKey, redisCache.refreshTime())) { // 抢到锁更新缓存数据 // 后端查询数据 result = point.proceed(); if (result == null) { return result; } // 将list作为Hash存储 String data = encodeObject(result); redisUtil.hset(key, field, data); // -1 不过期 if (redisCache.expire() != -1) { redisUtil.expire(key, redisCache.expire()); } return result; } else { // 加锁失败后 String value = redisUtil.hget(key, field); return decodeObject(value, method, redisCache); } }
private String encodeObject(Object result) {
return JSON.toJSONString(result);
}
private Object decodeObject(String result, Method method, RedisCache redisCache) { if (result == null) { return null; } if (!(result instanceof String)) { return result; } else if (redisCache.clazz() == String.class) { return result; } return decodeObject(result, redisCache.clazz(), method.getGenericReturnType()); }
private Object decodeObject(String result, Class clazz, Type type) { if (String.valueOf(result).startsWith("[")) { // "[" 开头为列表,使用JSONArray反序列化 return JSONArray.parseArray(result, clazz); } else { // 不以"[" 开头为实体,使用JSONObject反序列化 if (clazz != null) { return JSONObject.parseObject(result, clazz); } else { return JSONObject.parseObject(result, type); } } }}

更新缓存切面

@Aspect@Component@Slf4j@Order(1)public class RedisCacheRefreshAspect extends RedisCacheAbstractAspect { @Around("@annotation(redisCacheRefresh)") public Object doAround(ProceedingJoinPoint point, RedisCacheRefresh redisCacheRefresh) throws Throwable {
Object result = null; try { result = point.proceed(); // 获取签名和方法 MethodSignature methodSignature = (MethodSignature) point.getSignature(); // 所有参数的值 Object[] params = point.getArgs(); // 所有参数的名字 String[] paramNames = methodSignature.getParameterNames(); // 缓存个性化字段 String[] cacheFields = redisCacheRefresh.fields(); String[] cacheKeys = redisCacheRefresh.keys();
if (redisCacheRefresh.keys().length != cacheFields.length) { log.error("【切面缓存】清除锁keys与fields长度不一致"); return result; }
// 循环所有的keys,为EL动态取值的属性赋值 for (int j = 0; j < cacheFields.length; j++) { String cacheField = cacheFields[j]; String cacheKey = cacheKeys[j]; // 缓存个性化字段的EL值 String fieldName = cacheField.replaceAll(EL_REPLACE_PATTERN, ""); for (int i = 0; i < paramNames.length; i++) { String name = paramNames[i]; if (name.equals(fieldName.split("\\.")[0])) { if (super.isEl(cacheField)) { cacheFields[j] = getterMethod(params[i], fieldName, true) + ""; } else { cacheFields[j] = redisCacheRefresh.fields()[j]; }
// 从keys里筛选El表达式列表 List<String> keyElList = new ArrayList<>(); Pattern p = Pattern.compile(EL_PATTERN); Matcher m = p.matcher(cacheKey); while (m.find()) { keyElList.add(m.group()); } // keys中若配置多个EL全部处理 for (String keyEl : keyElList) { String elStr = keyEl.replaceAll(EL_REPLACE_PATTERN, ""); if (!name.equals(elStr.split("\\.")[0])) { continue; } cacheKeys[j] = cacheKeys[j].replace(keyEl, getterMethod(params[i], elStr, true) + ""); } } } } // 循环所有的fields值,根据keys-fields对应值拼装需要清除的锁,并清除, for (int i = 0; i < cacheKeys.length; i++) { String key = cacheKeys[i]; String clearKey = key + (StringUtils.isEmpty(cacheFields[i]) ? "" : RedisConstants.REDIS_SEPARATE + cacheFields[i]); super.releaseLock(clearKey); } } catch (Exception e) { log.error("【redis aop handler error】", e); return result; } return result; }

}

Cache-As-SoR

1 Read-Through:

Read-Through 也是在查询的时候更新缓存,跟Cache-Aside的区别就是当缓存失效的时候Cache-Aside 是由业务代码负责把数据加载入缓存而 Read-Through 则用缓存服务自己来加载对业务代码是透明的。比如 Caffeine Cache 中的 CacheLoader


public static void readThroughTest() {
LoadingCache<String, String> loadingCache = Caffeine.newBuilder().expireAfterWrite(2, TimeUnit.SECONDS).build(new CacheLoader<String, String>() { @Nullable @Override public String load(@NonNull String o) throws Exception { return getFrommysql(o); } }); System.out.println(loadingCache.get("A")); System.out.println(loadingCache.get("A"));}

2 Write-Through:

Write-Through 和 Read-Through类似,只不过是在更新数据时发生。当有数据更新的时候,如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由缓存自己 同步 更新数据库。

3 Write-Behind:

Write-Behind 也叫 Write-Back。就是在更新数据的时候,只更新缓存, 不同步 更新数据库,而是异步地更新数据库。这种模式实现起来技术比较复杂,一般情况下很少使用。








以上是关于聊聊缓存回收策略跟缓存更新策略的主要内容,如果未能解决你的问题,请参考以下文章

三种缓存策略:Cache Aside 策略Read/Write Through 策略Write Back 策略

具有LRU回收策略的Java缓存

常见的缓存回收策略

常见的缓存回收策略

性能优化--缓存的回收策略优化思路雪崩

架构设计 | 缓存管理模式,监控和内存回收策略