[Redis][9][数据库]

第 9 章 数据库

本章将对Redis服务器的数据库实现进行详细介绍,说明服务器保存数据库的方法客户端切换数据库的方法,数据库保存键值对的方法,以及针对数据库的添加、删除、查看更新操作的实现方法等。除此之外,本章还会说明服务器保存键的过期时间的方法,以及服务器自动删除过期键的方法,以及数据库通知功能的实现方法。

9.1 服务器中的数据库

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

1
2
3
4
5
6
7
8
9
10
11
struct redisServer {
//...

//数据库
redisDb *db;

//服务器的数据库数量
int dbnum;

//...
}

dbnum属性决定了Redis初始化时创建数据库的数量,默认为16个,结构如下图

9.2 切换数据库

每个Redis客户端都有自己的目标数据库,每当客户端执行数据库操作的时候,目标数据库就会成为这些命令的操作对象
默认情况下,Redis客户端的目标数据库为0号数据库,但客户端可以通过执行SELECT命令来切换目标数据库。

1
SELECT 2

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

到目前为止,Redis仍然没有可以返回客户端目标数据库的命令。在数次切换数据库之后,你很可能会忘记自己当前正在使用的是哪个数据库。当出现这种情况时,为了避免对数据库进行误操作,在执行Redis命令特别是像FLUSHDB这样的危险命令之前,最好执行一个SELECT命令,显式切换到指定的数据库,然后才执行别的命令。

9.3 数据库键空间

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

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

9.3.1 添加新键

添加一个新键值对到数据库,实际上就是将一个新键值对添加到键空间字典里面,其中键为字符串对象,而值则为任意一种类型的Redis对象。

9.3.2 删除键

删除数据库中的一个键,实际上就是在键空间里面删除键所对应的键值对对象。

9.3.3 更新键

9.3.4 对键取值

9.3.5 其他键空间操作

用于清空整个数据库的FLUSHDB命令,就是通过删除键空间中的所有键值对来实现的。

又比如说,用于随机返回数据库中某个键的 RANDOMKEY命令,就是通过在键空间中随机返回一个键来实现的。

9.3.6 读写键空间时的维护操作

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

  • 在读取一个键之后,服务器会根据键是否存在更新服务器的键空间命中次数或键空间不命中次数

  • 在读取一个键之后,服务器会更新键的LRU(最后一次使用)时间,这个值可以用于计算键的闲置时间

  • 如果服务器在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作

  • 如果有客户端使用WATCH命令监视了某个键,那么服务器在对被监视的键进行修改之后,会将这个键标记为,从而让事务程序注意到这个键已经被修改过

9.4 设置键的生存时间或过期时间

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

9.4.1 设置过期时间

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

  • EXPIRE:用于将键key的生存时间设置为xx
  • PEXPIRE:用于将键key的生存时间设置为xx毫秒
  • EXPIREAT:用于将键key的过期时间设置为指定的秒数时间戳
  • PEXPIREAT:用于将键key的过期时间设置为指定的毫秒数时间戳

9.4.2 保存过期时间

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

  • 过期字典的是一个指针,这个指针指向键空间中的某个键对象
  • 过期字典的是一个long long类型的整数,这个整数保存了键所指向的数据库键的过期时间,它是一个毫秒精度的UNIX时间戳

例如下图

9.4.3 移除过期时间

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

1
PERSIST message

9.4.4 计算并返回剩余生存时间

TTL命令以为单位返回键的剩余生存时间,而PTTL命令则以毫秒为单位返回键的剩余生存时间:

1
2
TTL message
PTTL message

9.4.5 过期键的判定

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

  1. 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间。
  2. 检查当前UNIX时间戳是否大于键的过期时间:如果是的话,那么键已经过期;否则的话,键未过期。

9.5 过期键删除策略

过期键有三种删除策略

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

在这三种策略中,第一种和第三种为主动删除策略,而第二种则为被动删除策略。

9.5.1 定时删除

定时删除策略对内存是最友好的:通过使用定时器,定时删除策略可以保证过期键会尽可能快地被删除,并释放过期键所占用的内存。

它对CPU时间是最不友好的:在过期键比较多的情况下,删除过期键这一行为可能会占用相当一部分CPU时间,

9.5.2 惰性删除

