(术)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. 读取数据时的一致性:

    • 当应用程序需要读取数据时,首先会检查缓存中是否存在所需的数据:
      • 若缓存中存在数据,则直接从缓存中获取并返回给应用程序
      • 若缓存中不存在数据,则应用程序会从数据库中读取数据,并将数据存储到缓存中。这时数据从数据库读取的同时,也会更新缓存,以便下一次读取可以直接从缓存中获取
        1
        2
        3
        4
        5
        6
        7
        8
        9
                        ┌── 命中 → 返回
        应用 → Redis ───┤
        └── 未命中

        MySQL

        写入 Redis

        返回
  2. 写入(更新)数据时的一致性:

    • 当应用程序需要更新数据时,它会首先更新数据库中的数据
    • 接着,应用程序会删除缓存中的数据
    • 这样,在下一次读取该数据时,应用程序会从数据库中获取最新的数据,并将其存储到缓存中,以保持一致性
      1
      2
      3
      4
      5
      应用

      MySQL

      删除 Redis

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

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

注意事项

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

  • 尽可能的缩短数据不一致的时间窗口
  • 处理好缓存与数据库操作时的失败的原子性问题

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 适用于以下场景:

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

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

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

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

Read/Write Through Pattern

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

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

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

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

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

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

三者最大的区别:数据一致性和性能

可以从这个角度理解:

维度 Cache Aside Read/Write Through Write Behind
数据库是真实数据源 通常是 通常是
应用感知 DB
缓存层感知 DB
写 DB 同步 同步 异步
写性能 较好 较好 非常高
数据一致性 较容易控制 较容易控制 相对困难
数据丢失风险 较低 较低 较高
实现复杂度
常见于普通业务系统 非常常见 较少 较少

一致性处理方案

  对于缓存的一致性处理,我们从逻辑上可以使用 4 个方案:

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

  从方案的选择上,我们一般使用最后一种,为什么呢,比如说我们会有一些疑问:

  • 为什么是删除缓存,而不是更新缓存呢?
  • 为什么是先更新数据库,再删除缓存,而不是反过来?

下面我们来分别详细探究下。

为什么是删除缓存,而不是更新缓存呢?

  原因主要有以下几点;

  • 更新缓存容易产生并发问题
  • 更新缓存可能出现数据丢失
  • 更新缓存可能需要重新计算
  • 删缓存可以将计算责任转移
  • 删缓存可以避免缓存长期保存脏数据

更新缓存容易产生并发问题

  若同时存在两个线程对同一个商品更新不同的值,那么其执行过程是这样的:

  • 线程 A 先更新数据库(id = 132,count = 999),xxxxxxxxx 中断时间 xxxxxxxxxxx,后更新缓存(id = 132, value = 999)
  • 线程 B 先更新数据库(id = 132,count = 998),后更新缓存(id = 132, value = 998)

  由于一些原因(比如网络原因,系统程序调度),导致线程 B 操作缓存的时间顺序比线程 A 早,那么最终缓存的数据为线程 A 更新的数据(value = 999)

  那么,以上做法将导致一个并发问题:缓存的数据属于脏数据。

更新缓存可能出现数据丢失

  若存在一个线程对商品进行更新操作,其执行过程为:

  • 先更新缓存(k = 132, value = 999), 后更新数据库(id = 132,count = 999)

  由于一些原因导致在更新缓存完后缓存中间件宕机了,由于缓存中间件的持久化机制可能数据未刷盘,将导致数据丢失。

  那么,以上做法将导致一个后果:数据库的数据属于脏数据。

更新缓存可能需要重新计算

  假设数据库里不是简单的user.age = 20,而是一个比较复杂的数据:用户基本信息 + 订单数量 + 余额 + 积分 + 权限 + 商品统计

  此时,Redis 里面缓存的是复杂对象:

1
2
3
4
5
6
{
"userId": 1001,
"orderCount": 35,
"balance": 2000,
"score": 890
}

  如果此时用户修改了某个数据库字段,我们就需要考虑:Redis 中这个对象到底哪些字段需要更新?

  甚至缓存的数据可能来自多个表:

1
2
3
4
user
order
account
permission

  此时需要去查询多张表,更新缓存就变得异常复杂。

  而如果我们通过删除缓存进行处理,则非常简单:DEL user:1001一条命令搞定。在下一次访问是,执行Redis miss --> 重新查询数据库 --> 重新组装完整对象 --> 写入 Redis流程,可以非常方便的将缓存写入 Redis 中。

  因此:删除缓存天然适合复杂缓存对象。

删缓存可以将计算责任转移

  初始:MySQL = 100、Redis = 100

  修改数据:

1
2
① UPDATE MySQL SET value = 200
② DEL Redis key

  最终:MySQL = 200、Redis 不存在该数据

  下一次查询:查询 Redis -> 没有缓存 -> 查询 MySQL -> 得到 200 -> 写入 Redis -> Redis = 200

  换而言之:删除缓存实际上是把“缓存重新计算”的责任交给下一次读请求,此时我们不需要在写请求里,负责把缓存精准更新成正确状态。

