一、本地缓存是什么?它都解决了什么问题?

1.概念

缓存涉及的范围很广,如web页面缓存、客户端缓存、数据库缓存、磁盘缓存等。
在后端程序中,缓存主要分为本地缓存和远端缓存,其中:
远端缓存常见的有Redis、MongoDB等,
本地缓存一般区分堆内缓存堆外缓存

1.1 堆内缓存与堆外缓存

1.1.1 使用场景及特点

image.png

1.1.2 常用实现工具

image.png

2.本地缓存的优劣

2.1 本地缓存的优势

  • 快速读写
  • 节省数据库资源
  • 减少服务远程网络调用
  • 提升服务QPS

    2.2 本地缓存的劣势

  • 存储容量受JVM内存大小限制

  • 影响GC频率

二、设计一款本地缓存,你会如何设计?

1.本地缓存的特征

1.1 命中率

命中率 = 命中数 / (命中数 + 没有命中数)
命中率越高,缓存的利用率也就越高

1.2 最大空间

1.2.1 定义

缓存中可以容纳最大元素的数量
当缓存存放的数据超过最大空间时,就需要根据淘汰算法来淘汰部分数据,以存放新到达的数据

1.3 淘汰算法

1.3.1 定义

如果缓存满了,而又没有命中缓存,那么就会按照某一种策略,把缓存中的旧对象踢出,而把新的对象加入缓存池。而这些策略统称为替代策略(缓存算法)

1.3.2 分类

一般来说,淘汰算法主要有三种淘汰机制,分别为

  • 基于容量淘汰
  • 定时淘汰
    • 按照写入时间,最早写入的最先淘汰
    • 按照访问时间,最早访问的最先淘汰
  • 基于引用淘汰

1. FIFO (first in first out)
定义:先进先出,按对象进入缓存的顺序来移除它们;常见使用队列Queue来实现
设计思路:**
1). 用普通的hashMap保存缓存数据。
2). 需要额外的map用来保存key的过期特性,例子中使用了TreeMap,将“剩余存活时间”作为key,利用TreeMap的排序特性。

  1. public class FIFOCache {
  2. //按照访问时间排序,保存所有key-value
  3. private final Map<String,Value> CACHE = new LinkedHashMap<>();
  4. //过期数据,只保存有过期时间的key
  5. //暂不考虑并发,我们认为同一个时间内没有重复的key,如果改造的话,可以将value换成set
  6. private final TreeMap<Long, String> EXPIRED = new TreeMap<>();
  7. private final int capacity;
  8. public FIFOCache(int capacity) {
  9. this.capacity = capacity;
  10. }
  11. public Object get(String key) {
  12. //
  13. Value value = CACHE.get(key);
  14. if (value == null) {
  15. return null;
  16. }
  17. //如果不包含过期时间
  18. long expired = value.expired;
  19. long now = System.nanoTime();
  20. //已过期
  21. if (expired > 0 && expired <= now) {
  22. CACHE.remove(key);
  23. EXPIRED.remove(expired);
  24. return null;
  25. }
  26. return value.value;
  27. }
  28. public void put(String key,Object value) {
  29. put(key,value,-1);
  30. }
  31. public void put(String key,Object value,int seconds) {
  32. //如果容量不足,移除过期数据
  33. if (capacity < CACHE.size()) {
  34. long now = System.nanoTime();
  35. //有过期的,全部移除
  36. Iterator<Long> iterator = EXPIRED.keySet().iterator();
  37. while (iterator.hasNext()) {
  38. long _key = iterator.next();
  39. //如果已过期,或者容量仍然溢出,则删除
  40. if (_key > now) {
  41. break;
  42. }
  43. //一次移除所有过期key
  44. String _value = EXPIRED.get(_key);
  45. CACHE.remove(_value);
  46. iterator.remove();
  47. }
  48. }
  49. //如果仍然容量不足,则移除最早访问的数据
  50. if (capacity < CACHE.size()) {
  51. Iterator<String> iterator = CACHE.keySet().iterator();
  52. while (iterator.hasNext() && capacity < CACHE.size()) {
  53. String _key = iterator.next();
  54. Value _value = CACHE.get(_key);
  55. long expired = _value.expired;
  56. if (expired > 0) {
  57. EXPIRED.remove(expired);
  58. }
  59. iterator.remove();
  60. }
  61. }
  62. //如果此key已存在,移除旧数据
  63. Value current = CACHE.remove(key);
  64. if (current != null && current.expired > 0) {
  65. EXPIRED.remove(current.expired);
  66. }
  67. //如果指定了过期时间
  68. if(seconds > 0) {
  69. long expireTime = expiredTime(seconds);
  70. EXPIRED.put(expireTime,key);
  71. CACHE.put(key,new Value(expireTime,value));
  72. } else {
  73. CACHE.put(key,new Value(-1,value));
  74. }
  75. }
  76. private long expiredTime(int expired) {
  77. return System.nanoTime() + TimeUnit.SECONDS.toNanos(expired);
  78. }
  79. public void remove(String key) {
  80. Value value = CACHE.remove(key);
  81. if(value == null) {
  82. return;
  83. }
  84. long expired = value.expired;
  85. if (expired > 0) {
  86. EXPIRED.remove(expired);
  87. }
  88. }
  89. class Value {
  90. long expired; //过期时间,纳秒
  91. Object value;
  92. Value(long expired,Object value) {
  93. this.expired = expired;
  94. this.value = value;
  95. }
  96. }
  97. }

