数据库

1 数据库的实现

服务器数据库

Redis服务器负责与多个客户端建立连接,处理客户端的命令请求,在数据库中保存命令产生的数据,并通过资源管理来维持服务器自身的运转。

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

1
2
3
4
5
6
7
8
9
10
struct redisServer {
// ...
redisDb *db;
int dbnum; // 数据库的数量
// ...
list *clients;
redisClient *lua_client; // Lua伪客户端,服务器运行时一直存在
// ...
// ...
};
  • 其中dbnum的值有服务器配置的database选项决定,默认为16。
  • redisServer结构保存了一个clients链表,保存了所有连接的客户端的状态信息。

客户端数据库

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

服务器内部,客户端状态redisClient结构的db属性记录了客户端当前的当前信息,以及执行相关功能需要的数据结构:

  • 客户端的套接字描述符
  • 客户端的名字
  • 客户端的标志值(flag)
  • 客户端正在使用的数据库的指针及号码
  • 客户端当前要执行的命令、参数
  • 客户端的输入输出缓冲区
  • 客户端的复制状态信息
  • 客户端的事务状态
  • 客户端执行发布与订阅功能用到的数据结构
  • 客户端的身份验证标识
  • 客户端的统计信息,如创建时间、最后一次通行时间、缓冲区大小超出限制的时间
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
typedef struct redisClient {
/*
fd记录客户端正在使用的套接字描述符
伪客户端的fd为-1,不需要套接字连接,目前用于 1. AOF还原, 2. 执行Lua脚本的Redis命令
普通客户端为大于-1的整数。CLIENT list命令可以查看当前正在使用的套接字描述符
*/
int fd;

// 连接到服务器的客户端默认没有名字,CLIENT setname可以设置一个名字。
robj *name;

/*
flags记录了客户端的role,以及目前所处的状态
所以,flags可以是多个二进制或,所有标志在redis.h中定义
*/
int flags;

// 输入缓冲区用于保存客户端发送的命令请求
sds querybuf;

// 解析querybuf的请求,得出命令参数及命令个数
// argv是个数组,每个元素都是一个字符串对象,其中argv[0]是要执行的命令
robj **argv;
int argc;

// redisCommand保存了命令的实现函数,标识、参数个数、总执行次数等统计信息
struct redisCommand *cmd;

// 输出缓冲区保存命令的回复,其中
// 1. buf是固定缓冲区,用于保存长度较小的回复
// 2. reply可变缓冲区,保存长度较大的回复
char bug[REDIS_REPLY_CHUNK_BYTES];
int bufpos;
list *reply;

// 记录客户端是否通过了验证
int authenticated;

time_t ctime;
time lastinteraction;
time_t obuf_soft_limit_reached_time;

// ...
} redisClient;

创建客户端

客户端使用connect函数连接到服务器,服务器就会调用连接事件处理器,为客户端创建相应的客户端状态,并添加到链表的末尾。

关闭客户端

  • 客户端进程被杀死
  • 发送的协议不符合格式
  • 客户端成了CLIENT KILL命令的目标
  • 服务器配置了timeout选项,客户端空转被断开
  • 超出输入/输出缓冲区限制。输出缓冲区的限制包括:硬性限制、弱性限制。超过软性限制一段时间,客户端也会被关闭。

数据库键空间

Redis是一个键值对(key-value pair)数据库服务器。redisDb结构的dict字典保存了数据库的所有键值对,这个字典就是键空间:

1
2
3
4
5
typedef struct redisDb {
// ...
dict *dict;
// ...
} redisDb;

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

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

所有针对数据库的操作,实际上都是通过键空间字典来实现。

2 数据库的操作

数据库基本操作

增删查改

  • 添加新键。添加一个新键值对到数据库,就是将新键值对添加到键空间字典中。
  • 删除键。删除数据库中的一个键,就是在键空间中删除键所对应的键值对对象。
  • 更新键。更新数据库的一个键,就是对键空间里键所对应的值对象进行更新。根据值对象类型的不同,更新的具体方法也不同。
  • 对键取值。对一个数据库键取值,就是在键空间中取出键所对应的值对象。

读写键空间时的维护操作

当Redis对数据库读写时,不仅对键空间执行指定的操作,还会执行一些额外的维护:

  1. 读取一个键后,更新服务器的键命中次数或不命中次数。这两个值可通过INFO stats命令查看。
  2. 读取一个键后,更新LRU时间。OBJECT idletime <key>查看。
  3. 读取键时发现已过期,删除。
  4. 如果有客户端WATCH了某个键,修改后将键标记为dirty,从而让事物程序注意到它。
  5. 每次修改一个键后,将dirty键计数器的值+1,这个计数器会触发服务器的持久化和赋值操作。
  6. 如果服务器开启了通知功能,键修改后,服务器会按照配置发送通知。

3 服务器初始化过程

初始化服务器状态结构

