(术)Redis 缓存处理

序言

  如果没有缓存,一个高并发系统会怎样?用户每一次查询都访问数据库,数据库的 CPU、磁盘 IO、连接数都会快速达到瓶颈,系统响应时间越来越长,最终无法支撑业务增长。因此,缓存几乎成为了现代互联网高并发系统的标准配置。

  但是,当我们引入缓存之后,又会发现新的问题接踵而至:

  • 数据库已经更新了,为什么用户看到的还是旧数据?
  • 为什么有的缓存 Key 过期了,数据库就突然被打挂了?
  • 为什么在 Redis 集群中,有的节点压力很大而有的却相对空闲?

  这些问题背后,其实都指向同一个核心:缓存不是数据源,而是数据源的副本

  只要是副本,就必然存在一致性问题;只要存在失效,就必然存在高并发下的流量冲击。

  因此,缓存设计的本质,不是简单地把数据放进 Redis,而是在性能、复杂度和一致性之间寻找一个合理的平衡点。

  接下来,我们将从缓存一致性问题开始,逐步分析缓存的工作原理,以及高并发场景下各种典型问题的解决方案。

缓存一致性问题

什么是缓存的一致性问题?

  缓存的一致性问题指的是在使用缓存的系统中,缓存数据与数据库之间可能存在不一致的情况。当数据库中的数据发生变化时,如更新、插入或删除操作,需要同步对缓存中的相应数据进行及时更新或进行失效删除的处理,在并发请求的场景中,可能出现缓存与数据库之间的数据不一致的现象。

  缓存的一致性问题简单来说,就是缓存中的数据是旧数据,脏数据,当一个请求从缓存中读取数据时,如果缓存中的数据与数据库中的数据不一致,即缓存中的数据已经过期或无效,但仍被读取,那么客户查询看到的就是旧数据,脏数据。

如何应对缓存的一致性问题?

  我们尽量多角度的进行一个考虑,缓存的一致性问题说白了就是客户看到了旧数据,那么思考一下:

  • 站在我们开发者的的角度,我们希望客户看到旧数据嘛?

    • 不希望,但不希望就需要保证数据的强一致性,那么就需要引入锁机制来实现,这会降低程序的并发性能,实现起来也比较麻烦
    • 那么,我们真的需要加锁这么做,以达到强一致性嘛?
  • 站在客户的角度,客户希望看到怎样的数据?

    • 你直接问客户,客户肯定是希望看到最新的数据的
  • 站在客户的角度,客户是怎么操作我们系统的呢?
    • 客户若想看到最新的数据,应该是知道一些常识的操作的:
      • 如果自己提交了订单,那么为了查看现在订单的最新数据,应该会自己刷新订单列表页面来看数据
      • 如果自己看的的是旧数据,那么会重复刷新页面来看等待获取最新的数据,因为客户也知道,程序处理数据需要一定时间

  经过以上思考,我们最终其实可以得到一个答案:客户是可以容忍旧数据存在一定时间的,只要不是太长都没什么问题

  那么,这就好办了,对于我们开发者而言,我们只要尽量缩短缓存中脏数据存在的时间就好。

衡量缓存一致性好坏的指标是什么?

  我的一个理解是,缓存中脏数据的停留时间的长短决定了这个好坏:

  • 缓存中脏数据的停留时间长,那么相对而言,缓存一致性就低
  • 缓存中脏数据的停留时间短,那么相对而言,缓存一致性就好
  • 缓存中脏数据的停留时间完全没有,那么相对而言,缓存一致性就是完全一致性

一致性是越高越好嘛?

  这个是不一定的,需要结合具体的业务来看。

缓存数据处理方案不同导致一致性也不同

内存淘汰 超时剔除 ==主动更新==
说明 利用Redis的内存淘汰机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存 给缓存数据添加 TTL 时间,到期后自动删除 编写业务逻辑,在修改数据库的同时操作缓存
一致性
维护成本

  业务场景:

  • 低一致性需求:使用内存淘汰机制。例如店铺类型的查询缓存
  • 中一致性需求:使用超时剔除机制,比如三方接口调用可以缓存一定时间,来降低调用费用
  • 高一致性需求:主动更新,并可以结合使用超时剔除作为兜底方案