可以避免缓存长期保存脏数据

  假设我们采用:更新数据库、更新缓存的逻辑处理。

  如果 Redis 更新失败:

1
2
MySQL = 200
Redis = 100

  那么缓存就脏了。

  例如:

1
2
3
4
5
6
7
UPDATE MySQL

成功

UPDATE Redis

失败

  最终:

1
2
数据库:200
缓存:100

  而如果采用:

1
2
3
UPDATE MySQL

DEL Redis

  即使删除之后 Redis 出现问题,至少缓存不会主动继续保留旧值。

  正常情况下:

1
2
MySQL = 200
Redis = 不存在

  下一次查询再重新建立缓存。

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

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

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

  正常情况如下图:

image-20230909104604401

  异常情况如下图:

image-20230909104621975

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

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

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

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

正常情况

异常情况

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

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

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

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

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

处理好缓存与数据库操作的原子性问题

  • 单体系统,将缓存与数据库操作放在一个事务
  • 分布式系统,利用 TCC 等分布式事务方案

高并发下的缓存问题

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

  • 缓存穿透:当请求查询某个数据时,如果该数据在缓存中不存在,而在数据源中也不存在,就导致了缓存穿透的问题。这会导致请求直接访问数据源,增加了系统的负载
  • 缓存击穿:指一个 Key 非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个 Key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,可能导致系统崩溃
  • 缓存雪崩:当缓存中的大量数据同时过期或失效,导致大量请求直接访问数据源,给数据库和应用服务器带来巨大的压力,甚至可能导致系统崩溃
  • 缓存过期:Redis 中的键可以设置过期时间,当到达过期时间时会通过一定策略清除对应 key
  • 内存淘汰:Redis 内存不够用时,可以通过淘汰策略从已有数据中挑一些 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 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,可能导致系统崩溃。

解决方案

  • 互斥锁:可在第一个查询数据的请求上使用一个互斥锁来锁住它,等第一个线程查询到了数据,做缓存。后面的线程进来发现已有缓存了,就直接走缓存
  • 逻辑过期:对查询的数据,将过期时间作为 Value 的一部分存进去,然后由应用程序自己判断它是否过期
  • 监控实时调整:监控热门的数据,实时调整 key 的过期时长,此方案不太现实
  • 热点数据不过期:业务上线前可以提前预热,设置热点数据永远不过期(也可以设置业务结束时间后几天失效)

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

互斥锁流程图例

互斥锁

逻辑过期流程图例

逻辑过期流程图

缓存雪崩

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

解决方案

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

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

缓存过期

  由于部分业务场景需要,会对一些 Key 设置过期时间。

  那么,若一个 Key 过期了,它什么时候才会被删除呢?

删除策略

  此问题从逻辑上来说,存在三种可能答案,它们分别代表了三种不同的删除策略:

  • 定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作
  • 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键
  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定

  在这三种策略中,第一种和第三种为主动删除策略,而第二种则为被动删除策略。

定时删除

  通过使用定时器,定时删除策略可以保证过期键会尽可能快地被删除,并释放过期键所占用内存。

优点

  显而易见,定时删除策略对内存是最友好的。

缺点

  定时删除策略对 CPU 时间是最不友好的:在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分 CPU 时间,在内存不紧张但是 CPU 时间非常紧张的情况下,将 CPU 时间用在删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。

  例如,如果正有大量的命令请求在等待服务器处理,并且服务器当前不缺少内存,那么服务器应该优先将 CPU 时间用在处理客户端的命令请求上面,而不是用在删除过期键上面。

  除此之外,创建一个定时器需要用到 Redis 服务器中的时间事件,而当前时间事件的实现方式——无序链表,查找一个事件的时间复杂度为 O(N)——并不能高效地处理大量时间事件。

结论

  因此,要让服务器创建大量的定时器,从而实现定时删除策略,实际落地是不太现实的。

惰性删除

  程序只会在取出键时才对键进行过期检查,这可以保证删除过期键的操作只会在非做不可的情况下进行,并且删除的目标仅限于当前处理的键,这个策略不会在删除其他无关的过期键上花费任何 CPU 时间。

优点

  惰性删除策略对 CPU 时间来说是最友好的。

缺点

  惰性删除策略对内存是最不友好的:若一个键已经过期,而这个键又仍然保留在数据库中,那么只要这个过期键不被删除,它所占用的内存就不会释放。

  在使用惰性删除策略时,若数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么它们也许永远也不会被删除(除非用户手动执行 FLUSHDB),我们甚至可以将这种情况看作是一种内存泄漏——无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的 Redis 服务器来说,肯定不是一个好消息。

  举个例子,对于一些和时间有关的数据,比如日志(log),在某个时间点之后,对它们的访问就会大大减少,甚至不再访问,如果这类过期数据大量地积压在数据库中,用户以为服务器已经自动将它们删除了,但实际上这些键仍然存在,而且键所占用的内存也没有释放,那么造成的后果肯定是非常严重的。

