云服务器网:购买云服务器和VPS必上的网站!

redis 过期策略及内存回收机制解析

5.1 LRU 模式
5.2 LFU 模式redis作为缓存的场景下,内存淘汰策略决定的redis的内存使用效力。斟酌到这个很多大厂给出的“送分题”,但常人很少能讲清楚,除非你对真的对过期策略、怠惰删除、LRU、LRU有一定的研究。1. 过期策略
Redis

redis作为缓存的场景下,内存淘汰策略决定的redis的内存使用效力。斟酌到这个很多大厂给出的“送分题”,但常人很少能讲清楚,除非你对真的对过期策略、怠惰删除、LRU、LRU有一定的研究。

1. 过期策略

Redis 所有的数据结构都可以设置过期时间,时间一到,就会自动删除。就像死神,时刻盯着所有设置了过期时间的 key,寿命一到就会立即收割。

站在死神的角度思考:是不是在同一时间太多的 key 过期(Redis 是单线程的,收割的时间也会占用线程的处理时间,如果收割的太过于繁忙),以致于忙不过来?是不是致使线上读写指令出现卡顿?

1.1 过期的 key 集合

redis 会将每一个设置了过期时间的 key 放入到一个独立的字典中,以后会定时遍历这个字典来删除到期的 key。

redis 采取两种策略:

  • 定时删除是集中处理
  • 惰性删除是零散处理

1.2 定时扫描策略

Redis 默许会每秒进行十次过期扫描,过期扫描不会遍历过期字典中所有的 key,而是采取了一种简单的贪心策略。

  • 从过期字典中随机 20 个 key;
  • 删除这 20 个 key 中已过期的 key;
  • 如果过期的 key 比率超过 1/4,那就重复步骤 1;

同时,为了保证过期扫描不会出现循环过度,致使线程卡死现象,算法还增加了扫描时间的上限,默许不会超过 25ms。

如果Redis 实例中所有的 key (几十万个)在同一时间过期会怎样?

Redis 会延续扫描过期字典 (循环屡次),直到过期字典中过期的 key 变得稀疏,才会停止 (循环次数明显降落)。内存管理器需要频繁回收内存页,此时会产生一定的 CPU 消耗,必定致使线上读写要求出现明显的卡顿现象。

当客户端要求到来时(服务器如果正好进入过期扫描状态),客户真个要求将会等待最少 25ms 后才会进行处理,如果客户端将超时时间设置的比较短,比如 10ms,那末就会出现大量的链接由于超时而关闭,业务端就会出现很多异常。而且这时候你还没法从 Redis 的 slowlog 中看到慢查询记录,由于慢查询指的是逻辑处理进程慢,不包括等待时间。

其实这个故障在社区中经常爆出 ,业务开发人员一定要注意不宜全部在同一时间过期,可以给目标过期时间的基础上再加一个随机范围(redis.expire_at(key, random.randint(86400) + expire_ts)),分散过期处理的压力。

1.3 从库的过期策略

从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过履行这条 del 指令来删除过期的 key。

由于指令同步是异步进行的,所以主库过期的 key 的 del 指令没有及时同步到从库的话,会出现主从数据的不一致,主库没有的数据在从库里还存在,散布式锁的算法漏洞就是由于这个同步延迟产生的。

2. 怠惰删除

怠惰删除(lazy free),在客户端访问key时再进行检查如果过期了就立即删除

为何要怠惰删除?

Redis内部实际上其实不是只有一个主线程,它还有几个异步线程专门用来处理一些耗时的操作。删除指令 del 会直接释放对象的内存,大部份情况下,这个指令非常快,没有明显延迟。

不过如果删除的 key 是一个非常大的对象,那末删除操作就会致使单线程卡顿,怎样破?

Redis 4.0 版本引入了 unlink 指令(为了解决这个卡顿问题),它能对删除操作进行懒处理,丢给后台线程来异步回收内存。

> unlink key

OK

你肯定会担心这里的线程安全问题,是不是出现多个线程同时并发修改数据结构的情况存在?

这里我打个比方:可以将全部 Redis 内存里面所有有效的数据想象成一棵大树。当 unlink 指令发出时,它只是把大树中的一个树枝别断了,然后扔到旁边的火堆里燃烧 (异步线程池)。树枝离开大树的一瞬间,它就再也没法被主线程中的其它指令访问到了,由于主线程只会沿着这颗大树来访问。

2.1 异步线程