存在问题:缓存命中率低,比如先进的数据恰恰是访问频率最高的,后进的则是访问频率最低的

2. LRU (least recently used)
定义:
最近最少使用,移除最长时间不被使用的对象,常见使用LinkedHashMap来实现,多数本地缓存默认策略,每次访问数据都会将其放在我们的队尾,如果需要淘汰数据,就只需要淘汰队首即可
设计思路:**

  • LRU的基础算法,需要了解;每次put、get时需要更新key对应的访问时间,我们需要一个数据结构能够保存key最近的访问时间且能够排序。
  • 既然包含过期时间特性,那么带有过期时间的key需要额外的数据结构保存。

与FIFO基本相同,唯一不同点为get()方法,对于LRU而言,get方法需要重设访问时间(即调整所在cache中顺序)

public Object get(String key) {
    //
    Value value = CACHE.get(key);
    if (value == null) {
        return null;
    }

    //如果不包含过期时间
    long expired = value.expired;
    long now = System.nanoTime();
    //已过期
    if (expired > 0 && expired <= now) {
        CACHE.remove(key);
        EXPIRED.remove(expired);
        return null;
    }
    //相对于FIFO,增加顺序重置
    CACHE.remove(key);
    CACHE.put(key,value);
    return value.value;
}

存在问题:
同样会存在命中率问题,假设某个数据在1小时内的前59分钟被访问1万次,最后1分钟不被访问,且有其他数据访问,则可能导致该热点数据被淘汰

3. LFU (Less frequently used)
定义:最少频率使用,区别于LRU主要在于LRU的淘汰规则是基于访问时间,而LFU是基于访问次数的,利用额外的空间(可通过HashMap)记录每个数据的使用频率,然后选出频率最低进行淘汰。
优点:避免了LRU不能处理时间段的问题
设计思路:**
1). 用普通的hashMap保存缓存数据。
2). 需要额外的map用来保存每个key的访问次数。
3). 用TreeMap记录访问相同次数的key列表,以在容量达到阀值时淘汰访问次数最少的key

public class LFUCache {

    //主要容器,用于保存k-v
    private Map<String, Object> keyToValue = new HashMap<>();

    //记录每个k被访问的次数
    private Map<String, Integer> keyToCount = new HashMap<>();

    //访问相同次数的key列表,按照访问次数排序,value为相同访问次数到key列表。
    private TreeMap<Integer, LinkedHashSet<String>> countToLRUKeys = new TreeMap<>();

    privateint capacity;

    public LFUCache(int capacity) {
        this.capacity = capacity;
        //初始化,默认访问1次,主要是解决下文
    }

    public Object get(String key) {
        if (!keyToValue.containsKey(key)) {
            return null;
        }

        touch(key);
        return keyToValue.get(key);
    }

    /**
     * 如果一个key被访问,应该将其访问次数调整。
     * @param key
     */
    private void touch(String key) {
        int count = keyToCount.get(key);
        keyToCount.put(key, count + 1);//访问次数增加
        //从原有访问次数统计列表中移除
        countToLRUKeys.get(count).remove(key);

        //如果符合最少调用次数到key统计列表为空,则移除此调用次数到统计
        if (countToLRUKeys.get(count).size() == 0) {
            countToLRUKeys.remove(count);
        }

        //然后将此key的统计信息加入到管理列表中
        LinkedHashSet<String> countKeys = countToLRUKeys.get(count + 1);
        if (countKeys == null) {
            countKeys = new LinkedHashSet<>();
            countToLRUKeys.put(count + 1,countKeys);
        }
        countKeys.add(key);
    }

