Redis的设计与实现——内部数据结构、IO模型、内存淘汰/过期策略

0 阅读25分钟

概述

本节要点:

  • Redis 内部数据结构
  • 缓存淘汰策略
  • 缓存过期策略

一、数据结构和内部实现

type 命令实际返回的就是当前键的数据结构类型,它们分别是:string(字符串)、hash(哈希)、list(列表)、set(集合)、zset(有序集合),但这些只是 Redis 对外的数据结构。

实际上每种数据结构都有自己底层的内部编码实现,而且是多种实现,这样 Redis 会在合适的场景选择合适的内部编码。

image.png

每种数据结构都有两种以上的内部编码实现,例如 list 数据结构包含了 linkedlistziplist 两种内部编码。同时有些内部编码,例如 ziplist,可以作为多种外部数据结构的内部实现,可以通过 object encoding 命令查询内部编码。

Redis 这样设计有两个好处:

  1. 可以改进内部编码,而对外的数据结构和命令没有影响,这样一旦开发出更优秀的内部编码,无需改动外部数据结构和命令,例如 Redis3.2 提供了 quicklist,结合了 ziplistlinkedlist 两者的优势,为列表类型提供了一种更为优秀的内部编码实现,而对外部用户来说基本感知不到。

  2. 多种内部编码实现可以在不同场景下发挥各自的优势,例如 ziplist 比较节省内存,但是在列表元素比较多的情况下,性能会有所下降,这时候 Redis 会根据配置选项将列表类型的内部实现转换为 linkedlist

1.1 redisobject对象

Redis 存储的所有值对象在内部定义为 redisobject 结构体,内部结构如图所示。

image.png

typedef struct redisObject {
    unsigned type:4;       // 对象的类型 (string, list, set, zset, hash)
    unsigned encoding:4;   // 对象的底层编码方式 (raw, int, ht, ziplist, etc.)
    unsigned lru:LRU_BITS; // LRU/LFU 信息 (淘汰策略用)
    int refcount;          // 引用计数 (对象共享/释放时使用)
    void *ptr;             // 实际存储的数据 (指向具体实现结构)
} robj;

1.1.1 type字段

type 字段:表示当前对象使用的数据类型,Redis 主要支持 5 种数据类型:stringhashlistsetzset。可以使用 type {key} 命令查看对象所属类型,type 命令返回的是值对象类型,键都是 string 类型。

1.1.2 encoding字段

encoding 字段:表示 Redis 内部编码类型,encoding 在 Redis 内部使用,代表当前对象内部采用哪种数据结构实现。理解 Redis 内部编码方式对于优化内存非常重要,同一个对象采用不同的编码实现内存占用存在明显差异。

1.1.3 lru字段

lru 字段:记录对象最后次被访问的时间,当配置了 maxmemorymaxmemory-policy=volatile-lru 或者 allkeys-lru 时,用于辅助 LRU 算法删除键数据。可以使用 object idletime {key} 命令在不更新 lru 字段情况下查看当前键的空闲时间。

可以使用 scan + object idletime 命令批量查询哪些键长时间未被访问,找出长时间不访问的键进行清理,可降低内存占用。

1.1.4 refcount字段

refcount 字段:记录当前对象被引用的次数,用于通过引用次数回收内存,当 refcount=0 时,可以安全回收当前对象空间。使用 object refcount {key} 获取当前对象引用。当对象为整数且范围在 [0,9999][0, 9999] 时,Redis可以使用共享对象的方式来节省内存。

面试题,Redis 的对象垃圾回收算法————引用计数法。

1.1.5 *ptr字段

*ptr 字段:与对象的数据内容相关,如果是整数,直接存储数据,否则表示指向数据的指针。

Redis 新版本类型是字符串且大小 <= 44 Bytes 的数据,字符串 sdsredisobject 一起分配,从而只要一次内存操作即可。

高并发写入场景中,在条件允许的情况下,建议字符串长度控制在 44 Bytes 以内,减少创建 redisobject 内存分配次数,从而提高性能。

1.2 key和value组织结构

1.2.1 全局哈希表

为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

image.png

哈希桶中的 entry 元素中保存了 *key*value 指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过 *value 指针被查找到。因为这个哈希表保存了所有的键值对,所以它也被称为全局哈希表

哈希表的最大好处很明显,就是让我们可以用 O(1)O(1) 的时间复杂度来快速查找到键值对:我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。

但当你往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在的风险,那就是哈希表的冲突问题和 rehash(重新哈希) 可能带来的操作阻塞。

当你往哈希表中写入更多数据时,哈希冲突是不可避免的问题。这里的哈希冲突,两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。

