其他缓存

本文详细探讨了缓存系统中的常见问题,包括缓存雪崩和缓存穿透,以及如何通过分散过期时间、限流和布隆过滤器等手段进行解决。此外,还介绍了LRU缓存算法的实现,并讨论了基于双向链表和ConcurrentHashMap的不同实现方式。文章最后提出了缓存与数据库双写不一致的四种解决方案,并分析了各自的优缺点,强调了根据数据实时性和并发量选择合适策略的重要性。
摘要由CSDN通过智能技术生成

一、典型面试例题及思路分析

问题 1:什么是缓存雪崩?一般怎么解决?

​ 缓存雪崩,即缓存同一时间大面积的失效(比如说大量的 key 设置了相同的过期时间,导致在缓存在同一时刻全部失效),造成瞬时 DB 请求量过大、压力骤增。

通常的解决方案:

​ (1) 将缓存过期时间分散开,比如说设置过期时间时再加上一个较小的随机值时间,使得每个 key 的过期时间,不会集中在同一时刻失效;

​ (2)采用限流算法,限制请求流量,业务有损;

​ (3)加锁访问,但是吞吐量会明显下降。

问题 2:什么是缓存穿透?一般怎么解决?

​ 缓存穿透,是指访问一个不存在的 key,此时请求会穿透到 DB,如果我们查询的某个数据在缓存中一直不存在,那么每次查询都会穿透到 DB,流量大时 DB 压力很大甚至会挂掉。

通常的解决方案:

​ (1)采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,在查询的时候先去布隆过滤器去查询 key 是否存在,不存在的 key 就直接返回;

​ (2)查询不存在的 key 时 DB 返回结果为空,同时可以将该 key 在缓存中对应一个特殊的值,这样当下次再查询这个特殊的 key 时,就不再回查 DB;

​ (3)多级缓存。L1 为原始缓存(过期时间设置为短期),L2 为二级缓存(过期时间设置为长期)。L1 失效时可以继续访问 L2,避免同时失效。不过多了一级缓存,存储成本更高,而且多个缓存之间的数据一致性问题挑战也大。

点评:

​ 缓存雪崩和缓存穿透都是缓存管理设计中比较常见的点,在应用中一般为了保证稳定性,会尽量让缓存数据的失效过程尽可能平滑。

缓存雪崩的原因通常有两种:一种是恶意攻击,这种恶意攻击正好碰到缓存失效,这种情景下方案 2 的限流算法效果更好,还可以在网络接入层、应用层进行拦截;二是特定业务批量插入缓存,这种情况方案 1 的随机过期时间方案更合适;

​ 缓存穿透的原因通常有两种:一种是恶意攻击,可以用上述两种方案进行应对;另外一种是网站并发访问高,一个缓存失败可能出现多个进程同时查询 DB,通常情况下,这并不会有什么问题,但如果并发量很大,也可能造成回查 DB 压力过大。这种方式可以对缓存查询加锁,如果 KEY 不存在就加锁再回查 DB,然后将结果放入缓存,再解锁;其他进程发现有锁就等待,然后等解锁后返回数据或者回源查询。

问题 3:实现一个 LRU 缓存?
/ * 
 * 用双向链表实现,链表尾表示最近被访问的元素,越靠近链表头表示越早之前被访问的元素
 */
public class LRUCache {

    private LinkedList<Node> cache;
    private int capacity;

    public LRUCache(int capacity) {
        this.cache = new LinkedList<>();
        this.capacity = capacity;
    }
    
    /**
     * 查询一个元素,返回""表示没有找到
     * @param key
     * @return
     */
    public String get(String key) {
        Iterator<Node> iterator = cache.descendingIterator();
        String result = "";  // 默认为空
        while (iterator.hasNext()) {
            Node node = iterator.next();
            if (node.key == key) {
                result = node.value;
                iterator.remove();
                put(key, result); // 把原来该位置的元素删除,并把新元素添加到链表尾部
                break;
            }
        }
        return result;
    }

    /**
     * 插入一个元素
     * @param key
     * @param value
     */
    public void put(String key, String value) {
        //遍历查找是否有key的元素,如果有则删除
        Iterator<Node> iterator = cache.iterator();
        while (iterator.hasNext()) {
            Node node = iterator.next();
            if (node.key.equals(key)) {
                iterator.remove();
                break;
            }
        }

        //缓存已满,删除一个最近最少访问的元素(位于链表头)
        if (capacity == cache.size()) {
            cache.removeFirst();
        }
        //添加元素至链表尾
        Node addNode  = new Node(key,value);
        cache.add(addNode);
    }

    /**
     * 缓存节点
     */
    class Node {
        String key;
        String value;

        public Node(String key, String value) {
            this.key = key;
            this.value = value;
        }
    }
}

点评:

​ LRU(Least Recently used,最近最少使用)算法是常用的缓存淘汰算法,其思想是如果数据最近一段时间没有被访问过,那么将来被访问的概率也很小。换言之,最后一次访问时间是 LRU 算法决定是否淘汰元素的唯一标准。

​ 这里没有采用广为流传的复写 LinkedHashMap 的 removeEldestEntry () 方法的方案,一方面是因为 JDK 不同版本的 LinkedHashMap 的实现有一定差异;另一方面也需要看到面试官出这个题的目的,是考察候选人设计小型系统的基本能力,本质上是希望候选人用基本数据结构更原生地实现。同时基于这个题目可以衍生出很多问题,比如说用 LinkedList 实现有什么缺陷?比如说用 HashMap 怎么来实现?比如说如为了能够避免缓存超过容量上限,有什么方法优化等等;