缓存模式

  • Cache Aside Pattern(旁路缓存模式):由缓存的调用者,在更新数据库后删除缓存
  • Write Behind Caching Pattern(写后缓存模式):调用者只操作缓存,由其它线程异步的将缓存数据持久化到数据库
  • Read/Write Through Pattern(读写透写模式):缓存与数据库整合为一个中间件服务,由中间件服务来维护一致性。调用者调用该服务,无需关心缓存一致性问题

Cache Aside Pattern

  Cache Aside Pattern(旁路缓存模式)是一种常见的缓存管理模式。

  在 Cache Aside Pattern 下数据处理会使用下面的步骤:
1.读取数据时的一致性:

  • 当应用程序需要读取数据时,首先会检查缓存中是否存在所需的数据。如果缓存中存在数据,则直接从缓存中获取并返回给应用程序
  • 如果缓存中不存在数据,则应用程序会从数据库中读取数据,并将数据存储到缓存中。这时数据从数据库读取的同时,也会更新缓存,以便下一次读取可以直接从缓存中获取
    2.更新数据时的一致性:
  • 当应用程序需要更新数据时,它会首先更新数据库中的数据
  • 接着,应用程序会删除缓存中的数据
  • 这样,在下一次读取该数据时,应用程序会从数据库中获取最新的数据,并将其存储到缓存中,以保持一致性

  Cache Aside Pattern 的优点是简单且易于实施。它避免了缓存和数据存储之间的强耦合,使得缓存的添加、删除和更新操作更加高效。然而,它也有一些潜在的问题,如缓存与数据存储之间的数据一致性问题和缓存击穿问题。为了解决这些问题,可以采用其他方案解决,如添加缓存失效时间、延迟双删。

  然而,Cache Aside Pattern也存在一致性延迟的问题。在更新数据库和使缓存失效之间的时间窗口内,读取操作可能会获取到旧的数据。这种情况下,应用程序需要根据具体需求和数据一致性要求来权衡使用缓存的优势和一致性需求。在某些场景下,可能需要采用更复杂的缓存策略或结合其他技术手段来解决一致性问题。

使用时的注意点

  操作缓存和数据库时,主要需要解决的是数据的一致性问题,扩展而言,我们需要考虑:

  • 尽可能的缩短数据不一致的时间窗口
  • 处理好缓存与数据库操作时的失败的原子性问题
为什么是删除缓存,而不是更新缓存呢?

  原因很简单,很多时候,缓存的数据,不简单是数据库中直接取出来的值,比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据,并进行运算,才能计算出缓存最新的值的。

  更新缓存的代价是很高的,如果你频繁修改一个缓存涉及的多个表,那么这个缓存会被频繁的更新。

  但是问题在于,这个缓存到底会不会被频繁访问到?
  举个例子,一个缓存涉及的表的字段,在 1分钟内修改了 20 次,或者是 100 次,那么缓存更新 20 次,100 次:但是这个缓存在1分钟内就被读取了1次, 有大量的冷数据,此时如果我们采用更新缓存的措施,无疑开销很大。

  实际上,如果你只是删除缓存的话,那么 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。

为什么是先更新数据库,再删除缓存?

  核心是为了减少缓存和数据库数据不一致的时间窗口。

  • 先更新缓存再更新数据库(数据丢失的可能,事务原子性问题)
  • 先更新数据库再更新缓存(会有脏数据的问题)
  • 先删除缓存,再操作数据库(会有脏数据的问题,事务原子性问题)
  • 先更新数据库,再删除缓存
先删除缓存,再操作数据库

  正常情况如下图:

image-20230909104604401

  异常情况如下图:

image-20230909104621975

  从上面可得出一个结论:先删除缓存,再操作数据库的场景,将最终导致存在缓存了脏数据这个情况出现,由于读操作相对写操作是更多的,所以在异常情况下删除缓存时很大概率有读请求进来,那么发生错误的情况概率较高。

  除此以外,若执行出错,事务需要回滚,此逻辑下不仅要回滚了数据库,还需要回滚缓存,而 Redis 事务功能较弱实现起来比较麻烦。

  有没有其他方案能降低这个概率呢?比如说先操作数据库,再删除缓存?

