单机 Redis 的实现


十一月的第四周,学习单机数据库下的 Redis 实现。

本文的主要内容和部分图片来源自《Redis 设计与实现》,该书的 Redis 版本是 3.0,粗浅学习一下。


Redis 数据库设计

Redis 服务器的属性都保存在 redisServer 类中,其中就包含数据库。

Redis 默认会创建 16 个数据库,以数组的形式保存在 RedisServer 中,数组的每一个元素都是一个 redisDb 对象,里面有 dict 属性,包含着所有键值对。

Redis数据结构

每一个 Redis 客户端都有自己的目标数据库,默认情况下会选择 0 号数据库,对数据的增删查改全部都在这一个数据库内进行。

客户端命令新增一条数据时,会在数据库(redisDb 类)中的 dict 字典中增加一个 key-value。

如果客户端需要设置键的过期时间,除了在 dict 字典中增加键值对,还会同时在 expires 字典中增加一个 key-value,value 是毫秒精度的 UNIX 时间戳,也就是过期时间。(Redis 过期时间在底层存储时,都是绝对时间)


Redis 的客户端和服务端

Redis 是一对多服务器程序,即一个服务器可以与多个客户端建立网络连接。

服务端基于 I/O 多路复用,能够同时接受多个客户端的请求,然后将 socket 连接加入到队列当中,逐个处理,单线程单进程处理命令请求。

客户端

客户端以 redisClient 类的形式存在于 Redis 中,其中包括 name、fd 等通用属性,以及执行事务所需的 mstate 等特定功能属性。

所有客户端(redisClient 类对象)以链表的形式,存储在 redisServer 的 clients 属性中,即服务端可以获取到所有客户端。

通过 client list 命令可以获取到所有客户端信息(属性):

1
id=6 addr=127.0.0.1:59038 fd=7 name= age=5 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=0 qbuf-free=32768 obl=0 oll=0 omem=18446744073709537584 events=r cmd=client

重要的客户端属性如下:

属性 属性作用
fd socket 文件描述符(-1 代表伪客户端,例如 AOF 文件还原)
name 名称,默认没有
flags 标志,记录客户端的角色和状态,可多个(如 REDIS_MULTI 代表客户端正在执行事务)
querybuf 输入缓冲区,记录所有客户端发来的所有命令(类型 SDS)
argv 最新一条命令的输入参数,数组(如 [“SET”, “key”, “value”])
argc argv 数组的长度
cmd 命令的实现函数(类型 redisCommand)
buf 输出缓冲区
bufpos buf 已使用的字节数量
authenticated 身份验证(0 未通过,1 通过)
ctime 创建客户端的时间
lastinteraction 客户端与服务端最后一次互动的时间
obuf_soft_limit_reached_time 输出缓冲区第一次到达软性限制的时间

服务端

服务端在启动时,会依次执行如下内容:

  1. 初始化服务器的状态结构,如 ID、运行频率、默认端口号等
  2. 载入 conf 配置文件
  3. 初始化服务器数据结构,如 db 数组
  4. 还原数据库持久化数据,开启 AOF 则使用 AOF 文件还原,没有则使用 RDB 文件还原
  5. 执行事件循环(loop),由事件驱动程序运行

当一条命令请求,从客户端传送到服务端,整个过程可以概括如下:

  1. 客户端发送请求,经编码后发送给服务器。

    如发送 SET key value ,编码成 *3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$3\r\nvalue\r\n 送达服务端。

  2. 服务端检测到 socket 可读,包装成事件交给命令请求处理器(这段过程详情参考下一节)

  3. 读取命令,处理后写入到 redisClient 属性中

  4. 查找命令函数(例如查找 SET 函数的实现)

  5. 调用命令函数

  6. 执行后续工作(例如写 AOF 缓冲区)

  7. 将命令回复发送给客户端


事件驱动

Redis 服务器是一个事件驱动程序,来一个事件,处理一个事件,并且是单线程处理的。

Redis 有两类时间:文件事件、时间事件。

文件事件

服务器通过 socket 与客户端连接,文件事件就是服务器对 socket 操作的抽象。

多个客户端与同一个服务端建立连接,会使用不同的 socket,服务端通过 I/O 多路复用器,将多个 socket 放到一个队列里面,有序、同步、逐个地向文件事件分派器传送 socket,这可以保证 Redis 以单线程处理网络请求。

文件事件

最常见的事件是 AE_READABLE 事件(可读事件)和 AE_WRITABLE 事件(可写事件),例如客户端连接服务端,或对 socket 执行 write 操作时,就会触发可读事件,而客户端对 socket 执行 read 操作时,就会触发可写事件。

事件处理器有很多种,最常用的是连接应答处理器、命令请求处理器、命令回复处理器。

这些操作跟 netty 等网络框架的概念差不多,不详述了。

时间事件

时间事件是 Redis 服务端对定时操作的抽象。

Redis 有两种时间事件:定时事件、周期性事件,在 Redis 3.0 版本时还没有使用定时事件。

Redis 服务器将所有的时间事件都放在一个链表当中,每个链表节点代表一个时间事件,记录着 id、when(执行时间,UNIX 时间戳)、timeProc(执行函数)。周期性事件会在执行时间事件之后,更新 when 时间戳。

Redis时间事件

