浅析Redis

浅析Redis

什么是Redis

Redis本质上是一个Key-Value类型的内存数据库,整个数据库加载在内存当中操作,定期通过异步操作把数据库中的数据flush到硬盘上进行保存。

因为是纯内存操作,Redis的性能非常出色,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value 数据库。

Redis底层

Redis的底层请见 https://www.bozhu12.cc/backend/redis2/#_1-前言 这篇文章 讲的非常详细

Redis的线程模型

redis 内部使用文件事件处理器 file event handler ,它是单线程的,所以redis才叫做单线程模型。它采用IO多路复用机制同时监听多个 socket,将产生事件的 socket 压入内存队列中,事件分派器根据 socket 上的事件类型来选择对应的事件处理器进行处理。

文件事件处理器的结构:

  • 多个 socket
  • IO 多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
  1. Redis 启动初始化的时候,Redis 会将连接应答处理器与 AE_READABLE 事件关联起来。
  2. 如果一个客户端跟 Redis 发起连接,此时 Redis 会产生一个 AE_READABLE 事件,由于开始之初 AE_READABLE 是与连接应答处理器关联,所以由连接应答处理器来处理该事件,这时连接应答处理器会与客户端建立连接,创建客户端响应的 socket,同时将这个 socket 的 AE_READABLE 事件与命令请求处理器关联起来。
  3. 如果这个时间客户端向 Redis 发送一个命令(set k1 v1),这时 socket 会产生一个 AE_READABLE 事件,IO 多路复用程序会将该事件压入队列中,此时事件分派器从队列中取得该事件,由于该 socket 的 AE_READABLE 事件已经和命令请求处理器关联了,因此事件分派器会将该事件交给命令请求处理器处理,命令请求处理器读取事件中的命令并完成。操作完成后,Redis 会将该 socket 的 AE_WRITABLE 事件与命令回复处理器关联。
  4. 如果客户端已经准备好接受数据后,Redis 中的该 socket 会产生一个 AE_WRITABLE 事件,同样会压入队列然后被事件派发器取出交给相对应的命令回复处理器,由该命令回复处理器将准备好的响应数据写入 socket 中,供客户端读取。
  5. 命令回复处理器写完后,就会删除该 socket 的 AE_WRITABLE 事件与命令回复处理器的关联关系。

单线程处理流程

  1. 主线程处理网络 I/O 和命令执行:
    • 在单线程模式下,Redis 的主线程既负责从客户端读取请求,也负责执行命令和发送响应。所有的工作都是按照请求的顺序,依次完成。
    • 主线程会轮询所有的客户端连接,一个一个地处理请求。
  2. 处理客户端 A 的请求:
    • 主线程首先从客户端 A 读取 SET key1 value1 请求。
    • 读取完成后,主线程立即解析并执行该命令,将 key1 设置为 value1
    • 然后,主线程将 OK 结果发送回客户端 A。
  3. 处理客户端 B 的请求:
    • 接下来,主线程从客户端 B 读取 GET key1 请求。
    • 读取完成后,主线程解析并执行该命令,查询 key1 的值,得到 value1
    • 主线程将结果 value1 返回给客户端 B。
  4. 处理客户端 C 的请求:
    • 最后,主线程从客户端 C 读取 SET key2 value2 请求。
    • 主线程解析并执行该命令,将 key2 设置为 value2
    • 然后将 OK 结果返回给客户端 C。

具体步骤解释

  • 步骤 1:网络 I/O 和命令执行的顺序处理
    • Redis 依次轮询客户端 A、B、C 的连接,并从中读取请求数据。在主线程中,网络 I/O 和命令执行都是同步完成的,意味着 Redis 会处理完一个客户端的所有操作,才会继续处理下一个客户端的请求。
  • 步骤 2:命令解析与执行
    • 当主线程读取了一个完整的命令后,它会立即解析命令并执行。例如,主线程从客户端 A 读取 SET key1 value1 后,立即将 key1 设置为 value1,并返回 OK
  • 步骤 3:响应回写
    • 主线程执行完命令后,会立刻将响应结果发送回客户端。例如,客户端 B 请求 GET key1,主线程查询后,立即将查询结果 value1 发送给客户端 B。

多线程机制

客户端请求示例

假设有 3 个客户端同时向 Redis 发送请求:

  1. 客户端 A 发送 SET key1 value1
  2. 客户端 B 发送 GET key1
  3. 客户端 C 发送 SET key2 value2