惰性删除策略对CPU时间来说是最友好的:程序只会在取岀键时才对键进行过期检査这可以保证删除过期键的操作只会在非做不可的情况下进行,并且删除的目标仅限于当前处理的键,这个策略不会在删除其他无关的过期键上花费任何CPU时间。

惰性删除策略的缺点是,它对内存是最不友好的:如果一个键已经过期,而这个键又仍然保留在数据库中,那么只要这个过期键不被访问,它所占用的内存永远不会释放

9.5.3 定期删除

定期删除策略是前两种策略的一种整合和折中

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

9.6 Redis的过期删除策略

Redis服务器实际使用的是惰性删除定期删除两种策略

9.6.1 惰性删除策略的实现

惰性删除策略由db.c/expireIfNeeded函数实现,所有读写数据库的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查

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

流程图如下

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/*
* 检查 key 是否已经过期,如果是的话,将它从数据库中删除。
*
* 返回 0 表示键没有过期时间,或者键未过期。
*
* 返回 1 表示键已经因为过期而被删除了。
*/
int expireIfNeeded(redisDb *db, robj *key) {

// 取出键的过期时间
mstime_t when = getExpire(db,key);
mstime_t now;

// 没有过期时间
if (when < 0) return 0; /* No expire for this key */

/* Don't expire anything while loading. It will be done later. */
// 如果服务器正在进行载入,那么不进行任何过期检查
if (server.loading) return 0;

/* If we are in the context of a Lua script, we claim that time is
* blocked to when the Lua script started. This way a key can expire
* only the first time it is accessed and not in the middle of the
* script execution, making propagation to slaves / AOF consistent.
* See issue #1525 on Github for more information. */
now = server.lua_caller ? server.lua_time_start : mstime();

/* If we are running in the context of a slave, return ASAP:
* the slave key expiration is controlled by the master that will
* send us synthesized DEL operations for expired keys.
*
* Still we try to return the right information to the caller,
* that is, 0 if we think the key should be still valid, 1 if
* we think the key is expired at this time. */
// 当服务器运行在 replication 模式时
// 附属节点并不主动删除 key
// 它只返回一个逻辑上正确的返回值
// 真正的删除操作要等待主节点发来删除命令时才执行
// 从而保证数据的同步
if (server.masterhost != NULL) return now > when;

// 运行到这里,表示键带有过期时间,并且服务器为主节点

/* Return when this key has not expired */
// 如果未过期,返回 0
if (now <= when) return 0;

/* Delete the key */
server.stat_expiredkeys++;

// 向 AOF 文件和附属节点传播过期信息
propagateExpire(db,key);

// 发送事件通知
notifyKeyspaceEvent(REDIS_NOTIFY_EXPIRED,
"expired",key,db->id);

// 将过期键从数据库中删除
return dbDelete(db,key);
}

9.6.2 定期删除策略的实现

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

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
39
40
41
42
43
44
45
46
# 默认每次检查的数据库数量
DEFAULT_DB_NUMBERS = 16

# 默认每个数据库检查的键数量
DEFAULT_KEY_NUMBERS = 2

# 全局变量,记录检查进度到哪个数据库
current_db = 0

def activeExpireCycle():
# 初始化要检查的数据库数量
# 如果服务器的数据库数量比 DEFAULT_DB_NUMBERS要小那么以服务器的数据库数量为准
if server.donum < DEFAULT_DB_NUMBERS:
db_numbers = server.dbnum
else:
db_numbers = DEFAULT_DB_NUMBERS

# 遍历各个数据库
for i in range(db numbers)

# 如果current_db的值等于服务器的数据库数量
# 这表示检查程序已经遍历了服务器的所有数据库一次
#将current_db重置为0,开始新的一轮遍历
if current_db = server.dbnum current_db= 0

# 获取当前要处理的数据库
redisDB = server.db[current db]

# 将数据库索引增1,指向下一个要处理的数据库
current_db += 1
# 检查数据库键
for j in range(DEFAULT KEY NUMBERS):
# 如果数据库中没有一个键带有过期时间,那么跳过这个数据库
if redisDb.expires.size()==0
break

# 随机获取一个带有过期时间的键
key_with_ttl = redisDb.expires.get_random_key()

# 检查键是否过期,如果过期就删除它
if is_expired(key_with_ttl):
delete_key(key_with_ttl)

