redis 过期策略

问题:

  • Redis会自己回收清理不用的数据吗?
  • 如果能,那如何配置?
  • 如果不能,如何防止数据累加后大量占用存储空间的问题?

    设置key过期时间

    Redis对存储值的过期处理实际上是针对该值的键(key)处理的,即时间的设置也是设置key的有效时间。Expires字典保存了所有键的过期时间,Expires也被称为过期字段

  • expire key time(以秒为单位)—这是最常用的方式

  • setex(String key, int seconds, String value)—字符串独有的方式

注: 1、除了字符串自己独有设置过期时间的方法外,其他方法都需要依靠expire方法来设置时间 2、注意:如果没有设置时间,那缓存就是永不过期 3、如果设置了过期时间,之后又想让缓存永不过期,使用persist key

设置过期时间常用方式

一般主要包括4种处理过期方,其中expire都是以秒为单位,pexpire都是以毫秒为单位

  1. EXPIRE key seconds  //将key的生存时间设置为ttl秒
  2. PEXPIRE key milliseconds  //将key的生成时间设置为ttl毫秒
  3. EXPIREAT key timestamp  //将key的过期时间设置为timestamp所代表的的秒数的时间戳
  4. PEXPIREAT key milliseconds-timestamp  //将key的过期时间设置为timestamp所代表的的毫秒数的时间戳

PS:

  • timestamp为unix时间戳(例如:timestamp=1650038400 表示将在2022-04-16过期)
  • 1、2两种方式是设置一个过期的时间段,就是咱们处理验证码最常用的策略,设置三分钟或五分钟后失效,把分钟数转换成秒或毫秒存储到Redis中。
  • 3、4两种方式是指定一个过期的时间 ,比如优惠券的过期时间是某年某月某日,只是单位不一样。

EXPIREAT 命令用法:
EXPIREAT 语法:
expireat key timestamp
Redis 过期策略和内存淘汰机制 - 图1
返回值:
一个整数值1或0,如下:

  • 如果成功地为该键设置了超时时间,返回 1
  • 如果键不存在或无法设置超时时间,返回 0

示例:

  1. 127.0.0.1:6379> set k1 123456 #设置key k1->123456
  2. OK
  3. 127.0.0.1:6379> get k1
  4. "123456"
  5. 127.0.0.1:6379> expireat k1 1650001380 # 设置过期时间
  6. (integer) 1
  7. 127.0.0.1:6379> ttl k1 #查询剩余过期时间
  8. (integer) 40
  9. 127.0.0.1:6379> ttl k1
  10. (integer) 22
  11. 127.0.0.1:6379> ttl k1 #key值已失效
  12. (integer) -2
  13. 127.0.0.1:6379> get k1 #无法获取到失效的key ,数据已被清理
  14. (nil)

Redis 过期策略和内存淘汰机制 - 图2

字符串设置过期时间

对字符串特殊处理的方式为SETEX命令,SETEX命令为指定的 key 设置值及其过期时间。如果 key 已经存在, SETEX命令将会替换旧的值。
SETEX 语法:

  1. setex key seconds value

返回值:
OK
示例:

  1. 127.0.0.1:6379> setex k1 30 123456789 #设置字符换的k1 以及过期时间 value
  2. OK
  3. 127.0.0.1:6379> get k1
  4. "123456789"
  5. 127.0.0.1:6379> ttl k1 # 过期时间
  6. (integer) 16
  7. 127.0.0.1:6379> ttl k1
  8. (integer) 8
  9. 127.0.0.1:6379> ttl k1 # 已过期
  10. (integer) -2
  11. 127.0.0.1:6379> get k1 # 无法获取到过期的key
  12. (nil)

Redis 过期策略和内存淘汰机制 - 图3