多线程 I/O 处理流程

  1. 网络 I/O 阶段:
    • Redis 的 4 个 I/O 线程开始工作,每个线程负责从不同客户端接收数据。例如:
      • I/O 线程 1 从客户端 A 读取 SET key1 value1 的请求。
      • I/O 线程 2 从客户端 B 读取 GET key1 的请求。
      • I/O 线程 3 从客户端 C 读取 SET key2 value2 的请求。
  2. 主线程命令解析与执行:
    • 一旦 I/O 线程从客户端接收到完整的请求数据后,它们会将数据传递给 Redis 的主线程。
    • 主线程负责解析命令并执行它们:
      • 首先,主线程处理 SET key1 value1,将 key1 设置为 value1
      • 然后,主线程处理 GET key1,读取并返回 key1 的值(value1)。
      • 最后,主线程处理 SET key2 value2,将 key2 设置为 value2
  3. 网络响应阶段:
    • 命令执行完成后,主线程将结果传递回 I/O 线程:
      • I/O 线程 1 将 OK 响应返回给客户端 A。
      • I/O 线程 2 将 value1 返回给客户端 B。
      • I/O 线程 3 将 OK 返回给客户端 C。

内存淘汰底层原理

1. 淘汰过程

Redis 内存淘汰执行流程如下:

1.每次当 Redis 执行命令时,若设置了最大内存大小 maxmemory,并设置了淘汰策略式,则会尝试进行一次 Key 淘汰;

2.Redis 首先会评估已使用内存(这里不包含主从复制使用的两个缓冲区占用的内存)是否大于 maxmemory,如果没有则直接返回,否则将计算当前需要释放多少内存,随后开始根据策略淘汰符合条件的 Key;当开始进行淘汰时,将会依次对每个数据库进行抽样,抽样的数据范围由策略决定,而样本数量则由 maxmemory-samples配置决定;

3.完成抽样后,Redis 会尝试将样本放入提前初始化好 EvictionPoolLRU 数组中,它相当于一个临时缓冲区,当数组填满以后即将里面全部的 Key 进行删除。

4.若一次删除后内存仍然不足,则再次重复上一步骤,将样本中的剩余 Key 再次填入数组中进行删除,直到释放了足够的内存,或者本次抽样的所有 Key 都被删除完毕(如果此时内存还是不足,那么就重新执行一次淘汰流程)。

在抽样这一步,涉及到从字典中随机抽样这个过程,由于哈希表的 Key 是散列分布的,因此会有很多桶都是空的,纯随机效率可能会很低。因此,Redis 采用了一个特别的做法,那就是先连续遍历数个桶,如果都是空的,再随机调到另一个位置,再连续遍历几个桶......如此循环,直到结束抽样。

你可以参照源码理解这个过程:

unsigned int dictGetSomeKeys(dict *d, dictEntry **des, unsigned int count) {
    unsigned long j; /* internal hash table id, 0 or 1. */
    unsigned long tables; /* 1 or 2 tables? */
    unsigned long stored = 0, maxsizemask;
    unsigned long maxsteps;
​
    if (dictSize(d) < count) count = dictSize(d);
    maxsteps = count*10;
​
    // 如果字典正在迁移,则协助迁移
    for (j = 0; j < count; j++) {
        if (dictIsRehashing(d))
            _dictRehashStep(d);
        else
            break;
    }
​
    tables = dictIsRehashing(d) ? 2 : 1;
    maxsizemask = d->ht[0].sizemask;
    if (tables > 1 && maxsizemask < d->ht[1].sizemask)
        maxsizemask = d->ht[1].sizemask;
​
    unsigned long i = random() & maxsizemask;
    unsigned long emptylen = 0;
​
    // 当已经采集到足够的样本,或者重试已达上限则结束采样
    while(stored < count && maxsteps--) {
        for (j = 0; j < tables; j++) {
            if (tables == 2 && j == 0 && i < (unsigned long) d->rehashidx) {
                if (i >= d->ht[1].size)
                    i = d->rehashidx;
                else
                    continue;
            }
​
            // 如果一个库的到期字典已经处理完毕,则处理下一个库
            if (i >= d->ht[j].size) continue;
            dictEntry *he = d->ht[j].table[i];
​
            // 连续遍历多个桶,如果多个桶都是空的,那么随机跳到另一个位置,然后再重复此步骤           
            if (he == NULL) {
                emptylen++;
                if (emptylen >= 5 && emptylen > count) {
                    i = random() & maxsizemask;
                    emptylen = 0;
                }
            } else {
                emptylen = 0;
                while (he) {
                    *des = he;
                    des++;
                    he = he->next;
                    stored++;
                    if (stored == count) return stored;
                }
            }
        }
​
        // 查找下一个桶
        i = (i+1) & maxsizemask;
    }
    return stored;
}

2. LRU 实现

LRU 的全称为 Least Recently Used,也就是最近最少使用。一般来说,LRU 会从一批 Key 中淘汰上次访问时间最早的 key。

它是一种非常常见的缓存回收算法,在诸如 Guava Cache、Caffeine等缓存库中都提供了类似的实现。我们自己也可以基于 JDK 的 LinkedHashMap 实现支持 LRU 算法的缓存功能。

