🌟 后端 | Redis 内存回收、缓存穿透、雪崩、击穿



内存回收

Redis 之所以性能强,最主要的原因就是基于内存存储。然而单节点的 Redis 其内存大小不宜过大,会影响持久化或主从同步性能。

当内存达到上限,就无法存储更多数据了。因此,Redis 内部会有两套内存回收的策略:

  • 内存过期策略
  • 内存淘汰策略

内存过期处理

存入 Redis 中的数据可以配置过期时间,到期后再访问会发现这些数据都不存在了,也就是被过期清理了。

过期命令

Redis 中通过 expire 命令可以给 KEY 设置 TTL(过期时间)

1
2
3
4
5
6
7
# 写入一条数据
set num 123
# 设置20秒过期时间
expire num 20

# set 命令本身也可以支持过期时间的设置
set num EX 20

过期策略

Redis 不管有多少种数据类型,本质是一个 KEY-VALUE 的键值型数据库,而这种键值映射底层正是基于 HashTable 来实现的,在 Redis 中叫做 Dict

Redis如何判断KEY是否过期呢?

在 Redis 中会有两个Dict,也就是 HashTable,其中一个记录 KEY-VALUE 键值对,另一个记录 KEY 和过期时间。要判断一个 KEY 是否过期,只需要到记录过期时间的 Dict 中根据KEY查询即可。

Redis 何时删除过期的 KEY 呢?

Redis 并不会在 KEY 过期时立刻删除 KEY, 因为要实习那这样的效果就必须给每一个过期的 KEY 设置时间并监控这些 KEY 的过期状态。这对 CPU 或者内存都带来极大的负担。

Redis的过期KEY删除策略有两种:

  • 惰性删除
  • 周期删除

1.惰性删除,顾明思议就是过期后不会立刻删除。那在什么时候删除呢?
Redis 会在每次访问 KEY 的时候判断当前 KEY 有没有设置过期时间,如果有,过期时间是否已经到期,如果到期则删除。

2.周期删除:顾明思议是通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。
执行周期有两种:

  • SLOW 模式:Redis 会设置一个定时任务 serverCron(),按照 server.hz 的频率来执行过期 key 清理
  • FAST 模式:Redis 的每个事件循环前执行过期 key 清理(事件循环就是 NIO事件处理的循环)。

内存淘汰策略

对于某些特别依赖于 Redis 的项目而言,仅仅依靠过期 KEY 清理是不够的,内存可能很快就达到上限。因此 Redis 允许设置内存告警阈值,当内存使用达到阈值时就会主动挑选部分 KEY 删除以释放更多内存。这叫做内存淘汰机制。

内存淘汰时机

Redis每次执行任何命令时,都会判断内存是否达到阈值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// server.c中处理命令的部分源码
int processCommand(client *c) {
// ... 略
if (server.maxmemory && !server.lua_timedout) {
// 调用performEvictions()方法尝试进行内存淘汰
int out_of_memory = (performEvictions() == EVICT_FAIL);
// ... 略
if (out_of_memory && reject_cmd_on_oom) {
// 如果内存依然不足,直接拒绝命令
rejectCommand(c, shared.oomerr);
return C_OK;
}
}
}

淘汰策略

Redis是如何判断该淘汰哪些Key的呢?

Redis支持8种不同的内存淘汰策略:

  • noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
  • volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
  • allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
  • volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
  • allkeys-lru: 对全体key,基于LRU算法进行淘汰
  • volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
  • allkeys-lfu: 对全体key,基于LFU算法进行淘汰
  • volatile-lfu: 对设置了TTL的key,基于LFI算法进行淘汰

比较容易混淆的有两个算法:

  • LRU(Least Recently Used),最近最久未使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
  • LFU(Least Frequently Used),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

Redis中的KEY可能有数百万甚至更多,每个KEY都有自己访问时间或者逻辑访问次数。我们要找出时间最早的或者访问次数最小的,难道要把Redis中所有数据排序?

要知道Redis的内存淘汰是在每次执行命令时处理的。如果每次执行命令都先对全量数据做内存排序,那命令的执行时长肯定会非常长,这是不现实的。

Redis采取的是抽样法,即每次抽样一定数量(maxmemory_smples)的key,然后基于内存策略做排序,找出淘汰优先级最高的,删除这个key。这就导致Redis 的算法并不是真正的LRU,而是一种基于抽样的近似LRU算法。

缓存

Redis经常被用作缓存,而缓存在使用的过程中存在很多问题需要解决。例如:

  • 缓存的数据一致性问题
  • 缓存击穿
  • 缓存穿透
  • 缓存雪崩

缓存一致性