先操作数据库,再删除缓存

image-20230909104856086

image-20230909104912597

  从上面可得出一个结论:先操作数据库,再这删除缓存此情景,最终在缓存中也可能存在脏数据的问题。虽然是这样,但是,以上问题出现的情况相对之前的情景三较小,因为情景四出现此现象必须同满足一些条件:

  • 同时存在多个并发请求
  • 某个请求执行数据读取操作时缓存恰好失效了,因此会查询数据库读取数据并写缓存
  • 在读取操作同时恰好存在对相同数据执行更新操作的请求,先更新数据库,再删除缓存
  • 对相同数据的执行更新操作的更新操作恰好在写缓存前
  • 1 –> 4 操作的时间比 2–> 3 更长(一般不会)

  另外,后删缓存的另外一个好处是,如果更新数据库失败,缓存并没有删除,只需要回滚数据库。

扩展:为什么要延时双删?

  防止极端情况数据不一致,延时一般为几秒,远远大于写入超时时间。

处理好缓存与数据库操作的原子性问题
  • 单体系统,将缓存与数据库操作放在一个事务
  • 分布式系统,利用 TCC 等分布式事务方案

Write Behind Caching Pattern

  Write Behind Caching Pattern(写后缓存模式)是一种缓存管理模式,用于优化写入操作的性能和响应时间。它的设计目标是减少写入操作对后端数据存储的负载,并提高应用程序的吞吐量和响应性能。

  在 Write Behind Caching Pattern 中,写操作被首先写入到缓存中,而不是直接写入到后端数据存储(如数据库)。缓存负责异步地将写操作同步到后端数据存储,以提高写入的效率。这样,应用程序可以更快地完成写入操作,并立即响应给用户,而不需要等待后端存储的响应。

  具体的流程如下:
1.当应用程序执行写入操作时,它首先将写操作发送给缓存,并将数据更新或插入到缓存中。
2.缓存负责异步地将写操作同步到后端数据存储。它可以使用不同的策略来控制同步的时间和方式。

  • 批量同步:缓存会积累一定数量的写操作,然后以批量的方式发送给后端数据存储。这样可以减少与后端存储之间的通信次数,提高写入的效率。
  • 定时同步:缓存会定期地将累积的写操作发送给后端存储,无论是否达到批量同步的条件。这样可以在一定程度上平衡写入的延迟和数据的一致性要求。
  • 异步同步:缓存可以采用异步方式将写操作发送给后端存储,不需要等待后端存储的响应。这样可以快速地完成写入操作,并立即响应给应用程序。
    3.后端数据存储收到写操作后,执行相应的更新或插入操作,并返回响应给缓存。

  Write Behind Caching Pattern 的优点是提高了写入操作的性能和响应时间。它减少了与后端数据存储的通信次数,并通过异步方式处理写操作,从而提高了应用程序的吞吐量。然而,它也带来了一致性的风险,因为缓存和后端存储之间存在一定的时间延迟。因此,在使用 Write Behind Caching Pattern 时,需要根据具体的应用场景和数据一致性要求来进行权衡和配置。