    public void put(String key, Object value) {
        if (capacity <= 0) {
            return;
        }

        if (keyToValue.containsKey(key)) {
            keyToValue.put(key, value);
            touch(key);
            return;
        }
        //容量超额之后,移除访问次数最少的元素
        if (keyToValue.size() >= capacity) {
            Map.Entry<Integer,LinkedHashSet<String>> entry = countToLRUKeys.firstEntry();
            Iterator<String> it = entry.getValue().iterator();
            String evictKey = it.next();
            it.remove();
            if (!it.hasNext()) {
                countToLRUKeys.remove(entry.getKey());
            }
            keyToCount.remove(evictKey);
            keyToValue.remove(evictKey);

        }

        keyToValue.put(key, value);
        keyToCount.put(key, 1);
        LinkedHashSet<String> keys = countToLRUKeys.get(1);
        if (keys == null) {
            keys = new LinkedHashSet<>();
            countToLRUKeys.put(1,keys);
        }
        keys.add(key);
    }
}

局限性:
假设一部新剧刚出来,使用LFU缓存下来,这部新剧在几天内访问了几亿次,但一个月后,新剧过气了,但由于前期访问量太高了,导致其他电视剧无法淘汰这个新剧,所以在这种模式下是有局限性的。

4. SOFT
定义:软引用基于垃圾回收器状态和软引用规则移除对象,常见使用SoftReference来实现

5. WEAK
定义:弱引用更主要基于垃圾收集器状态和弱引用规则移除对象,常见使用WeakReference来实现

1.3.3 实现成本及命中率对比

以常见的FIFO、LRU、LFU做比较,三者的实现成本及命中率对比如下:
实现成本:LFU > LRU > FIFO
命中率:LFU > LRU > FIFO

1.4 线程安全

本地缓存往往是多个线程同时访问的,所以线程安全问题也成了本地缓存不可忽视的问题。
一般来说,本地缓存会使用一些线程安全的类去存储数据,好比ConcurrentHashMap、SynchronizedCache等

1.5 其他特征

持久化:ehcache、redis等支持持久化,guava cache、caffeine等不支持持久化
简明的接口:提供常用的get、put、remove、clear、getSize方法

三、如何让缓存性能达到最佳?

1 Ehcache

EhCache 是一个纯Java的进程内缓存框架,具有快速、精干等特点,是Hibernate中默认的CacheProvider。
但对于Ehcache来说,由于其jar包很大,较重量级。除非是对于需要持久化和集群的一些功能的,可以选择Ehcache。

2 Guava Cache

Google Guava工具包是一个非常方便易用的本地化缓存实现,基于LRU算法实现,支持多种缓存过期策略。

Guava在每次访问缓存的时候判断cache数据是否过期,如果过期,这时才将其删除,并没有另起一个线程专门来删除过期数据。内部维护了2个队列accessQueue和writeQueue来记录缓存中数据访问和写入的顺序。访问缓存时,先用key计算出hash,从而找出所在的segment,然后再在segment中寻找具体数据,类似于使用ConcurrentHashMap数据结构来存放缓存数据。

3 Caffeine Cache

3.1 命中率对比

Caffeine Cache实现了W-TinyLFU(LFU+LRU算法的变种)淘汰算法,对比下各个淘汰算法的命中率:
image.png
可以发现,W-TinyLfu的命中率是最接近理想命中率的,而Guava Cache所使用的LRU算法在命中率的比较上则相形见绌了。

3.2 性能对比

吞吐量方面,Caffeine更是完爆Guava

  • 8个线程读,100%的读操作

image.png

  • 6个线程读,2个线程写,也就是75%的读操作,25%的写操作

image.png

  • 8个线程写,100%的写操作

image.png

3.3 W-TinyLfu的实现原理