缓存的通用模型有三种:

  • Cache Aside:有缓存调用者自己维护数据库与缓存的一致性。即:
    • 查询时:命中则直接返回,未命中则查询数据库并写入缓存
    • 更新时:更新数据库并删除缓存,查询时自然会更新缓存
  • Read/Write Through:数据库自己维护一份缓存,底层实现对调用者透明。底层实现:
    • 查询时:命中则直接返回,未命中则查询数据库并写入缓存
    • 更新时:判断缓存是否存在,不存在直接更新数据库。存在则更新缓存,同步更新数据库
  • Write Behind Cahing:读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库

目前企业中使用最多的就是 Cache Aside 模式,因为实现起来非常简单。但缺点也很明显,就是无法保证数据库与缓存的强一致性。

Cache Aside的写操作是要在更新数据库的同时删除缓存,那为什么不选择更新数据库的同时更新缓存,而是删除呢?

假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新。采用删除方案也就是延迟更新,什么时候有人查询了,什么时候更新。

如果是删除缓存后更新数据库,由于更新数据库的操作本身比较耗时,在期间有线程来查询数据库并更新(旧)缓存的概率非常高。因此不推荐这种方案。

更新数据库再删除缓存,如果此期间有请求查询缓存,未命中时会查询数据库,再写入缓存,而我们更新数据库后会再删除缓存,再查的话就是新的缓存了。

添加缓存的目的是为了提高系统性能,而你要付出的代价就是缓存与数据库的强一致性。如果你要求数据库与缓存的强一致,那就需要加锁避免并行读写。但这就降低了性能,与缓存的目标背道而驰。

因此不管任何缓存同步方案最终的目的都是尽可能保证最终一致性,降低发生不一致的概率。我们采用先更新数据库再删除缓存的方案,已经将这种概率降到足够低,目的已经达到了。

同时我们还要给缓存加上过期时间,一旦发生缓存不一致,当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案

缓存穿透

当请求查询缓存未命中时,需要查询数据库以加载缓存。如果我访问一个数据库中也不存在的数据,那么缓存中肯定也不存在。因此不管请求该数据多少次,缓存永远不可能建立,请求永远会直达数据库。

如果短时间有大量请求不存在的数据,由于缓存不可能生效,那么所有的请求都访问数据库,可能就会导致数据库因过高的压力而宕机。

解决这个问题有两种思路:

  • 缓存空值
  • 布隆过滤器

缓存空值

简单来说,就是当我们发现请求的数据即不存在与缓存,也不存在与数据库时,将空值缓存到 Redis,避免频繁查询数据库。

优点:

  • 实现简单,维护方便
    缺点:
  • 额外的内存消耗

布隆过滤器

布隆过滤是一种数据统计的算法,用于检索一个元素是否存在一个集合中。

一般我们判断集合中是否存在元素,都会先把元素保存到类似于树、哈希表等数据结构中,然后利用这些结构查询效率高的特点来快速匹配判断。但是随着元素数量越来越多,这种模式对内存的占用也越来越大,检索的速度也会越来越慢。而布隆过滤的内存占用小,查询效率却很高。

布隆过滤器的判断存在误差:

  • 当布隆过滤器认为元素不存在时,它肯定不存在
  • 当布隆过滤器认为元素存在时,它可能存在,也可能不存在

我们可以把数据库中的数据利用布隆过滤器标记出来,当用户请求缓存未命中时,先基于布隆过滤器判断。如果不存在则直接拒绝请求,存在则去查询数据库。尽管布隆过滤存在误差,但一般都在0.01% 左右,可以大大减少数据库压力。

缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时实效或者 Redis 服务宕机,导致大量请求到达数据库,带来巨大压力。

常见的解决方案有:

  • 给不同的 Key 的 TTL 添加随机值,这样 Key 的过期时间不同,不会大量 Key 同时过期
  • 利用 Redis 集群提高服务的可用性,避免缓存服务宕机
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存,比如先查询本地缓存,本地缓存未命中再查询 Redis , Redis 未命中再查询数据库。即便 Redis 宕机,也还有本地缓存可以抗压力

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的 key 突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

我们采用的是 Cache Aside 模式,当缓存失效时需要下次查询时才会更新缓存。当某个key缓存失效时,如果这个 key 是热点 key,并发访问量比较高。就会在一瞬间涌入大量请求,都发现缓存未命中,于是都会去查询数据库,尝试重建缓存。可能一瞬间就把数据库压垮了。

常见的解决方案有两种:

  • 互斥锁:给重建缓存逻辑加锁,避免多线程同时指向
  • 逻辑过期:热点key不要设置过期时间,在活动结束后手动删除。