Redis突破性能瓶颈:揭示缓存污染之谜并采取革命性防御措施

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

        当缓存污染不严重时,只有少量数据占用缓存空间,此时对缓存的影响不大。但是缓存污染一旦变得严重,就会有大量不在访问的数据滞留在缓存中。如果这时数据占满了缓存空间,再往缓存中写入新数据,就需要先淘汰数据,这回引入额外的开销,进而影响性能。

如何解决缓存污染

        要解决缓存污染,很容易想到解决方案,就是把不会再被访问的数据筛选出来并淘汰。不用等到缓存被写满后,再逐一淘汰旧数据之后,才能写入新数据。而哪些数据能留在缓存中,是由淘汰策略做决定的。

        Redis缓存淘汰策略        

        Redis4.0之前有6种内存淘汰策略,在4.0之后,又增加了2种。按照是否会进行数据淘汰把他们分成两类:

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

        默认情况下,Redis在使用的内存空间超过maxmemory值时,并不会淘汰数据,也就是设定的noeviction策略。对应到Redis缓存,指一旦缓存被写满,在有请求来时,Redis不在提供服务,而是直接返回错误。Redis做缓存时,实际的数据集通常大于缓存容量,总会有新的数据写入缓存,这个策略本身不淘汰数据,也就不会腾出新的缓存空间,不把它用在缓存中。

        首先,我们看下 volatile-random 和 allkeys-random 这两种策略。它们都是采用随机挑选数据的方式,来筛选即将被淘汰的数据。

        既然是随机挑选,那么Redis就不会根据数据的访问情况来筛选数据。如果被淘汰的数据再次被访问,就会造成缓存缺失。所以这两种策略无法避免缓存污染的情况。

        继续看volatile-ttl 策略,虽然该策略不再是随机选择淘汰数据了,但是剩余存活时间并不能直接反映数据再次访问的情况。所以,按照 volatile-ttl 策略淘汰数据,和按随机方式淘汰数据类似,也可能出现数据被淘汰后,被再次访问导致的缓存缺失问题。除非业务明确,将被访问的数据设置上不同的过期时间。

        在分析下LRU以及Redis4.0后实现的LFU策略。LRU策略会按照数据访问的时效性,来筛选即将被淘汰的数据,应用非常广泛。

LRU缓存策略

        如果一个数据被访问过,那么这个数据大概率是热点数据,后续还会被继续访问。Redis实现时会在RedisObject中设置一个lru字段,用来记录数据访问的时间戳。在进行数据淘汰时,LRU会在候选数据集中淘汰掉lru字段最小的数据。

        所以,业务中被频繁访问的数据,LRU的确能有效留存热点数据。但是,因为只看时间,有的场景还会存在问题。例如,在一次单次扫描时,应用汇读取大量的数据,每个数据都会被读取,因为这个数据刚被访问过,所以lru值都能大。

        在使用LRU策略淘汰数据时,这些数据会留存在缓存中很长一段时间,造成缓存污染,如果查询的数据量很大,这些数据占满了缓存空间,却又不会服务新的缓存请求,此时再有新数据写入缓存的话,还是需要先把旧数据淘汰出缓存,这会影响缓存的性能。

        所以,对于采用了LRU策略的Redis来说,扫描式单次查询会造成缓存污染,为了应对这类问题,Redis从4.0开始引入了LFU淘汰策略。

LFU缓存策略  

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

        和那些频繁被访问的数据相比,扫描式单次查询的数据因为不会被再次访问,所以他们的访问次数不会在增加。因此,LFU策略会优先把这些访问次数低的数据淘汰出缓存。

        LFU策略是如何实现的呢?既然LFU是在LRU的基础上做了优化,那他们的实现必定有关系。        

        为了避免操作链表的开销,Redis实现LRU时使用了两个近似方法:

  • Redis用RedisObject结构来保存数据,RedisObject中设置了一个lru字段,用来记录访问的时间戳;
  • Redis并没有为所有的数据维护一个全局链表,而是通过随机采样方式,选取一定数量的数据放入候选集合,后续在候选集合中根据lru字段值的大小进行筛选。

        在此基础上,Redis在实现LFU策略时,只是把原来24bit大小的lru字段,又进一步拆分成两部分:        

  • ldt值:lru的前16位bit,表示数据的访问时间戳;
  • counter:lru后8位,表示数据访问次数

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

        Redis只用了8bit记录数据的访问次数,而8bit的最大值是255,这样可以吗?

        实际应用中,一个数据可能被访问数万次。如果每访问一次,counter就加1,那么只要访问次数超过了255,counter值就都一样了。在进行数据淘汰时,LFU策略无法很好的区分,又退化成LRU了。

        例子,第一个数据A的累计访问次数是256,访问时间戳是202010010909,所以它的counter值是255,而第二个数据B的累计访问次数是1024,访问时间戳是202010010810,如果只能记255,那么数据B的counter值也是255。

        此时缓存写满了,Redis使用LFU进行淘汰。数据A和B的counter值都为255,在比较A和B访问时间戳,发现B的访问早于A,就把B淘汰了。但其实B的访问次数远大于A。这样,LFU就不合适了。

        Redis也注意到了这个问题,采用了一个更优化的技术规则。

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

        使用了这种技术规则后,可以通过设置lfu_log_factor配置项,来控制计数器值增加的速度,避免counter值很快到达255。

        为了进一步说明LFU策略递增的效果,Redis官网https://redis.io/docs/reference/eviction/icon-default.png?t=N7T8https://redis.io/topics/lru-cache提供了lfu_log_factor不同值,计数器的变化。

Redis突破性能瓶颈:揭示缓存污染之谜并采取革命性防御措施_第1张图片

        可以看到,当 lfu_log_factor 取值为 1 时,实际访问次数为 100K 后,counter 值就达到 255 了,无法再区分实际访问次数更多的数据了。而当 lfu_log_factor 取值为 100 时,当实际访问次数为 10M 时,counter 值才达到 255,此时,实际访问次数小于 10M 的不同数据都可以通过 counter 值区分出来。

        正是因为使用了非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU 策略也可以有效地区分不同的访问次数,从而进行合理的数据筛选。从刚才的表中,我们可以看到,当 lfu_log_factor 取值为 10 时,百、千、十万级别的访问次数对应的 counter 值已经有明显的区分了,所以,我们在应用 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 策略在它们不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。

你可能感兴趣的:(缓存,redis,数据库)