到这里,就很有必要看了W-TinyLfu的实现原理。
W-TinyLfu整个算法数据结构分成三个段,分别为 Eden、Probation、Protected 三个队列,
其中:

  • Eden队列:在caffeine中规定只能为缓存容量的1%,如果size=100,那这个队列的有效大小就等于1。这个队列中记录的是新到的数据,防止突发流量由于之前没有访问频率,而导致被淘汰。比如有一部新剧上线,在最开始是没有访问频率的,防止上线之后被其他缓存淘汰出去,而加入这个区域。

  • Probation队列:缓刑队列,在这个队列就代表你的数据相对比较冷,马上就要被淘汰了。这个有效大小为size减去eden减去protected。

  • Protected队列:如果Probation队列没有数据了或者Protected数据满了,队列中的数据将面临淘汰。缓存数据想要进入这个队列,需要把Probation访问一次之后,就会提升为Protected队列。这个有效大小为(size减去eden) X 80% 如果size =100,就会是79。

队列关系示意图如下:
image.png

  1. 所有的新数据都会进入Eden。
  2. Eden满了,淘汰进入Probation。
  3. 如果在Probation中访问了其中某个数据,则这个数据升级为Protected。
  4. 如果Protected满了又会继续降级为Probation。

对于发生数据淘汰的时候,会从Probation中进行淘汰,会把这个队列中的数据队头称为受害者,这个队头肯定是最早进入的,按照LRU队列的算法的话那他其实他就应该被淘汰,但是在这里只能叫他受害者,这个队列是缓刑队列,代表马上要给他行刑了。这里会取出队尾叫候选者,也叫攻击者。这里受害者会和攻击者做PK,通过我们的Count-Min Sketch中的记录的频率数据有以下几个判断:

  • 如果攻击者大于受害者,那么受害者就直接被淘汰。
  • 如果攻击者<=5,那么直接淘汰攻击者。
  • 其他情况,随机淘汰。

频率记录
在W-TinyLFU中使用Count-Min Sketch记录我们的访问频率,而这个也是布隆过滤器的一种变种。如下图所示:
image.png
如果需要记录一个值,那我们需要通过多种Hash算法对其进行处理hash,然后在对应的hash算法的记录中+1。
为什么需要多种hash算法呢?由于这是一个压缩算法必定会出现冲突,比如我们建立一个Long的数组,通过计算出每个数据的hash的位置。比如张三和李四,他们两有可能hash值都是相同,比如都是1那Long[1]这个位置就会增加相应的频率,张三访问1万次,李四访问1次那Long[1]这个位置就是1万零1,如果取李四的访问评率的时候就会取出是1万零1,但是李四命名其实只访问了1次,就会导致获取李四的访问率数据不准确。
image.png
为了解决这个问题,Count-Min Sketch用了多个hash算法可以理解为long[][]二维数组的一个概念,比如在第一个算法张三和李四冲突了,但是在第二个,第三个中很大的概率不冲突,比如一个算法大概有1%的概率冲突,那四个算法一起冲突的概率是1%的四次方。
通过这个模式我们取李四的访问率的时候取所有算法中,李四访问最低频率的次数,这也是Count-Min Sketch名字的由来。
利用Count-Min Sketch算法后
image.png

四、Caffeine这么强大,我们该如何使用它?

caffeine的api借鉴了Guava的api,可以发现其基本一模一样。

1 引入依赖

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.6.2</version>
</dependency>

2 构建缓存对象

public static void main(String[] args) {
    Cache<String, String> cache = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterAccess(1,TimeUnit.SECONDS)
        .maximumSize(10)
        .build();
    cache.put("hello","hello");
}

3 创建参数介绍

initialCapacity: 初始的缓存空间大小
maximumSize: 缓存的最大数量
maximumWeight: 缓存的最大权重
expireAfterAccess: 最后一次读或写操作后经过指定时间过期
expireAfterWrite: 最后一次写操作后经过指定时间过期
refreshAfterWrite: 创建缓存或者最近一次更新缓存后经过指定时间间隔,刷新缓存
weakKeys: 打开key的弱引用
weakValues:打开value的弱引用
softValues:打开value的软引用
recordStats:开发统计功能

注意:
expireAfterWrite和expireAfterAccess同时存在时,以expireAfterWrite为准。
maximumSize和maximumWeight不可以同时使用。

4 缓存填充策略

Caffeine Cache提供了三种缓存填充策略:手动、同步加载和异步加载。

4.1 手动加载

在每次get key的时候指定一个同步的函数,如果key不存在就调用这个函数生成一个值。

