面试||缓存

什么是缓存?

缓存,就是数据交换的缓冲区,针对服务对象的不同(本质就是不同的硬件)都可以构建缓存。
目的是,把读写速度慢的介质的数据保存在读写速度快的介质中,从而提高读写速度,减少时间消耗。例如:

  • CPU 高速缓存 :高速缓存的读写速度远高于内存
    • CPU 读数据时,如果在高速缓存中找到所需数据,就不需要读内存
    • CPU 写数据时,先写到高速缓存,再回写到内存。
  • 磁盘缓存:磁盘缓存其实就把常用的磁盘数据保存在内存中,内存读写速度也是远高于磁盘的。
    • 读数据,时从内存读取。
    • 写数据时,可先写到内存,定时或定量回写到磁盘,或者是同步回写。

请说说有哪些缓存算法?是否能手写一下 LRU 代码的实现?

缓存算法

缓存算法,比较常见的是三种:

手写 LRU 代码的实现

手写 LRU 代码的实现,有多种方式。其中,最简单的是基于 LinkedHashMap 来实现,代码如下:

class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int CACHE_SIZE;

    /**
     * 传递进来最多能缓存多少数据
     *
     * @param cacheSize 缓存大小
     */
    public LRUCache(int cacheSize) {
        // true 表示让 LinkedHashMap 按照访问顺序来进行排序,最近访问的放在头部,最老访问的放在尾部。
        super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
        CACHE_SIZE = cacheSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当 map 中的数据量大于指定的缓存个数的时候,就自动删除最老的数据。
        return size() > CACHE_SIZE;
    }
    
}

用了缓存之后,有哪些常见问题?

当查询缓存报错,怎么提高可用性?

缓存可以极大的提高查询性能,但是缓存数据丢失和缓存不可用不能影响应用的正常工作。

因此,一般情况下,如果缓存出现异常,需要手动捕获这个异常,并且记录日志,并且从数据库查询数据返回给用户,而不应该导致业务不可用。

当然,这样做可能会带来缓存雪崩的问题。

缓存何时写入?并且写时如何避免并发重复写入?

缓存如何失效?

除了缓存服务器自带的缓存自动失效策略之外,我们还可以根据具体的业务需求进行自定义的“手动”缓存淘汰,常见的策略有两种:
1、定时去清理过期的缓存。
2、当有用户请求过来时,再判断这个请求所用到的缓存是否过期,过期的话就去底层系统得到新数据并更新缓存。
两者各有优劣,第一种的缺点是维护大量缓存的 key 是比较麻烦的,第二种的缺点就是每次用户请求过来都要判断缓存失效,逻辑相对比较复杂!

缓存和 DB 的一致性如何保证?

产生原因
主要有两种情况,会导致缓存和 DB 的一致性问题:

  • 并发的场景下,导致读取老的 DB 数据,更新到缓存中。
  • 缓存和 DB 的操作,不在一个事务中,可能只有一个操作成功,而另一个操作失败,导致不一致。

当然,有一点我们要注意,缓存和 DB 的一致性,我们指的更多的是最终一致性。我们使用缓存只要是提高读操作的性能,真正在写操作的业务逻辑,还是以数据库为准。例如说,我们可能缓存用户钱包的余额在缓存中,在前端查询钱包余额时,读取缓存,在使用钱包余额时,读取数据库。
《缓存更新的套路》
《缓存架构设计细节二三事》
《缓存与数据库一致性优化》
分布式之数据库和缓存双写一致性方案解析

比较重要的就是解决两个问题:

1、将缓存可能存在的并行写,实现串行写。
2、实现数据的最终一致性。

1)先淘汰缓存,再写数据库
因为先淘汰缓存,所以数据的最终一致性是可以得到有效的保证的。

  • 缓存删除失败:删除缓存失败,那么数据库信息也没有修改,保持了数据的一致性。
  • 缓存删除成功,数据库删除失败:此时查询数据发现缓存不存在,查询数据库的数据,更新缓存,数据还是一直的.

但是,这种方案仍然会存在缓存和 DB 的数据会不一致的情况:写操作 删除完缓存更新数据库操作的时候,如果一个请求过来 查询数据,缓存不存在,查询数据库中的旧数据,更新旧数据到缓存中。此时就会出现读写冲突的问题。那么如何解决这一方案出现的问题那?。

解决方法

  1. 理论上只需将写操作时的数据 作为唯一标识放入jvm内部队列中去,在读操作时判断内部队列中是否存在写操作,如果存在写操作,那么等待写操作完成 才去进行数据的读取。(同时如果多个读请求过来,如果发现前面有读请求操作,直接用前面的数据就好了)

  2. 通过双删除策略保证在 更新数据库中没有错误的缓存数据被更新,具体方法就是先删除缓存 ,更新数据库后,线程休眠1秒,然后再删除缓存一遍。通过双删除策略 可以保证你在读取错误数据及时的被修正过来(可以将线程休眠再删除缓存进行异步操作,启用一个线程去执行第二次删除缓存的操作)

  3. 我们需要使用分布式锁解决缓存并行写,实现串行写。比较简单的方式,引入分布式锁

    1. 在写请求时,先获取该分布式锁,再淘汰缓存。
    2. 在读请求时,发现缓存不存在时,先获取分布式锁。
      这样,缓存的并行写就成功的变成串行写。实际上,就是 「如果避免缓存”击穿”的问题?」 的【方案一】互斥锁的加强版。

