Redis核心技术与实战
实践篇
27 | 缓存被污染了,该怎么办?
在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。
当缓存污染不严重时,只有少量数据占据缓存空间,此时,对缓存系统的影响不大。但是,缓存污染一旦变得严重后,就会有大量不再访问的数据滞留在缓存中。如果这时数据占满了缓存空间,再往缓存中写入新数据时,就需要先把这些数据逐步淘汰出缓存,这就会引入额外的操作时间开销,进而会影响应用的性能。
如何解决缓存污染问题?
Redis 有 8 种数据淘汰策略,分别是 noeviction、volatile-random、volatile-ttl、volatile-lru、volatile-lfu、allkeys-lru、allkeys-random 和 allkeys-lfu 策略。
noeviction 策略不会进行数据淘汰,不能用来解决缓存污染问题。
volatile-random 和 allkeys-random 这两种策略都是采用随机挑选数据的方式,来筛选即将被淘汰的数据。这两种策略下,Redis 不会根据数据的访问情况来筛选数据。如果被淘汰的数据又被访问,就会发生缓存缺失,应用则需要到后端数据库中访问这些数据,降低了应用的请求响应速度。所以,volatile-random 和 allkeys-random 策略,在避免缓存污染这个问题上的效果非常有限。
volatile-ttl 针对的是设置了过期时间的数据,把这些数据中剩余存活时间最短的筛选出来并淘汰掉。虽然 volatile-ttl 策略不再是随机选择淘汰数据,但是剩余存活时间并不能直接反映数据再次访问的情况。所以,按照 volatile-ttl 策略淘汰数据,和按随机方式淘汰数据类似,也可能出现数据被淘汰后,被再次访问导致的缓存缺失问题。
LRU 缓存策略
LRU 策略的核心思想:如果一个数据刚刚被访问,那么这个数据肯定是热数据,还会被再次访问。
Redis 中的 LRU 策略,会在每个数据对应的 RedisObject 结构体中设置一个 lru 字段,用来记录数据的访问时间戳。在进行数据淘汰时,LRU 策略会在候选数据集中淘汰掉 lru 字段值最小的数据(也就是访问时间最久的数据)。
因为只看数据的访问时间,使用 LRU 策略在处理扫描式单次查询操作时,无法解决缓存污染。 所谓的扫描式单次查询操作,就是指应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。此时,因为这些被查询的数据刚刚被访问过,所以 lru 字段值都很大。
在使用 LRU 策略淘汰数据时,这些数据会留存在缓存中很长一段时间,造成缓存污染。如果查询的数据量很大,这些数据占满了缓存空间,却又不会服务新的缓存请求,此时,再有新数据要写入缓存的话,还是需要先把这些旧数据替换出缓存才行,这会影响缓存的性能。
举例:数据 6 被访问后,被写入 Redis 缓存。但是,在此之后,数据 6 一直没有被再次访问,这就导致数据 6 滞留在缓存中,造成了污染。
对于采用了 LRU 策略的 Redis 缓存来说,扫描式单次查询会造成缓存污染。
LFU 缓存策略的优化
LFU 策略中会从两个维度来筛选并淘汰数据:
- 一是,数据访问的时效性(访问时间离当前时间的远近);
- 二是,数据的被访问次数。
LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。
和那些被频繁访问的数据相比,扫描式单次查询的数据因为不会被再次访问,所以它们的访问次数不会再增加。因此,LFU 策略会优先把这些访问次数低的数据淘汰出缓存。这样一来,LFU 策略就可以避免这些数据对缓存造成污染。
为了避免操作链表的开销,Redis 在实现 LRU 策略时使用了两个近似方法:
- Redis 用 RedisObject 结构来保存数据,RedisObject 结构中设置了一个 lru 字段,用来记录数据的访问时间戳;
- Redis 并没有为所有的数据维护一个全局的链表,而是通过随机采样方式,选取一定数量(例如 10
个)的数据放入候选集合,后续在候选集合中根据 lru 字段值的大小进行筛选。
Redis 在实现 LFU 策略的时候,只是把原来 24bit 大小的 lru 字段,又进一步拆分成两部分:
- ldt:lru 字段的前 16bit,表示数据的访问时间戳;
- counter:lru 字段的后 8bit,表示数据的访问次数。
Redis 只使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255。所以,在实现 LFU 策略时,Redis 并没有采用数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。
LFU 策略实现的计数规则是:每当数据被访问一次时,首先,用计数器当前的值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。
计数规则公式:1 / (count * lfu_log_factory + 1) > r ? count = count + 1 : count = count(count 为当前计数器的值,r 为 0 到 1 之间的随机数)
随着计数器数值的增大,计数器加 1 的概率也越来越小,很好地限制了计数器数值的增长。
当 lfu_log_factor 取不同值时,在不同的实际访问次数情况下,计数器的值的变化如下图:
从上图可知,lfu_log_factor 越大,计数增长越缓慢。lfu_log_factor 取值为 10 时,百、千、十万级别的访问次数对应的 counter 值已经有明显的区分。
在应用 LFU 策略时,一般可以将 lfu_log_factor 取值为 10(Redis 6 中 lfu_log_factor 的默认值为 10)。
在一些场景下,有些数据在短时间内被大量访问后就不会再被访问。如果再按照访问次数来筛选,这些数据会被留存在缓存中,但不会提升缓存命中率。为此,Redis 在实现 LFU 策略时,还设计了一个 counter 值的衰减机制。
LFU 策略使用衰减因子配置项 lfu_decay_time 来控制访问次数的衰减。 LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。
如果业务应用中有短时高频访问的数据,建议把 lfu_decay_time 值设置为 1,这样一来,LFU 策略在它们不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。