public Object manulOperator(String key) {
    Cache<String, Object> cache = Caffeine.newBuilder()
        .expireAfterWrite(1, TimeUnit.SECONDS)
        .expireAfterAccess(1, TimeUnit.SECONDS)
        .maximumSize(10)
        .build();
    //如果一个key不存在,那么会进入指定的函数生成value
    Object value = cache.get(key, t -> setValue(key).apply(key));
    cache.put("hello",value);

    //判断是否存在如果不存返回null
    Object ifPresent = cache.getIfPresent(key);
    //移除一个key
    cache.invalidate(key);
    return value;
}

public Function<String, Object> setValue(String key){
    return t -> key + "value";
}

4.2 同步加载

构造Cache时候,build方法传入一个CacheLoader实现类。实现load方法,通过key加载value。

public Object syncOperator(String key){
    LoadingCache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .build(k -> setValue(key).apply(key));
    return cache.get(key);
}

public Function<String, Object> setValue(String key){
    return t -> key + "value";
}

4.3 异步加载

AsyncLoadingCache是继承自LoadingCache类的,异步加载使用Executor去调用方法并返回一个CompletableFuture。异步加载缓存使用了响应式编程模型。
如果要以同步方式调用时,应提供CacheLoader。要以异步表示时,应该提供一个AsyncCacheLoader,并返回一个CompletableFuture。

public Object asyncOperator(String key){
    AsyncLoadingCache<String, Object> cache = Caffeine.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(1, TimeUnit.MINUTES)
        .buildAsync(k -> setAsyncValue(key).get());

    return cache.get(key);
}

public CompletableFuture<Object> setAsyncValue(String key){
    return CompletableFuture.supplyAsync(() -> {
        return key + "value";
    });
}

5 过期策略

Caffeine 为我们提供了三种过期策略

  • 基于大小(size-based)
  • 基于时间(time-based)
  • 基于引用(reference-based)

    5.1 基于引用

    先说下各个引用的区别:
    Java4种引用的级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用
引用类型 被垃圾回收时间 用途 生存时间
强引用 从来不会 对象的一般状态 JVM停止运行时终止
软引用 内存不足时 对象缓存 内存不足时终止
弱引用 垃圾回收时 对象缓存 gc运行后终止
虚引用 任何时候都可能被垃圾回收 可以用虚引用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知 JVM停止运行时终止

基于引用的过期策略,其实就是将缓存交由JVM管理了。

6 统计功能

Cache<Key, Graph> graphs = Caffeine.newBuilder()
    .maximumSize(10_000)
    .recordStats()
    .build();

通过使用Caffeine.recordStats()方法可以打开数据收集功能。Cache.stats()方法将会返回一个CacheStats对象,其将会含有一些统计指标,比如:
hitRate(): 查询缓存的命中率
evictionCount(): 被驱逐的缓存数量
averageLoadPenalty(): 新值被载入的平均耗时
这些统计指标在缓存的调优中十分重要,可以实时监控缓存当前的状态,以评估缓存的健康程度以及缓存命中率等,方便后续调整参数。

7 淘汰监听

有很多时候我们需要知道Caffeine中的缓存为什么被淘汰了呢,从而进行一些优化?这个时候我们就需要一个监听器,代码如下所示:

Cache<String, String> cache = Caffeine.newBuilder()
                .removalListener(((key, value, cause) -> {
                    System.out.println(cause);
                }))
                .build();

在Caffeine中被淘汰的原因有很多种:

  • EXPLICIT: 这个原因是用户造成的,通过调用remove方法从而进行删除。
  • REPLACED: 更新的时候,其实相当于把老的value给删了。
  • COLLECTED: 用于我们的垃圾收集器,也就是我们上面减少的软引用,弱引用。
  • EXPIRED: 过期淘汰。
  • SIZE: 大小淘汰,当超过最大的时候就会进行淘汰。

当我们进行淘汰的时候就会进行回调,我们可以打印出日志,对数据淘汰进行实时监控。

五、和远端缓存相比,本地缓存还欠缺什么?

本地缓存最严重的问题:缓存一致性问题
数据的冗余必然造成一致性问题,而进程内缓存是每一个进程一份缓存,不同缓存之间会有一致性问题

六、还有更好的缓存方案吗?

1. 多级缓存