异步线程在 Redis 内部有一个特别的名称,它就是BIO,全称是Background IO,意思是在背后默默干活的 IO 线程。不过内存回收本身其实不是甚么 IO 操作,只是 CPU 的计算消耗可能会比较大而已。

异步线程演进之路

实现怠惰删除时,它其实不是一开始就想到了异步线程。最初的尝试是在主线程里,使用类似于字典渐进式搬迁那样来实现渐进式删除回收。怠惰删除是采取类似于 scan 操作的方法,通过遍历第一维数组来逐渐删除回收第二维链表的内容,等到所有链表都回收完了,再一次性回收第一维数组。这样也能够到达删除大对象时不阻塞主线程的效果。

但是说起来容易做起来却很难。渐进式回收需要仔细控制回收频率,它不能回收的太猛,这会致使 CPU 资源占用过量,也不能回收的蜗牛慢,由于内存回收不及时可能致使内存延续增长。

Antirez 需要采取适合的自适应算法来控制回收频率。他首先想到的是检测内存增长的趋势是增长 (+1) 或者降落 (⑴) 来渐进式调剂回收频率系数,这样的自适应算法实现也很简单。但是测试后发现在服务繁忙的时候,QPS 会降落到正常情况下 65% 的水平,这点非常致命。

所以 Antirez 才使用了如今使用的方案——异步线程。异步线程这套方案就简单多了,释放内存不用为每种数据结构适配一套渐进式释放策略,也不用弄个自适应算法来仔细控制回收频率。

不过使用异步线程也是有代价的,主线程和异步线程之间在内存回收器 (jemalloc) 的使用上存在竞争。这点竞争消耗是可以疏忽不计的,由于 Redis 的主线程在内存的分配与回收上花的时间相对整体运算时间而言是极少的。

异步线程方案相当复杂,具体可参阅援用资料。

2.2 flush

Redis 提供了 flushdb 和 flushall 指令,用来清空数据库,这也是极为缓慢的操作。

Redis 4.0 一样给这两个指令也带来了异步化,在指令后面增加 async 参数就能够将整棵大树拔起,扔给后台线程渐渐燃烧。

> flushall async

OK

2.3 异步队列

Redis4.0,主线程将对象的援用从「大树」中摘除后,会将这个 key 的内存回收操作包装成一个任务,塞进异步任务队列,后台线程会从这个异步队列中取任务。任务队列被主线程和异步线程同时操作,所以一定要是一个线程安全的队列。

不是所有的 unlink 操作都会延后处理,如果对应 key 所占用的内存很小,延后处理就没有必要了,这时候候 Redis 会将对应的 key 内存立即回收,跟 del 指令一样。

2.4 AOF Sync很慢的问题

Redis需要每秒一次(可配置)同步AOF日志到磁盘,确保消息尽可能不丢失,需要调用sync函数,这个操作会比较耗时,会致使主线程的效力降落,所以Redis也将这个操作移到异步线程来完成。

履行AOF Sync操作的线程是一个独立的异步线程,和前面的怠惰删除线程不是一个线程,一样它也有一个属于自己的任务队列,队列里只用来寄存AOF Sync任务。

2.5 更多异步删除点

Redis 回收内存除 del 指令和 flush 以外,还会存在于在 key 的过期、LRU 淘汰、rename 指令和从库全量同步时接受完 rdb 文件后会立即进行的 flush 操作。

Redis4.0 为这些删除点也带来了异步删除机制,打开这些点需要额外的配置选项。

  • slave-lazy-flush 从库接受完 rdb 文件后的 flush 操作
  • lazyfree-lazy-eviction 内存到达 maxmemory 时进行淘汰
  • lazyfree-lazy-expire key 过期删除
  • lazyfree-lazy-server-del rename 指令删除 destKey

3. 过期淘汰配置

当 Redis 已使用内存超越物理内存限制时,内存中的数据会开始和磁盘产生频繁的交换 (swap),交换会让 Redis 的性能急剧降落,而此时Redis的存取效力简直是龟速(基本上等于不可用)。在生产环境中这是不允许的,为了限制最大使用内存,Redis 提供了配置参数 maxmemory 来限制内存超越期望大小。

那如果实际内存超越 maxmemory 时该怎样办?

Redis提供了几种可选策略 (maxmemory-policy) 来让用户自己决定该如何腾出新的空间以继续提供读写服务。

