浅析如何应对缓存问题
为什么用缓存
使用缓存的目的,就是提升读写性能。而实际业务场景下,更多的是为了提升读性能,带来更好的性能,更高的并发量。
日常业务中,我们使用比较多的数据库是 MySQL,缓存是 Redis 。Redis 比 MySQL 的读写性能好很多。那么,我们将 MySQL 的热点数据,缓存到 Redis 中,提升读取性能,也减小 MySQL 的读取压力。例如说:
- 论坛帖子的访问频率比较高,且要实时更新阅读量,使用 Redis 记录帖子的阅读量,可以提升性能和并发
- 商品信息,数据更新的频率不高,但是读取的频率很高,特别是热门商品
缓存面临的问题
缓存穿透
业务场景
指查询一个一定不存在的数据,由于缓存是不命中时被动写,即从 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 有大量的并发请求过来,这些请求发现缓存过期一般都会从 DB 加载数据并设置到缓存,但是这个时候大并发的请求会瞬间时 DB 挂掉。
缓存击穿与缓存雪崩的区别在于,缓存击穿针对某 1 个 key 缓存,缓存雪崩则是很多 key
缓存击穿与缓存穿透的区别在于,缓存击穿的 key 是真实存在对应值的
解决方案
方案一,使用互斥锁:请求发现缓存不存在后,去 DB 查询前,使用分布式锁,保证有且只有一个线程去查询 DB,并更新缓存,具体思路如下:
- 获取分布式锁,直到成功或超时,如果超时,则抛出异常,如果成功,则继续执行
- 在等待并获取锁的过程中,可能其他的线程已经去 DB 查询并更新了缓存。因此,此时先去缓存中查询,如果存在值则返回;如果不存在则去 DB 查询,并更新缓存,返回值。
方案二,手动过期:缓存不设置过期时间,将过期时间存在 key 对应的 value 中,如果发现过期,通过一个后台的异步线程进行缓存的构建,也就是手动设置过期