初始化服务器的第一步就是创建一个redisServer类型的实例变量server,并为结构中的各个属性设置默认值。这个工作由redis.c/initServerConfig函数完成:

  • 设置服务器运行id
  • 为id加上结尾字符
  • 设置默认的配置文件路径
  • 设置默认服务器频率
  • 设置服务器的运行架构,64位 or 32位
  • 设置服务器的默认端口
  • 设置服务器的默认RDB和AOF条件
  • 初始化服务器的LRU时钟
  • 创建命令表

载入配置选项

启动服务器时,用户可以通过配置参数或者配置文件来修改服务器的默认配置。

redis.c/initServerConfig函数初始化完server变量后,开始载入用户给定的配置。

初始化服务器数据结构

载入用户的配置选项之后,才能正确地初始化数据结构,由initServer函数负责:

  • server.clients链表
  • server.db数组
  • server.pubsub_channels字典
  • server.luaLua环境
  • server.slowlog

除此之外,initServer还:

  • 为服务器设置进程信号处理器
  • 创建共享对象
  • 打开服务器的监听端口,并为套接字关联应答事件处理器
  • serverCron函数创建时间事件
  • 打开或创建的AOF文件
  • 初始化后台I/O模块

还原数据库状态

初始化完server后,服务器要载入RDB或AOF文件,还原数据库状态

执行事件循环

开始执行服务器的loop。

4 命令请求的执行过程

SET KEY VALUE命令的执行过程:

  1. 客户端向服务器发送命令请求SET KEY VALUE
  2. 服务器接收并处理命令请求,在数据库中设置操作,并产生命令回复OK
  3. 服务器将OK发送给客户端。
  4. 客户端接收服务器返回的命令OK,并打印给用户。

发送命令请求

用户:键入命令请求

客户端:将命令请求转换为协议格式然后发送给服务器

读取命令请求

当连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器执行以下操作:

  1. 读取套接字协议格式中的命令请求,并将其保存在客户端状态的输入缓冲区里。
  2. 对输入缓冲区的命令请求进行分析,提取命令参数及其个数,保存到客户端状态的argv和argc属性。
  3. 调用命令执行器,执行指定的命令。

命令执行器(1):查找命令实现

命令执行器要做的第一件事是根据客户端状态的argv[0]参数,在命令表(command table)中查找参数指定的命令,并将其保存到客户端状态的cmd属性里。

命令表是一个字典,键是命令名字,值是一个redisCommand结构。命令表使用的是大小写无关的查找算法。

命令执行器(2):执行预备操作

有了执行命令所需的命令实现函数、参数、参数个数,但程序还需要一些预备操作:

  • 检查客户端状态的cmd指针是否为NULL
  • 根据cmd属性指向redisCommand结构的arity属性,检查命令请求的参数个数是否正确。
  • 检查客户端是否通过了身份验证,未通过必须使用AUTH命令。
  • 如果服务器打开了maxmemory功能,检查内存占用情况,有需要时进行内存回收。
  • 如果上一次BGSAVE出错,且服务器打开了stop-writes-on-bgsave-error功能,且服务器要执行一个写命令,拒绝执行。
  • 如果客户端正在用SUBSCRIBE订阅频道,服务器只会执行订阅相关的命令。
  • 如果服务器正在进行输入载入,那么客户端发送的命令必须带有1标识才能被执行。
  • 如果服务器因为Lua脚本而超时阻塞,那么服务器只会执行客户端发来的SHUTDOWN nosaveSCRIPT KILL命令。
  • 如果客户端正在执行事务,那么服务器只会执行客户端发来的EXECDISCARDMULTIWATCH命令,其余命令进入事务队列。
  • 如果服务器打开监视器功能,要将执行的命令和参数等信息发给监视器,其后才真正执行命令。

命令执行器(3):调用命令的实现函数

client->cmd->proc(client);

相当于执行语句:

sendCommand(client);

命令回复会保存在输出缓冲区,之后实现函数还会为套接字关联命令回复处理器,将回复返回给客户端。

命令执行器(4):执行后续工作

  • 如果开启了慢查询,添加新的日志。
  • redisCommand结构的calls计数器+1。
  • 写入AOF缓冲区。
  • 同步从服务器。

将命令回复发送给客户端

当客户端套接字变为可写时,服务器将输出缓冲区的命令发送给客户端。发送完毕后,清空输出缓冲区。

客户端接收并打印命令回复

服务器:回复处理器将协议格式的命令返回给客户端。

客户端:将回复格式化成人类可读的格式,打印。

4 serverCron函数执行过程

更新服务器时间缓存

每次获取系统的当前时间都要执行一次系统调用,为了减少系统调用,服务器状态中保存了当前时间的缓存:

1
2
3
4
5
6
struct redisServer {
// 秒级的系统当前UNIX时间戳
time_t unixtime;
// 毫秒级的系统当前UNIX时间戳
long long mstime;
};