noeviction 不会继续服务写要求 (DEL 要求可以继续服务),读要求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能延续进行。这是默许的淘汰策略。
volatile-lru 尝试淘汰设置了过期时间的 key,最少使用的 key 优先被淘汰。没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。
volatile-ttl 跟上面一样,除淘汰的策略不是 LRU,而是 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰。
volatile-random 跟上面一样,不过淘汰的 key 是过期 key 集合中随机的 key
allkeys-lru 区分于 volatile-lru,这个策略要淘汰的 key 对象是全部的 key 集合,而不只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。
allkeys-random 跟上面一样,不过淘汰的策略是随机的 key

redis.conf中配置

maxmemory <bytes> #最大内存(单位字节)

maxmemory-policy noeviction #默许

小结

  • volatile-xxx 策略只会针对带过期时间的 key 进行淘汰
  • allkeys-xxx 策略会对所有的 key 进行淘汰

那该如何决定呢?

如果你只是拿 Redis 做缓存,那最好使用 allkeys-xxx,客户端写缓存时没必要携带过期时间。

如果你还想同时具有持久化功能,那就使用 volatile-xxx 策略,好处就是,没有设置过期时间的 key 不会被 LRU 算法淘汰

4. LRU 算法

实现 LRU(最近最少) 算法除需要 key/value 字典外,还需要附加一个链表,链表中的元素依照一定的顺序进行排列。

当空间满的时候,会踢掉链表尾部的元素。当字典的某个元素被访问时,它在链表中的位置会被移动到表头。所以链表的元素排列顺序就是元素最近被访问的时间顺序。

4.1 近似 LRU 算法

Redis 使用的是一种近似 LRU 算法,它跟 LRU 算法还不太一样。之所以不使用 LRU 算法,是由于需要消耗大量的额外的内存,需要对现有的数据结构进行较大的改造。近似 LRU 算法则很简单,在现有数据结构的基础上使用随机采样法来淘汰元素,能到达和 LRU 算法非常近似的效果。

Redis 为实现近似 LRU 算法,它给每一个 key 增加了一个额外的小字段,这个字段的长度是 24 个 bit,也就是最后一次被访问的时间戳。

前面讲过key 过期方式分为集中处理和怠惰处理,LRU 淘汰不一样,它的处理方式只有怠惰处理。当 Redis 履行写操作时,发现内存超越 maxmemory,就会履行一次 LRU 淘汰算法。这个算法也很简单,就是随机采样(可以通过maxmemory-policy配置)出 5个 key,然后淘汰掉最旧的 key,如果淘汰后内存或者超越 maxmemory,那就继续随机采样淘汰,直到内存低于 maxmemory为止。

下面是随机 LRU 算法和严格 LRU 算法的效果对照图:绿色部份是新加入的 key,深灰色部份是老旧的 key,浅灰色部份是通过 LRU 算法淘汰掉的 key。可以看出采样数量越大,近似 LRU 算法的效果越接近严格 LRU 算法。同时 Redis3.0 在算法中增加了淘汰池,进一步提升了近似 LRU 算法的效果。

淘汰池是一个数组,它的大小是 maxmemory_samples,在每次淘汰循环中,新随机出来的 key 列表会和淘汰池中的 key 列表进行融会,淘汰掉最旧的一个 key 以后,保存剩余较旧的 key 列表放入淘汰池中留待下一个循环。

5. LRU

Redis 4.0 里引入了一个新的淘汰策略 —— LFU (Least Frequently Used)模式,作者认为它比 LRU 更加优秀。它表示按最近的访问频率进行淘汰,它比 LRU 更加精准地表示了一个 key 被访问的热度。

它淘汰策略配置参数maxmemory-policy增加了 2 个选项,分别是 volatile-lfu 和 allkeys-lfu,分别是对带过期时间的 key 进行 lfu 淘汰和对所有的 key 履行 lfu 淘汰算法。

如果一个 key 长时间不被访问,只是刚刚偶然被用户访问了一下,那末在使用 LRU 算法下它是不容易被淘汰的,由于 LRU 算法认为当前这个 key 是很热的。而 LFU 是需要追踪最近一段时间的访问频率,如果某个 key 只是偶然被访问一次是不足以变得很热的,它需要在近期一段时间内被访问很屡次才有机会被认为很热。

Redis 的所有对象结构头中都有一个 24bit 的字段,这个字段用来记录对象的「热度」。

// redis 的对象头
typedef struct redisObject {
    unsigned type:4; // 对象类型如 zset/set/hash 等等
    unsigned encoding:4; // 对象编码如 ziplist/intset/skiplist 等等
    unsigned lru:24; // 对象的「热度」
    int refcount; // 援用计数
    void *ptr; // 对象的 body
}robj;

