按照Redis源码剖析–源码结构解析一文中给自己规定的六个阶段来学习Redis。目前前三个阶段的学习以及完成了,这些都是和系统的耦合性比较小的部分,所以看起来也比较轻松。从这篇博客开始,就进入到第四阶段的源码剖析了。Redis的各个功能的实现将会顺着我们的逐步深入而变得清晰明了,如果读者跟着我的步伐一起学习,到了这一刻,想必也是兴奋的。废话也不多说了,前面所有的数据结构都是为后面的功能实现做铺垫。那么今天,就来啃掉数据库实现这块硬骨头。
Redis数据库概述
Redis服务器在运行的时候会创建大量的RedisObject,这个对象都存放在数据库中,为了有效率的索引到某个对象,Redis数据库采用字典结构设计。假设,当我们向服务器中添加一个名为hello
的集合对象时,通常可以将一个字符串对象"setHello"
与其关联起来,添加到字典结构中,这样一来,当客户端请求对hello
操作时,直接可以由"setHello"
来获取该对象,时间复杂度为O(1)。
数据库的结构
上述关于Redis数据库设计的猜想,立刻在源码中体现出来了,但是Redis对于数据库的设计远不止数据存储那么简单,先不管其他的,来看看Redis定义的数据结构吧。
1
2
3
4
5
6
7
8
9
10
|
typedef
struct redisDb {
dict *dict;
dict *expires;
dict *blocking_keys;
dict *ready_keys;
dict *watched_keys;
struct evictionPoolEntry *eviction_pool;
int id;
long
long avg_ttl;
} redisDb;
|
在这些参数中,blocking_keys
和ready_keys
在Redis源码剖析—列表t_list一文中有相应的解释,watched_keys
也将会在事务一节中去分析,本篇博客主要讨论键空间、键过期时间和数据库编码这三个参数,让大家对Redis数据库有一个全方位的理解。
下图是一个RedisDb的示例,该数据库存放有五个键值对,分别是sRedis,INums,hBooks,SortNum和sNums,它们各自都有自己的值对象,另外,其中有三个键设置了过期时间,当前数据库是服务器的第0号数据库。有了这么一个概览,接下来就从源码的角度来分析Redis的这个数据库结构设计吧。
RedisDatabase
数据库的切换
每一个数据库的结构体都有一个id
用来标识该数据库的编号,Redis的配置文件redis.conf中提供了如下参数来控制Redis在初始化的时候需要创建多少个数据库。
在Redis服务器结构中,定义了数据库的结构,及其数据库个数。
1
2
3
4
5
|
struct redisServer {
redisDb *db;
int dbnum;
}
|
Redis提供了SELECT命令,来选择当前使用的数据库。其操作如下:
1
2
3
4
5
|
127.0
.0
.1:
6379> select
1
OK
127.0
.0
.1:
6379[
1]> select
2
OK
127.0
.0
.1:
6379[
2]>
|
SELECT命令的源码也比较容易理解,这里就贴出来大家看看。
1
2
3
4
5
6
|
int selectDb(client *c, int id) {
if (id <
0 || id >= server.dbnum)
return C_ERR;
c->db = &server.db[id];
return C_OK;
}
|
数据库的键空间
Redis数据库中存放的数据都是以键值对形式存在,其充分利用了字典结构的高效索引特性,其中:
- 字典的键:通常是一个字符串对象
- 字典的值:可是是字符串,哈希,链表,集合和有序集合
在示例中也可以看到,每一个字符串键都对应了自己的值对象,例如hBooks
对应着一个哈希对象。接下来我们去源码中找找关于键空间的操作函数。
键空间操作
Redis为数据库的键空间操作提供了下列操作函数,每个函数的功能都以注释的形式写出,后面会分析部分源码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
/* 从数据库中取出指定键对应的值对象,如不存在则返回NULL */
robj *lookupKey(redisDb *db, robj *key, int flags);
/* 先删除过期键,再从数据库中取出指定键对应的值对象,如不存在则返回NULL
* 底层调用lookupKey函数
*/
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags);
/* 先删除过期键,以读操作的方式从数据库中取出指定键对应的值对象
* 如不存在则返回NULL,底层调用lookupKey函数
*/
robj *lookupKeyRead(redisDb *db, robj *key);
/* 先删除过期键,以写操作的方式从数据库中取出指定键对应的值对象
* 如不存在则返回NULL,底层调用lookupKeyReadWithFlags函数
*/
robj *lookupKeyWrite(redisDb *db, robj *key);
/* 先删除过期键,以读操作的方式从数据库中取出指定键对应的值对象
* 如不存在则返回NULL,底层调用lookupKeyRead函数
* 此操作需要向客户端回复
*/
robj *lookupKeyReadOrReply(client *c, robj *key, robj *reply);
/* 先删除过期键,以写操作的方式从数据库中取出指定键对应的值对象
* 如不存在则返回NULL,底层调用lookupKeyWrite函数
* 此操作需要向客户端回复
*/
robj *lookupKeyWriteOrReply(client *c, robj *key, robj *reply) ;
/* 添加元素到指定数据库 */
void dbAdd(redisDb *db, robj *key, robj *val);
/* 重写指定键的值 */
void dbOverwrite(redisDb *db, robj *key, robj *val);
/* 设定指定键的值 */
void setKey(redisDb *db, robj *key, robj *val);
/* 判断指定键是否存在 */
int dbExists(redisDb *db, robj *key);
/* 随机返回数据库中的键 */
robj *dbRandomKey(redisDb *db);
/* 删除指定键 */
int dbDelete(redisDb *db, robj *key);
/* 清空所有数据库,返回键值对的个数 */
long long emptyDb(void(callback)(void*));
|
在server.c中可以找到Redis对于键空间的初始化操作,由于键都是字符串类型,Redis为其设定了特定的字典结构。
1
2
3
4
5
6
7
8
9
|
dictType dbDictType = {
dictSdsHash,
NULL,
NULL,
dictSdsKeyCompare,
dictSdsDestructor,
dictObjectDestructor
};
|
在服务器初始化时,关于服务器的键空间初始化操作如下:
1
2
3
4
5
6
7
|
for (j =
0; j < server.dbnum; j++) {
server.db[j].dict = dictCreate(&dbDictType,
NULL);
server.db[j].id = j;
}
|
初始化键空间之后,就可以对该键空间操作了,下面一起来看看数据库增、删、查和改操作的源码吧。
查找键值对
查找键值对的操作都是由底层函数lookupKey完成,它的源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr);
if (de) {
robj *val = dictGetVal(de);
if (server.rdb_child_pid ==
-1 &&
server.aof_child_pid ==
-1 &&
!(flags & LOOKUP_NOTOUCH))
{
val->lru = LRU_CLOCK();
}
return val;
}
else {
return
NULL;
}
}
|
添加键值对
添加键值对在前面分析Redis五大数据类型的时候经常会看到,它由dbAdd函数来实现,传入的参数是待添加的数据库,键对象和值对象,其源码如下:
1
2
3
4
5
6
7
8
9
10
|
void dbAdd(redisDb *db, robj *key, robj *val) {
sds copy = sdsdup(key->ptr);
int retval = dictAdd(db->dict, copy, val);
serverAssertWithInfo(
NULL,key,retval == DICT_OK);
if (val->type == OBJ_LIST) signalListAsReady(db, key);
if (server.cluster_enabled) slotToKeyAdd(key);
}
|
修改键值对
设定键值对的操作完成对指定键关联上值对象,也是前面分析五大数据类型的时候常见到的操作,其源码如下:
1
2
3
4
5
6
7
8
9
10
|
void setKey(redisDb *db, robj *key, robj *val) {
if (lookupKeyWrite(db,key) ==
NULL) {
dbAdd(db,key,val);
}
else {
dbOverwrite(db,key,val);
}
incrRefCount(val);
removeExpire(db,key);
signalModifiedKey(db,key);
}
|
删除键值对
删除键值对操作需要删除该键值对且删除过期时间字典中关于该键值对的选项。其源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
|
int dbDelete(redisDb *db, robj *key) {
if (dictSize(db->expires) >
0) dictDelete(db->expires,key->ptr);
if (dictDelete(db->dict,key->ptr) == DICT_OK) {
if (server.cluster_enabled) slotToKeyDel(key);
return
1;
}
else {
return
0;
}
}
|
其他关于键空间的操作,本篇博客就不一一分析了,有兴趣的去db.c
文件中查看。
数据库的键过期操作
前面提到的expires指针就指向一个字典结构,该字典存放着每个键及其对应的过期时间,与键空间一样,expires字典的键是字符串对象。Redis同样为其声明了一个特定的字典结构,由于过期时间为一个整数,因此其值释放函数可以不设定。
1
2
3
4
5
6
7
8
|
dictType keyptrDictType = {
dictSdsHash,
NULL,
NULL,
dictSdsKeyCompare,
NULL,
NULL
};
|
在服务器初始化的时候,也对expires字典进行的初始化,采用keyptrDictType结构。
1
2
3
4
5
6
7
8
|
for (j =
0; j < server.dbnum; j++) {
server.db[j].expires = dictCreate(&keyptrDictType,
NULL);
server.db[j].id = j;
}
|
那么,接下来就是设定键过期时间,删除键过期时间等操作的源码分析时间了。
设定键过期时间
设定键过期时间,需要将键及其过期时间添加到对应的字典结构中,其源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
void setExpire(redisDb *db, robj *key, long long when) {
dictEntry *kde, *de;
kde = dictFind(db->dict,key->ptr);
serverAssertWithInfo(
NULL,key,kde !=
NULL);
de = dictReplaceRaw(db->expires,dictGetKey(kde));
dictSetSignedIntegerVal(de,when);
}
|
获取键过期时间
1
2
3
4
5
6
7
8
9
10
11
12
|
long long getExpire(redisDb *db, robj *key) {
dictEntry *de;
if (dictSize(db->expires) ==
0 ||
(de = dictFind(db->expires,key->ptr)) ==
NULL)
return
-1;
serverAssertWithInfo(
NULL,key,dictFind(db->dict,key->ptr) !=
NULL);
return dictGetSignedIntegerVal(de);
}
|
删除键过期时间
删除键过期时间,首先需要在键空间查找该键还存不存在,如果不存在直接报错;反之就在expires字典中删除该键和它的过期时间。
1
2
3
4
5
6
7
|
int removeExpire(redisDb *db, robj *key) {
serverAssertWithInfo(
NULL,key,dictFind(db->dict,key->ptr) !=
NULL);
return dictDelete(db->expires,key->ptr) == DICT_OK;
}
|
过期键删除策略
如果一个键设置了删除时间,那么面临的问题是以怎样的策略去删除该键。我们很容易理解下面三个删除策略:
- 定时删除:如果一个键设置了过期时间,就为其创建一个定时器,在定时器结束时,立刻对该键执行删除操作
- 惰性删除:在访问该键时,判断其过期时间是否到了,如果已过期,则执行删除操作。
- 定期删除:每个一段时间,对数据库中的键进行一次遍历,删除其中的过期键,
其中,定时删除可以及时的删除过期键,但它为每一个设定了过期时间的键都开了一个定时器,使得CPU的负载变高,从而导致服务器的响应时间和吞吐量收到影响。
惰性删除有效的克服了定时删除对CPU的影响,但是,如果一个键长时间没有被访问,且这个键已经过期很久了,显然,大量的过期键会占用内存,从而导致内存上的消耗过大。
定时删除可以算是上述两种策略的折中。设定一个定时器,每隔一段时间遍历数据库,删除其中的过期键,有效的缓解了定时删除对CPU的占用以及惰性删除对内存的占用。
Redis采用了惰性删除和定时删除两种策略来对过期键进行处理,在上面的lookupKeyWrite等函数中就利用到了惰性删除策略,定时删除策略则是在根据服务器的例行处理程序serverCron来执行删除操作,该程序每100ms调用一次。
惰性删除函数
惰性删除由expireIfNeeded函数实现,其源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
* 并将删除命令写入AOF文件以及附属节点(主从复制和AOF持久化相关)
* 返回0代表该键还没有过期,或者没有设置过期时间
* 返回1代表该键因为过期而被删除
*/
int expireIfNeeded(redisDb *db, robj *key) {
mstime_t when = getExpire(db,key);
mstime_t now;
if (when <
0)
return
0;
if (server.loading)
return
0;
now = server.lua_caller ? server.lua_time_start : mstime();
if (server.masterhost !=
NULL)
return now > when;
if (now <= when)
return
0;
server.stat_expiredkeys++;
propagateExpire(db,key);
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",key,db->id);
return dbDelete(db,key);
}
|
定期删除策略
Redis定义了一个例行处理程序serverCron
,该程序每隔100ms执行一次,在其执行过程中会调用databasesCron
函数,这个函数里面才会调用真正的定期删除函数activeExpireCycle
。该函数每次执行时遍历指定个数的数据库,然后从expires字典中随机取出一个带过期时间的键,检查它是否过期,如过期直接删除。
每隔100处理数据库的个数由CRON_DBS_PER_CALL
参数决定,该参数的默认值如下:
#define CRON_DBS_PER_CALL 16 // 每次处理16个数据库
|
删除过期键的操作由activeExpireCycleTryExpire
函数执行,其源码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
int activeExpireCycleTryExpire(redisDb *db, dictEntry *de, long long now) {
long
long t = dictGetSignedIntegerVal(de);
if (now > t) {
sds key = dictGetKey(de);
robj *keyobj = createStringObject(key,sdslen(key));
propagateExpire(db,keyobj);
dbDelete(db,keyobj);
notifyKeyspaceEvent(NOTIFY_EXPIRED,
"expired",keyobj,db->id);
decrRefCount(keyobj);
server.stat_expiredkeys++;
return
1;
}
else {
return
0;
}
}
|
关于删除过期键对AOF和主从复制的影响,在剖析相关功能实现的时候讲解,本篇博客不涉及到。
数据库命令
数据库的命令主要包括两类,一类是对数据库键空间的操作命令,另一类是对键过期时间的操作命令。下面分别从这两个部分讲解数据库命令。
键空间命令
键空间的操作命令的实现函数主要如下,这里只罗列出函数名及其功能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
void flushdbCommand(client *c);
void flushallCommand(client *c);
void delCommand(client *c);
void existsCommand(client *c);
void selectCommand(client *c);
void randomkeyCommand(client *c);
void keysCommand(client *c);
void scanCommand(client *c);
void dbsizeCommand(client *c);
void lastsaveCommand(client *c);
void typeCommand(client *c);
void shutdownCommand(client *c);
* 当newkey存在时,将覆盖旧值
*/
void renameCommand(client *c);
void renamenxCommand(client *c);
void moveCommand(client *c)
|
过期命令
过期时间命令全部是设定键的过期时间,看看下面的函数名及其功能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
* 即,该键的生存时间为给定的秒数
*/
void expireCommand(client *c);
* 即,该键到给定时间戳是过期
*/
void expireatCommand(client *c);
* 同上
*/
void pexpireCommand(client *c);
* 同上
*/
void pexpireatCommand(client *c);
|
在过期命令中,Redis还提供了几个关于ttl的命令,用来获取指定键还剩下的生存时间。
1
2
|
void ttlCommand(client *c);
void pttlCommand(client *c);
|
命令格式和功能
还是按照我的习惯,附上一张命令个数和功能的对应表,让大家了解数据库操作的命令怎么运用。
命令格式 | 功能 |
---|
FLUSHD | 清空当前数据库 |
FLUSHALL | 清空所有数据库 |
DBSIZE | 返回当前数据库的键个数 |
DEL key [key …] | 删除一个或多个键 |
EXISTS key | 检查给定key是否存在 |
SELECT id | 选择指定编码的数据库 |
RANDOMKEY | 从当前数据库中随机返回一个键 |
KEYS pattern | 查找左右符合给定模式pattern的key |
SCAN cursor [MATCH pattern][COUNT count] | 扫描当前数据库 |
LASTSAVE | 返回最近一次成功将数据保存到磁盘上的时间 |
TYPE key | 返回指定键的对象类型 |
SHUTDOWN | 停止所有客户端 |
RENAME key newkey | 重命名指定的key,newkey存在时覆盖 |
RENAMENX key newkey | 重命名指定的key,当且仅当newkey不存在时操作 |
MOVE key db | 移动key到指定数据库 |
EXPIRE key seconds | 设定key的过期时间 |
TTL key | 返回key的剩余生存空间 |
数据库小结
本片博客从数据库的三个方面讲了Redis数据库的实现原理,分别是数据库的切换,键空间和过期键。其中,设计到AOF持久化和主从复制等方面的知识没有进行过多的讲解,后面分析到具体的功能的时候,会对这些进行一个全面的分析。最后,总结了数据库的各类命令的执行格式和函数实现,由于实现源码都较为简单,故没有贴出来分析,各位读者可以根据函数名和功能去db.c文件中阅读以下相关源码,如有疑惑或者值得讨论的地方,可以在下方留言,期待和大家一起讨论Redis!