三种Redis过期策略

  • 定时删除策略
    • 含义:在设置key的过期时间的同时,为该key创建一个定时器,让定时器在key的过期时间来临时,对key进行删除
    • 优点:保证内存被尽快释放
    • 缺点:
      • 若过期key很多,删除这些key会占用很多的CPU时间,在CPU时间紧张的情况下,CPU不能把所有的时间用来做要紧的事儿,还需要去花时间删除这些key
      • 定时器的创建耗时,若为每一个设置过期时间的key创建一个定时器(将会有大量的定时器产生),性能影响严重
  • 惰性删除策略
    • 含义:key过期的时候不删除,每次从数据库获取key的时候去检查是否过期,若过期,则删除,返回null。
    • 优点:删除操作只发生在从数据库取出key的时候发生,而且只删除当前key,所以对CPU时间的占用是比较少的,而且此时的删除是已经到了非做不可的地步(如果此时还不删除的话,我们就会获取到了已经过期的key了)
    • 缺点:若大量的key在超出超时时间后,很久一段时间内,都没有被获取过,那么可能发生内存泄露(无用的垃圾占用了大量的内存)
  • 定期删除策略
    • 含义:每隔一段时间执行一次删除(在redis.conf配置文件设置hz,1s刷新的频率)过期key操作
    • 优点:
      • 通过限制删除操作的时长和频率,来减少删除操作对CPU时间的占用—处理”定时删除”的缺点
      • 定期删除过期key—处理”惰性删除”的缺点
    • 缺点
      • 在内存友好方面,不如”定时删除”
      • 在CPU时间友好方面,不如”惰性删除”
    • 难点
      • 合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除)【消耗资源】(这个要根据服务器运行情况来定了)

结论:
定时删除和定期删除为主动删除:Redis会定期主动淘汰一批已过去的key
惰性删除为被动删除:用到的时候才会去检验key是不是已过期,过期就删除
惰性删除为redis服务器内置策略
定期删除可以通过:

  • 第一、配置redis.conf 的hz选项,默认为10 (即1秒执行10次,100ms一次,值越大说明刷新频率越快,最Redis性能损耗也越大)
  • 第二、配置redis.conf的maxmemory最大值,当已用内存超过maxmemory限定时,就会触发主动清理策略
  • memcached只是用了惰性删除,而Redis同时使用了惰性删除与定期删除,这也是二者的一个不同点(可以看做是redis优于memcached的一点)
  • 对于惰性删除而言,并不是只有获取key的时候才会检查key是否过期,在某些设置key的方法上也会检查(eg.setnx key2 value2:该方法类似于memcached的add方法,如果设置的key2已经存在,那么该方法返回false,什么都不做;如果设置的key2不存在,那么该方法设置缓存key2-value2。假设调用此方法的时候,发现redis中已经存在了key2,但是该key2已经过期了,如果此时不执行删除操作的话,setnx方法将会直接返回false,也就是说此时并没有重新设置key2-value2成功,所以对于一定要在setnx执行之前,对key2进行过期检查)

    Redis采用的过期策略

    惰性删除+定期删除

  • 惰性删除流程惰性删除实现: 过期键的惰性删除删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeed函数对输入键进行检查:

    • 在进行get或setnx等操作时,先检查key是否过期,
    • 若过期,删除key,然后执行相应操作;
    • 若没过期,直接执行相应操作
    1. 如果键已经过期,那么expireIfNeeded函数将键删除
    2. 如果键未过期,那么expireIfNeeded函数不做操作命令调用expireIfNeeded函数过程如下图:

Redis 过期策略和内存淘汰机制 - 图4
PS:另外因为每个被访问的键都可能被删除,所以每个命令都必须能同时处理键存在以及不存在的情况。 下图表示get命令的执行过程
Redis 过期策略和内存淘汰机制 - 图5

  • 定期删除流程(简单而言,对指定个数个库的每一个库随机删除小于等于指定个数个过期key)

    • 过期键的定期删除策略由redis.c/activeExpireCycle函数实现,每当Redis的服务器周期性操作redis.c/serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定时间内,分多次遍历服务器中各个数据库
    • Redis 默认每秒进行 10 次过期扫描,过期扫描不会遍历过期字典中所有的 key, 而是采用了一种简单的贪心策略,步骤如下:
      • 检查当前库中的指定个数个key(默认是每个库检查20个key,注意相当于该循环执行20次,循环体时下边的描述)
        • 如果当前库中没有一个key设置了过期时间,直接执行下一个库的遍历
        • 随机获取一个设置了过期时间的key,检查该key是否过期,如果过期,删除key
        • 如果过期的 key的比例超过 1/4,那就重复选择20key再次执行
        • 为了保证过期扫描不会出现循环过度,导致结程卡死的现象,判断定期删除操作是否已经达到指定时长【默认25ms】,若已经达到,直接退出定期删除。

          RDB对过期key的处理

          过期key对RDB没有任何影响
  • 从内存数据库持久化数据到RDB文件

    • 持久化key之前,会检查是否过期,过期的key不进入RDB文件
  • 从RDB文件恢复数据到内存数据库

    • 数据载入数据库之前,会对key先进行过期检查,如果过期,不导入数据库(主库情况)

      AOF对过期key的处理

      过期key对AOF没有任何影响
  • 从内存数据库持久化数据到AOF文件:

    • 当key过期后,还没有被删除,此时进行执行持久化操作(该key是不会进入aof文件的,因为没有发生修改命令)
    • 当key过期后,在发生删除操作时,程序会向aof文件追加一条del命令(在将来的以aof文件恢复数据的时候该过期的键就会被删掉)
  • AOF重写
    • 重写时,会先判断key是否过期,已过期的key不会重写到aof文件