适用场景

  Write Behind Caching Pattern 适用于以下场景:

  1. 高写入负载的应用程序:对于需要处理大量写入操作的应用程序,使用写后缓存模式可以显著提高写入操作的性能和响应时间。通过将写操作先写入缓存,应用程序可以快速完成写入操作并立即响应给用户,而不需要等待后端存储的响应。
  2. 读多写少的应用程序:当应用程序的读取操作频率远高于写入操作时,写后缓存模式可以有效地减少对后端存储的写入压力。缓存可以暂时保存写入操作,然后异步地将其同步到后端存储,从而减少与后端存储的通信次数,提高整体的性能。
  3. 数据一致性要求相对较低的应用程序:写后缓存模式在一定程度上牺牲了数据的实时一致性,因为写操作首先写入缓存,然后异步同步到后端存储。因此,适用于一些对数据一致性要求相对较低的应用场景,例如缓存中间结果、日志记录等。
  4. 需要高吞吐量和低延迟的应用程序:写后缓存模式可以提高应用程序的吞吐量和响应性能,特别是在高并发的情况下。通过异步方式处理写入操作,可以快速地完成写入并立即响应给用户,从而降低延迟并提高系统的整体性能。

  需要注意的是,写后缓存模式在一致性方面存在一定的风险:

  • 由于缓存与后端存储之间存在一定的时间延迟,可能导致缓存中的数据与后端存储不一致
  • 由于缓存中间件的持久性机制的缺陷,缓存中间件宕机后数据可能无法恢复(完全基于内存或者未落盘),所以存在数据丢失的风险

  因此,在使用写后缓存模式时,需要权衡性能和一致性要求,并根据具体的应用场景进行配置和调整。

Read/Write Through Pattern

  Read/Write Through Pattern(读写透写模式)是一种缓存管理模式,用于实现缓存与后端数据存储(如数据库)之间的一致性。它的设计目标是在读取和写入操作时保持缓存与后端存储的数据一致性。

  在 Read/Write Through Pattern 中,所有的读取和写入操作都通过缓存进行,而缓存负责将这些操作透明地同步到后端存储中。具体的流程如下:
1.读取操作:

  • 当应用程序需要读取数据时,它首先从缓存中检查数据是否存在
  • 如果缓存中存在数据,则直接返回给应用程序
  • 如果缓存中不存在数据,则应用程序会从后端存储(如数据库)中读取数据,并将数据存储到缓存中,以便下次读取时可以直接从缓存获取
    2.写入操作:
  • 当应用程序执行写入操作时,它首先将写操作发送给缓存
  • 缓存负责将写操作透明地同步到后端存储中,确保数据的一致性
  • 缓存在完成写入操作后,会返回响应给应用程序,表示写入操作已成功

  通过 Read/Write Through Pattern,缓存扮演了一个中间层的角色,应用程序通过缓存进行数据的读取和写入操作。缓存负责保持缓存与后端存储的数据一致性,确保读取到的数据始终是最新的,并将写入操作同步到后端存储中。

  Read/Write Through Pattern 的优点是简单和直观,应用程序无需关注数据的读取和写入细节,一切都由缓存来处理。它提供了一致性的读写操作,并减少了与后端存储的直接交互次数,提高了性能和响应时间。

  然而,Read/Write Through Pattern也存在一些潜在的缺点。如果读取操作频繁,而缓存命中率较低,就会增加对后端存储的负载。此外,写入操作的延迟取决于后端存储的响应时间,可能会影响整体的性能。因此,在使用 Read/Write Through Pattern 时,需要根据具体的应用场景和性能要求进行评估和调整。

高并发下的缓存问题

  缓存在高并发的业务场景中,可能会遇到下面的一些问题:

  • 缓存穿透:当请求查询某个数据时,如果该数据在缓存中不存在,而在数据源中也不存在,就导致了缓存穿透的问题。这会导致请求直接访问数据源,增加了系统的负载
  • 缓存击穿:指一个 Key 非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个 Key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,可能导致系统崩溃
  • 缓存雪崩:当缓存中的大量数据同时过期或失效,导致大量请求直接访问数据源,给数据库和应用服务器带来巨大的压力,甚至可能导致系统崩溃

  针对这个几个问题,我们下面分别介绍,并提供一些解决方案。

缓存穿透

  用户不断发起请求缓存和数据库中都没有的数据。
  举个例子,攻击者对某个接口发起为 id 值为 -1 的数据或 id 为特别大的这种其实不存在的数据,这种请求会直接请求数据库,压力都给到数据库,严重会击垮数据库。