定期删除

  从上面对定时删除和惰性删除的讨论来看,这两种删除方式在单一使用时都有明显的缺陷:

  • 定时删除占用太多CPU时间,影响服务器的响应时间和吞吐量
  • 惰性删除浪费太多内存,有内存泄漏的危险

  定期删除策略则是前两种策略的一种整合和折中:

  • 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响
  • 除此之外,通过定期删除过期键,定期删除策略有效地减少了因为过期键而带来的内存浪费

  定期删除策略的难点是确定删除操作执行的时长和频率:

  • 若删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成定时删除策略,以至于将 CPU 时间过多地消耗在删除过期键上面
  • 若删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除策略一样,出现浪费内存的情况

  因此,若想采用定期删除策略,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。

Redis 采用的删除策略

  Redis 服务器实际上结合使用了惰性删除定期删除两种策略,通过配合使用它们,服务器可以很好地在合理使用 CPU 时间和避免浪费内存空间之间取得平衡。

  • 惰性删除:由db.c/expireIfNeeded函数实现,所有键读写命令执行之前都会调用该函数对其进行检查,
    • 若已过期,则删除该键,然后执行键不存在的操作;
    • 若未过期,则不作操作,继续执行原有的命令
  • 定期删除:由redis.c/activeExpireCycle函数实现,函数以设定的频率运行,每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键

定期删除策略频率修改

  定期删除函数的运行频率,在 Redis2.6 版本中,规定 100ms 运行一次。在 Redis2.8 版本后,可以通过修改配置文件 redis.conf 的 hz 属性来修改这个次数。

  建议不要将这个值设置超过 100,否则会对 CPU 造成比较大的压力。

内存淘汰

  Redis 中的键会设置过期时间,当到达过期时间时会通过一定策略清除对应 key,但是,redis 内存是存在上限的,当达到内存上限时 Redis 就无法继续工作了。

  为了解决这个问题,Redis 引入了内存淘汰策略,可将 Redis 中的一些键进行移除,使得 Redis 能继续进行工作。

  当现有内存大于 maxmemory 时,便会触发 Redis 主动淘汰内存策略,因此存在以下八种淘汰策略:

  • noeviction:不淘汰任何 key,只返回一个写错误 ,默认选项
  • allkeys-random:无差别随机淘汰 key
  • volatile-ttl:淘汰即将过期的 key
  • volatile-random:淘汰设置过过期时间的随机 key
  • allkeys-lru`:从所有键中淘汰最近最少使用的 key
  • volatile-lru:从所有过期键中淘汰最近最少使用的 key
  • allkeys-lfu:从所有键中淘汰使用频率最少的 key
  • volatile-lfu:从所有过期键中淘汰使用频率最少的 key

  记忆关键词:key 是否设置过期时间,最近最少使用,使用频率。可以建立一个非常简单的脑图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
                  Redis 内存不足

maxmemory-policy

┌─────────────────┼─────────────────┐
│ │ │
↓ ↓ ↓
noeviction allkeys-* volatile-*
│ │ │
↓ ↓ ↓
不淘汰 所有Key参与 只有TTL Key参与
│ │
┌────┼────┐ ┌────┼────┐
↓ ↓ ↓ ↓ ↓ ↓
LRU LFU random LRU LFU TTL

  其中:

1
2
3
4
LRU    → 最近最少使用
LFU → 最不经常使用
random → 随机
TTL → 剩余TTL最短

参数配置

  在配置文件 redis.conf 中,可以通过参数进行一些配置:

  • maxmemory-policy:此参数用于设置八种淘汰策略之一
  • maxmemory <bytes>:用于设定最大内存,不设定该参数默认是无限制的,但是通常会设定其为物理内存的四分之三

生产最佳实践

如果是典型的 Redis Cache-Aside 缓存:

场景 首选 次选 建议
B 端管理后台 allkeys-lru allkeys-lfu 优先 LRU
C 端互联网业务 allkeys-lfu allkeys-lru 优先 LFU
热点非常明显的 C 端 allkeys-lfu LFU 很合适
访问模式变化很快 allkeys-lru LFU LRU 更稳妥
所有 Key 都是纯缓存 allkeys-* 通常优于 volatile-*
Redis 同时存缓存+不可淘汰数据 volatile-* 或拆实例 更推荐拆 Redis
Redis 存核心业务数据 noeviction 不应该让缓存淘汰机制随意删数据

Redis 官方也明确把 allkeys-lru 作为不知道具体访问模式时的一个很好的默认选择;而纯缓存场景通常使用 allkeys-lruallkeys-lfu

一般遇到用 Redis 比较高频的场景,对数据要求较高,建议不要吝啬内存,直接noeviction一步到位,做好 key 优化工作即可。

集群数据倾斜

image-20231029170318064

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

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

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

解决方案

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

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

文章信息

时间 说明
2026-08-01 初稿
0%