通过使用Redis和Caffeine来做缓存,我们会发现一些问题。

  • 如果只使用Redis来做缓存我们会有大量的请求到redis,但是每次请求的数据都是一样的,假如这一部分数据就放在应用服务器本地,那么就省去了请求redis的网络开销,请求速度就会快很多。但是使用redis横向扩展很方便。
  • 如果只使用Caffeine来做本地缓存,我们的应用服务器的内存是有限,并且单独为了缓存去扩展应用服务器是非常不划算。所以,只使用本地缓存也是有很大局限性的。

至此我们有了一个想法:两者结合形成多级缓存。
将热点数据放本地缓存(一级缓存),将非热点数据放Redis缓存(二级缓存)。
image.png
进程内缓存的数据一致性比分布式的缓存面临更大的挑战。数据更新的时候,如何通知其他进程也更新自己的缓存呢?
如果按照分布式缓存的思路,我们可以设置极短的缓存失效时间,这样不必实现复杂的通知机制。
但是不同进程内的数据依然会面临不一致的问题,并且不同进程缓存失效时间不统一,同一个请求到了不同的进程,可能出现反复幻读的情况。

解决方案:
通过 Redis 的 Pub/Sub,可以通知其他进程缓存对此缓存进行删除。如果 Redis 挂了或者订阅机制不靠谱,依靠超时设定,依然可以做兜底处理。
image.png

2. 客户端缓存(Redis 6.0 新特性)

官网介绍: Client side caching is a technique used in order to create high performance services. It exploits the available memory in the application servers, that usually are distinct computers compared to the database nodes, in order to store some subset of the database information directly in the application side

客户端缓存是一种用于创建高性能服务的技术,在此技术下,应用程序端将数据库中的数据缓存在应用端的内存中,当应用程序访问数据时直接从本机内存中读取,而无需连接数据库端,减少了网络IO,提升了应用程序的响应速度,同时也减少了数据库端的压力。
Redis6.0 之前:
image.png
Redis6.0 提供客户端缓存后:
image.png

2.1 整体思想

Redis在服务端记录访问的连接和相关的key, 当key有变化时,通知相应的连接(应用)。应用收到请求后自行处理有变化的key, 进而实现Client Cache与Redis的一致。
Redis对客户端缓存的支持方式被称为Tracking,分为两种模式:默认模式,广播模式。

2.2 默认模式

Server 端记录每个Client访问的Key,当发生变更时,向Client推送数据过期消息。
优点:只对Client发送其访问过的被修改的数据
缺点:Server端需要额外存储较大的数据量。

2.3 广播模式

客户端订阅key前缀的广播(空串表示订阅所有失效广播),服务端记录key前缀与Client的对应关系。当相匹配的key发生变化时,通知Client。
优点:服务端记录信息比较少
缺点:Client会收到自己未访问过的key的失效通知。

PS: 目前lettuce 支持此功能,jedis 还未支持

七、缓存就一定是银弹吗?

缓存的使用,极大的提升了应用程序的性能和效率,特别是数据查询方面。但同时,它也带来了一些问题。其中,最要害的问题,就是数据的一致性问题,从严格意义上讲,这个问题无解。如果对数据的一致性要求很高,那么就不能使用缓存。
另外的一些典型问题就是,缓存穿透、缓存雪崩和缓存击穿。

八、如何正确且更好得使用缓存?

1.最佳实践

1.1 设置过期时间【必选】

缓存使用的键值都须设置过期时间,缓存过期时间避免趋同,例如可以通过随机300秒内数字。原因:第一是避免缓存当存储使用,浪费内存空间;第二避免同一时刻所有缓存失效,导致数据回源,缓存穿透。

1.2 缓存对象越小越好【可选】

缓存对象越小越好,既可以减少内存占用、中间件缓存时减少带宽、同时还可以避免序列化与反序列化的性能问题,如果需要缓存复制对象,可以考虑redis的相关数据结构,减少序列化和反序列化。

1.3 反向cache【可选】

反向Cache就是将一个不存在的key放在缓存中,也就是在缓存中存一个空值,同时必要场景下最好提供缓存key的规则校验,避免外部访问攻击,提前拒绝请求,避免数据全部未命中回源。

1.4 数据回源规范【必选】

提供数据回源机制,同时控制或限制回源并发数,特别是热点数据失效。
原因:
1.避免重复查询相同数据进行重复缓存,浪费资源;
2. 防止回源QPS大于数据库处理能力或者数据库连接池占用满,影响正常业务获取连接。

参考链接: https://github.com/ben-manes/caffeine/wiki https://redis.io/topics/client-side-caching https://juejin.im/post/6844903660653117447