解决方案

  • 黑名单:将 IP 每秒访问次数超出阈值拉黑
  • 白名单:使用 bitmaps 类型定义一个允许访问的白名单,id 作为 bitmaps的偏移量,每次访问和 bitmap 里面的 id 进行比较,若访问 id 不在 bitmaps 里面,进行拦截,不允许访问。
  • 接口层增加校验:对请求参数进行校验,不合法的参数直接代码返回
  • 限流:做好热点参数的限流,每个用户访问频率进行阈值限制
  • 缓存空值:之所以会发生穿透,就是因为缓存中没有存储这些空数据的 key。从而导致每次查询都到数据库去了。那么我们就可以为这些 key 对应的值设置为 null 丢到缓存里面去。后面再出现查询这个 key 的请求的时候,直接返回 null
  • 布隆过滤器(Bloom Filter):布隆过滤器是用于判断某个元素(key)是否存在于某个集合中。先把数据库的数据都加载到过滤器中,在缓存之前再加一层 BloomFilter,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在才查缓存或 DB

  一般用的比较多的解决方案是缓存空值,实现简单,维护方便,当然可能造成短期的数据访问不一致,需要根据具体业务来分析。

缓存击穿

  指一个 Key 非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个 Key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,可能导致系统崩溃。

解决方案

  • 热点数据不过期:业务上线前可以提前预热,设置热点数据永远不过期(也可以设置业务结束时间后几天失效)
  • 监控实时调整:监控热门的数据,实时调整 key 的过期时长
  • 加互斥锁:可在第一个查询数据的请求上使用一个互斥锁来锁住它,等第一个线程查询到了数据,做缓存。后面的线程进来发现已有缓存了,就直接走缓存
  • 逻辑过期:

  一般用的比较多的解决方案是第一种,事前从方案上设计好,不过多开发代码,成本小。

缓存雪崩

  当缓存中的大量数据同时过期或失效,导致大量请求直接访问数据源,给数据库和应用服务器带来巨大的压力,可能导致系统崩溃。

解决方案

  • 构建多级缓存架构: nginx 缓存 + redis 缓存+ 本地缓存
  • 分散不同数据的缓存失效时间:在原有的失效时间基础上増加个随机值,比如 1-5 分钟随机,这样每一个存的过期时间的重复率就会降低,就很难引发集体失效的事件
  • 使用锁或队列:用加锁或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。不适用高并发情況
  • 设置过期标志更新缓存:记录存数据是否过期(设置提前量),如果过期会触发通知另外的线程在后台去更新实际 key 的绶存

  前两种解决方案用的较多。

集群数据倾斜

image-20231029170318064

  如上图所示,Redis 集群中的数据倾斜是指在集群的不同节点之间,数据分布不均匀,导致一些节点的负载很高,而其他节点的负载很低。这可能会导致性能问题和资源浪费。

  数据倾斜可能由多种原因引起,包括以下一些常见因素:

  • 部分数据集过大: 如果某个 Key 为 BigKey,那么可能占用大内存,导致该节点的数据倾斜
  • Key 分布不均匀: 如果不同的 Key 集中在某几个节点上,而其他节点上的 Key 很少,就会导致数据倾斜。这可能是因为应用程序的访问模式或Key的生成规则不均匀。
  • 节点故障和重分布: 当 Redis 集群中的某个节点发生故障时,数据需要进行重新分布到其他节点上。如果重新分布不均匀,可能导致数据倾斜。

  为了解决Redis集群中的数据倾斜问题,可以采取以下措施:

  • 合理的 Key 设计: 设计合理的Key,以确保数据均匀分布在不同的节点上。避免某个Key集中在一个节点上
  • 使用一致性哈希: 使用一致性哈希算法来确保数据均匀分布到不同的节点上。一致性哈希算法会在数据和节点发生变化时自动调整分布
  • 监控和警报:部署监控工具来监视集群的数据分布情况。如果出现数据倾斜问题,及早发现并采取措施
  • 手动迁移数据:如果发现数据倾斜问题,可以手动迁移数据,将部分数据从一个节点移到另一个节点,以平衡负载。这需要小心操作,以避免数据丢失
  • 重新平衡数据: Redis 提供了一些工具和命令,如CLUSTER REBALANCE,可用于在 Redis Cluster 中重新平衡数据
0%