基于Redis实现缓存系统的注意点与一些常见问题

1. 从计算机系统的缓存开始

在计算机系统中,默认有两种缓存:

  • CPU 里面的末级缓存,即 LLC,用来缓存内存中的数据,避免每次从内存中存取数据;
  • 内存中的高速页缓存,即 page cache,用来缓存磁盘中的数据,避免每次从磁盘中存取数据。

LLC一般几MB,page cache一般几GB,磁盘一般几TB,可以看出缓存系统有以下特征

  • 在一个层次化的系统中,缓存一定是一个快速子系统,数据存在缓存中时,能避免每次从慢速子系统中存取数据。
  • 缓存系统的容量大小总是小于后端慢速系统的,我们不可能把所有数据都放在缓存系统中。

2. Redis的缓存策略

所谓缓存,就是将需要多次读取的数据暂存起来,这样在后面,应用程序需要多次读取的时候,就不必从数据源重复加载数据了,这样就可以降低数据源的计算负载压力,提高数据响应速度。

一般来说,有三种策略:

  1. **旁路缓存(Cache Aside)策略:读操作命中缓存直接返回,否则从后端数据库加载到缓存再返回,并写入缓存中。写操作直接更新数据库,然后删除缓存。这种策略的优点是一切以后端数据库为准,可以保证缓存和数据库的一致性。缺点是写操作会让缓存失效,再次读取时需要从数据库中加载。**Redis一般都是使用这个策略。
  2. **通读缓存(Read Throught)策略:应用层读写只需要操作缓存,不需要关心后端数据库。应用层在操作缓存时,缓存层会自动从数据库中加载或写回到数据库中,这种策略的优点是,缓存中拥有最新的数据,对于应用层的使用非常友好,只需要操作缓存即可,缺点是需要缓存层支持和后端数据库的联动。**Redis无法自动与Mysql等后端数据库联动,需要额外写业务代码来实现这个策略。
  3. 写回策略(Write Back)策略:这里主要是指异步写回策略,同步就是通读缓存了。写操作只写入缓存,等数据就要过期(淘汰)时写入到数据库中。优点是写入飞快,缺点是数据丢失风险较大

3. 缓存大小的设置

一般遵循“八二原理”设置为总数据量的 15% 到 30%,兼顾访问性能和内存空间开销。

4. 缓存删除方案

Redis的内存总是会满的,因此我们需要设置相应的的删除方案,对内存数据进行清理。

总结来说就是三块:

  1. 定期删除
  2. 惰性删除
  3. 内存淘汰

4.1. 定期删除

Redis每隔一端时间会检查一部分已经过期的key进行删除。

4.2. 惰性删除

Redis会在获取某个Key时检查是否过期,过期就删除

4.3. 内存淘汰(内存驱逐)

%E5%9F%BA%E4%BA%8ERedis%E5%AE%9E%E7%8E%B0%E7%BC%93%E5%AD%98%E7%B3%BB%E7%BB%9F%E7%9A%84%E6%B3%A8%E6%84%8F%E7%82%B9%E4%B8%8E%E4%B8%80%E4%BA%9B%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%20db3e5ddc3927461c937399c3bcd95e1e/Untitled.png

需要注意,volatile-lfu和allkeys-lfu是Redis 4.0 后新增的。

  • noevction不进行数据淘汰
  • volatile-ttl 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。
  • volatile-random 就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。
  • volatile-lru 会使用 LRU 算法筛选设置了过期时间的键值对。
  • volatile-lfu 会使用 LFU 算法选择设置了过期时间的键值对。
  • allkeys-random 策略,从所有键值对中随机选择并删除数据;
  • allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。
  • allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。

注意:

  • LRU算法,全称Least recently used,即最近最少使用
  • LFU算法,全称Least Frequently Used,即最不经常使用

LRU 会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU 端和 LRU 端,分别代表最近最常使用的数据和最近最不常用的数据:

%E5%9F%BA%E4%BA%8ERedis%E5%AE%9E%E7%8E%B0%E7%BC%93%E5%AD%98%E7%B3%BB%E7%BB%9F%E7%9A%84%E6%B3%A8%E6%84%8F%E7%82%B9%E4%B8%8E%E4%B8%80%E4%BA%9B%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%20db3e5ddc3927461c937399c3bcd95e1e/Untitled%201.png

