Redis - 缓存问题:一致性, 穿击, 穿透, 雪崩, 污染等
# 为什么要理解 Redis 缓存问题
在高并发的业务场景下,数据库大多数情况都是用户并发访问最薄弱的环节。所以,就需要使用 redis 做一个缓冲操作,让请求先访问到 redis,而不是直接访问 MySQL 等数据库。这样可以大大缓解数据库的压力。
当缓存库出现时,必须要考虑如下问题:
- 缓存穿透
- 缓存穿击
- 缓存雪崩
- 缓存污染(或者满了)
- 缓存和数据库一致性
# 缓存穿透
笔记
被恶意请求不存在的数据
- 问题来源
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。
如发起为 id 为 “-1” 的数据或 id 为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
- 解决方案
- 接口层增加校验,如用户鉴权校验,id 做基础校验,id<=0 的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将 key-value 对写为 key-null,缓存有效时间可以设置短点,如 30 秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个 id 暴力攻击
- 布隆过滤器。bloomfilter 就类似于一个 hash set,用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个 key 是否存在于某容器,不存在就直接返回。布隆过滤器的关键就在于 hash 算法和容器大小,
# 缓存击穿
笔记
一个热点 key 缓存过期,并发访问数据库。
- 问题来源
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
- 解决方案
1、设置热点数据永远不过期。
2、接口限流与熔断,降级。重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些 服务 不可用时候,进行熔断,失败快速返回机制。
3、加互斥锁
# 缓存雪崩
笔记
多个热点 key 缓存过期,并发访问数据库。
- 问题来源
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至 down 机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
- 解决方案
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
- 设置热点数据永远不过期。
# 缓存污染(或满了)
缓存污染问题说的是缓存中一些只会被访问一次或者几次的的数据,被访问完后,再也不会被访问到,但这部分数据依然留存在缓存中,消耗缓存空间。
缓存污染会随着数据的持续增加而逐渐显露,随着服务的不断运行,缓存中会存在大量的永远不会再次被访问的数据。缓存空间是有限的,如果缓存空间满了,再往缓存里写数据时就会有额外开销,影响 Redis 性能。这部分额外开销主要是指写的时候判断淘汰策略,根据淘汰策略去选择要淘汰的数据,然后进行删除操作。
# 最大缓存设置多大
系统的设计选择是一个权衡的过程:大容量缓存是能带来性能加速的收益,但是成本也会更高,而小容量缓存不一定就起不到加速访问的效果。一般来说,我会建议把缓存容量设置为总数据量的 15% 到 30%,兼顾访问性能和内存空间开销。
对于 Redis 来说,一旦确定了缓存最大容量,比如 4GB,你就可以使用下面这个命令来设定缓存的大小了:
CONFIG SET maxmemory 4gb
不过,缓存被写满是不可避免的,所以需要数据淘汰策略。
# 缓存淘汰策略
Redis 共支持八种淘汰策略,分别是 noeviction、volatile-random、volatile-ttl、volatile-lru、volatile-lfu、allkeys-lru、allkeys-random 和 allkeys-lfu 策略。
怎么理解呢?主要看分三类看:
- 不淘汰
- noeviction (v4.0 后默认的)
- 对设置了过期时间的数据中进行淘汰
- 随机:volatile-random
- ttl:volatile-ttl
- lru:volatile-lru
- lfu:volatile-lfu
- 全部数据进行淘汰
- 随机:allkeys-random
- lru:allkeys-lru
- lfu:allkeys-lfu
# noeviction(不淘汰)
该策略是 Redis 的默认策略。在这种策略下,一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。这种策略不会淘汰数据,所以无法解决缓存污染问题。一般生产环境不建议使用。
其他七种规则都会根据自己相应的规则来选择数据进行删除操作。
# volatile-random(随机淘汰会过期的)
这个算法比较简单,在设置了过期时间的键值对中,进行随机删除。因为是随机删除,无法把不再访问的数据筛选出来,所以可能依然会存在缓存污染现象,无法解决缓存污染问题。
# volatile-ttl(淘汰快过期的)
这种算法判断淘汰数据时参考的指标比随机删除时多进行一步过期时间的排序。Redis 在筛选需删除的数据时,越早过期的数据越优先被选择。
# volatile-lru(淘汰会过期的最久访问)
LRU算法
LRU 算法的全称是 Least Recently Used,按照最近最少使用的原则来筛选数据。这种模式下会使用 LRU 算法筛选设置了过期时间的键值对。
Redis 优化的 LRU 算法实现:
Redis 会记录每个数据的最近一次被访问的时间戳。在 Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。通过随机读取待删除集合,可以让 Redis 不用维护一个巨大的链表,也不用操作链表,进而提升性能。
Redis 选出的数据个数 N,通过配置参数 maxmemory-samples
进行配置。个数 N 越大,则候选集合越大,选择到的最久未被使用的就更准确,N 越小,选择到最久未被使用的数据的概率也会随之减小。
笔记
volatile-lru
不是绝对的 “最久” 没用,而是通过随机选出的局部 “最久”。
# volatile-lfu(淘汰会过期的最少最久访问的)
会使用 LFU 算法选择设置了过期时间的键值对。
LFU 算法
LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。
Redis 的 LFU 算法实现:
当 LFU 策略筛选数据时,Redis 会在候选集合中,根据数据 lru 字段的后 8bit 选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据 lru 字段的前 16bit 值大小,选择访问时间最久远的数据进行淘汰。
Redis 只使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255,这样在访问快速的情况下,如果每次被访问就将访问次数加一,很快某条数据就达到最大值 255,可能很多数据都是 255,那么退化成 LRU 算法了。所以 Redis 为了解决这个问题,实现了一个更优的计数规则,并可以通过配置项,来控制计数器增加的速度。
参数 :
`` ,用计数器当前的值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。
lfu-decay-time
, 控制访问次数衰减。LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。
lfu-log-factor
设置越大,递增概率越低,lfu-decay-time 设置越大,衰减速度会越慢。
我们在应用 LFU 策略时,一般可以将 lfu_log_factor 取值为 10。 如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1。可以快速衰减访问次数。
volatile-lfu 策略是 Redis 4.0 后新增。
笔记
- 找出最少访问次数的,如有相同,再判断最久访问的。
- 通过算法降低计数器增长速度
# allkeys-lru(淘汰最久访问)
使用 LRU 算法在所有数据中进行筛选。具体 LFU 算法跟上述 volatile-lru 中介绍的一致,只是筛选的数据范围是全部缓存,这里就不在重复。
# allkeys-random(随机淘汰)
从所有键值对中随机选择并删除数据。volatile-random 跟 allkeys-random 算法一样,随机删除就无法解决缓存污染问题。
- allkeys-lfu 使用 LFU 算法在所有数据中进行筛选。具体 LFU 算法跟上述 volatile-lfu 中介绍的一致,只是筛选的数据范围是全部缓存,这里就不在重复。
allkeys-lfu 策略是 Redis 4.0 后新增。
# 数据库和缓存一致性
- 问题来源
使用 redis 做一个缓冲操作,让请求先访问到 redis,而不是直接访问 MySQL 等数据库:
读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存 (Redis) 和数据库(MySQL)间的数据一致性问题。
不管是先写 MySQL 数据库,再删除 Redis 缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。举一个例子:
如果删除了缓存 Redis,还没有来得及写库 MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。
如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。
因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。
# 4 种相关模式
更新缓存的的 Design Pattern 有四种:Cache aside, Read through, Write through, Write behind caching; 我强烈建议你看看这篇,左耳朵耗子的文章:缓存更新的套路 (opens new window)
节选最最常用的 Cache Aside Pattern, 总结来说就是
- 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
- 更新的时候,先更新数据库,然后再删除缓存。
其具体逻辑如下:
- 失效:应用程序先从 cache 取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从 cache 中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,再让缓存失效。
Q:为什么不是先删除缓存,再更新数据库呢?
A:在极端情况下,有可能出现在线程 1 删除缓存后,又被线程 2 更新了缓存(旧数据),线程 1 再更新数据库,导致后续的查询操作一直都在取旧的数据。
Q:先更新数据库,在删除缓存就没问题吗?
A:有问题,在并发写的情况下,同样会出现缓存不一致的情况。
概率极低,这是因为它必须满足 3 个条件:
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)
仔细想一下,条件 3 发生的概率其实是非常低的。
因为写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的。
这么来看,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
所以,我们应该采用这种方案,来操作数据库和缓存。
Q:如果删除缓存失败,不是也会导致数据不一致?
A:是的,无论是 更新-更新
还是 更新-删除
,第二步操作执行失败都会导致缓存,因此需要一些机制去确保第二步能够成功执行。
目前主流的解决方法为,以下提到的两种方法,分别是「消息队列」或「订阅变更日志」。
# 方案:队列 + 重试机制
流程如下所示
- 更新数据库数据;
- 缓存因为种种问题删除失败
- 将需要删除的 key 发送至消息队列
- 自己消费消息,获得需要删除的 key
- 继续重试删除操作,直到成功
缺点:对业务线代码造成大量的侵入。
改进思路:启动一个订阅程序去订阅数据库的 binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。
# 方案:异步更新缓存 (基于订阅 binlog 的同步机制)
技术整体思路:
MySQL binlog 增量订阅消费 + 消息队列 + 增量数据更新到 redis
读:热数据基本都在 Redis
写:增删改都是操作 MySQL
更新 Redis 数据:MySQ 的数据操作 binlog,来更新到 Redis
Redis 更新
1)数据操作主要分为两大块:
- 一个是全量(将全部数据一次写入到 redis)
- 一个是增量(实时更新)
这里说的是增量,指的是 mysql 的 update、insert、delate 变更数据。
2)读取 binlog 后分析 ,利用消息队列,推送更新各台的 redis 缓存数据。
这样一旦 MySQL 中产生了新的写入、更新、删除等操作,就可以把 binlog 相关的消息推送至 Redis,Redis 再根据 binlog 中的记录,对 Redis 进行更新。
其实这种机制,很类似 MySQL 的主从备份机制,因为 MySQL 的主备也是通过 binlog 来实现的数据一致性。
这里可以结合使用 canal(阿里的一款开源框架),通过该框架可以对 MySQL 的 binlog 进行订阅,而 canal 正是模仿了 mysql 的 slave 数据库的备份请求,使得 Redis 的数据更新达到了相同的效果。
当然,这里的消息推送工具你也可以采用别的第三方:kafka、rabbitMQ 等来实现推送更新 Redis。
笔记
在这个方案中,读写分离,读方法永远只查缓存,查到什么返回什么,不会有查询数据库的动作。因此需要在应用启动时,将一次性将数据库里的数据存到缓存中,
# 方案:延迟双删
即时使用了「先更新数据库,再删除缓存」方案,在「读写分离 + 主从复制延迟」情况下,仍然会出现缓存和数据库一致性的问题。
参考资料: