缓存问题

2019/04/01 分布式 Redis 缓存

缓存与数据库的一致性、分布式缓存、缓存穿透、缓存雪崩、缓存击穿

什么是缓存?

缓存,就是数据交换的缓冲区,针对服务对象的不同(本质就是不同的硬件)都可以构建缓存。

目的是,把读写速度慢的介质的数据保存在读写速度快的介质中,从而提高读写速度,减少时间消耗。 例如:

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

为什么要用缓存?

使用缓存的目的,就是提升读写性能。而实际业务场景下,更多的是为了提升读性能,带来更好的性能,更高的并发量。

日常业务中,我们使用比较多的数据库是 MySQL,缓存是 Redis 。Redis 比 MySQL 的读写性能好很多。那么,我们将 MySQL 的热点数据,缓存到 Redis 中,提升读取性能,也减小 MySQL 的读取压力。例如说:

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

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

image

缓存与数据库的一致性问题

问题的产生

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

解决方案

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

先淘汰缓存,即使写数据库发生异常,也就是下次缓存读取时,多读取一次数据库,这样理论上来说保证了数据的 一致性。但是实际在并发环境下仍然会出现数据不一致的情况。

首先来说写流程:先淘汰缓存,再写 DB;

然后是读流程:先读缓存,如果未命中再读 DB,然后将 DB 中读出来的数据更新到缓存中。并发环境下,在数据库层面并发的读写并不能保证完全顺序,也就是说后发出的读请求可能先完成。举例来说

  • 线程 T1 发出了写请求,淘汰了缓存;然后写数据库,发出修改请求。
  • 线程 T2 发出了读请求,先读取缓存,未命中,再去读取数据库,发出读取请求,这时候,线程 T1 的写数据还未完成,导致 T2 读取了一个脏数据放入缓存,这样就导致了数据不一致。

这种情况下,可以引入分布式锁实现“串行化”来解决。

  • 在写请求时,先获取分布式锁,再淘汰缓存,更新完数据库后再释放锁。
  • 在读请求时,如果缓存未命中,则先获取分布式锁,加锁失败说明写请求还未完成,继续等待;加锁成功则说明写请求已完成,再去缓存查询一次,如果未命中,则再去 DB 查询并即使更新到缓存。

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

由于操作缓存和操作数据库不是原子的,则第一步写数据库操作成功,第二步淘汰缓存失败,就会出现 DB 中是新数据,缓存中是旧数据,数据不一致的情况。在这种逻辑下,只有保证写数据库和更新缓存在同一个事务中,才能保证最终一致性。

基于定时任务来实现

  • 先写入数据库,然后在写入数据库所在的事务中,插入一条记录到任务表,该记录会存储需要更新的缓存 key 和 value。
  • 定时任务每秒扫描任务表,更新到缓存中,之后删除该记录。

基于消息队列实现

  • 首先写入数据库,然后发送带有缓存 key 和 value 的事务消息,此时需要有支持事务消息特性的消息队列。
  • 消费者消费该消息,更新到缓存中。

方案三: 基于数据库的 binlog 日志

image

  • 应用直接写数据到数据库中。
  • 数据库更新 binlog 日志。
  • 利用 Canal 中间件读取 binlog 日志。
  • Canal 借助于限流组件按频率将数据发到 MQ 中。
  • 应用监控 MQ 通道,将 MQ 的数据更新到 Redis 缓存中。

可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,而且这个方案虽然比较重,但是却容易形成统一的解决方案。

备注说明: 上述的订阅 binlog 程序在 mysql 中有现成的中间件叫 canal,可以完成订阅 binlog 日志的功能。

缓存穿透

业务场景

指查询一个一定不存在的数据,由于缓存是不命中时被动写,即从 DB 查询到数据,则更新到缓存中,并且出于容错考虑,如果从 DB 查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要去 DB 查询,失去了缓存的意义。在流量大时,DB 可能就挂掉了。

举个栗子。系统A,每秒 5000 个请求,结果其中 4000 个请求是黑客发出的恶意攻击。数据库 id 是从 1 开始的,而黑客发过来的请求 id 全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。

image

解决方案

方案一: 缓存空对象,当从 DB 查询数据为空,我们仍然将这个空结果进行缓存,具体的值需要使用特殊的标识, 能和真正缓存的数据区分开,另外将其过期时间设为较短时间。

方案二: 使用布隆过滤器,在缓存的基础上,构建布隆过滤器数据结构,在布隆过滤器中存储对应的 key,如果存在,则说明 key 对应的值为空。这样整个业务逻辑如下:

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

缓存雪崩

业务场景

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

比如,对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机。缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。

image

解决方案

方案一: 缓存高可用:使用 Redis Sentinel 等搭建缓存的高可用,避免缓存挂掉无法提供服务的情况,从而降低出现缓存雪崩的情况。

方案二: 使用本地缓存:如果使用本地缓存,即使分布式缓存挂了,也可以将 DB 查询的结果缓存到本地,避免后续请 求全部达到 DB 中。当然引入本地缓存也会有相应的问题,比如本地缓存实时性如何保证。对于这个问题,可以使用消息队列,在数据更新时,发布数据更新的消息,而进程中有相应的消费者消费该消息,从而更新本地缓存;简单点可以通过设置较短的过期时间,请求时从 DB 重新拉取。

方案三: 请求限流和服务降级:通过限制 DB 的每秒请求数,避免数据库挂掉。对于被限流的请求,采用服务降级处理,比如提供默认的值,或者空白值。

缓存击穿

业务场景

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

解决方案

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

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

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

缓存并发竞争

某个时刻,多个系统实例都去更新某个 key。可以基于 zookeeper 实现分布式锁。每个系统通过 zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 key,别人都不允许读和写。

image

要写入缓存的数据都是从 mysql 里查出来的,都得写入 mysql 中,写入 mysql 中的时候必须保存一个时间戳,从 mysql 查出来的时候,时间戳也要查出来。

每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。

Search

    Table of Contents