设置多大的缓存容量合适?

建议把缓存容量设置为总数据量的 15% 到 30%区间,兼顾访问性能和内存空间开销。

设定缓存的固定大小命令:

1
CONFIG SET maxmemory 4gb

Redis 缓存有哪些淘汰策略?

Redis 4.0 之前一共实现了 6 种内存淘汰策略,在 4.0 之后,又增加了 2 种策略。我们可以按照是否会进行数据淘汰把它们分成两类:

  • 不进行数据淘汰的策略,只有 noeviction 这一种。
  • 会进行淘汰的 7 种其他策略。

会进行淘汰的 7 种策略,我们可以再进一步根据淘汰候选数据集的范围把它们分成两类:

  • 在设置了过期时间的数据中进行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 后新增)四种。
  • 在所有数据范围内进行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0 后新增)三种。

noeviction策略

特征
  • Redis 在使用的内存空间超过 maxmemory 值时,不会淘汰数据
  • 缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误

设置过期时间淘汰策略

volatile-random、volatile-ttl、volatile-lru 和 volatile-lfu 这四种淘汰策略,它们筛选的候选数据范围,被限制在已经设置了过期时间的键值对上。也正因为此,即使缓存没有写满,这些数据如果过期了,也会被删除。

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

所有键值淘汰策略

一个键值对被删除策略选中了,即使它的过期时间还没到,也需要被删除。当然,如果它的过期时间到了但未被策略选中,同样也会被删除。

  • allkeys-random 策略,从所有键值对中随机选择并删除数据;
  • allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。
  • allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。

LRU 算法

LRU 算法的全称是 Least Recently Used,这是按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出来,而最近频繁使用的数据会留在缓存中。

特征

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

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

LRU 算法在实际实现时,需要用链表管理所有的缓存数据,这会带来额外的空间开销。而且,当有数据被访问时,需要在链表上把该数据移动到 MRU 端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

Redis LRU算法

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

Redis 提供了一个配置参数 maxmemory-samples,这个参数就是 Redis 选出的数据个数 N。例如,我们执行如下命令,可以让 Redis 选出 100 个数据作为候选数据集:

1
CONFIG SET maxmemory-samples 100

当需要再次淘汰数据时,Redis 需要挑选数据进入第一次淘汰时创建的候选集合。这儿的挑选标准是:能进入候选集合的数据的 lru 字段值必须小于候选集合中最小的 lru 值。当有新数据进入候选数据集后,如果候选数据集中的数据个数达到了 maxmemory-samples,Redis 就把候选数据集中 lru 字段值最小的数据淘汰出去。

这样一来,Redis 缓存不用为所有的数据维护一个大链表,也不用在每次数据访问时都移动链表项,提升了缓存的性能。

使用 LFU 算法以外的 5 种缓存淘汰策略,我再给你三个使用建议。

  • 优先使用 allkeys-lru 策略。这样,可以充分利用 LRU 这一经典缓存算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。如果你的业务数据中有明显的冷热数据区分,我建议你使用 allkeys-lru 策略。
  • 如果业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用 allkeys-random 策略,随机选择淘汰的数据就行。
  • 如果你的业务中有置顶的需求,比如置顶新闻、置顶视频,那么,可以使用 volatile-lru 策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。

LFU 策略

LFU 策略中会从两个维度来筛选并淘汰数据:

  • 数据访问的时效性(访问时间离当前时间的远近)
  • 数据的被访问次数

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

Redis 在实现 LRU 策略时使用了两个近似方法:

  • Redis 是用 RedisObject 结构来保存数据的,RedisObject 结构中设置了一个 lru 字段,用来记录数据的访问时间戳;
    • 原来 24bit 大小的 lru 字段,又进一步拆分成了两部分。
      • ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
      • counter 值:lru 字段的后 8bit,表示数据的访问次数。
  • Redis 并没有为所有的数据维护一个全局的链表,而是通过随机采样方式,选取一定数量(例如 10 个)的数据放入候选集合,后续在候选集合中根据 lru 字段值的大小进行筛选。

总结一下:当 LFU 策略筛选数据时,Redis 会在候选集合中,根据数据 lru 字段的后 8bit 选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据 lru 字段的前 16bit 值大小,选择访问时间最久远的数据进行淘汰。

