本章将对Redis服务器的数据库实现进行介绍,介绍键空间、过期键,数据库通知的实现方法。

1. 数据库的切换

Redis服务器将所有数据库都保存在服务器状态redis.h/redisServer结构的db数组中,db数组的每个项都是一个redis.h/redisDb结构,每个redisDb结构代表一个数据库

struct redisServer 
{ 
    // ... 
    redisDb *db; 
    int dbnum;
    // ...
};

初始化时,程序会根据当前服务器的dbnum属性来决定建立数据库的个数,默认创建16个。


每个Redis客户端都有自己的目标数据库,当客户端执行读写命令时,就需要切换数据库

默认情况下,Redis客户端的目标数据库为0号数据库,但客户端可以通过执行SELECT命令来切换目标数据库。

redis> SET msg "hello world"
OK

redis> GET msg
"hello world"

redis> SELECT 2
OK

redis[2]> GET msg
(nil)

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

typedef struct redisClient 
{
    // ...
    //记录客户端当前正在使用的数据库
    redisDb *db;
    // ...
} redisClient;

如果某个客户端的目标数据库为1号数据库,那么这个客户端所对应的客户端状态和服务器状态之间的关系如图:

通过修改指针,使他指向服务器中不同的数据库,从而达到切换的目的。

2. 数据库键空间

2.1 键空间结构

Redis是一个键值对数据库服务器,每个数据库都是一个redis.h/redisDb结构。其中dict字典保存了数据库中所有的键值对,我们将这个字典称为键空间(key space)

typedef struct redisDb 
{ // ... 
    // 数据库键空间,保存着数据库中的所有键值对 
    dict *dict; 
    // ...
} redisDb;

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

当我们输入以下命令时:

redis> SET message "hello world"
OK

redis> RPUSH alphabet "a" "b" "c"
(integer)3

redis> HSET book name "Redis in Action"
(integer) 1

redis> HSET book author "Josiah L. Carlson"
(integer) 1

redis> HSET book publisher "Manning"
(integer) 1

数据库的键空间结构如下:

2.2 键空间的增删改查

(1)添加和修改键

添加新键值对和修改键值的操作是一样的,区别在于键是新的是旧的

对象 命令
字符串对象 SET date 2020/1/1
MSET date1 19 date2 20
哈希对象 HSET book name C++primer
HMSET fruit name apple size large
列表对象 LSET cloth 0 shirt
LPUSH food potato
RPUSH brand apple
LRANGE level 0 5
集合对象 SADD occupation firefighter
有序集合 ZADD grade 87 Tom 65 Terry

(2)删除键