5.1 LRU 模式

lru 字段存储的是 Redis 时钟server.lruclock,Redis 时钟是一个 24bit 的整数,默许是 Unix 时间戳对 2^24 取模的结果,大约 97 天清零一次。当某个 key 被访问一次,它的对象头的 lru 字段值就会被更新为server.lruclock,该值一直是递增的,通过这个逻辑就能够精准计算出对象多长时间没有被访问——对象的空闲时间。如果超过server.lruclock折返了。

有了对象的空闲时间,就能够相互之间进行比较谁新谁旧,随机 LRU 算法靠的就是比较对象的空闲时间来决定谁该被淘汰了。

默许 Redis 时钟值每毫秒更新一次,在定时任务serverCron里主动设置,在serverCron里面其实有很多很多定时任务,比如大型 hash 表的渐进式迁移、过期 key 的主动淘汰、触发 bgsave、bgaofrewrite 等等。

为何 Redis 要缓存系统时间戳?

在java中我们使用System.currentTimeInMillis(),而Redis 不能这样,由于每次获得系统时间戳都是一次系统调用,系统调用相对来讲是比较费时间的,这样的消耗对redis而言是伤不起的,所以获得时间都直接从缓存中直接拿。

5.2 LFU 模式

lru 字段 24 个 bit 用来存储两个值,分别是ldt(last decrement time)和logc(logistic counter)。

logc是 8 个 bit,用来存储访问频次(最大整数值为 255)。存储频次远远不够(太小),所以这 8 个 bit 存储的是频次的对数值,并且这个值还会随时间衰减。如果它的值比较小,就很容易被回收,为了确保新创建的对象不被回收,新对象的初始化默许是LFU_INIT_VAL=5。

ldt 是 16 个位,用来存储上一次 logc 的更新时间(精度不可能很高),它取的是分钟时间戳对 2^16 进行取模,大约每隔 45 天就会折返。同 LRU 模式一样,我们也能够使用这个逻辑计算出对象的空闲时间,只不过精度是分钟级别的。

server.unixtime 是当前 redis 记录的系统时间戳,和 server.lruclock 一样,它也是每毫秒更新一次。

// nowInMinutes
// server.unixtime 为 redis 缓存的系统时间戳
unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535;
}
// idle_in_minutes
unsigned long LFUTimeElapsed(unsigned long ldt) {
    unsigned long now = LFUGetTimeInMinutes();
    if (now >= ldt) return now-ldt; // 正常比较
    return 65535-ldt+now; // 折返比较
}

ldt 的值不是在对象被访问时更新的,它在 Redis 的淘汰逻辑进行时进行更新,淘汰逻辑只会在内存到达 maxmemory 的设置时才会触发,在每个指令的履行之前都会触发。每次淘汰都是采取随机策略,随机挑选若干个 key,更新这个 key 的「热度」,淘汰掉「热度」最低的。由于 Redis 采取的是随机算法,如果 key 比较多的话,那末 ldt 更新的可能会比较慢。不过既然它是分钟级别的精度,也没有必要更新的过于频繁。

ldt 更新的同时也会一同衰减 logc 的值,衰减也有特定的算法。它将现有的 logc 值减去对象的空闲时间 (分钟数) 除以一个衰减系数,默许这个衰减系数lfu_decay_time是 1。如果这个值大于 1,那末就会衰减的比较慢。如果它等于零,那就表示不衰减,它是可以通过配置参数lfu_decay_time进行配置。

logc 的更新和 LRU 模式的 lru 字段一样,都会在 key 每次被访问的时候更新,只不过它的更新不是简单的+1,而是采取几率法进行递增,由于 logc 存储的是访问计数的对数值,不能直接+1。

总结,通过LFU 和LRU的介绍,我们知道redis的设计有多优秀,不浪费一丁点内存!

以上为个人经验,希望能给大家一个参考,也希望大家多多支持。

本文来源:https://www.yuntue.com/post/235571.html | 云服务器网,转载请注明出处!

关于作者: yuntue

云服务器(www.yuntue.com)是一家专门做阿里云服务器代金券、腾讯云服务器优惠券的网站,这里你可以找到阿里云服务器腾讯云服务器等国内主流云服务器优惠价格,以及海外云服务器、vps主机等优惠信息,我们会为你提供性价比最高的云服务器和域名、数据库、CDN、免费邮箱等企业常用互联网资源。

为您推荐

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注