写在前
在看redis缓存雪崩、击穿和穿透之前,先回答一下几个缓存的问题。
为什么要用 redis 而不用 map/guava 做缓存?
缓存分为本地缓存和分布式缓存。
以Java为例,使⽤⾃带的 map 或者 guava 实现的是本地缓存,最主要的特点是轻量以及快速,⽣命周期随着 jvm 的销毁⽽结束,并且在多实例的情况下,每个实例都需要各⾃保存⼀份缓存,缓存不具有⼀致性。
使⽤ redis 或 memcached 之类的称为分布式缓存,在多实例的情况下,各实例共⽤⼀份缓存数据,缓存具有⼀致性。缺点是需要保持 redis 或 memcached服务的⾼可⽤(需要维护),整个程序架构上为较为复杂。
Redis 与 Memcached的区别
两者都是非关系型(NoSql)内存键值数据库,主要有以下不同:
(1)数据类型。Memcached 仅支持字符串类型,而 Redis 支持五种不同的数据类型,可以更灵活地解决问题。
(2)数据持久化。Redis 支持两种持久化策略:RDB 快照和 AOF 日志,而 Memcached 不支持持久化。
(3)分布式。Memcached 不支持分布式,只能通过在客户端使用一致性哈希来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。Redis Cluster 实现了分布式的支持。
(4)内存管理机制。在 Redis 中,并不是所有数据都一直存储在内存中,可以将一些很久没用的 value 交换到磁盘(设置过期时间),而 Memcached 的数据则会一直在内存中。Memcached 将内存分割成特定长度的块来存储数据,以完全解决内存碎片的问题。但是这种方式会使得内存的利用率不高,例如块的大小为 128 bytes,只存储 100 bytes 的数据,那么剩下的 28 bytes 就浪费掉了。
(5)Memcached是多线程,⾮阻塞IO复⽤的⽹络模型;Redis使⽤单线程的多路 IO 复⽤模型。
使用Redis有什么缺点?存在的问题?
(1)缓存和数据库双写一致性问题
(2)缓存雪崩问题
(3)缓存击穿问题
(4)缓存穿透(并发竞争)问题
缓存雪崩
为了使查询速度更快,我们选择使用缓存来保存数据,使原本每次请求都需要查询数据库的操作变成先查询缓存,缓存有直接返回,缓存没有则查询数据库然后再写入缓存中,通常缓存都是有有效时长的,否则就会一直占用内存空间。
问题描述:当大量请求在访问都会先从缓存查询,如果此时大部分缓存同时过期失效,那么这些请求都查询不到缓存,此时他们会全部将请求到数据库,当请求数量足够大时此时将会把数据库压垮。简言之,如果缓存挂掉了,就意味着大量的请求都跑到数据库去了,压垮数据库,这就是缓存雪崩。
解决方案:
缓存数据的过期时间后边加一个随机值,防止同一时间大量数据过期现象发生,让数据均匀失效。
一般并发量不是特别多的时候,使用最多的解决方案是加锁排队。
给每一个缓存数据增加相应的缓存标记,记录缓存是否失效,如果缓存标记失效,则更新数据缓存。
热点数据可以考虑不失效。
Redis是如何判断数据是否过期的呢?
Redis 通过一个叫做过期字典(可以看作是hash表)来保存数据过期的时间。过期字典的键是一个指针,这个指针指向键空间中的某个键对象( 也即是某个数据库键)。过期字典的值是一个long long 类型的整数,这个整数保存了键所指向的数据库键的过期时间:一个毫秒精度的UNIX 时间戳。
过期字典是存储在 redisDb 这个结构里的:键空间+键的过期时间
typedef struct redisDb {
...
dict *dict; //数据库键空间,保存着数据库中所有键值对
dict *expires // 过期字典,保存着键的过期时间
...
} redisDb;
通过过期字典,程序可以用以下步骤检查一个给定键是否过期:
(1)检查给定键是否存在于过期字典: 如果存在,那么取得键的过期时间。
(2)检查当前UNIX 时间戳是否大于键的过期时间: 如果是的话,那么键已经过期;否则的话,键未过期。
Redis 给缓存数据设置过期时间有啥用?
(1)有助于缓解内存的消耗,避免长时间占用内存。如果缓存中的所有数据都是一直保存的话,分分钟直接 Out of memory。
(2)实际业务场景需要。很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在1分钟内有效,用户登录的 token 可能只在 1 天内有效。如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。
127.0.0.1:6379> exp key 60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56
注意:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间, ttl查看键还有多久过期
redis 设置过期时间,怎么处理过期数据呢?(过期键删除策略)
Redis中有个设置时间过期的功能,即对存储在 redis 数据库中的值可以设置⼀个过期时间。作为⼀个 缓存数据库,这是⾮常实⽤的。如我们⼀般项⽬中的 token 或者⼀些登录信息,尤其是短信验证码都是有时间限制的,按照传统的数据库处理⽅式,⼀般都是⾃⼰判断过期,这样⽆疑会严重影响项⽬性 。
通过key设置过期时间:我们 set key 的时候,都可以给⼀个 expire time,就是过期时间。通过过期时间我们可以指定这个key可以存活的时间。如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢(策略)?
- 惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
- 定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。对内存友好。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。
但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就 Out of memory 了。
怎么解决这个问题呢?答案就是: Redis 内存淘汰机制。
Redis 内存淘汰机制
作为内存数据库,出于对性能和内存消耗的考虑,Redis 的淘汰算法实际实现上并非针对所有 key,而是抽样一小部分并且从中选出被淘汰的 key。
no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!
allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用(时间上最久的)的 key。
volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
redis 4.0 版本后增加以下两种(针对最少使用的淘汰机制):
volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常(用的频率最低的淘汰)使用的数据淘汰
allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key
ps:MySQL⾥有2000w数据,Redis中只存20w的数据,如何保证Redis中的数据都是热点数据?如何提高缓存命中率?
- 使用 Redis 缓存数据时,为了提高缓存命中率,需要保证缓存数据都是热点数据。可以将内存最大使用量设置为热点数据占用的内存量,然后启用 allkeys-lru 淘汰策略,将最近最少使用的数据淘汰。
缓存击穿(缓存雪崩的另一个场景,热点数据在某一时刻过期失效)
问题描述:对于一些设置了过期时间的key,当redis缓存中有一个key是大量请求同时访问的热点数据,如果突然这个key时间到了,那么大量的请求在缓存中获取不到该key,穿过缓存直接来到数据库导致数据库崩溃,这样因为单个key失效而穿过缓存到数据库称为缓存击穿。
- 相比于缓存雪崩是大量key在同一时间过期引发的问题,缓存击穿强调的是某一热点key过期的瞬间引发的问题。两者都是由key过期导致大量并发请求直接到数据库。
热点缓存失效解决方案
可以使用互斥锁避免大量请求同时落到db。对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询。
布隆过滤器,判断某个容器是否在集合中,例如请求的参数不合法(请求参数不存在等)。
可以将热点数据设置为永不过期。
做好熔断、降级,防止系统崩溃。
ps:上述两种问题,针对redis服务器不可用情况:
采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
限流,避免同时处理大量的请求。
缓存穿透
问题描述:缓存穿透是指查询一个一定不存在的数据。由于缓存不命中,并且出于容错考虑,如果从数据库查不到数据,则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,失去了缓存的意义。这样,如果请求的数据在缓存大量不命中,导致大量的请求走向数据库,就很可能将数据库搞垮,导致整个服务瘫痪。这种通常是恶意查询和被攻击几率较大。
- 击穿和穿透不同,穿透是key不存在,可以理解为直接绕过redis缓存去使得数据库崩掉。而击穿可以理解为击穿缓存,这种通常为大量并发对热点key(常用的)进行大规模的读写操作导致数据库崩溃。
解决方案:
接口层增加校验:如用户鉴权校验,key值基础校验,id<=0的直接拦截;
从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null(即将查到的null设置为该key的缓存对象),缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击;
采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。
ps:布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们需要的就是判断 key 是否合法,有没有感觉布隆过滤器就是我们想要找的那个“人”。
具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端(无效的请求),存在的话才会走下面的流程。
但是,需要注意的是布隆过滤器可能会存在误判的情况。总结来说就是: 布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。
为什么会出现误判的情况呢? 我们还要从布隆过滤器的原理来说!
我们先来看一下,当一个元素加入布隆过滤器中的时候,会进行哪些操作:
- 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
- 根据得到的哈希值,在位数组中把对应下标的值置为 1。
我们再来看一下,当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行哪些操作:
- 对给定元素再次进行相同的哈希计算;
- 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
然后,一定会出现这样一种情况:不同的字符串可能哈希出来的位置相同。 (优化方案:可以适当增加位数组大小或者调整我们的哈希函数来降低概率)
更多关于布隆过滤器的内容参考:《不了解布隆过滤器?一文给你整的明明白白!》
缓存与数据库双写一致性
问题描述:从理论上来说,只要我们设置了键的过期时间,我们就能够保证缓存和数据库的数据最终一致性。因为只要缓存数据过期了,就会被删除,下次读的时候因为缓存里面没有,就会从数据库中查询并更新到缓存中。但是,在缓存数据没过期的时间内,缓存数据和数据库数据是不同步的。
怎样保证在写入数据库的同时,同步更新缓存中的数据。就是缓存与数据库双写一致性问题。
对于读操作,流程是这样的:如果我们的数据在缓存里面有,那就直接读取缓存的数据;如果缓存里面没有,则先去查询数据库,然后将数据库查出来的数据写入到缓存中,最后再将数据返回给请求。
如果仅仅只是查询的话,缓存的数据和数据库的数据都是没问题的。但是,当我们要更新的时候,有一些情况就很可能造成数据库和缓存的数据不一致了。举个例子,数据库的库存值是999,但是缓存的库存值是1000,那么很可能在一段时间内,页面拿到的是缓存1000的值,尽管实际上的库存是999(数据库的值)。
怎样解决缓存与数据库双写一致性问题?
方案:解决思路基本上都是删除缓存。因为这样的话,下一次读就会到数据库中读到缓存中,保证缓存的一致性。就算数据库更新操作失败了,也不会有缓存数据与数据库数据不一致的问题,即使缓存数据和数据库数据都是旧数据。只是删除缓存的时机不同会引发不同的问题。
先更新数据库,再删除缓存。可能出现删除缓存失败导致不一致。
先删除缓存,再更新数据库。可能出现读取脏数据,即在更新数据库之前读到数据。
写请求先将缓存修改为指定值,再更新数据库,再更新缓存。读请求过来之后,先读缓存,判断是指定值,则进入等待状态,等待写请求更新缓存之后再读缓存。如果等待超时,则直接到数据库中读取数据,更新缓存。这种方案可以保证读写的一致性,但是因为读请求需要等待写请求的完成(串行化了),降低了吞吐量。
如果为了短时间的不一致性问题,选择让系统设计变得更加复杂的话,完全没必要。这里聊聊,Cache Aside Pattern(旁路缓存模式)更新数据库,删除缓存,如果数据库删除成功,缓存删除失败,解决方案:
- 缓存失效时间变短(不推荐,治标不治本) :我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
- 增加 cache 更新重试机制(常用): 如果 cache 服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。如果多次重试还是失败的话,我们可以把当前更新失败的 key 存入队列中,等缓存服务可用之后,再将 缓存中对应的 key 删除即可。
ps:为什么是删除缓存,而不是更新缓存?
(1)缓存可能复杂:很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。
(2)更新代价高:另外更新缓存的代价有时候是很高的。
总结:
一般来说,如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,最好不要做这个方案,即:读请求和写请求串行化,串到一个内存队列里去。
串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上的一个请求。
补充
什么是缓存预热?
缓存预热是一个比较常见的概念,就是指在系统上线后,先将相关的缓存数据直接加载到缓存系统。这样,用户请求的时候就不需要先去查询数据库,再将数据放入缓存了,用户可以直接拿到实现被预热的缓存数据。
什么是缓存(熔断)降级?
熔断机制:“我们提供过载保护。当某个服务故障或者异常发生时,若这个异常条件需要我们处理,我们会采取一些保护措施---直接熔断整个服务,而不是一直等到此服务超时,从而防止整个系统的故障。”
什么是缓存降级?
当访问量剧增,服务出现问题(比如响应慢或不响应)或非核心服务影响到核心流程的性能时。仍然需要保证服务还是可用的,及时是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。
关键点:降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的,比如加入购物车、结算等服务。
在进行降级之前,要先对系统进行梳理,看看系统是不是可以弃帅保车,进而梳理出哪些是核心服务(不可降级),哪些是非核心服务(可降级)。
拿日志级别设置预案作为参考:
一般级别。比如某些服务偶尔因为网络抖动或者服务正在上线而超时,就可以自动降级。
警告级别。有些服务在一段时间内成功率有波动(比如在95~100%之间),就可以自动降级或人工降级,并发送警告。
错误级别。比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级。
严重错误级别。比如因为特殊原因数据错误了,此时就需要紧急人工降级。
巨人的肩膀:
https://www.cnblogs.com/yanggb/p/11110706.html
https://blog.csdn.net/qq_38550836/article/details/108044871