image.png

Redis 解决哈希冲突的方式,就是链式哈希。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

当然如果这个数组一直不变,那么哈希冲突会变很多,这个时候检索效率会大打折扣,所以 Redis 就需要把数组进行扩容(一般是扩大到原来的两倍),但是问题来了,扩容后每个哈希桶的数据会分散到不同的位置,这里涉及到元素的移动,必定会阻塞 I/O,所以这个 ReHash 过程会导致很多请求阻塞。

1.2.2 渐进式哈希

为了避免这个问题,Redis 采用了渐进式 Rehash。

首先,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash。

  1. 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍。
  2. 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中。
  3. 释放哈希表 1 的空间。

在上面的第 2 步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。

image.png

在 Redis 开始执行 rehash,Redis 仍然正常处理客户端请求,但是要加入一个额外的处理:

  1. 处理第 1 个请求时,把哈希表 1 中的第 1 个索引位置上的所有 entries 拷贝到哈希表 2 中。
  2. 处理第 2 个请求时,把哈希表 1 中的第 2 个索引位置上的所有 entries 拷贝到哈希表 2 中。

如此循环,直到把所有的索引位置的数据都拷贝到哈希表 2 中。这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。

所以这里基本上也可以确保根据 keyvalue 的操作在 O(1)O(1) 左右。

不过这里要注意,如果 Redis 中有海量的 key 值的话,这个 Rehash 过程会很长很长,虽然采用渐进式 Rehash,但在 Rehash 的过程中还是会导致请求有不小的卡顿。并且像一些统计命令也会非常卡顿:比如 keys

按照 Redis 的配置每个实例能存储的最大的 key 的数量为 2322^{32},即 2.52.5 亿,但是尽量把 key 的数量控制在千万以下,这样就可以避免 Rehash 导致的卡顿问题,如果数量确实比较多,建议采用分区 hash 存储。

1.3 线程与IO模型

image.png

Redis 基于 Reactor 模式开发了自己的网络事件处理器————文件事件处理器(file event handler,后文简称为 FEH),而该处理器又是单线程的,所以 Redis 设计为单线程模型。

采用 I/O 多路复用同时监听多个 socket,根据 socket 当前执行的事件来为 socket 选择对应的事件处理器。

当被监听的 socket 准备好执行 acceptreadwriteclose 等操作时,和操作对应的文件事件就会产生,这时 FEH 就会调用 socket 之前关联好的事件处理器来处理对应事件。

所以虽然 FEH 是单线程运行,但通过 I/O 多路复用监听多个 socket,不仅实现高性能的网络通信模型,又能和 Redis 服务器中其它同样单线程运行的模块交互,保证了 Redis 内部单线程模型的简洁设计。

下面来看文件事件处理器的几个组成部分。

1.3.1 socket

文件事件就是对 socket 操作的抽象, 每当一个 socket 准备好执行连接 acceptreadwriteclose 等操作时, 就会产生一个文件事件。一个服务器通常会连接多个 socket,多个 socket 可能并发产生不同操作,每个操作对应不同文件事件。

1.3.2 I/O多路复用

I/O 多路复用程序会负责监听多个 socket。

image.png

1.3.3 文件事件分派器

文件事件分派器接收 I/O 多路复用程序传来的 socket,并根据 socket 产生的事件类型,调用相应的事件处理器。

1.3.4 文件事件处理器

服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,它们定义了某个事件发生时,服务器应该执行的动作。

Redis 为各种文件事件需求编写了多个处理器,若客户端连接 Redis,对连接服务器的各个客户端进行应答,就需要将 socket 映射到连接应答处理器写数据到 Redis,接收客户端传来的命令请求,然后映射到命令请求处理器从 Redis 读数据,向客户端返回命令的执行结果,最后映射到命令回复处理器。当主服务器和从服务器进行复制操作时, 主从服务器都需要映射到特别为复制功能编写的复制处理器。

1.3.5 Redis v6.0 的多线程

Redis 6.0 之前的版本真的是单线程吗?

Redis 在处理客户端的请求时,包括获取(socket 读)、解析、执行、内容返回(socket 写)等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。但如果严格来讲从 Redis 4.0 之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。

Redis 6.0之前为什么一直不使用多线程?

官方曾做过类似问题的回复:使用 Redis 时,几乎不存在 CPU 成为瓶颈的情况,Redis 主要受限于内存和网络。例如在一个普通的 Linux 系统上,Redis 通过使用 pipeline 每秒可以处理 100 万个请求,所以如果应用程序主要使用 O(N)O(N)O(logN)O(logN) 的命令,它几乎不会占用太多 CPU。