2)先写数据库,再更新缓存
按照“先写数据库,再更新缓存”,我们要保证 DB 和缓存的操作,能够在“同一个事务”中,从而实现最终一致性。

  1. 基于定时任务列表来实现

    • 首先,写入数据库。
    • 然后,在写入数据库所在的事务中,插入一条记录到任务表。该记录会存储需要更新的缓存 KEY 和 VALUE 。
    • 【异步】:最后,定时任务每秒扫描任务表,更新到缓存中,之后删除该记录。
  2. 基于消息队列来实现:

    • 首先,写入数据库。
    • 然后,发送带有缓存 KEY 和 VALUE 的事务消息。此时,需要有支持事务消息特性的消息队列,或者我们自己封装消息队列,支持事务消息。
    • 【异步】:最后,消费者消费该消息,更新到缓存中。

这两种方式,可以进一步优化,可以先尝试更新缓存,如果失败,则插入任务表,或者事务消息。

首先,要明确的是,1)和2)无论我们选择哪个,我们都希望这两个操作要么同时成功,要么同时失败。所以,这会演变成一个分布式事务的问题。

3)基于数据库的 binlog 日志
应用直接写数据到数据库中。
数据库更新binlog日志。
利用Canal中间件读取binlog日志。
Canal借助于限流组件按频率将数据发到MQ中。
应用监控MQ通道,将MQ的数据更新到Redis缓存中。
可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,而且这个方案虽然比较重,但是却容易形成统一的解决方案。

使用缓存过程中,经常会遇到缓存数据的不一致性和脏读现象。一般情况下,采取缓存双淘汰机制,在更新数据库的前淘汰缓存。此外,设定超时时间,例如三十分钟。
极端场景下,即使有脏数据进入缓存,这个脏数据也最存在一段时间后自动销毁。

如何避免缓存穿透的问题

缓存穿透,是指查询一个一定不存在的数据,由于缓存是不命中时被动写( 被动写,指的是从 DB 查询到数据,则更新到缓存中 )的,并且处于容错考虑,如果从 DB 查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,失去了缓存的意义。
MySQL 的性能是远不如 Redis 的,如果大量的请求直接打到 MySQL ,则会直接打挂 MySQL 。在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。

如何解决
有两种方案可以解决:

  • 方案一,缓存空对象:当从 DB 查询数据为空,我们仍然将这个空结果进行缓存,具体的值需要使用特殊的标识,能和真正缓存的数据区分开。另外,需要设置较短的过期时间,一般建议不要超过 5 分钟。
  • 方案二,BloomFilter 布隆过滤器:在缓存服务的基础上,构建 BloomFilter 数据结构,在 BloomFilter 中存储对应的 KEY 是否存在,如果存在,说明该 KEY 对应的值为空。那么整个逻辑的如下:
    1、根据 KEY 查询缓存。如果存在对应的值,直接返回;如果不存在,继续向下执行。
    2、根据 KEY 查询在缓存 BloomFilter 的值。如果存在值,说明该 KEY 不存在对应的值,直接返回空;如果不存在值,继续向下执行。
    3、查询 DB 对应的值,如果存在,则更新到缓存,并返回该值。如果不存在值,更新到 缓存 BloomFilter 中,并返回空。
    在这里插入图片描述

如何避免缓存击穿的问题?

缓存击穿,是指某个极度“热点”数据在某个时间点过期时,恰好在这个时间点对这个 KEY 有大量的并发请求过来,这些请求发现缓存过期一般都会从 DB 加载数据并回设到缓存,但是这个时候大并发的请求可能会瞬间 DB 压垮。

对于一些设置了过期时间的 KEY ,如果这些 KEY 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑这个个问题。
缓存被“击穿”的问题,和缓存“雪崩“”的区别在于,前者针对某一 KEY 缓存,后者则是很多 KEY
缓存被“击穿”的问题,和缓存“穿透“”的区别在于,这个 KEY 是真实存在对应的值的

