一、服务器中的数据库

Redis服务器将所有数据库都保存在服务器状态redisServer结构的db数组中,db数组的每个项都是一个redisDb结构,每个redisDb结构代表一个数据库。在初始化服务器时,程序会根据服务器状态的dbnum属性来决定应该创建多少个数据库,默认为16个

struct redisServer {
	...
    
    // 一个数组保存服务器所有的数据库
    redisDb *db;
     
    // 服务器的数据库的数量
    int dbnum;                      /* Total number of configured DBs */

    ...    
}

二、切换数据库

每个Redis客户端都有自己的目标数据库,每个客户端执行数据库写命令的时候或者读命令的时候,目标数据库就会成为这些命令的操作对象

默认情况下,客户端在0号数据库设置,可以使用SELECT 切换数据库,如下
在这里插入图片描述

在服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的的目标数据库,这个属性是指向redisDb结构的指针

typedef struct redisClient {
	. . .

	// 记录客户端正在使用的数据库
	redisDb *db;

	. . . 
}

注意:

目前为止,Redis仍然没有可以返回客户端目标数据库的命令,但是如果使用其他语言操作Redis,并且该语言的客户端没有显示数据库,那么经过数次数据库切换可能会忘记在哪个数据库,特别是执行FLUSHDB这一类的操作之前,一定要先执行SELECT 操作。

三、数据库键空间

Redis是一个键值对(key-value pair)数据库服务器,服务器中的每个数据库都由一个redisDb结构表示,其中,redisDb结构的dict字典保存了数据库中的所有键值对,我们将这个字典称为键空间(key space)

键空间和用户所见的数据库是直接对应的

  • 键空间的键也就是数据库的键,每个键都是一个字符串对象
  • 键空间的值也就是数据库的值,每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的任意一种Redis对象

例如执行操作

在这里插入图片描述

会形成如下数据结构

在这里插入图片描述
对于键值的各种增删改查,也比较容易联想,就不说了

读写键空间的维护操作

当使用Redis命令对数据库进行读写时,服务器不仅会对键空间执行指定读写操作,还会执行一些额外的维护操作

  • 在读取一个键之后(包括读操作和写操作),服务器会根据键是否存在,来更新服务器的键命中和不命中次数,可以通过INFO stats 命令的 keyspace_hits 属性和 keyspace_misses 属性查看
  • 在读取一个键之后,服务器会更新键的LRU(最后一次使用时间),这个值可以用于计算键的限制时间,使用OBJECT idletime < key > 命令查看
  • 如果服务器在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作
  • 如果有客户端使用WATCH命令监视某个key,那么服务器在对被监视的键进行修改之后,会将这个键标记为脏,从而让事务程序注意到这个键已经被修改过
  • 服务器每次修改一个键之后,都会对脏键计数器的值增1,这个计数器会触发服务器的持久化以及复制操作(例如自动BGSAVE操作,就是查看在多少时间内对键进行了多少次修改,达到设定值则进行RDB持久化)
  • 如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知。

四、设置键的生存时间或过期时间

通过 EXPIRE 命令或者 PEXPIRE 命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间(Time To Live,TTL),在经过指定的秒数或者毫秒数之后,服务器就会自动删除生存时间为0的键

在这里插入图片描述
通过 EXPIREATPEXPIREAT 命令,可以设定一个时间戳,该过期时间是一个UNIX时间戳,当键的过期时间来临时,服务器就会自动从数据库中删除这个键,可以通过TIME命令查看UNIX的时间

在这里插入图片描述
TTL命令和PTTL命令接受一个带有生存时间或者过期时间的键,返回这个键的剩余生存时间,也就是,返回距离这个键被服务器自动删除还有多长时间

1. 设置过期时间

通过上面的例子,可以看出Redis有四个不同的命令可以用于设置键的生存时间(键可以存在多久)或过期时间(键什么时候会被删除)

  • EXPIRE<key><ttl>命令用于将键key的生存时间设置为ttl秒
  • PEXPIRE<key><ttl>命令用于将键key的生存时间设置为ttl毫秒
  • EXPIREAT<key><timestamp>命令用于将键key的过期时间设置为timestamp所指定的秒数时间戳
  • PEXPIREAT<key><timestamp>命令用于将键key的过期时间设置为timestamp所指定的毫秒数时间戳