使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。Redis 通过 AE 事件模型以及 I/O 多路复用等技术,处理性能非常高,因此没有必要使用多线程。单线程机制使得 Redis 内部实现的复杂度大大降低,Hash 的惰性 Rehash、Lpush 等等,“线程不安全”的命令都可以无锁进行。

Redis 6.0为什么要引入多线程呢?

Redis 将所有数据放在内存中,内存的响应时长大约为 100ns。对于小数据包,Redis 服务器可以处理 80,000~100,000 QPS,这也是 Redis 处理的极限了,对于 80% 的公司来说,单线程的 Redis 已经足够使用了。

但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的 QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,但该方案有非常大的缺点,例如要管理的 Redis 服务器太多,维护代价大。某些适用于单个 Redis 服务器的命令不适用于数据分区。数据分区无法解决热点读/写问题。数据偏斜,重新分配和放大/缩小变得更加复杂等等。

所以总结起来,Redis 支持多线程主要就是两个原因:

• 可以充分利用服务器 CPU 资源,目前主线程只能利用一个核。 • 多线程任务可以分摊 Redis 同步 I/O 读写负荷。

Redis 6.0默认是否开启了多线程?

Redis 6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis.conf 配置文件:io-threads-do-reads yes

开启多线程后,还需要设置线程数,否则是不生效的。同样修改 redis.conf 配置文件。

关于线程数的设置,官方有一个建议:4 核的机器建议设置为 2 或 3 个线程,8 核的建议设置为 6 个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了 8 个基本就没什么意义了。

Redis 6.0采用多线程后,性能的提升效果如何?

Redis 作者 antirez 在 RedisConf 2019 分享时曾提到:Redis 6 引入的多线程 I/O 特性对性能提升至少是一倍以上。国内也有大牛曾使用 unstable 版本在阿里云 esc 进行过测试,GET/SET 命令在 4 线程 I/O 时性能相比单线程是几乎是翻倍了。如果开启多线程,至少要 4 核的机器,且 Redis 实例已经占用相当大的 CPU 耗时的时候才建议采用,否则使用多线程没有意义。

三、缓存淘汰算法

当 Redis 内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换(swap)。交换会让 Redis 的性能急剧下降,对于访问量比较频繁的 Redis 来说,这样龟速的存取效率基本上等于不可用。

3.1 maxmemeroy

在生产环境中我们是不允许 Redis 出现交换行为的,为了限制最大使用内存,Redis 提供了配置参数 maxmemory 来限制内存超出期望大小。

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

image.png

3.2 noeviction