有两种方案可以解决:

  • 方案一,使用互斥锁:请求发现缓存不存在后,去查询 DB 前,使用分布式锁,保证有且只有一个线程去查询 DB ,并更新到缓存。流程如下:
    1、获取分布式锁,直到成功或超时。如果超时,则抛出异常,返回。如果成功,继续向下执行。
    2、再去缓存中。如果存在值,则直接返回;如果不存在,则继续往下执行。 因为,获得到锁,可能已经被“那个”线程去查询过 DB ,并更新到缓存中了。
    3、查询 DB ,并更新到缓存中,返回值。
  • 方案二,手动过期:缓存上从不设置过期时间,功能上将过期时间存在 KEY 对应的 VALUE 里,如果发现要过期,通过一个后台的异步线程进行缓存的构建,也就是“手动”过期。通过后台的异步线程,保证有且只有一个线程去查询 DB。
    在这里插入图片描述

如果避免缓存雪崩的问题?

缓存雪崩,是指缓存由于某些原因无法提供服务( 例如,缓存挂掉了 ),所有请求全部达到 DB 中,导致 DB 负荷大增,最终挂掉的情况。

如何解决
预防和解决缓存雪崩的问题,可以从以下多个方面进行共同着手。

事发前:实现Redis的高可用(主从架构+Sentinel 或者Redis Cluster),尽量避免Redis挂掉这种情况发生。
事发中:万一Redis真的挂了,我们可以设置本地缓存(ehcache)+限流(hystrix),尽量避免我们的数据库被干掉(起码能保证我们的服务还是能正常工作的)
事发后:redis持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据。

  • 1)缓存高可用
    通过搭建缓存的高可用,避免缓存挂掉导致无法提供服务的情况,从而降低出现缓存雪崩的情况。
    假设我们使用 Redis 作为缓存,则可以使用 Redis SentinelRedis Cluster 实现高可用。

  • 2)本地缓存
    如果使用本地缓存时,即使分布式缓存挂了,也可以将 DB 查询到的结果缓存到本地,避免后续请求全部到达 DB 中。
    当然,引入本地缓存也会有相应的问题,例如说:

    • 本地缓存的实时性怎么保证?
      • 方案一,可以引入消息队列。在数据更新时,发布数据更新的消息;而进程中有相应的消费者消费该消息,从而更新本地缓存。
        也可以使用 Redis Pub / Sub 取代消息队列来实现,但是此时 Redis 可能已经挂了,所以也不一定合适。
      • 方案二,设置较短的过期时间,请求时从 DB 重新拉取。
      • 方案三,手动过期。
    • 每个进程可能会本地缓存相同的数据,导致数据浪费?
      • 方案一,需要配置本地缓存的过期策略和缓存数量上限。
  • 3)请求 DB 限流
    通过限制 DB 的每秒请求数,避免把 DB 也打挂了。这样至少能有两个好处:
    可能有一部分用户,还可以使用,系统还没死透。
    未来缓存服务恢复后,系统立即就已经恢复,无需在处理 DB 也挂掉的情况。
    当然,被限流的请求,我们最好也要有相应的处理,走 4)服务降级」 。
    如果我们使用 Java ,则可以使用 Guava RateLimiter、Sentinel 实现限流的功能。

  • 4)服务降级
    如果请求被限流,或者请求 DB 超时,我们可以服务降级,提供一些默认的值,或者友情提示,甚至空白的值也行。
    如果我们使用 Java ,则可以使用 Hystrix、Sentinel 实现限流的功能。

  • 5)提前演练
    在项目上线前,演练缓存宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定。

什么是缓存预热?如何实现缓存预热?

缓存预热
在刚启动的缓存系统中,如果缓存中没有任何数据,如果依靠用户请求的方式重建缓存数据,那么对数据库的压力非常大,而且系统的性能开销也是巨大的。
此时,最好的策略是启动时就把热点数据加载好。这样,用户请求时,直接读取的就是缓存的数据,而无需去读取 DB 重建缓存数据。
如何实现
一般来说,有如下几种方式来实现:

  • 数据量不大时,项目启动时,自动进行初始化。
  • 写个修复数据脚本,手动执行该脚本。
  • 写个管理界面,可以手动点击,预热对应的数据到缓存中。

缓存如何存储 POJO 对象?

实际场景下,缓存值可能是一个 POJO 对象,就需要考虑如何 POJO 对象存储的问题。目前有两种方式:

方案一,将 POJO 对象序列化进行存储,适合 Redis 和 Memcached 。
可参考 《Redis 序列化方式StringRedisSerializer、FastJsonRedisSerializer和KryoRedisSerializer》 文章。
方案二,使用 Hash 数据结构,适合 Redis 。
可参考 《Redis 之序列化 POJO》 文章。
Redis的Hash实际是内部存储的Value为一个HashMap,并提供了直接存取这个Map成员的接口,如下图:
在这里插入图片描述
Key仍然是用户ID, value是一个Map,这个Map的key是成员的属性名,value是属性值,这样对数据的修改和存取都可以直接通过其内部Map的Key(Redis里称内部Map的key为field), 也就是通过 key(用户ID) + field(属性标签) 就可以操作对应属性数据了,既不需要重复存储数据,也不会带来序列化和并发修改控制的问题。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值