serverCron默认会100毫秒更新一次这两个属性,所以它们的精确度并不高。对于一些高精度要求的操作,还是会再次执行系统调用。

更新LRU时钟

1
2
3
4
5
6
7
8
9
10
struct redisServer {
// 默认10秒更新一次的时钟缓存,用于计算键的空转时长
// INFO server可查看
unsigned lruclock:22;
};

// 每个Redis对象都有一个lru属性,计算键的空转时长,就是用服务器的lruclock减去对象的lru时间
typedef struct redisObject {
unsigned lru:22;
} robj;

更新服务器每秒执行命令次数

serverCron函数中的trackOperationPerSecond函数以每100毫秒一次的频率执行,该函数以抽样计算的方式,估算并记录服务器在最近一秒内处理的命令请求数量,这个值可以用过INFO status命令查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct redisServer {
// 上一次抽样的时间
long long ops_sec_last_sample_time;

// 上一次抽样时,服务器已执行命令的数量
long long ops_sec_last_sample_ops;

// REDIS_OPS_SEC_SAMPLES 大小默认16
// 环形数组中的每个项记录了一次抽样结果
long long ops_sec_samples[REDIS_OPS_SEC_SAMPLES];

// ops_sec_samples 数组的索引值,每次抽样后自动+1
// 让 ops_sec_samples 数组构成一个环形数组
int ops_sec_idx;
};

客户端执行INFO命令,服务器会调用getOperationsPerSecond函数,根据ops_sec_samples中的抽样结果,计算出instantaneous_ops_per_sec属性的值。

更新服务器内存峰值记录

1
2
3
4
struct redisServer {
// 已使用内存峰值
size_t stat_peak_memory;
};

每次serverCron执行,程序都会查看当前的内存数量,更新stat_peak_memoryINFO memory可查看。

处理SIGTERM信号

启动时,Redis会为服务器进程的SIGTERM信号关联处理器sigtermHandler函数。它在接到该信号后,打开服务器状态的shutdown_asap标识。每次serverCron执行,程序都会检查该标识,并决定是否关闭服务器。

1
2
3
4
struct redisServer {
// 关闭服务器的标识:1,关闭;2,不做操作。
int shutdown_asap;
};

管理客户端资源

serverCron每次都会调用clientsCron函数,后者会对一定数量的客户端作如下检查:

  • 连接是否超时
  • 输入缓冲区是否超过长度,如果是,新建缓冲区

管理数据库资源

serverCron每次都会调用databasesCron函数,检查一部分的数据库,删除过期键,对字典进行收缩等。

执行被延迟的BGREWRITEAOF

服务器执行BGSAVE期间,会阻塞BGREWRITEAOF命令。

1
2
3
4
struct redisServer {
// 记录是否有BGREWRITEAOF被延迟
int aof_rewrite_scheduled;
};

检查持久化操作的运行状态

1
2
3
4
5
6
7
8
struct redisServer {
// 记录执行BGSAVE命令的子进程ID
// 如果服务器没有执行BGSAVE,值为-1
pid_t rdb_child_pid;

// 记录执行BGREWRITEAOF命令的子进程ID
pid_t aof_child_pid;
};

serverCron执行时,只要两个属性有一个为-1,则执行wait3函数,检查是否有信号发来服务器进程:

  • 如果有信号达到,表明新的RDB文件生成完毕,或AOF文件重写完毕,服务器需要执行相应命令的后续操作
  • 没有信号就不做操作

如果两个属性都不为-1,表明服务器没有再做持久化操作,则:

serverCron的其他操作:

  • 将AOF缓冲区的内容写入AOF文件

  • 关闭异步客户端(超出输入缓冲区限制)

  • 增加cronloops计数器(它的唯一作用就是复制模块中实现『每执行serverCron函数N次就执行一次指定代码』的功能”)

5 服务器客户端通知过程

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

“某个键执行了什么命令”的通知成为「键空间通知」。“某个命令被什么键执行了”是「键时间通知」。服务器配置的notify-keyspace-events选项决定了服务器发送通知的类型。

发送通知的功能由notify.h/notifyKeyspaceEvent函数实现的:

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

伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def notifyKeyspaceEvent(type, event, key, bdid):
if not (server.notify_keyspace_events & type):
return

# 发送键空间通知
if server.notify_keyspace_events & REDIS_NOTIFY_KEYSPACE:
# 将通知发送给频道 __keyspace@<dbid>__:<key>
chan = "_keyspace@{bdid}__:{key}".format(dbid_dbid, key=key)
pubsubPublishMessage(chan, event)

# 发送键时间通知
if server.notify_keyspace_events & REDIS_NOTIFY_KEYEVENT:
chan = "_keyspace@{bdid}__:{event}".format(dbid_dbid, event=event)
pubsubPublishMessage(chan, event)
pubsubPublishMessage(chan, key)