noeviction 不会继续服务写请求(DEL 请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。

3.3 volatile-lru

volatile-lru 尝试淘汰设置了过期时间的 key,最少使用的 key 优先被淘汰。没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。

3.4 volatile-ttl

volatile-ttl 跟上面一样,除了淘汰的策略不是 LRU,而是 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰。

3.5 volatile-random

volatile-random 跟上面一样,不过淘汰的 key 是过期 key 集合中随机的 key。

3.6 allkeys-lru

allkeys-lru 区别于 volatile-lru,这个策略要淘汰的 key 对象是全体的 key 集合,而不只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。

3.7 allkeys-random

allkeys-random 跟上面一样,不过淘汰的策略是随机的 key。

volatile-xxx 策略只会针对带过期时间的 key 进行淘汰,allkeys-xxx 策略会对所有的 key 进行淘汰。如果你只是拿 Redis 做缓存,那应该使用 allkeys-xxx,客户端写缓存时不必携带过期时间。如果你还想同时使用 Redis 的持久化功能,那就使用 volatile-xxx 策略,这样可以保留没有设置过期时间的 key,它们是永久的 key 不会被 LRU 算法淘汰。

3.8 LRU算法

实现 LRU 算法除了需要 key/value 字典外,还需要附加一个链表,链表中的元素按照一定的顺序进行排列。当空间满的时候,会踢掉链表尾部的元素。当字典的某个元素被访问时,它在链表中的位置会被移动到表头。所以链表的元素排列顺序就是元素最近被访问的时间顺序。

位于链表尾部的元素就是不被重用的元素,所以会被踢掉。位于表头的元素就是最近刚刚被人用过的元素,所以暂时不会被踢。

3.9 近似LRU

Redis 使用的是一种近似 LRU 算法,它跟 LRU 算法还不太一样。之所以不使用 LRU 算法,是因为需要消耗大量的额外的内存,需要对现有的数据结构进行较大的改造。

近似 LRU 算法则很简单,在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和 LRU 算法非常近似的效果。Redis 为实现近似 LRU 算法,它给每个 key 增加了一个额外的小字段,这个字段的长度是 24 个 bit,也就是最后一次被访问的时间戳。

当 Redis 执行写操作时,发现内存超出 maxmemory,就会执行一次 LRU 淘汰算法。这个算法也很简单,就是随机采样出 5(可以配置 maxmemory-samples)个 key,然后淘汰掉最旧的 key,如果淘汰后内存还是超出 maxmemory,那就继续随机采样淘汰,直到内存低于 maxmemory 为止。

如何采样就是看 maxmemory-policy 的配置,如果是 allkeys 就是从所有的 key 字典中随机,如果是 volatile 就从带过期时间的 key 字典中随机。每次采样多少个 key 看的是 maxmemory_samples 的配置,默认为 5。

采样数量越大,近似 LRU 算法的效果越接近严格 LRU 算法。

同时 Redis3.0 在算法中增加了淘汰池,新算法会维护一个候选池(大小为 16),池中的数据根据访问时间进行排序,第一次随机选取的 key 都会放入池中,随后每次随机选取的 key 只有在访问时间小于池中最小的时间才会放入池中,直到候选池被放满。当放满后,如果有新的 key 需要放入,则将池中最后访问时间最大(最近被访问)的移除。进一步提升了近似 LRU 算法的效果。

Redis 维护了一个 24 位时钟,可以简单理解为当前系统的时间戳,每隔一定时间会更新这个时钟。每个 key 对象内部同样维护了一个 24 位的时钟,当新增 key 对象的时候会把系统的时钟赋值到这个内部对象时钟。比如我现在要进行 LRU,那么首先拿到当前的全局时钟,然后再找到内部时钟与全局时钟距离时间最久的(差最大)进行淘汰,这里值得注意的是全局时钟只有 24 位,按秒为单位来表示才能存储 194 天,所以可能会出现 key 的时钟大于全局时钟的情况,如果这种情况出现那么就两个相加而不是相减来求最久的 key。

3.10 LFU算法

LFU 算法是 Redis 4.0 里面新加的一种淘汰策略。它的全称是 Least Frequently Used,它的核心思想是根据 key 的最近被访问的频率进行淘汰,很少被访问的优先被淘汰,被访问的多的则被留下来。

LFU 算法能更好的表示一个 key 被访问的热度。假如你使用的是 LRU 算法,一个 key 很久没有被访问到,只刚刚是偶尔被访问了一次,那么它就被认为是热点数据,不会被淘汰,而有些 key 将来是很有可能被访问到的则被淘汰了。如果使用 LFU 算法则不会出现这种情况,因为使用一次并不会使一个 key 成为热点数据。LFU 原理使用计数器来对 key 进行排序,每次 key 被访问的时候,计数器增大。计数器越大,可以约等于访问越频繁。具有相同引用计数的数据块则按照时间排序。

LFU一共有两种策略:

  • volatile-lfu:在设置了过期时间的 key 中使用LFU算法淘汰 key。
  • allkeys-lfu:在所有的 key 中使用 LFU 算法淘汰数据

LFU 把原来的 key 对象的内部时钟的 24 位分成两部分,前 16 位 ldt 还代表时钟,后 8 位 logc 代表一个计数器。

logc 是 8 个 bit,用来存储访问频次,因为 8 个 bit 能表示的最大整数值为 255,存储频次肯定远远不够,所以这 8 个 bit 存储的是频次的对数值,并且这个值还会随时间衰减,如果它的值比较小,那么就很容易被回收。为了确保新创建的对象不被回收,新对象的这 8 个 bit 会被初始化为一个大于零的值 LFU INIT_VAL(默认是 5)。

ldt 是 16 个 bit,用来存储上一次 logc 的更新时间。因为只有 16 个 bit,所精度不可能很高。它取的是分钟时间戳对 2162^{16} 进行取模。

ldt 的值和 LRU 模式的 lru 字段不一样的地方是,ldt 不是在对象被访问时更新的,而是在 Redis 的淘汰逻辑进行时进行更新,淘汰逻辑只会在内存达到 maxmemory 的设置时才会触发,在每一个指令的执行之前都会触发。每次淘汰都是采用随机策略,随机挑选若干个 key,更新这个 key 的“热度”,淘汰掉“热度”最低的key。因为 Redis 采用的是随机算法,如果 key 比较多的话,那么 ldt 更新得可能会比较慢。不过既然它是分钟级别的精度,也没有必要更新得过于频繁。

ldt 更新的同时也会一同衰减 logc 的值。

3.11 缓存时间戳

我们平时使用系统时间戳时,常常是不假思索地使用 System.currentTimeInMillis 或者 time.time() 来获取系统的毫秒时间戳。Redis 不能这样,因为每一次获取系统时间戳都是一次系统调用,系统调用相对来说是比较费时间的,作为单线程的 Redis 承受不起,所以它需要对时间进行缓存,由一个定时任务,每毫秒更新一次时间缓存,获取时间都是从缓存中直接拿。

四、过期策略和惰性删除

4.1 过期策略

Redis 所有的数据结构都可以设置过期时间,时间一到,就会自动删除。但是会不会因为同一时间太多的 key 过期,以至于忙不过来。同时因为 Redis 是单线程的,删除的时间也会占用线程的处理时间,如果删除的太过于繁忙,会不会导致线上读写指令出现卡顿。

4.1.1 过期的 key 集合

Redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定时遍历这个字典来删除到期的 key。除了定时遍历之外,它还会使用惰性策略来删除过期的 key,所谓惰性策略就是在客户端访问这个 key 的时候,redis 对 key 的过期时间进行检查,如果过期了就立即删除。定时删除是集中处理,惰性删除是零散处理。

4.1.2 定时扫描策略

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

  1. 从过期字典中随机 20 个 key。
  2. 删除这 20 个 key 中已经过期的 key。
  3. 如果过期的 key 比率超过 1/41/4,那就重复步骤 1。

设想一个大型的 Redis 实例中所有的 key 在同一时间过期了,会出现怎样的结果?

毫无疑问,Redis 会持续扫描过期字典 (循环多次),直到过期字典中过期的 key 变得稀疏,才会停止(循环次数明显下降)。这就会导致线上读写请求出现明显的卡顿现象。导致这种卡顿的另外一种原因是内存管理器需要频繁回收内存页,这也会产生一定的 CPU 消耗。

所以业务开发人员一定要注意过期时间,如果有大批量的 key 过期,要给过期时间设置一个随机范围,而不能全部在同一时间过期。

4.1.3 从库的过期策略

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

因为指令同步是异步进行的,所以主库过期的 key 的 del 指令没有及时同步到从库的话,会出现主从数据的不一致,主库没有的数据在从库里还存在,比如上一节的集群环境分布式锁的算法漏洞就是因为这个同步延迟产生的。

4.2 惰性删除

所谓惰性策略就是在客户端访问这个 key 的时候,Redis 对 key 的过期时间进行检查,如果过期了就立即删除,不会给你返回任何东西。

定期删除可能会导致很多过期 key 到了时间并没有被删除掉。所以就有了惰性删除。假如你的过期 key,靠定期删除没有被删除掉,还停留在内存里,除非你的系统去查一下那个 key,才会被 Redis 给删除掉。这就是所谓的惰性删除,即当你主动去查过期的 key 时,如果发现 key 过期了,就立即进行删除,不返回任何东西。

总结:定期删除是集中处理,惰性删除是零散处理。

4.3 lazyfree

使用 DEL 命令删除体积较大的键, 又或者在使用 FLUSHDBFLUSHALL 删除包含大量键的数据库时,造成 Redis 阻塞的情况。另外 Redis 在清理过期数据和淘汰内存超限的数据时,如果碰巧撞到了大体积的键也会造成服务器阻塞。

为了解决以上问题,Redis 4.0 引入了 lazyfree 的机制,它可以将删除键或数据库的操作放在后台线程里执行,从而尽可能地避免服务器阻塞。

lazyfree 的原理不难想象,就是在删除对象时只是进行逻辑删除,然后把对象丢给后台,让后台线程去执行真正的 destruct,避免由于对象体积过大而造成阻塞。Redis 的 lazyfree 实现即是如此,下面我们由几个命令来介绍下 lazyfree 的实现。

4.0 版本引入了 unlink 指令,它能对删除操作进行懒处理,丢给后台线程来异步回收内存。

UNLINK 的实现中,首先会清除过期时间,然后调用 dictUnlink 把要删除的对象从数据库字典摘除,再判断下对象的大小(太小就没必要后台删除),如果足够大就丢给后台线程,最后清理下数据库字典的条目信息。

主线程将对象的引用从“大树”中摘除后,会将这个 key 的内存回收操作包装成一个任务,塞进异步任务队列,后台线程会从这个异步队列中取任务。任务队列被主线程和异步线程同时操作,所以必须是一个线程安全的队列。

Redis 提供了 flushdbflushall 指令,用来清空数据库,这也是极其缓慢的操作。Redis 4.0 同样给这两个指令也带来了异步化,在指令后面增加 async 参数就会进入后台删除逻辑。

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

image.png

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