Redis 笔记八

Posted by MatthewHan on 2021-07-19

Q:什么是缓存污染

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

如何解决缓存污染?

LRU 的不足:

因为只看数据的访问时间,使用 LRU 策略在处理扫描式单次查询操作时,无法解决缓存污染。所谓的扫描式单次查询操作,就是指应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。此时,因为这些被查询的数据刚刚被访问过,所以 LRU 字段值都很大。

LFU 的优化

LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。

当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

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

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

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

  1. ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
  2. counter 值:lru 字段的后 8bit,表示数据的访问次数。

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

Redis 只使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255

Redis 也注意到了这个问题。因此,在实现 LFU 策略时,Redis 并没有采用数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。

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

1
2
3
4
double r = (double)rand()/RAND_MAX;
...
double p = 1.0/(baseval*server.lfu_log_factor+1);
if (r < p) counter++;

以下是记录了当 lfu_log_factor 取不同值时,在不同的实际访问次数情况下,计数器的值是如何变化的。

img

正是因为使用了非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU 策略也可以有效地区分不同的访问次数,从而进行合理的数据筛选。从刚才的表中,我们可以看到,当 lfu_log_factor 取值为 10 时,百、千、十万级别的访问次数对应的 counter 值已经有明显的区分了。

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

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

Q:Redis如何应对并发访问

这里是指的是多条 Redis 命令不具备原子性,Redis 是单线程执行单条指令时当然不会被发生竞争的问题。

为了保证并发访问的正确性,Redis 提供了两种方法,分别是加锁和原子操作。

原子操作是另一种提供并发访问控制的方法。原子操作是指执行过程保持原子性的操作,而且原子操作执行时并不需要再加锁,实现了无锁操作。这样一来,既能保证并发控制,还能减少对系统并发性能的影响。

并发访问中需要对什么进行控制?

并发访问控制对应的操作主要是数据修改操作。当客户端需要修改数据时,基本流程分成两步:

  1. 客户端先把数据读取到本地,在本地进行修改;
  2. 客户端修改完数据后,再写回 Redis。

我们把这个流程叫做“读取 - 修改 - 写回”操作(Read-Modify-Write,简称为 RMW 操作)。当有多个客户端对同一份数据执行 RMW 操作的话,我们就需要让 RMW 操作涉及的代码以原子性方式执行。访问同一份数据的 RMW 操作代码,就叫做临界区代码。

img

可以看到,客户端 A 在 t1 时读取库存值 10 并扣减 1,在 t2 时,客户端 A 还没有把扣减后的库存值 9 写回 Redis,而在此时,客户端 B 读到库存值 10,也扣减了 1,B 记录的库存值也为 9 了。等到 t3 时,A 往 Redis 写回了库存值 9,而到 t4 时,B 也写回了库存值 9。如果按正确的逻辑处理,客户端 A 和 B 对库存值各做了一次扣减,库存值应该为 8。所以,这里的库存值明显更新错了。

Redis 的两种原子操作方法

为了实现并发控制要求的临界区代码互斥执行,Redis 的原子操作采用了两种方法:

  1. 把多个操作在 Redis 中实现成一个操作,也就是单命令操作;
  2. 把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。

我们先来看下 Redis 本身的单命令操作。Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。当然,Redis 的快照生成、AOF 重写这些操作,可以使用后台线程或者是子进程执行,也就是和主线程的操作并行执行。不过,这些操作只是读取数据,不会修改数据,所以,我们并不需要对它们做并发控制。

Redis 提供了 INCR/DECR 命令,把这三个操作转变为一个原子操作了。INCR/DECR 命令可以对数据进行增值 / 减值操作,而且它们本身就是单个命令操作,Redis 在执行它们时,本身就具有互斥性。

所以,如果我们执行的 RMW 操作是对数据进行增减值的话,Redis 提供的原子操作 INCR 和 DECR 可以直接帮助我们进行并发控制。

lua 脚本

Redis 的 Lua 脚本可以包含多个操作,这些操作都会以原子性的方式执行,绕开了单命令操作的限制。不过,如果把很多操作都放在 Lua 脚本中原子执行,会导致 Redis 执行脚本的时间增加,同样也会降低 Redis 的并发性能。所以建议在编写 Lua 脚本时,避免把不需要做并发控制的操作写入脚本中。