虽然有多种不同单位和不同形式的设置命令,但实际上EXPIRE、PEXPIRE、EXPIREAT三个命令都是使用PEXPIREAT命令来实现的

在这里插入图片描述

2. 保存过期时间

redisDb结构的expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典

  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(也即是某个数据库键)
  • 过期字典的值是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间——一个毫秒精度的UNIX时间戳
typedef struct redisDb {
    ...
        
    // 过期字典,用于存放有过期时间的对象
    dict *expires;              /* Timeout of keys with a timeout set */
   
    ...
        
} redisDb;

移除过期时间

PERSIST命令可以移除一个键的过期时间

PERSIST命令就是PEXPIREAT命令的反操作:PERSIST命令在过期字典中查找给定的键,并解除键和值(过期时间)在过期字典中的关联

3. 计算并返回剩余时间和过期键的判定

计算并返回剩余生存时间

TTL命令以秒为单位返回键的剩余生存时间,而PTTL命令则以毫秒为单位返回键的剩余生存时间。这两个命令都是通过计算键的过期时间和当前时间之间的差来实现的

过期键的判定

通过过期字典,程序可以用以下步骤检查一个给定键是否过期

  • 检查给定键是否存在于过期字典
    • 如果存在,那么取得键的过期时间
  • 检查当前UNIX时间戳是否大于键的过期时间
    • 是的话,那么键已经过期
    • 不是的话,键未过期

五、过期删除策略

通过上面的知识,我们知道了数据库键的过期时间都保存在过期字典中,又知道了如何根据过期时间去判断一个键是否过期,现在剩下的问题是:如果一个键过期了,那么它什么时候会被删除呢?

有以下三种删除策略

  • 定时删除:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作
  • 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键
  • 定期删除:每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定

定时删除

  • 优点
    • 定时删除策略对内存是最友好的:通过使用定时器,定时删除策略可以保证过期键会尽可能快地被删除,并释放过期键所占用的内存
  • 缺点
    • 它对CPU时间是最不友好的:在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU时间,在内存不紧张但是CPU时间非常紧张的情况下,将CPU时间用在删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响
    • 创建一个定时器需要用到Redis服务器中的时间事件,而当前时间事件的实现方式——无序链表,查找一个事件的时间复杂度为O(N)——并不能高效地处理大量时间事件

惰性删除

惰性删除的优缺点和定时删除恰恰相反

  • 优点
    • 惰性删除策略对CPU时间来说是最友好的:程序只会在取出键时才对键进行过期检查,这可以保证删除过期键的操作只会在非做不可的情况下进行,并且删除的目标仅限于当前处理的键,这个策略不会在删除其他无关的过期键上花费任何CPU时间
  • 缺点
    • 它对内存是最不友好的:如果一个键已经过期,而这个键又仍然保留在数据库中,那么只要这个过期键不被删除,它所占用的内存就不会释放

定期删除

定期删除是一种较为综合的删除策略,能够兼顾CPU与内存

  • 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响
  • 通过定期删除过期键,定期删除策略有效地减少了因为过期键而带来的内存浪费

六、Redis的删除策略

Redis服务器实际使用的是惰性删除和定期删除两种策略,通过配合使用这两个删除策略,可以很好地合理使用CPU时间和避免浪费内存空间之间取得平衡

1. 惰性删除策略的实现

过期键的惰性删除策略由expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查(该函数就像一个AOP的前置通知,在真正执行命令之前删除过期的键)

  • 如果输入键已经过期,那么expireIfNeeded函数将输入键从数据库中删除
  • 如果输入键未过期,那么expireIfNeeded函数不做动作

2. 定期删除策略的实现

过期键的定期删除策略由activeExpireCycle函数实现,每当Redis的服务器周期性操作serverCron函数执行时,activeExpireCycle函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的expires字典中随机检查一部分键的过期时间,并删除其中的过期键

activeExpireCycle函数的工作模式可以总结如下

  • 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键
  • 全局变量current_db会记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理。比如说,如果当前activeExpireCycle函数在遍历10号数据库时返回了,那么下次activeExpireCycle函数执行时,将从11号数据库开始查找并删除过期键
  • 随着activeExpireCycle函数的不断执行,服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0,然后再次开始新一轮的检查工作

七、Redis过期淘汰策略

因为只使用以上过期删除的策略,仍有可能因为有大量的Key没有被删除而导致OOM,所以需要引入过期淘汰策略,在内存使用量超出的时候删除键值对。