2.1 近似 LRU

传统的 LRU 算法实现通常会维护一个链表,当访问过某个节点后就将该节点移至链表头部。如此反复后,链表的节点就会按最近一次访问时间排序。当缓存数量到达上限后,我们直接移除尾节点,即可移除最近最少访问的缓存。

不过,对于 Redis 来说,如果每个 Key 添加的时候都需要额外的维护并操作这样一条链表,要额外付出的代价显然是不可接受的,因此 Redis 中的 LRU 是近似 LRU(NearlyLRU)。

当每次访问 Key 时,Redis 会在结构体中记录本次访问时间,而当需要淘汰 Key 时,将会从全部数据中进行抽样,然后再移除样本中上次访问时间最早的 key。

它的特点是:

  • 仅当需要时再抽样,因而不需要维护全量数据组成的链表,这避免了额外内存消耗。

  • 访问时仅在结构体上记录操作时间,而不需要操作链表节点,这避免了额外的性能消耗。

当然,有利就有弊,这种实现方式也决定 Redis 的 LRU 是并不是百分百准确的,被淘汰的 Key 未必真的就是所有 Key 中最后一次访问时间最早的。

2.2 抽样大小

根据上述的内容,我们不难理解,当抽样的数量越大,LRU 淘汰 Key 就越准确,相对的开销也更大。因此,Redis 允许我们通过 maxmemory-samples 配置采样数量(默认为 5),从而在性能和精度上取得平衡。

3. LFU 实现

LFU 全称为 Least Frequently Used ,也就是最近最不常用。它的特点如下:

  • 同样是基于抽样实现的近似算法,maxmemory-samples 对其同样有效。

  • 比较的不是最后一次访问时间,而是数据的访问频率。当淘汰的时候,优先淘汰范围频率最低 Key。

它的实现与 LRU 基本一致,但是在计数部分则有所改进。

3.1 概率计数器

在 Redis 用来存储数据的结构体 redisObj 中,有一个 24 位的 lru数值字段:

  • 当使用 LRU 算法时,它用于记录最后一次访问时间的时间戳。

  • 当使用 LFU 算法时,它被分为两部分,高 16 位关于记录最近一次访问时间(Last Decrement Time),而低 8 位作为记录访问频率计数器(Logistic Counter)。

LFU 的核心就在于低 8 位表示的访问频率计数器(下面我们简称为 counter),是一个介于 0 ~ 255 的特殊数值,它会每次访问 Key 时,基于时间衰减和概率递增机制动态改变。

| 这种基于概率,使用极小内存对大量事件进行计数的计数器被称为莫里斯计数器,它是一种概率计数法的实现。

3.2 时间衰减

每当访问 Key 时,根据当前实际与该 Key 的最后一次访问时间的时间差对 counter 进行衰减。

衰减值取决于 lfu_decay_time 配置,该配置表示一个衰减周期。我们可以简单的认为,每当时间间隔满足一个衰减周期时,就会对 counter 减一。

比如,我们设置 lfu_decay_time为 1 分钟,那么如果 Key 最后一次访问距离现在已有 3 分 30 秒,那么 counter 就需要减 3。

3.3 概率递增

在完成衰减后,Redis 将根据 lfu_log_factor 配置对应概率值对 counter 进行递增。

这里直接放上源码:

/* Logarithmically increment a counter. The greater is the current counter value
 * the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {
    // 若已达最大值 255,直接返回
    if (counter == 255) return 255;
    // 获取一个介于 0 到 1 之间的随机值
    double r = (double)rand()/RAND_MAX;
    // 根据当前 counter 减去初始值得到 baseval
    double baseval = counter - LFU_INIT_VAL; 
    if (baseval < 0) baseval = 0;
    // 使用 baseval*server.lfu_log_factor+1 得到一个概率值 p
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    // 当 r < p 时,递增 counter
    if (r < p) counter++;
    return counter;
}

简而言之,直接从代码上理解,我们可以认为 counter和 lfu_log_factor 越大,则递增的概率越小:

当然,实际上也要考虑到访问次数对其的影响,Redis 官方给出了相关数据:

3.4 计数器的初始值

为了防止新的 Key 由于 counter 为 0 导致直接被淘汰,Redis 会默认将 counter设置为 5。

3.5 抽样大小的选择

值得注意的是,当数据量比较大的时候,如果抽样大小设置的过小,因为一次抽样的样本数量有限,冷热数据因为时间衰减导致的权重差异将会变得不明显,此时 LFU 算法的优势就难以体现,即使的相对较热的数据也有可能被频繁"误伤"。

所以,如果你选择了 LFU 算法作为淘汰策略,并且同时又具备比较大的数据量,那么不妨将抽样大小也设置的大一些。