前言

Redis详解——存储 中介绍了Redis的基础数据结构,本文我们来看看Redis是如何组织这些数据类型,来构建一个内存数据库的。

一、内存数据库

以下是Redis数据库的结构: image.png

Redis服务器程序所有的数据库都保存在redisService结构体中,其中有个db数组,为redisDb类型,每个元素为一个数据库。

 

db数组可配置,默认为16个,redisDb中保存了一个字典,该字典保存了数据库中所有的键值对,我们也称该字典为键空间(key space)。

 

字典的key为String类型,字典的value就是我们上一节提到的各种数据类型了,这些数据类型让Redis可以存储多样化的数据,利用特定的数据结构实现一些业务场景。

客户端连接哪个数据库? 默认情况下,Redis客户端会连接0号数据库,可以通过SELECT命令切换数据库。

127.0.0.1:6379> SET name arthinking
OK
127.0.0.1:6379> GET name
"arthinking"
127.0.0.1:6379> DEL name
(integer) 1
127.0.0.1:6379> GET name
(nil)

二、读写键的时候做了啥

当通过命令对数据库进行了读写之后,Redis同时会做一些维护工作: image.png

三、如何存储键的过期时间

3.1、过期相关命令

  • EXPIRE key seconds,设置key的生存秒数;
  • PEXPIRE key milliseconds,设置key的生存毫秒数;
  • EXPIREAT key timestamp,设置key的过期时间戳(秒);
  • PEXPIREAT key timestamp,设置key的过期时间戳(毫秒)
  • SETEX,设置一个字符串的过期时间;
  • TTL与PTTL,接收一个带有生存时间的键,返回键的剩余生成时间。

这些设置过期时间命令,本质上都会转成PEXPIREAT命令来执行,数据库中存储的是键的过期时间点。

3.2、应该使用哪个过期命令比较靠谱?

注意:建议直接使用EXPIREAT命令来设置过期时间,避免主从同步延迟,导致从库实际的EXPIREAT时间比主库的晚,最终客户端在从库上读取到了过期的数据(主库已过期,从库未过期)。

3.3、过期字典

我们注意到,上面的数据库结构图中,包含了一个expires过期字典,该字典的键是一个纸指向键空间中某个数据库键的指针,值是一个long long类型的整数,保存数据库键的过期时间(毫秒时间戳)。

image.png

四、如何删除过期键

在一般程序设计中,我们也会用三种策略来实现数据的过期删除: image.png

定时删除策略是一种方案,但是如果设置的不合理,就会即浪费CPU,或者内存及时删除。为此,Redis采用了惰性删除和定期删除配合工作的方式。

  • Redis中的惰性删除:接收读写数据库命令,判断是否已过期,如果过期则删除并返回空回复,否则执行实际的命令流程;
  • Redis中的定期删除:每次运行,从一定量数据库中取出一定量随机键进行检查,然后把过期的键删除掉,通过一个全局表示current_db记录处理进度,确保所有数据库都可以被处理。

4.1、从库的KEY过期了可以被清理掉吗?

当主库键key过期时时,会同步一个DEL操作到从库,从库不会自己删除过期key,只会应用从主库同步过来的DEL操作,这样就避免了缓存一致性的错误。

 

这样就会有一个问题,如果从库在同步DEL操作之前,就有客户端请求从库获取key,那么就有可能读取到主库已经删除,但是从库还未删除的key。

 

好在从Redis 3.2开始,对从库读取key做了优化:在从库发起读请求的时候,会先判断这个key是否过期,如果过期了,就直接返回nil,从而避免了在从库中读取到了过期的key的问题。

 

另外:建议直接使用EXPIREAT命令来设置过期时间,避免主从同步延迟,导致从库实际的EXPIREAT时间比主库的晚,最终客户端在从库上读取到了过期的数据(主库已过期,从库未过期)。

五、Redis中的发布订阅机制

Redis的发布订阅功能有以下命令组成:

  • SUBSCRIBE channel [channel ...]

    • 订阅给定的一个或多个频道的信息;
    • SUBSCRIBE channel

  • UNSUBSCRIBE [channel [channel ...]]

    • 退订给定的频道
    • UNSUBSCRIBE channel

  • PUBLISH channel message

    • 用于将信息发送到指定的频道;
    • PUBLISH channel itzhai.com

  • PUBSUB subcommand [argument [argument ...\]]

    • 查看订阅与发布系统状态
    • PUBSUB CHANNELS

  • PSUBSCRIBE pattern [pattern ...]

    • 订阅一个或多个符合给定模式的频道
    • PSUBSCRIBE site.*

  • PUNSUBSCRIBE [pattern [pattern ...\]]

    • 退订给定模式的频道
    • UNSUBSCRIBE site.*

如下,通过给定的模式频道进行订阅和发布消息:

# 客户端A订阅模式频道
127.0.0.1:6379> PSUBSCRIBE site.*
psubscribe
site.*
1

# 客户端B向模式频道发布消息
127.0.0.1:6379> PUBLISH site.itzhai "hello world!!!"
1

# 客户端A输出
pmessage
site.*
site.itzhai
hello world!!!

Redis在服务端通过链表的形式维护了每个频道的客户端的订阅记录,每次发布消息的时候,都从链表中找到所有相关的客户端的socket连接,并发送订阅消息给各个客户端。Redis中存放客户端订阅关系的相关数据结构:

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

具体结构如下图所示: image.png

在dict字典中,每个键值对存储一个频道的订阅关系,key为频道名称,value为链表结构,存储该频道所有订阅的客户端。

 

每当执行SUBSCRIBE命令的时候,执行把客户端追加到字典中对应频道的key的values链表中即可。

 

每当执行PUBLISH命令的时候,从字典中找到对应的频道键值对,依次遍历values中所有的客户端进行发送消息即可。

 