redis 内存淘汰机制(eviction)

redis 支持缓存淘汰(eviction)并提供相应的了配置项:

1. maxmemory 最大内存
PS: 设置内存使用上限,该值不能设置为小于 1M 的容量,选项的默认值为 0,此时系统会自行计算一个内存上限。
问题:Redis 设置多大的内存容量呢?
解决:根据“八二原理“,即 80% 的请求访问了 20% 的数据,因此如果按照这个原理来配置,将 Redis 内存大小设置为数据总量的 20%,就有可能拦截到 80% 的请求。当然,只是有可能,对于不同的业务场景需要进行不同的配置,一般建议把缓存容量设置为总数据量的 15% 到 30%,兼顾访问性能和内存空间开销

2. maxmemory-policy 内存淘汰策略
redis数据库维护了两个字典:

  • db.dict:数据库中所有键值对,也被称作数据库的 keyspace
  • db.expires:带有生命周期的 key 及其对应的 TTL(存留时间),因此也被称作 expire set

当达到内存使用上限maxmemory时,可指定的清理缓存所使用的策略有:

  • noeviction 当达到最大内存时直接返回错误,不覆盖或逐出任何数据,不淘汰数据
  • allkeys-lfu 淘汰整个 keyspace 中最不常用的 (LFU) 键 (4.0 或更高版本)
  • allkeys-lru 淘汰整个 keyspace 最近最少使用的 (LRU) 键
  • allkeys-random 淘汰整个 keyspace 中的随机键
  • volatile-ttl 淘汰 expire set 中 TTL 最短的键
  • volatile-lfu 淘汰 expire set 中最不常用的键 (4.0 或更高版本)
  • volatile-lru 淘汰 expire set 中最近最少使用的 (LRU) 键
  • volatile-random 淘汰 expire set 中的随机键

当 expire set 为空时,volatile-* 与 noeviction 行为一致

3. maxmemory-samples 采样数量
为了保证性能,redis 中使用的 LRU 与 LFU 算法是一类近似实现。 简单来说就是:算法选择被淘汰记录时,不会遍历所有记录,而是以 随机采样 的方式选取部分记录进行淘汰。maxmemory-samples 选项控制该过程的采样数量,增大该值会增加 CPU 开销,但算法效果能更逼近实际的 LRU 与 LFU 。

4. lazyfree-lazy-eviction
清理缓存就是为了释放内存,但这一过程会阻塞主线程,影响其他命令的执行。 当删除某个巨型记录(比如:包含数百条记录的 list)时,会引起性能问题,甚至导致系统假死。延迟释放 机制会将巨型记录的内存释放,交由其他线程异步处理,从而提高系统的性能。 开启该选项后,可能出现使用内存超过 maxmemory 上限的情况。

淘汰策略算法

缓存能使用的内存是有限的,当空间不足时,应该优先淘汰那些将来不再被访问的数据,保留那些将来还会频繁访问的数据。因此淘汰算法会围绕 时间局部性 原理进行设计,即:如果一个数据正在被访问,那么在近期很可能会被再次访问
为了适应缓存读多写少的特点,实际应用中会使用哈希表来实现缓存。当需要实现某种特定的缓存淘汰策略时,需要引入额外的簿记 book keeping 结构。
下面我们 3 种最常见的缓存淘汰策略:

1. FIFO(先进先出)

  • 越早进入缓存的数据,其不再被访问的可能性越大
  • 因此在淘汰缓存时,应选择在内存中停留时间最长的缓存记录

