跳至主要內容

Redis-应用篇-①缓存并发问题

holic-x...大约 20 分钟RedisRedis

Redis-应用篇-①缓存并发问题

学习核心

  • 缓存异常场景和应对方案
    • 缓存击穿、穿透、雪崩
    • 缓存设计方案扩展问题(场景应用、问题解决方案、优化点等)

学习资料

缓存场景应用

​ 用户的数据一般都是存储于数据库,数据库的数据是落在磁盘上的,磁盘的读写速度可以说是计算机里最慢的硬件了。

​ 如果用户的请求都访问数据库的话,一旦请求数量上来,数据库很容易就奔溃。因此为了避免用户直接访问数据库,会选用 Redis 作为缓存层。因为 Redis 是内存数据库,可以将数据库的数据缓存在 Redis 里,相当于数据缓存在内存,内存的读写速度比硬盘快好几个数量级,以此大大提高了系统性能

image-20240724211704128

​ 通常为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存

image-20240724211809277

​ 缓存是一种常见的解决“查询请求频繁”的设计方案,这种方案在查询请求并发较高时,经常会有缓存异常的三个问题:缓存穿透、缓存击穿、缓存雪崩

缓存穿透

1.问题背景

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。

​ 在流量大时,可能导致DB挂掉,如果有人利用不存在的key频繁攻击的应用,就会导致出现很大的系统漏洞。缓存穿透的发生一般有两种情况:

  • 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
  • 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;

2.解决方案

【1】限制非法请求:接口层增加校验,如用户鉴权校验、id做基础校验,id<=0的直接拦截;

​ 当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库

【2】临时缓存(缓存空值或者默认值)通用推荐方案:从缓存取不到的数据,在数据库中也没有取到,此时也可以将key-value键值对写为key-null,缓存有效时间设置要适中(如30s),防止攻击用户反复用同一个id暴力攻击

  • 临时缓存有效时间太短可能不起作用
  • 临时缓存有效时间太长会挤占正常缓存的内存,可能对正常业务造成影响(例如某个值一开始不存在,后面又增加了,如果临时缓存有效时间设置太长就可能导致正常的数据比较长时间拿不到)
    • 此处可能会思考,如果新增的时候附带更新下缓存或许就能解决这个问题。但具体还是要结合实际的业务场景和缓存更新方案,例如一般新增操作不会更新缓存,只有修改的时候才会操作缓存。且纯粹的旁路缓存不一定会进行删除操作,因此要结合实际场景分析

【3】布降过滤器存在误判情况,谨慎使用:使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在

​ bloomfilter就类似于一个hash set,用于快速判某个元素是否存在于集合中。其典型的应用场景就是快速判断一个key是否存在于某容器,不存在就直接返回。布隆过滤器的关键就在于hash算法和容器大小可以用布隆过滤器来应对,布隆过滤器是一种比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告知“某样东西一定不存在或者可能存在”

image-20240724202115327

​ 可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。

​ 即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的

布隆过滤器原理

​ 布降过滤器底层是一个bit 数组,将字符串用多个Hash函数映射不同的二进制位置,将对应位置设置为1。

​ 布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。布隆过滤器会通过 3 个操作完成标记:

  • 【步骤1】:使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
  • 【步骤2】:将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置;
  • 【步骤3】:将每个哈希值在位图数组的对应位置的值设置为 1;

image-20240724215221454

​ 结合上述图示分析,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、3、6,然后把位图数组的第 1、3、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、3、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中

​ 布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。

​ 在查询的时候,如果一个字符串所有Hash函数映射的值都存在,那么数据可能存在。为什么说可能?就是因为其他字符可能占据该值,提前点亮。

​ 布隆过滤器优缺点都很明显,优点是空间、时间消耗都很小,缺点是结果不是完全准确

缓存击穿

1.问题背景

​ 业务通常会有几个数据会被频繁地访问,比如秒杀活动相关,这类被频地访问的数据被称为热点数据

