缓存与数据库的一致性、分布式缓存、缓存穿透、缓存雪崩、缓存击穿
什么是缓存?
缓存,就是数据交换的缓冲区,针对服务对象的不同(本质就是不同的硬件)都可以构建缓存。
目的是,把读写速度慢的介质的数据保存在读写速度快的介质中,从而提高读写速度,减少时间消耗。 例如:
- CPU 高速缓存 :高速缓存的读写速度远高于内存。
- CPU 读数据时,如果在高速缓存中找到所需数据,就不需要读内存
- CPU 写数据时,先写到高速缓存,再回写到内存。
- 磁盘缓存:磁盘缓存其实就把常用的磁盘数据保存在内存中,内存读写速度也是远高于磁盘的。
- 读数据时,从内存读取。
- 写数据时,可先写到内存,定时或定量回写到磁盘,或者是同步回写。
为什么要用缓存?
使用缓存的目的,就是提升读写性能。而实际业务场景下,更多的是为了提升读性能,带来更好的性能,更高的并发量。
日常业务中,我们使用比较多的数据库是 MySQL,缓存是 Redis 。Redis 比 MySQL 的读写性能好很多。那么,我们将 MySQL 的热点数据,缓存到 Redis 中,提升读取性能,也减小 MySQL 的读取压力。例如说:
- 论坛帖子的访问频率比较高,且要实时更新阅读量,使用 Redis 记录帖子的阅读量,可以提升性能和并发。
- 商品信息,数据更新的频率不高,但是读取的频率很高,特别是热门商品。
分布式缓存系统面临的问题

缓存与数据库的一致性问题
问题的产生
- 并发场景下,读取旧的 DB 数据并更新到缓存中。
- 缓存和 DB 的操作不在一个事务中,可能只有一个操作成功,另一个操作失败,导致不一致。
解决方案
方案一: 先淘汰缓存,再更新数据库
先淘汰缓存,即使写数据库发生异常,也就是下次缓存读取时,多读取一次数据库,这样理论上来说保证了数据的 一致性。但是实际在并发环境下仍然会出现数据不一致的情况。
首先来说写流程:先淘汰缓存,再写 DB;
然后是读流程:先读缓存,如果未命中再读 DB,然后将 DB 中读出来的数据更新到缓存中。并发环境下,在数据库层面并发的读写并不能保证完全顺序,也就是说后发出的读请求可能先完成。举例来说
- 线程 T1 发出了写请求,淘汰了缓存;然后写数据库,发出修改请求。
- 线程 T2 发出了读请求,先读取缓存,未命中,再去读取数据库,发出读取请求,这时候,线程 T1 的写数据还未完成,导致 T2 读取了一个脏数据放入缓存,这样就导致了数据不一致。
这种情况下,可以引入分布式锁实现“串行化”来解决。
- 在写请求时,先获取分布式锁,再淘汰缓存,更新完数据库后再释放锁。
- 在读请求时,如果缓存未命中,则先获取分布式锁,加锁失败说明写请求还未完成,继续等待;加锁成功则说明写请求已完成,再去缓存查询一次,如果未命中,则再去 DB 查询并即使更新到缓存。
方案二: 先写数据库,再更新缓存
由于操作缓存和操作数据库不是原子的,则第一步写数据库操作成功,第二步淘汰缓存失败,就会出现 DB 中是新数据,缓存中是旧数据,数据不一致的情况。在这种逻辑下,只有保证写数据库和更新缓存在同一个事务中,才能保证最终一致性。
基于定时任务来实现
- 先写入数据库,然后在写入数据库所在的事务中,插入一条记录到任务表,该记录会存储需要更新的缓存 key 和 value。
- 定时任务每秒扫描任务表,更新到缓存中,之后删除该记录。
基于消息队列实现
- 首先写入数据库,然后发送带有缓存 key 和 value 的事务消息,此时需要有支持事务消息特性的消息队列。
- 消费者消费该消息,更新到缓存中。
方案三: 基于数据库的 binlog 日志

- 应用直接写数据到数据库中。
- 数据库更新 binlog 日志。
- 利用 Canal 中间件读取 binlog 日志。
- Canal 借助于限流组件按频率将数据发到 MQ 中。
- 应用监控 MQ 通道,将 MQ 的数据更新到 Redis 缓存中。
可以看到这种方案对研发人员来说比较轻量,不用关心缓存层面,而且这个方案虽然比较重,但是却容易形成统一的解决方案。
备注说明: 上述的订阅 binlog 程序在 mysql 中有现成的中间件叫 canal,可以完成订阅 binlog 日志的功能。
缓存穿透
业务场景
指查询一个一定不存在的数据,由于缓存是不命中时被动写,即从 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 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。