对象 命令
字符串对象 DEL date
哈希对象 HDEL myhash field1 myhash field2
列表对象 BLPOP list1 100
BRPOP list1 150
LPOP list2
LREM list3 -2 “hello”
集合对象 SPOP food “rice”
SREM food “noodle”
有序集合 ZREM website google.com
ZREMRANGEBYLEX drink [sprit (coco
ZREMRANGEBYRANK salary 0 2
ZREMRANGEBYSCORE salary 1500 3500

POP在删除的同时,会返回结果,打印到控制台,而REM则是单纯的删除。BLPOP在移除元素时,如果列表没有元素则会等待至超时或发现元素为止。

有序集合范围删除中,LEX表示键, [ ( 表示区间开闭。而ZREMRANGEBYRANK salary 0 2表示删除salary最高的三个。

(3)查询键

对象 命令
字符串对象 GET time
MGET time1 time2
哈希对象 HGET site baidu
HMGET site baidu google
HGETALL site
HKEYS site
列表对象 LINDEX mylist 2
LRANGE mylist 0 2
集合对象 SISMEMBER myset1 “hello”
有序集合

HGET是根据键返回值,HGETALL则返回所有键值对,HKEYS返回所有键。列表对象根据主要根据下标返回结果。

3. 过期键

3.1 设置过期时间

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

redis> SET key value
OK

redis> EXPIRE key 5
(integer) 1

redis> GET key // 5秒之内
"value"
redis> GET key // 5秒之后
(nil)

与前面相似,客户端可以通过EXPIREAT命令PEXPIREAT命令,以秒或者毫秒精度给数据库中的某个键设置过期时间(expire time)。过期时间由UNIX时间戳表示。

TTL命令PTTL命令则返回一个键的剩余生存时间。

所有的命令在Redis中最终都会转化为PEXPIREAT执行。


在RedisDb结构中,在键空间之外,有一个expires字典专门保存所有键的过期时间,我们称之为过期字典。过期字典保存的值是long long 类型整数,保存一个毫秒精度的UNIX时间戳

typedef struct redisDb 
{ // ... 
    // 过期字典,保存着键的过期时间 
    dict *expires; 
    // ...
} redisDb;

虽然键空间和过期时间都有相同的键,但他们以指针形式指向同一个键,不会造成空间浪费。

3.2 过期键的删除策略

通过过期字典知道了哪些键已经过期,那么过期的键什么时候会被删除呢?删除策略有三种:

  • 定时删除:在设置键的过期时间的同时,创建一个定时器(timer),定时结束后删除。
  • 惰性删除:放着不管,每次从键空间获取时检查是否过期,过期就删除。
  • 定期删除:每隔一段时间,程序检查一次数据库,删除过期键。

(1)定时删除

定时删除有利于内存管理,但对CPU不友好。如果过期键太多,删除会占用相当一部分CPU。

所以策略应该是:当有大量命令请求服务器处理时,并且服务器内存充足,就应该优先将CPU资源安排在处理客户端请求上,而不是删除过期键。

创建一个定时器需要用到Redis服务器中的时间事件,而当前时间事件的实现方式——无序链表,查找一个事件的时间复杂度为$O(N)$,并不能高效地处理大量时间事件

(2)惰性删除

对CPU最友好但浪费内存。如果数据库中有很多过期键,而这些过期键永远也不会被访问的话,他们就会永远占据空间,可视为内存泄漏

一些和时间有关的数据,比如日志,在某个时间点后,他们的访问就会很少。如果这类过期数据大量积压,会造成严重的内存浪费。

(3)定期删除

定期删除是一种折中,通过选择较为空闲的时间点来处理过期键,减少CPU压力。同时也能及时释放内存,避免内存泄漏。


在Redis中,实际使用的是惰性删除和定期删除这两种

(1)Redis中的惰性删除

存在于db.c/expireIfNeeded函数。所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查

  • 过期,函数将输入键删除
  • 不过期,函数不动作

(2)Redis中的定期删除

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

全局变量current_db记录当前activeExpireCycle函数检查的进度,并在下一次检查时接着上一次的进度进行处理。比如说,如果当前activeExpireCycle函数在遍历10号数据库时返回了,那么下次就会从11号数据库开始工作。

如果所有数据库都被检查了一遍,则current_db将会被置0,然后开始新一轮检查。

4. 数据库通知

通知是Redis2.8新增的功能,可以让客户端通过订阅给定的频道或者模式,来获知数据库中键的变化,以及数据库中命令的执行情况。

4.1 订阅通知

订阅有两种模式:

  • 订阅某一个键,返回键的所有操作
  • 订阅某一个操作,返回执行这个操作的键

情况1,从0号数据库订阅了键message的消息。如果此时有其他客户端操作了message,则会将消息通知到此处。

127.0.0.1:6379> SUBSCRIBE _ _keyspace@0_ _:message
Reading messages... (press Ctrl-C to quit)

1) "subscribe" // 订阅信息
2) "__keyspace@0__:message"
3) (integer) 1

1) "message" //执行SET命令
2) "_ _keyspace@0_ _:message"
3) "set"

1) "message" //执行EXPIRE命令
2) "_ _keyspace@0_ _:message"
3) "expire"

情况2,客户端订阅了0号数据库中的DEL命令。

127.0.0.1:6379> SUBSCRIBE _ _keyevent@0_ _:del
Reading messages... (press Ctrl-C to quit)
1) "subscribe" // 订阅信息
2) "_ _keyevent@0_ _:del"
3) (integer) 1

1) "message" //键key执行了DEL命令
2) "_ _keyevent@0_ _:del"
3) "key"

1) "message" //键number执行了DEL命令
2) "_ _keyevent@0_ _:del"
3) "number"

4.2 发送通知

发送数据库通知的功能是由notify.c/notifyKeyspaceEvent函数实现,函数声明如下:

void notifyKeyspaceEvent(int type,char *event,robj *key,int dbid);

type参数是发送的通知的类型,event、keys和dbid分别是事件的名称、产生事件的键,以及产生事件的数据库编号,函数会根据type参数以及这三个参数来构建事件通知的内容,以及接收通知的频道名。

比如SADD命令的实现函数中,通知的发送方式是

void saddCommand(redisClient* c)
{
    //...
    if(added)
    {
        //...添加成功,发送通知
           notifyKeyspaceEvent(REDIS_NOTIFY_SET,"add",c->argv[1],c->db->id);
        //...
    }
}

当SADD命令成功地向集合添加了一个集合元素之后,命令就会发送通知,该通知的类型为REDIS_NOTIFY_SET(表示这是一个集合键通知),名称为sadd(表示这是执行SADD命令所产生的通知)。

发布时调用的notifyKeyspaceEvent函数逻辑是:

  1. 检查服务器是否允许发送此类通知,如果不允许就返回
  2. 是否允许发送键空间通知(4.1提到的情况1),允许就发送
  3. 是否允许发送键事件通知(4.2提到的情况2),允许就发送