# 已达到时间上限,停止处理
if reach_time_limit():
return

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

  • 函数每次运行时,都从一定数量的数据库中取出一定数量的随机键进行检查,并删除其中的过期键
  • 全局变量current_db记录当前activeExpireCycle函数检查的进度,并在下一次activeExpireCycle函数调用时,接着上一次的进度进行处理
  • 服务器中的所有数据库都会被检查一遍,这时函数将current_db变量重置为0

9.7 AOP、RDB和复制功能对过期键的处理

在这一节,我们将探讨过期键对Redis服务器中其他模块影响

9.7.1 生成RDB文件

在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的进行检查,已过期的键不保存到新创建的RDB文件中。

9.7.2 载入RDB文件

在启动Redis服务器时,如果服务器开启了RDB功能,那么服务器将对RDB文件进行载入

  • 如果服务器以主服务器模式运行,那么在载入RDB文件时,程序会对文件中保存的键进行检査,未过期的键会被载入到数据库中,而过期键则会被忽略
  • 如果服务器以从服务器模式运行,那么在载入RDB文件时,文件中保存的所有键,不论是否过期都会载入到数据库中。不过,因为主从服务器在进行数据同步的时候,从服务器的数据库就会被清空,所以一般来讲,过期键对载入RDB文件的从服务器也不会造成影响

9.7.3 AOF文件写入

当服务器以AOF持久化模式运行时,如果数据库中的某个已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响
当过期键惰性删除或者定期删除之后,程序会向AOF文件追加(append)一条DEL命令,来显式地记录该键已被删除。

9.7.4 AOF重写

在执行AOF重写的过程中,程序会对数据库中的键进行检查,已过期的键不会保存到重写后的AOF文件中。

9.7.5 复制

当服务器运行在复制模式下时,从服务器的过期键删除动作由主服务器控制

  • 主服务器删除一个过期键之,会显式地向所有从服务器发送一个DEL命令告知从服务器删除这个过期键。
  • 从服务器执行客户端发送的读命令时,即使碰到过期键也不会将过期键删除,而是继续像处理未过期的键一样来处理过期键。
  • 从服务器只有接到主服务器发来的DEL命令之后,才会删除过期键。

具体流程如下



9.8 数据库通知

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

其中

  • 关注“某个键执行了什么命令”的通知称为键空间通知
  • 关注“某个命令被什么键执行了”的通知称为键事件通知

9.8.1 发送消息

发送数据库通知的功能是由notify.c/notifykeyspaceEvent函数实现的:

每当一个Redis命令需要发送数据库通知的时候,该命令的实现函数就会调用notifyKeyspaceEvent函数,并向函数传递该命令所引发的事件的相关信息。

例如下面是SADD命令的实现函数saddCommand的一部分代码

1
2
3
4
5
6
7
8
9
10
11
void saddCommand(redisClient *c){
//...

//如果至少有一个元素被成功添加,则执行
if(added){
//...

//发送事件通知
notifyKeyspaceEvent(REDIS_NOTIFY_SET, "sadd", c->argv[1], c->db->id)
}
}

9.8.2 发送通知的实现

下面是notifyKeyspaceEvent函数的伪代码实现

def notifykeyspaceEvent(type, event, key, dbid):
    
    # 如果给定的通知不是服务器允许发送的通知,那么直接返回
    if not (server.notify_keyspace_events & type):
        return
    
    # 发送键空间通知
    if server.notify_keyspace_events & REDIS_NOTIFY_KEYSPACE:

        # 将通知发送给频道"__keyspace@{dbid}__:{key}
        # 内容为键所发生的事件<event>
        
        # 构建频道名字
        chan = "__keyspace@{dbid}__:{key}". format(dbid=dbid, key=key)

        # 发送通知
        pubsubPublishMessage(chan,event)

    # 发送键事件通知
    if server.notify_keyspace_events & REDIS_NOTIFY_KEYEVENT:
        
        # 将通知发送给频道"__keyevent@{dbid}__:{event}
        # 内容为发生事件的键<key>

        # 构建频道名字
        chan = "__keyspace@{dbid}__:{event}". format(dbid=dbid, event=event)
        
        # 发送通知
        pubsubPublishMessage(chan, key)