​ 缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。缓存击穿,一般指是指热键在过期失效的一瞬间,还没来得及重新产生,就有海量数据直达数据库

image-20240724203315416

2.解决方案

【1】热点数据支持续期,持续访问的数据可以不断续期,避免因为过期失效而被击穿

  • 热点续期的常见方案:只要有请求访问,就按照一定规则增加过期时间(可以理解为发现过期时间快到了,自动续期)

【2】**互斥锁方案 **(加互斥锁、重建缓存):发现缓存失效,重建缓存加互斥锁,当线程査询缓存发现缓存不存在就会尝试加锁,线程争抢锁,拿到锁的线程就会进行查询数据库、重建缓存,争抢锁失败的线程可以加一个睡眠然后循环重试

  • 方案1:使用 redis 里的 redssion 分布式锁。当线程拿到缓存发现过期了就会尝试加锁,线程争抢锁,拿到锁的线程就会进行查询数据库、重建缓存,争抢锁失败的线程,可以加一个睡眠然后循环重试(redission 直接实现了可重试和可重入还保证了原子性)
  • 方案2:如果担心线程资源会占用很大,可以用逻辑过期来解决缓存击穿,就是给缓存加上过期时间戳而不设置 ttl,判断这个逻辑时间是否过期然后加锁重建缓存,此处争抢锁失败的线程就不再争抢了,而是直接返回已过期数据,具体解决方案还是得根据应用场景来使用。此处的时间戳概念是将当前时间序列传给redis,然后将其和要把保存的数据一起作为对象存到value中,然后在获取这个数据的时候进行反序列化,判断这个数据是否过期

缓存雪崩

1.问题背景

缓存雪崩:是指大量的应用请求因为异常无法在Redis缓存中进行处理,像雪崩一样,直接打到数据库。

​ 雪崩主要原因:缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至宕机。在一些资料学习里,会把Redis宕机算进来,原因是Redis宕机了也就无法处理缓存请求,但细究来看Redis当即也会出现缓存击穿问题。考虑缓存击穿针对的是热点数据,缓存雪崩针对的是大量数据同时失效,一些场景中会把Redis宕机当作是缓存雪崩的一种特殊场景。

image-20240724204430731

缓存击穿和缓存雪崩概念很相似,可以理解为缓存击穿是缓存雪崩的一个子集,但缓存击穿指热点数据在Redis没得到及时重建,缓存雪崩是一大批数据在Redis同时失效

2.解决方案

【大量数据同时过期】

​ 不同的缓存雪崩诱因不同,其相应解决方案不同

【1】设置均匀的缓存过期时间:缓存数据的过期时间加上随机数,避免设置同一时间,防止同一时间大量数据过期现象发生

【2】**互斥锁方案 **(加互斥锁、重建缓存):当线程拿到缓存发现缓存不存在就会尝试加锁(加互斥锁,确保同一时间内只有一个请求来构建缓存),线程争抢锁,拿到锁的线程就会进行查询数据库、重建缓存,对于争抢锁失败的线程可以加一个睡眠然后循环重试

【3】后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新

​ 事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,从业务的视角就以为是数据丢失了。解决上面的问题的方式有两种。

  • 方式1:后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效(可能是系统资源紧张而被淘汰的),则需从数据库读取数据,并更新到缓存。

    • 这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。
  • 方式2:在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存

    • 后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存
    • 这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。

在业务刚上线的时候,最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情

【Redis宕机】

针对 Redis 故障宕机而引发的缓存雪崩问题,常见的应对方法有下面这几种:

  • 服务熔断或请求限流机制;
  • 构建 Redis 缓存高可靠集群;

1. 服务熔断或请求限流机制

因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。

服务熔断机制是保护数据库的正常运行,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作

为了减少对业务的影响,我们可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。

2. 构建 Redis 缓存高可靠集群