非线性增长的计数器访问次数

Redis 的counter 值使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255。在实现 LFU 策略时,Redis 并没有采用数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。

LFU 策略实现的计数规则是:每当数据被访问一次时,首先,用计数器当前的值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* Logarithmically increment a counter. The greater is the current counter value
* the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {
// Logistic Counter最大值为255
if (counter == 255) return 255;
// 取一个0~1的随机数r
double r = (double)rand()/RAND_MAX;
// counter减去LFU_INIT_VAL (LFU_INIT_VAL为每个key的Logistic Counter初始值,默认为5)
double baseval = counter - LFU_INIT_VAL;
// 如果衰减之后已经小于5了,那么baseval < 0取0
if (baseval < 0) baseval = 0;
// lfu-log-factor在这里被使用
// 可以看出如果lfu_log_factor的值越大,p越小
// r < p的概率就越小,Logistic Counter增加的概率就越小(因此lfu_log_factor越大增长越缓慢)
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;
return counter;
}

通过设置不同的 lfu_log_factor 配置项,来控制计数器值增加的速度,避免 counter 值很快就到 255 了。 lfu_log_factor值越大,增速就会越慢。

我们在应用 LFU 策略时,一般可以将 lfu_log_factor 取值为 10。

计数器的衰减

Redis 在实现 LFU 策略时,还设计了一个 counter 值的衰减机制。有些数据在短时间内被大量访问后就不会再被访问了。那么再按照访问次数来筛选的话,这些数据会被留存在缓存中,但不会提升缓存命中率。

LFU 策略使用衰减因子配置项 lfu_decay_time 来控制访问次数的衰减。LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。

简单举个例子,假设 lfu_decay_time 取值为 1,如果数据在 N 分钟内没有被访问,那么它的访问次数就要减 N。如果 lfu_decay_time 取值更大,那么相应的衰减值会变小,衰减效果也会减弱。所以,如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1,这样一来,LFU 策略在它们不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去。

总结

在实际业务应用中,LRU 和 LFU 两个策略都有应用。LRU 和 LFU 两个策略关注的数据访问特征各有侧重,LRU 策略更加关注数据的时效性,而 LFU 策略更加关注数据的访问频次。通常情况下,实际应用的负载具有较好的时间局部性,所以 LRU 策略的应用会更加广泛。但是,在扫描式查询的应用场景中,LFU 策略就可以很好地应对缓存污染问题了,建议你优先使用。

此外,如果业务应用中有短时高频访问的数据,除了 LFU 策略本身会对数据的访问次数进行自动衰减以外,我再给你个小建议:你可以优先使用 volatile-lfu 策略,并根据这些数据的访问时限设置它们的过期时间,以免它们留存在缓存中造成污染。

使用了 LFU 策略后,你觉得缓存还会被污染吗?

被污染的概率取决于LFU的配置,也就是lfu-log-factor和lfu-decay-time参数。

1、根据LRU counter计数规则可以得出,counter递增的概率取决于2个因素:

a) counter值越大,递增概率越低

b) lfu-log-factor设置越大,递增概率越低

所以当访问次数counter越来越大时,或者lfu-log-factor参数配置过大时,counter递增的概率都会越来越低,这种情况下可能会导致一些key虽然访问次数较高,但是counter值却递增困难,进而导致这些访问频次较高的key却优先被淘汰掉了。

另外由于counter在递增时,有随机数比较的逻辑,这也会存在一定概率导致访问频次低的key的counter反而大于访问频次高的key的counter情况出现。

2、如果lfu-decay-time配置过大,则counter衰减会变慢,也会导致数据淘汰发生推迟的情况。

3、另外,由于LRU的ldt字段只采用了16位存储,其精度是分钟级别的,在counter衰减时可能会产生同一分钟内,后访问的key比先访问的key的counter值优先衰减,进而先被淘汰掉的情况。

可见,Redis实现的LFU策略,也是近似的LFU算法。Redis在实现时,权衡了内存使用、性能开销、LFU的正确性,通过复用并拆分lru字段的方式,配合算法策略来实现近似的结果,虽然会有一定概率的偏差,但在内存数据库这种场景下,已经做得足够好了。