基本的业务架构
当客户端发送请求时,业务服务会先去 Redis 缓存中查询,判断数据是否在缓存中。如果数据不在缓存中,就去 MySQL 数据库层查询,然后把查询到的数据,缓存到 Redis 中,再把结果返回给客户端。
当下一次请求同样的数据时,就可以直接从 Redis 缓存中获取数据,避免了再去 MySQL 数据库层查询,从而减轻底层数据库的压力。这在很大程度上提升了程序的响应速度。
缓存穿透
当数据查询在 Redis 缓存中没有数据时,该查询会下沉到 MySQL 数据库层,同时如果数据库层也没有该数据,就无法进行缓存。当出现大量这种查询(或被恶意攻击)时,查询请求全部透过缓存访问底层数据库,而数据库中也没有这些数据,这种现象就是缓存穿透。
缓存穿透会穿透 Redis 缓存的保护,让底层数据库的负载压力变大。
对于缓存穿透,至少有以下两种解决方案。
解决方案一
在业务服务访问层对请求进行校验,判断是否来自恶意用户(比如爬虫、超大循环递增商品的 id)的请求。比如对请求参数进行校验和检查一段时间内请求同一个服务的次数。
解决方案二
采用布隆过滤器进行拦截。将数据库层有关数据的键提前存入布隆过滤器中,用来判断访问的键是否在底层数据库中。
可以将布隆过滤器的判断逻辑,置于查询 Redis 缓存之前。请求在查询 Redis 缓存之前,先判断请求的 key 是否存在于布隆过滤器中。如果在,就查询缓存,如果缓存中没有,才去查询数据库;如果不在布隆过滤器中,就直接返回。
当然,也可以把布隆过滤器的层级放到数据库的上一层。请求数据时,先去缓存查询。如果缓存里有,就直接返回结果;如果缓存里没有,就先判断是否在布隆过滤器中。如果在,才允许查询 MySQL 数据库,如果不在布隆过滤器中,就直接返回。
通过前置一层布隆过滤器,可以将绝大部分的穿透查询屏蔽在 Redis 层,极大地降低了底层数据库的压力。
缓存击穿
热点数据在某个时间点过期,恰好在这个时间点,大量并发请求前来查询这些 key ,就会将查询请求下沉到数据库层,导致数据库层的负载压力增大,这种现象就是缓存击穿。
热点数据(比如秒杀活动的数据)一般会有下面的特征:
- 并发量非常大
- 重建缓存无法在短时间内完成,可能需要很多次 IO、复杂的 SQL 查询等
对于缓存击穿,至少有以下两种解决方案。
解决方案一
热点数据的过期时间设为永不过期。
另一种思路是:对于有过期时间的 key,我们可以在对应的 value 上加一个逻辑过期时间(logic_expire),逻辑过期时间必须小于 key 的过期时间,当发现当前时间超过逻辑过期时间时,就采用异步的方式更新缓存。
解决方案二
利用互斥锁(mutex lock)保证同一时刻只有一个客户端可以查询底层数据库和重建缓存。
对于热点 key,查询缓存时若没有数据,先不要查询数据库,而是使用 setnx 和 expire,来保证只会有一个请求争抢到锁,抢到锁后才去查询数据库和重建缓存。其他没有抢到锁的请求,等待重试,重新查询缓存即可。
大多数系统设计者考虑用加锁或队列的方式来保证缓存的单线程写,从而避免失效时大量的并发请求落到底层数据库。
互斥锁是业界比较常用的做法。
缓存雪崩
大面积的缓存数据几乎同时过期,之后,大量并发的查询穿过 Redis,直接冲击底层数据库,导致数据库层的负载压力激增。这种现象就是缓存雪崩。
相比于缓存击穿,缓存雪崩更容易发生。
对于缓存雪崩,至少有以下两种解决方案。
解决方案一
根据实际情况,将缓存数据的过期时间设为永不过期。
另一种思路是:对于有过期时间的 key,我们可以在对应的 value 上加一个逻辑过期时间(logic_expire),逻辑过期时间必须小于 key 的过期时间,当发现当前时间超过逻辑过期时间时,就采用异步的方式更新缓存。
解决方案二
同缓存击穿的解决方案二。
解决方案三
在一定的时间范围内,均匀分散设置键的过期时间,来防止大量的键在同一时刻过期。
比如,之前一些键的过期时间是 1 个小时,现在就可以设置为不同的时间,比如 1 小时 1 分钟、1小时 2 分钟等。可根据业务情况选择不同的随机因子。
解决方案四
采用多级缓存策略。不同级别的缓存设置的过期时间不同,即使某一级缓存过期了,也会有其他级别的缓存兜底。
区别
- 缓存穿透:缓存和底层数据库中都没有数据,无法进行缓存。
- 缓存击穿:热点数据过期,缓存中没有数据,但底层数据库中有数据。
- 缓存雪崩:大面积的缓存数据同时过期,缓存中没有数据,但底层数据库中有数据。