redis.conf 中共有八种可以配置
在这里插入图片描述

  • volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使⽤的数据淘汰。
  • allkeys-lru(least recently used):当内存不⾜以容纳新写⼊数据时,在键空间中,移除最近最少使⽤的 key(这个是最常⽤的)。
  • volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使⽤的数据淘汰。
  • allkeys-lfu(least frequently used):当内存不⾜以容纳新写⼊数据时,在键空间中,移除最不经常使⽤的 key。
  • volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。
  • allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰。
  • volatile-ttl(time to live):从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。
  • no-eviction:禁⽌驱逐数据,也就是说当内存不⾜以容纳新写⼊数据时,新写⼊操作会报错。(默认配置,但是不应该用这个)。

LRU 的实现:

Redis 内部维护了一个24位时钟,每个对象也维护了一个时钟,每次使用对象的时候,该时钟就会变成当前系统的时间戳,当需要进行LRU的时候,就选择时钟举例当前系统时间最长的对象淘汰。

LFU 的实现

将24位的时钟分成两部分,前16位代表时间,后8位代表使用的次数,如果以前使用的次数很多,而最近一直不使用会根据时间进行衰减,另外,为了防止某个key刚加入就被删除,所以会对新加入的key赋上一个值初始值5。

九、Redis 为什么快?

  • 纯内存操作,不需要进行磁盘IO等费时操作。
  • 单线程执行命令。避免多线程的切换以及竞争的消耗。
  • 通过 epoll IO多路复用服务大量请求。
  • 巧妙的数据结构。

十、Redis 的发布订阅

1. 概述

Redis的发布与订阅功能由 PUBLISH、SUBSCRIBE、PSUBSCRIBE 等命令组成。

  • 通过执行SUBSCRIBE命令,客户端可以订阅一个或多个频道。将三个客户端订阅同一个频道。

在这里插入图片描述
在这里插入图片描述

  • 再使用一个客户端执行 PUBLISH 命令向频道发送内容。三个订阅该频道的客户端都收到了信息。

在这里插入图片描述
在这里插入图片描述

  • PSUBSCRIBE 命令可以订阅匹配模式的频道。例如RabbitMQ 的 topic 模式,可以利用通配符来进行模式匹配的绑定。如下图,对于news.it 和 news.et 都可以接受。

在这里插入图片描述

2. 【频道】的订阅与退订

当一个客户端执行SUBSCRIBE命令订阅某个或某些频道的时候,这个客户端与被订阅频道之间就建立起了一种订阅关系。

Redis 将所有频道的订阅关系都保存在服务器状态的 pubsub_channels 字典里面,这个字典的键是某个被订阅的频道值是一个链表,链表里面记录了所有订阅这个频道的客户端。

struct redisServer {
	// ...
	// 保存所有频道的订阅关系
	dict *pubsub_channels;
	// ...
};
  • SUBSCRIBE 命令订阅频道,如果之前没有订阅,则将该频道加入字典中,并构建订阅者的链表,如果已经存在,则只需要将该订阅者加入到链表。
  • UNSUBSCRIBE 命令则是退订操作,将该订阅者从链表中移除。如果订阅者全部没有了,则删除该频道。

3. 【模式】的订阅与退订

所有模式的订阅存放在一个链表中。

struct redisServer {
	// ...
	// 保存所有模式订阅关系
	list *pubsub_patterns;
	// ...
};

链表中每个节点又记录了订阅模式和订阅该模式的客户端。

typedef struct pubsubPattern {
	// 订阅模式的客户端
	redisClient *client;
	// 被订阅的模式
	robj *pattern;
} pubsubPattern;
  • 可以通过 PSUBSCRIBE 命令订阅某些模式并构造节点加入到链表中。
  • 或者通过 PUNSUBSCRIBE 命令退订某模式,也就是删除该链表中的某个节点。

4. 发送消息

当 Redis 客户端执行如下指令之后,会向该频道以及对应匹配的模式发送消息。

PUBLISH <channel> <message>
  • 消息发送给【频道】订阅者
    • 从字典中找到对应的频道,遍历订阅者链表,给所有订阅者发送消息。
  • 消息发送给【模式】订阅者
    • 遍历整个链表,检验匹配的模式并给对应的客户端发送消息。

参考《Redis 的设计与实现》

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