服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群

如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题

缓存并发问题总结

缓存并发问题产生原因解决方案
缓存穿透数据既不在缓存中,也不在数据库中【1】限制接口请求条件
【2】设置临时缓存(对不存在的key设置value为空或者默认值)
【3】引入布隆过滤器(快速校验数据是否存在,避免大量请求落到数据库上,但可能存在误判现象)
缓存击穿热点数据在某一瞬间失效,但此时数据库还没来得及更新缓存【1】自动续期(针对热点数据,在查询的时候可根据限定条件为快到期的数据自动续期)
【2】互斥锁方案(加互斥锁、重建缓存):确保同一时刻只有一个请求做更新缓存操作(加互斥锁,成功获取锁资源的请求可查询数据库、重建缓存),其他未获取锁资源的请求可选择直接返回默认值,或者重试
缓存雪崩大量数据在同一时间失效【1】均匀分布过期时间(针对数据的过期时间设定,可以添加随机数,避免大量数据在同一时间同时过期)
【2】互斥锁方案(加互斥锁、重建缓存):确保同一时刻只有一个请求做更新缓存操作(加互斥锁,成功获取锁资源的请求可查询数据库、重建缓存),其他未获取锁资源的请求可选择直接返回默认值,或者重试
【3】后台定时更新缓存(将原ttl方案交由后台完全控制,缓存不再设定过期时间,而是由后台线程进行业务逻辑操作实现定时更新)
Redis宕机Redis宕机在一些场景下也被认为是缓存雪崩的一种情况,此处Redis宕机主要是由于故障引发【1】服务熔断或请求限流机制
【2】构建 Redis 缓存高可靠集群

缓存设计问题扩展

日常开发场景问题

  • 项目中用的 Redis 是如何部署的?用的是 Redis Cluster 还是 Redis Sentinel?
  • 公司有没有出现过 Redis 崩溃的问题?如果有,是什么原因引发的?
  • 公司有没有出现过Redis连不上的问题?如果有,后面有没有使用什么方案来容错?
  • 有没有遇到过缓存穿透、击穿或者雪崩等问题?如果有,是怎么解决的,有没有可以改进的点?
  • 公司有没有保护数据库的措施?比如说防止缓存失效,导致数据库不堪重负直接崩溃?

缓存设计问题

会有一些缓存设计问题,比如:

  • 怎么设计一个动态缓存热点数据的策略?
  • 怎么设计一个缓存操作与业务分离的架构?

​ 由于数据存储受限,系统并不是将所有数据都需要存放到缓存中的,而只是将其中一部分热点数据缓存起来,那么就引出来一个问题,即如何设计一个缓存策略,可以动态缓存热点数据呢?

​ 以电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。

image-20240724221923782

解决缓存热点问题,缓存策略的总体思路:就是通过判断数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据,具体细节如下

  • 【1】先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前
  • 【2】同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中
  • 【3】当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回
  • 【4】在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作

​ 前面的内容中,都是将缓存操作与业务代码耦合在一起,这样虽然在项目初期实现起来简单容易,但是随着项目的迭代,代码的可维护性会越来越差,并且也不符合架构的“高内聚,低耦合”的设计原则,那么如何解决这个问题呢? =》将缓存操作与业务代码解耦,实现方案上可以通过 MySQL Binlog + Canal + MQ 的方式

​ 举一个实际的场景,比如用户在应用系统的后台添加一条配置信息,配置信息存储到了 MySQL 数据库中,同时数据库更新了 Binlog 日志数据,接着再通过使用 Canal 组件来获读取最新的 Binlog 日志数据,然后解析日志数据,并通过事先约定好的数据格式,发送到 MQ 消息队列中,最后再由应用系统将 MQ 中的数据更新到 Redis 中,这样就完成了缓存操作和业务代码之间的解耦,以解决缓存操作与业务系统分离

image-20240724222106116

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3