LRU 算法背后的想法非常朴素:它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在 MRU 端;长久不访问的数据,肯定就不会再被访问了,所以就让它逐渐后移到 LRU 端,在缓存满时,就优先删除它。

在 Redis 中,LRU 算法被做了简化,以减轻数据淘汰对缓存性能的影响(否则需要维护链表)。具体来说,Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。然后,Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。

LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

总结来看:

  • LRU策略是只有时间维度因素,更加关注数据的时效性
  • LFU策略按优先级,有访问次数纬度因素 + 时间维度因素,更加关注数据的访问频次。

特别注意,在设置数据的过期时间时,尽量指定过期时间,而不是指定多久后后删除,在主从环境中会出现不一致的情况(从库保留数据时间 = 从库同步到数据 + 过期时间,而从库同步到数据的时间 在 主库写入数据的时间之后)

5. 缓存使用过程中的一些问题

5.1. Redis缓存与数据库不一致问题

这里用蒋德钧老师的一张图:

%E5%9F%BA%E4%BA%8ERedis%E5%AE%9E%E7%8E%B0%E7%BC%93%E5%AD%98%E7%B3%BB%E7%BB%9F%E7%9A%84%E6%B3%A8%E6%84%8F%E7%82%B9%E4%B8%8E%E4%B8%80%E4%BA%9B%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%20db3e5ddc3927461c937399c3bcd95e1e/Untitled%202.png

重点说下一个容易造成困惑的概念:

  • 延迟双删:在线程 A 更新完数据库值以后,让它先 sleep 一小段时间,再进行一次缓存删除操作,这样后续访问的线程就可以触发缓存缺失,进而将新数据写入缓存。

5.2. 缓存雪崩

缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。

可能有两个原因:

  1. 缓存中有大量数据同时过期,导致大量请求无法得到处理。
  2. Redis服务器宕机。

针对第1点,有以下方案:

  • 调整过期时间,适当加一个随机数时间避免同时过期
  • 服务降级(核心业务直接访问数据库 | 非核心业务返回空值等)

针对第2点,有以下方案:

  • 是在业务系统中实现服务熔断或请求限流机(暂停业务应用对缓存系统的接口访问,直接返回)。
  • 事前预防,搭建高可用的主从集群

5.3. 缓存击穿

缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。

一般考虑是对于访问特别频繁的热点数据,不设置过期时间。

5.4. 缓存穿透

缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。

有以下方案:

  • 缓存设置空值或缺省值。
  • 布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。
  • 前端请求检测

下面重点说以下布隆过滤器,这是一个概率学的算法实现,布隆过滤器有以下特点:

符合布隆过滤器不一定存在,不符合布隆过滤器一定不存在

布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:

  • 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
  • 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
  • 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。

这样,当应用想要查询 X 时,只要 比较原来为1的位置 是否包含于 x计算后为1的位置中,一旦又一个不符合(为0),则不在缓存中,否则可能在。

%E5%9F%BA%E4%BA%8ERedis%E5%AE%9E%E7%8E%B0%E7%BC%93%E5%AD%98%E7%B3%BB%E7%BB%9F%E7%9A%84%E6%B3%A8%E6%84%8F%E7%82%B9%E4%B8%8E%E4%B8%80%E4%BA%9B%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%20db3e5ddc3927461c937399c3bcd95e1e/Untitled%203.png

5.5. 缓存污染

在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染

需要根据业务设置合适的缓存淘汰(驱逐)策略。

5.6. 数据倾斜

在Redis切片集群中比较容易遇到的一个问题:数据倾斜

  • 数据量倾斜:在某些情况下,实例上的数据分布不均衡,某个实例上的数据特别多。
  • 数据访问倾斜:虽然每个集群实例上的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁。

这里po一个表格:

%E5%9F%BA%E4%BA%8ERedis%E5%AE%9E%E7%8E%B0%E7%BC%93%E5%AD%98%E7%B3%BB%E7%BB%9F%E7%9A%84%E6%B3%A8%E6%84%8F%E7%82%B9%E4%B8%8E%E4%B8%80%E4%BA%9B%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98%20db3e5ddc3927461c937399c3bcd95e1e/Untitled%204.png

6. 参考