在list链表中,保存了所有的模式频道订阅关系。

 

每当执行PUBLISH命令的时候,除了在pubsub_channels寻找频道订阅关系,发给具体的频道的所有客户端之外,同时,Redis会遍历pubsub_patterns中的所有订阅模式频道,找到与当前发布消息频道匹配的模式频道,将消息发送给该模式频道的客户端。

5.1、发布订阅的优缺点

通过使用Redis的发布订阅机制,很容易实现消息的订阅与通知,可以快速实现一个消息队列。

 

但是,该消息队列的缺点也比较明显,请大家慎用:

  • 发布的消息不支持持久化,如果Redis挂了,那么发布的消息也就丢失了;或者消息发送给了一半的订阅者,Redis就挂了,那么剩下的一般订阅者也就不会收到消息了;或者准备发送消息给其中一个订阅者的时候,该订阅者失去连接了,消息也会丢失;
  • 消息发送缺少ACK机制,不能保证消息一定会被消费...

针对可靠性要求低的业务,为了简单快速实现,可以使用Redis的发布订阅机制来实现消息通知。而对于可靠性要求比较高的,则可以尝试Redis 5.0的新数据结构Stream,具体用法和原理。

六、实现数据库通知

基于Redis的发布订阅机制,我们就可以实现数据库通知功能了。该功能常常用于作为对数据或者命令的监控。

因为开启数据库通知需要消耗一定的CPU,所以默认配置下,是关闭状态的。为了开启这个功能更,我们可以修改redis.conf文件:

notify-keyspace-events KElg

如上,我们开启了:

  • K:键空间通知,所有通知以__keyspace@<db>__为前缀;
  • E:键事件通知,所有通知以__keyevent@<db>__为前缀;
  • l:列表命令通知;
  • g:DEL 、 EXPIRE 、 RENAME 等类型无关的通用命令的通知。

更多关于notify-keyspace-events的配置,请参考官方文档:Redis Keyspace Notifications

现在我们启动Redis服务器,就支持数据库通知了。

现在我们在一个客户端1订阅一个键空间通知,监听我的钱包my_money:

127.0.0.1:6379> SUBSCRIBE __keyspace@0__:my_money
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "__keyspace@0__:my_money"
3) (integer) 1

在另一个客户端2,给我的钱包打100块钱看看:

127.0.0.1:6379> SET my_money 100
OK

结果我们在客户端1可以收到以下到账通知:

1) "message"
2) "__keyspace@0__:my_money"
3) "set"

另外,我们也可以监听某一个命令:

127.0.0.1:6379> SUBSCRIBE __keyevent@0__:del

七、Redis客户服务程序设计

Redis在传输层,使用的是TCP协议,每当有客户端连接到服务器的时候,都会创建一个Socket连接,对应一个套接字文件描述符fd。

而在Redis服务器中,是如何维护这些网络连接的呢,接下来我们就来看看。

7.1、客户端信息

当客户端与Redis服务器连接之后,redisServer结构中会存储一个客户端的链表,该链表节点用来记录与客户端通信的各种信息: image.png

我们重点来关注redisClient以下信息:

  • int fd:文件描述符,-1代表伪客户端;

  • robj * name:客户端名字;

  • int flags:客户端标志,flags可以是单个标志,或者多个标志的二进制或,常见的标志:

    • REDIS_LUA_CLIENT:表示客户端是用于处理Lua脚本的伪户端;
    • REDIS_MONITOR:客户端正在执行MONITOR命令;
    • REDIS_UNIX_SOCKET:服务器使用UNIX套接字来连接客户端;
    • REDIS_BLOCKED:客户端正在被BRPOP、BLPOP等命令阻塞;
    • REDIS_UNBOKCKED:表示客户端已从阻塞状态中脱离出来,该标志只能在REDIS_BLOCKED标志已经打开的情况下使用;
    • REDIS_MULTI:客户端正在执行事务
    • REDIS_FORCE_AOF:强制将执行的命令写入到AOF文件里面,一般情况,Redis只会对数据库进行了修改的命令写入到AOF文件中,而对于PUBSUB和SCRIPT LOAD命令,则需要通过该标志,强制将这个命令写入到AOF文件中,另外,为了让主从服务器可以正确载入SCRIPT LOAD命令指定的脚本,需要使用REDIS_FORCE_REPL标志,强制将SCRIPT LOAD命令复制给所有从服务器;
  • sds querybuf:客户端输入缓冲区,命令请求字符串,最大大小不能超过1G,否则客户端将被服务器关闭;

  • robj **argv:要执行的命令,以及所有参赛构成的数组;

  • int argc:argv数组的长度;

  • struct redisCommand *cmd:客户端请求对应的命令,从字典结构的命令表中查找得到;

  • char buf[REDIS_REPLY_CHUNK_BYTES]:固定大小缓冲区,REDIS_REPLY_CHUNK_BYTES值默认为16KB;

  • int bufpos:固定大小缓冲区易用字节数量;

  • list *reply:可变大小缓冲区由链表组成,每个节点为一个字符串对象;

  • int authenticated:客户端身份验证状态,1表示验证通过,如果Redis启用了身份验证功能,则需要用到该字段;

  • time_t ctime:客户端连接创建时间;

  • time_t lastinteraction:客户端与服务器最后一次通信时间;

  • time_t obuf_soft_limit_reached_time:输出缓冲区第一次达到软性限制的时间;

7.2、命令执行流程

一个命令执行的完整流程如下图所示: image.png

可以发现,Redis执行命令的整个过程,相关的中间信息都存储在redisClient中。

 

参考: https://www.itzhai.com/articles/redis-technology-insider-cache-data-structure-concurrency-clustering-and-algorithm.html