二、总结

​ memcached 和 redis 都是属于分布式缓存,应用最广泛,相对来说也比较聚焦 。除了分布式缓存外,其他经常使用的还有本地缓存(比如说 google 的 Guava Cache),另外还有 level DB、Ehcache、Tair、EVCache 等等。除了这些各种各样的缓存外,缓存类经常考察的点还有高可用架构,以及衍生出的分布式理论等等。相关知识点可以小结如下:

图片描述

三、扩展阅读及思考题

问:用ConcurrentHashMap来实现LRU?
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
class LRU<Key, Value> {
 private int size;
 private ConcurrentLinkedQueue<Key> linkedQueue;
 private ConcurrentHashMap<Key, Value> hashMap;
 public LRU(final int size) {
   this.size = size;
   this.linkedQueue = new ConcurrentLinkedQueue<Key>();
   this.hashMap = new ConcurrentHashMap<Key, Value>(size);
 }
 
 public Value get(Key key) {
   Value value = hashMap.get(key);
   if (value != null) {
     linkedQueue.remove(key);
     linkedQueue.add(key);
   }
   return value;
 }
 
 public synchronized void put(final Key key, final Value value) {
   if (hashMap.containsKey(key)) {
     linkedQueue.remove(key);
   }
   while (linkedQueue.size() >= size) {
     Key oldestKey = linkedQueue.poll();
     if (oldestKey != null) {
       hashMap.remove(oldestKey);
     }
     linkedQueue.add(key);
     hashMap.put(key, value);
   }
 }
}
问:缓存与数据库双写不一致解决方案

保证最终一致性的解决方案是缓存设置过期时间。

方案一:先更新缓存,再更新数据库

不推荐。

先更新缓存若更新数据库失败,还需再更新缓存。

方案二:先更新数据库,再更新缓存

不推荐。

同时有请求A和请求B进行更新操作,请求A与B在不同线程,可能会出现:

  1. 请求A更新了数据库
  2. 请求B更新了数据库
  3. 请求B更新了缓存
  4. 请求A更新了缓存

这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。

方案三:先删除缓存,再更新数据库

有点问题。

有一个请求A进行更新操作,另一个请求B进行查询操作,可能会出现:

(1)、单个数据库

请求A进行写操作,删除缓存
请求B查询发现缓存不存在
请求B去数据库查询得到旧值
请求B将旧值写入缓存
请求A将新值写入数据库

(2)、读写分离架构

请求A进行写操作,删除缓存
请求A将数据写入数据库了,
请求B查询缓存发现,缓存没有值
请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
请求B将旧值写入缓存

数据库完成主从同步,从库变为新值

上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。* *

解决方案:延时双删策略

public void write(String key,Object data){
    redis.delKey(key);
    db.updateData(data);
    Thread.sleep(1000);
    redis.delKey(key);
}
  1. 先淘汰缓存
  2. 再写数据库(这两步和原来一样)
  3. 休眠1秒
  4. 再次淘汰缓存

自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

对于MySQL读写分离架构,只是睡眠时间修改为在主从同步的延时时间基础上,加几百ms。

方案四:先更新数据库,再删除缓存

极端情况有问题。

有一个请求A进行更新操作,另一个请求B进行查询操作,可能会出现:

  1. 请求A查询数据库得到一个旧值
  2. 请求B将新值写入数据库
  3. 请求B删除缓存
  4. 请求A将查到的旧值写入缓存

步骤2的写数据库操作比步骤1的读数据库操作耗时更短,才有可能使得步骤3先于步骤4。可是,大家想想,数据库的读操作的速度远快于写操作的,因此步骤2耗时比步骤1更短,这一情形很难出现。

解决方案:延时双删策略

public void write(String key,Object data){
    db.updateData(data);
    redis.delKey(key);
    Thread.sleep(1000);
    redis.delKey(key);
}
  1. 先写数据库
  2. 再淘汰缓存
  3. 休眠1秒
  4. 再次淘汰缓存

方案三与方案四还存在问题

  1. 同步双删导致并发降低
  2. 比如一个写数据请求,然后写入数据库了,删缓存失败了,这会就出现不一致的情况。

问题1解决方案

异步。

问题二解决方案

提供一个保障的重试机制。

方案一:消息队列方式

  1. 更新数据库数据
  2. 缓存因为种种问题删除失败
  3. 将需要删除的key发送至消息队列
  4. 自己消费消息,获得需要删除的key
  5. 继续重试删除操作,直到成功

业务线代码侵入较大。

方案二:订阅binlong方式

  1. 更新数据库数据
  2. 数据库会将操作信息写入binlog日志当中
  3. 订阅程序提取出所需要的数据以及key
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除缓存操作,发现删除失败
  6. 将这些信息发送至消息队列
  7. 重新从消息队列中获得该数据,重试操作。

订阅binlog程序在MySQL中有阿里开源的中间件叫canal。

如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试也可。

总结

根据数据实时性要求,以及系统并发量考虑。

实时性不强,则可以选择设定缓存过期时间,先删缓存再更新数据库或先更新数据库再删缓存方案都可行。

实时性较强的,又有大并发量可以考虑延迟双删策略。

至于其他如请求串行化,放入同一个队列中依次执行的,复杂没必要。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值