一个典型的时间事件是 serverCron 函数,它会更新时间、内存占用,清理过期键、失效客户端等等。

时间事件并不一定会准确地在 when 时间戳执行,因为 Redis 主程序会先处理文件事件,再处理时间事件,因此时间事件的实际执行时间通常会比设定的执行时间稍晚一点。


Redis 的持久化

Redis 有两种持久化机制,都可以将内存数据保存到硬盘中,并且能在重启后加载回内存。两种持久化机制分别是 RDB 和 AOF。

RDB 持久化是 Redis 的默认方式,它定期执行,将所有内存数据(键值对)保存成二进制文件。AOF 持久化会将 Redis 执行的写命令存储起来,重启 Redis 后执行所有命令来还原数据。

RDB 持久化

RDB(Redis Database)持久化会将 Redis 中的全部数据,以一定的数据结构,存储成一个二进制文件(RDB 文件)。

有两个 RDB 持久化的命令:SAVEBGSAVE

  • SAVE 会阻塞 Redis 服务器进程,执行时 Redis 服务器会阻塞所有客户端发送的命令
  • BGSAVE 执行时仍可继续处理客户端的命令,但会拒绝客户端 SAVEBGSAVE 的命令,延迟 BGREWRITEAOF 命令。

RDB 的存储过程是定期执行的(也可以手动触发),默认情况下有三种定时条件触发 BGSAVE 命令,配置在 conf 文件中:

1
2
3
4
5
6
# 服务器在 900 秒内,对数据库进行了至少 1 次修改
save 900 1
# 服务器在 300 秒内,对数据库进行了至少 10 次修改
save 300 10
# 服务器在 60 秒内,对数据库进行了至少 10000 次修改
save 60 10000

上次 RDB 持久化的时间,以及又修改了多少次,都存储在 RedisServer 类对象的 lastsave 属性(UNIX 时间戳)和 dirty 属性(long long 类型的计数器)中。

由于 RDB 持久化是有时间间隔的,因此可能发生数据丢失,如果服务端宕机,上次 RDB 之后的命令都会丢失。


持久化后的 RDB 文件是一种以 .rdb 为后缀的二进制文件,默认命名 dump.rdb 储存在 Redis 根目录里。

RDB 文件有特定的数据结构,可以将所有数据库的所有键值对都存储下来,具体可以参考官方文档《RDB 文件结构》和《RDB 历代更新说明》。

RDB数据结构

AOF 持久化

AOF(Append Only File)持久化会将 Redis 的所有写命令保存下来,默认不开启,如果开启,Redis 在重启后会优先选用 AOF 恢复。

AOF 默认命名 appendonly.aof 储存在 Redis 根目录里,内容是纯文本格式的,可以直接阅读。

1
2
3
4
5
6
SET msg "hello"
SADD fruits "apple" "banana" "peach"

# AOF文件存储的内容是(\r\n是回车换行):
# *3\r\n$3\r\nSET\r\n$3\r\nmsg\r\n$5\r\nhello\r\n
# *5\r\n$4\r\nSADD\r\n$6\r\n$5\napple\r\nbanana\r\n$5\npeach\r\n

AOF 的存储分为两部分,一部分是内存,一部分是硬盘。内存累计一些数据之后,同步到硬盘当中,内存起到缓存的作用,能提高文件的写入效率。Redis 执行一条写命令时,AOF 相关操作如下:

  1. 服务器执行完一条写命令
  2. 在 redisServer 对象中的 aof_buf 属性(SDS 字符串)的末尾,追加上这条命令
  3. 按照一定的逻辑,将 aof_buf 中的内容,同步到硬盘文件中。同步逻辑有三种:
    • 每次都同步
    • 每秒同步一次(默认)
    • 不同步,由操作系统来决定何时同步

由内存缓存,再同步到硬盘文件中,这种设计能提高写入效率,但可能会造成数据丢失。


AOF 有一个问题:随着命令的增多,AOF 文件的体积会不断膨胀,存储占用空间,还原所需的时间也很长。

在这种情况下,可以通过 BGREWRITEAOF 命令重写 AOF 文件。重写的逻辑是,将多条写命令等效成一条,例如:

1
2
3
4
5
6
7
RPUSH list "A" "B"
RPUSH list "C"
RPUSH list "D" "E"
LPOP list

# 以上命令可以等效为一条命令:【RPUSH list "B" "C" "D" "E"】
# 存储在AOF文件中就是:【*6\r\n$5\r\nRPUSH\r\n$4\r\nlist\r\n$1\r\nB\r\n$1\r\nC\r\n$1\r\nD\r\n$1\r\nE\r\n】

为避免阻塞,BGREWRITEAOF 重写在子进程中执行,但这样会带来脏写的问题:AOF 重写完一条 key-value 之后,这个 key-value 又修改了。

为解决这个问题,在 AOF 重写期间,所有的写命令除了需要写入 AOF 缓冲区(aof_buf)之外,还需要写入 AOF 重写缓冲区。

当子进程执行完 AOF 重写后,主进程将 AOF 重写缓冲区内的内容写入到新的 AOF 文件,再将新的 AOF 文件改名替换到原来的 AOF 文件,这样就可以避免脏写的问题。


这周就学习到这里,还要学习多机 Redis 的实现,以及部分拓展功能。