什么是缓存?

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


  • CPU 高速缓存 :高速缓存的读写速度远高于内存。

    • CPU 读数据时,如果在高速缓存中找到所需数据,就不需要读内存
    • CPU 写数据时,先写到高速缓存,再回写到内存。

  • 磁盘缓存:磁盘缓存其实就把常用的磁盘数据保存在内存中,内存读写速度也是远高于磁盘的。

    • 读数据时,从内存读取。
    • 写数据时,可先写到内存,定时或定量回写到磁盘,或者是同步回写。


为什么要用缓存?

使用缓存的目的,就是提升读写性能。而实际业务场景下,更多的是为了提升读性能,带来更好的性能,更高的并发量。 日常业务中,我们使用比较多的数据库是 MySQL,缓存是 Redis 。Redis 比 MySQL 的读写性能好很多。那么,我们将 MySQL 的热点数据,缓存到 Redis 中,提升读取性能,也减小 MySQL 的读取压力。例如说:


  • 论坛帖子的访问频率比较高,且要实时更新阅读量,使用 Redis 记录帖子的阅读量,可以提升性能和并发。
  • 商品信息,数据更新的频率不高,但是读取的频率很高,特别是热门商品。

分布式缓存系统面临的问题

缓存与数据库双写不一致

一般来说,如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,也就是说如果你的系统不是严格要求 “缓存+数据库” 必须保持一致性的话,最好不要做这个方案,即:读请求和写请求串行化,串到一个内存队列里去。 串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上的一个请求。 最经典的就是缓存+数据库读写的模式(Cache Aside Pattern)。


  • 读的时候,先读缓存,缓存没有的话,再读数据库,然后取出数据后放入缓存,同时返回响应。
  • 更新的时候,先更新数据库,然后再删除缓存。

缓存穿透和缓存雪崩缓存穿透

概念: 指查询一个一定不存在的数据,由于缓存是不命中时被动写,即从 DB 查询到数据,则更新到缓存中,并且出于容错考虑,如果从 DB 查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要去 DB 查询,失去了缓存的意义。在流量大时,DB 可能就挂掉了。 举个栗子。系统A,每秒 5000 个请求,结果其中 4000 个请求是黑客发出的恶意攻击。数据库 id 是从 1 开始的,而黑客发过来的请求 id 全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。 解决方案: 方案一: 缓存空对象,当从 DB 查询数据为空,我们仍然将这个空结果进行缓存,具体的值需要使用特殊的标识, 能和真正缓存的数据区分开,另外将其过期时间设为较短时间。 方案二: 使用布隆过滤器,在缓存的基础上,构建布隆过滤器数据结构,在布隆过滤器中存储对应的 key,如果存在,则说明 key 对应的值为空。这样整个业务逻辑如下:


  • 根据 key 查询缓存,如果存在对应的值,直接返回;如果不存在则继续执行。
  • 根据 key 查询缓存在布隆过滤器的值,如果存在值,则说明该 key 不存在对应的值,直接返回空,如果不存在值,继续向下执行。
  • 查询 DB 对应的值,如果存在,则更新到缓存,并返回该值,如果不存在值,则更新缓存到布隆过滤器中,并返回空。

缓存雪崩

概念: 缓存由于某些原因无法提供服务,所有请求全部达到 DB 中,导致 DB 负荷大增,最终挂掉的情况。 比如,对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。 解决方案:


  • 缓存高可用:使用 Redis Sentinel 等搭建缓存的高可用,避免缓存挂掉无法提供服务的情况,从而降低出现缓存雪崩的情况。
  • 使用本地缓存:如果使用本地缓存,即使分布式缓存挂了,也可以将 DB 查询的结果缓存到本地,避免后续请 求全部达到 DB 中。当然引入本地缓存也会有相应的问题,比如本地缓存实时性如何保证。对于这个问题,可以使用消息队列,在数据更新时,发布数据更新的消息,而进程中有相应的消费者消费该消息,从而更新本地缓存;简单点可以通过设置较短的过期时间,请求时从 DB 重新拉取。
  • 请求限流和服务降级:通过限制 DB 的每秒请求数,避免数据库挂掉。对于被限流的请求,采用服务降级处理,比如提供默认的值,或者空白值。

缓存击穿

概念: 某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。 解决方案:


  • 使用互斥锁 (mutex key):感知到缓存失效,去查询 DB 时,使用分布式锁,使得只有一个线程去数据库加载数据,加锁失败的线程,等待即可。

    • 获取分布式锁,直到成功或超时。如果超时,则抛出异常,返回。如果成功,继续向下执行。
    • 再去缓存中。如果存在值,则直接返回;如果不存在,则继续往下执行。因为,获得到锁,可能已经被“那个”线程去查询过 DB ,并更新到缓存中了。
    • 查询 DB ,并更新到缓存中,返回值。

  • 手动过期:redis 上从不设置过期时间,功能上将过期时间存在 key 对应的 value 里,如果发现要过期,通过一个后台的异步线程进行缓存的构建,也就是“手动”过期。

缓存并发竞争

某个时刻,多个系统实例都去更新某个 key。可以基于 zookeeper 实现分布式锁。每个系统通过 zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 key,别人都不允许读和写。 要写入缓存的数据都是从 mysql 里查出来的,都得写入 mysql 中,写入 mysql 中的时候必须保存一个时间戳,从 mysql 查出来的时候,时间戳也要查出来。 每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。