使用队列即可实现该策略:
PS:
优点:实现简单,适合线性访问的场景
缺点:无法适应特定的访问热点,缓存的命中率差 簿记开销:时间 O(1),空间 O(N)

2. LRU (最近最少使用)

  • 一个缓存被访问后,近期再被访问的可能性很大
  • 可以记录每个缓存记录的最近访问时间,最近未被访问时间最长的数据会被首先淘汰。

使用链表即可实现该策略:
Redis 过期策略和内存淘汰机制 - 图6
当更新 LRU 信息时,只需调整指针:
Redis 过期策略和内存淘汰机制 - 图7
PS:
优点:实现简单,能适应访问热点 缺点:对偶发的访问敏感,影响命中率 簿记开销:时间 O(1),空间 O(N)

3. LRU-K

原始的 LRU 算法缓存的是最近访问了 1 次的数据,因此不能很好地区分频繁和不频繁缓存引用。 这意味着,部分冷门的低频数据也可能进入到缓存,并将原本的热点记录挤出缓存。 为了减少偶发访问对缓存的影响,后续提出的 LRU-K 算法作出了如下改进:

  • 在 LRU 簿记的基础上增加一个历史队列 History Queue
  • 当记录访问次数小于 K 时,会记录在历史队列中(当历史队列满时,可以使用 FIFO 或 LRU 策略进行淘汰)
  • 当记录访问次数大于等于 K 时,会被从历史队列中移出,并记录到 LRU 缓存中

K 值越大,缓存命中率越高,但适应性差,需要经过大量访问才能将过期的热点记录淘汰掉。 综合各种因素后,实践中常用的是 LRU-2 算法:
PS:
优点:减少偶发访问对缓存命中率的影响 缺点:需要额外的簿记开销 簿记开销:时间 O(1),空间 O(N+M)

4. LFU (最不经常使用)

  • LFU 全称 Least Frequently Used
  • 一个缓存近期内访问频率越高,其再被访问的可能性越大。
  • 可以记录每个缓存记录的最近一段时间的访问频率,访问频率低的数据会被首先淘汰。

实现 LFU 的一个简单方式,是在缓存记录设置一个记录访问次数的计数器,然后将其放入一个小顶堆:
为了保证数据的时效性,还要以一定的时间间隔对计数器进行衰减,保证过期的热点数据能够被及时淘汰

redis 淘汰策略实现

Redis的LRU算法不是一个严格的LRU实现。这意味着Redis不能选择最佳候选键来回收,也就是最久未被访问的那些键。相反,Redis 会尝试执行一个近似的LRU算法,通过采样一小部分键,然后在采样键中回收最适合(拥有最久访问时间)的那个。
然而,从Redis3.0开始,算法被改进为维护一个回收候选键池。这改善了算法的性能,使得更接近于真实的LRU算法的行为。Redis的LRU算法有一点很重要,你可以调整算法的精度,通过改变每次回收时检查的采样数量。
这个参数可以通过如下配置指令:

  1. maxmemory-samples 5

Redis没有使用真实的LRU实现的原因,是因为这会消耗更多的内存,会产生链表移动。然而,近似值对使用Redis的应用来说基本上也是等价的。
Redis 对 LRU 的实现进行了一些改变:

  • 记录每个 key 最近一次被访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)
  • 在第一次淘汰数据时,会先随机选择 N 个数据作为一个候选集合,然后淘汰 lru 值最小的。(N 可以通过 config set maxmemory-samples 100 命令来配置)
  • 后续再淘汰数据时,会挑选数据进入候选集合,进入集合的条件是:它的 lru 小于候选集合中最小的 lru。
  • 如果候选集合中数据个数达到了 maxmemory-samples,Redis 就会将 lru 值小的数据淘汰出去。

注意:
在 Redis 4.0 时添加进来。它在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。
前面说到,LRU 使用了 RedisObject 中的 lru 字段记录时间戳,lru 是 24bit 的,LFU 将 lru 拆分为两部分:

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

使用 LFU 策略淘汰缓存时,会把访问次数最低的数据淘汰,如果访问次数相同,再根据访问的时间,将访问时间戳最小的淘汰。
为什么 Redis 有了 LRU 还需要 LFU 呢?
在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。
由于 LRU 是基于访问时间的,如果系统对大量数据进行单次查询,这些数据的 lru 值就很大,使用 LFU 算法就不容易被淘汰。