注:本文是《Redis 设计与实现》章节后的重点回顾,夹杂着一些个人理解
0
到 9999
的字符串对象。listNode
结构来表示, 每个节点都有一个指向前置节点和后置节点的指针, 所以 Redis 的链表实现是双端链表。list
结构来表示, 这个结构带有表头节点指针、表尾节点指针、以及链表长度等信息。NULL
, 所以 Redis 的链表实现是无环链表。set name abc
就是通过哈希字典实现的,键是字符串对象 “name”,值是字符串对象 “abc”,redis 底层所有的存储几乎都依赖于字典实现。ht[2
],ht[0] 用以平时使用,ht[1] 用以 rehash。扩展和收缩哈希表的工作可以通过执行 rehash (重新散列)操作来完成, Redis 对字典的哈希表执行 rehash 的步骤如下:
ht[1]
的大小为第一个大于等于 ht[0].used * 2
的 (2
的 n
次方幂);ht[1]
的大小为第一个大于等于 ht[0].used
的 。ht[0]
中的所有键值对 rehash 到 ht[1]
上面: rehash 指的是重新计算键的哈希值和索引值, 然后将键值对放置到 ht[1]
哈希表的指定位置上。ht[0]
包含的所有键值对都迁移到了 ht[1]
之后 (ht[0]
变为空表), 释放 ht[0]
, 将 ht[1]
设置为 ht[0]
, 并在 ht[1]
新创建一个空白哈希表, 为下一次 rehash 做准备。zskiplist
和 zskiplistNode
两个结构组成, 其中 zskiplist
用于保存跳跃表信息(比如表头节点、表尾节点、长度), 而 zskiplistNode
则用于表示跳跃表节点。1
至 32
之间的随机数。typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。
图 7-1 展示了压缩列表的各个组成部分, 表 7-1 则记录了各个组成部分的类型、长度、以及用途。
每个 entry 又是由evious_entry_length
、 encoding
、 content
三个部分组成,这指示着 节点的前一个节点的长度、节点的编码、节点的内容。
表 7-1 压缩列表各个组成部分的详细说明
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes |
uint32_t |
4 字节 |
记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。 |
zltail |
uint32_t |
4 字节 |
记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。 |
zllen |
uint16_t |
2 字节 |
记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX (65535 )时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entryX |
列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend |
uint8_t |
1 字节 |
特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
图 7-2 展示了一个压缩列表示例:
zlbytes
属性的值为 0x50
(十进制 80
), 表示压缩列表的总长为 80
字节。zltail
属性的值为 0x3c
(十进制 60
), 这表示如果我们有一个指向压缩列表起始地址的指针 p
, 那么只要用指针 p
加上偏移量 60
, 就可以计算出表尾节点 entry3
的地址。zllen
属性的值为 0x3
(十进制 3
), 表示压缩列表包含三个节点。图 7-3 展示了另一个压缩列表示例:
zlbytes
属性的值为 0xd2
(十进制 210
), 表示压缩列表的总长为 210
字节。zltail
属性的值为 0xb3
(十进制 179
), 这表示如果我们有一个指向压缩列表起始地址的指针 p
, 那么只要用指针 p
加上偏移量 179
, 就可以计算出表尾节点 entry5
的地址。zllen
属性的值为 0x5
(十进制 5
), 表示压缩列表包含五个节点。我们都知道在计算机底层内存都是 8 字节对齐的,这意味着如果使用不满 8 字节仍然会被填充以 8 字节对齐,这就产生了内存碎片,压缩列表就是为了解决这些内部碎片而生的,压缩列表使每一个元素紧密的连接在一起以避免内部碎片,在 C 语言中只需使用 void* 就可以做到这一点,但代价是必须要有额外的信息表示每个元素的大小或链表的总长度。
压缩列表是一种为节约内存而开发的顺序型数据结构。
压缩列表被用作列表键和哈希键的底层实现之一,在表示哈希时,键值是严格靠在一起的。
压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
添加新节点到压缩列表, 或者从压缩列表中删除节点, 可能会引发连锁更新操作, 但这种操作出现的几率并不高。
这是因为节点的 previous_entry_length 要么为 1字节(能够指示 255 字节大小的程度),要么为 5 字节,这取决与前一个节点的大小,如果前一个节点小于 255 字节,那么 previous_entry_length 为 1字节,否则为 5 字节。
现在如果某节点的 previous_entry_length 占用 1字节,而现在向其前面插入大小更大的节点,那么该节点的 previous_entry_length 的大小应该就会被重新分配,从而导致节点的大小被重新分配,从而又导致其他节点的更新。
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// 最后一次被访问的时间
unsigned lru:22;
// 引用计数
unsigned count:32;
// 更多字段......
} robj;
Redis 数据库中的每个键值对的键和值都是一个对象。
Redis 共有 字符串、列表、哈希、集合、有序集合 五种类型的对象, 每种类型的对象至少都有两种或以上的编码方式, 不同的编码可以在不同的使用场景上优化对象的使用效率。
ziplist
或者 linkedlist
,这取决于列表对象的大小,较小的对象采用 ziplist
;intset
或者 linkedlist
,这取决于具体的数据类型。zipkist
或者 skiplist 和 hashtable
,跳跃表和哈希表的共同使用的效果类似与 Java 中的 TreeMap;int
、 raw
或者 embstr
,例如 set age 123
则会使用 int 类型来保存 age 对应的字符串,raw 是简单动态字符串,embstr 是针对短字符串的,其分配内存的次数减少了一次,例如 set name abcd
,row 编码会为 name 和 abcd 各分配一次,而 embstr 会一次性分配足够的空间。TYPE key 可以查看 key 对应的 val 的对象类型,OBJECT ENCODING key 命令可以查看 key 对应的 val 的编码方式。
服务器在执行某些命令之前, 会先检查给定键的类型能否执行指定的命令, 而检查一个键的类型就是检查键的值对象的类型。
Redis 的对象系统带有引用计数实现的内存回收机制, 当一个对象不再被使用时, 该对象所占用的内存就会被自动释放。
Redis 会共享值为 0
到 9999
的字符串对象。
对象会记录自己的最后一次被访问的时间, 这个时间可以用于计算对象的空转时间。
Redis 服务器的所有数据库都保存在 redisServer.db
数组中, 而数据库的数量则由 redisServer.dbnum
属性保存。
客户端通过修改目标数据库指针, 让它指向 redisServer.db
数组中的不同元素来切换不同的数据库。
数据库主要由 dict
和 expires
两个字典构成, 其中 dict
字典负责保存键值对, 而 expires
字典则负责保存键的过期时间。
因为数据库由字典构成, 所以对数据库的操作都是建立在字典操作之上的。
数据库的键总是一个字符串对象, 而值则可以是任意一种 Redis 对象类型, 包括字符串对象、哈希表对象、集合对象、列表对象和有序集合对象, 分别对应字符串键、哈希表键、集合键、列表键和有序集合键。
expires
字典的键指向数据库中的某个键, 而值则记录了数据库键的过期时间, 过期时间是一个以毫秒为单位的 UNIX 时间戳。
Redis 使用惰性删除和定期删除两种策略来删除过期的键: 惰性删除策略只在碰到过期键时才进行删除操作, 定期删除策略则每隔一段时间, 主动查找并删除部分过期键,这通常是由 redis 的后台线程serverCron
函数完成,默认的间隔是 100ms。
执行 SAVE 命令或者 BGSAVE 命令所产生的新 RDB 文件不会包含已经过期的键。
执行 BGREWRITEAOF 命令所产生的重写 AOF 文件不会包含已经过期的键。
当一个过期键被删除之后, 服务器会追加一条 DEL 命令到现有 AOF 文件的末尾, 显式地删除过期键。
当主服务器删除一个过期键之后, 它会向所有从服务器发送一条 DEL 命令, 显式地删除过期键。
从服务器即使发现过期键, 也不会自作主张地删除它, 而是等待主节点发来 DEL 命令, 这种统一、中心化的过期键删除策略可以保证主从服务器数据的一致性。
当 Redis 命令对数据库进行修改之后, 服务器会根据配置, 向客户端发送数据库通知。
当内存达到限制时,Redis 具体的回收策略是通过 maxmemory-policy 配置项配置的。
RDB 文件用于保存和还原 Redis 服务器所有数据库中的所有键值对数据。
SAVE 命令由服务器进程直接执行保存操作,所以该命令会阻塞服务器。
BGSAVE 命令由子进程执行保存操作,所以该命令不会阻塞服务器,而是在后台工作。
服务器状态中会保存所有用 save
选项设置的保存条件,当任意一个保存条件被满足时,服务器会自动执行 BGSAVE 命令,这就需要 redis 在执行完命名后记录一些数据,例如是否变脏。
# 配置
save 900 1 # 900s 内对数据库有 1 次修改
save 300 10 # 300s 内对数据库有 10 次修改
save 60 10000 # 60s 内对数据库有 10000 次修改
检测配置文件仍然是由后台线程serverCron
函数完成,而该函数默认的间隔是 100ms,该函数会先检查时间间隔最短的配置,例如上面默认配置中,先检查 60s 内是否有 10000 次修改,如果没有则检查 300s 内是否有 10 次修改…
RDB 文件是一个经过压缩的二进制文件,由多个部分组成。
对于不同类型的键值对, RDB 文件会使用不同的方式来保存它们。
RDB 的保存方式是直接存储数据,对于不同类型的键值对, RDB 文件会使用不同的方式来保存它们。
AOF 文件通过保存所有修改数据库的写命令请求来记录服务器的数据库状态。
AOF 文件中的所有命令都以 Redis 命令请求协议的格式保存。
命令请求会先保存到 AOF 缓冲区里面, 之后再定期写入并同步到 AOF 文件。
appendfsync
选项的不同值对 AOF 持久化功能的安全性、以及 Redis 服务器的性能有很大的影响。
- always 每次都将缓冲区的内容立即写入aof文件种
- everysec(默认的) 距离上次同步超过一秒就进行同步,有专门的线程负责执行
- no 就是由磁盘决定什么时候将缓冲区存入磁盘
服务器只要载入并重新执行保存在 AOF 文件中的命令, 就可以还原数据库本来的状态。
AOF 重写可以产生一个新的 AOF 文件, 这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样, 但体积更小。
AOF 重写是一个有歧义的名字, 该功能是通过读取数据库中的键值对来实现的, 程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。
在执行 BGREWRITEAOF 命令时, Redis 服务器会维护一个 AOF 重写缓冲区, 该缓冲区会在子进程创建新 AOF 文件的期间, 记录服务器执行的所有写命令。 当子进程完成创建新 AOF 文件的工作之后, 服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾, 使得新旧两个 AOF 文件所保存的数据库状态一致。 最后, 服务器用新的 AOF 文件替换旧的 AOF 文件, 以此来完成 AOF 文件重写操作。
AOF 通过记录客户写操作命令来完成备份,好处是你可以很清晰的知道在数据丢失这段时间内客户执行了什么操作,坏处是这可能会造成一定的冗余,例如对同一个键执行多次 set 命令,那么只有最后一次 set 是有效的,前几次都是冗余的,因此长时间后,AOF 文件会变得臃肿,所以才有 BGREWRITEAOF(AOF 重写)这种操作出现。
Redis 基于 Reactor 模式 开发了自己的网络事件处理器: 这个处理器被称为文件事件处理器(file event handler):
虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字, 文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。
文件事件是对套接字操作的抽象, 每当一个套接字准备好执行连接应答(accept)、写入、读取、关闭等操作时, 就会产生一个文件事件。 因为一个服务器通常会连接多个套接字, 所以多个文件事件有可能会并发地出现。
I/O 多路复用程序负责监听多个套接字, 并向文件事件分派器传送那些产生了事件的套接字。
尽管多个文件事件可能会并发地出现, 但 I/O 多路复用程序总是会将所有产生事件的套接字都入队到一个队列里面, 然后通过这个队列, 以有序)、同步、每次一个套接字的方式向文件事件分派器传送套接字: 当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕), I/O 多路复用程序才会继续向文件事件分派器传送下一个套接字, 如图 。
文件事件分派器接收 I/O 多路复用程序传来的套接字, 并根据套接字产生的事件的类型, 调用相应的事件处理器。
服务器会为执行不同任务的套接字关联不同的事件处理器, 这些处理器是一个个函数, 它们定义了某个事件发生时, 服务器应该执行的动作。
事件调度程序伪代码:
void aeProcessEvents() {
while (true) {
// 获取距离当前时间最近的事件事件
timeEvent = aeSerachNearestEvent();
// 计算距离现在还剩多久
deltaTime = timeEvent.when - nowTime;
// 如果到时间了,则立即执行
if (deltaTime <= 0) {
doTimeEvent(timeEvent);
continue;
}
// 否则等待堵塞文件事件产生
// 堵塞等待的最大时间为 deltaTime
fileEvent = aeApiPoll(deltaTime);
// 如果有文件事件发生,则执行文件事件
if (fileEvent != null) {
doFileEvent(fileEvent);
}
// 如果到时间了,则执行时间事件,注意由于可能会等待文件事件执行完毕,deltaTime 可能为负
if (deltaTime <= 0) {
doTimeEvent(timeEvent);
}
}
}
AE_READABLE
事件(读事件)和 AE_WRITABLE
事件(写事件)两类。serverCron
函数一个时间事件, 并且这个事件是周期性事件。typedef struct redisClient {
// 使用哪个数据库
int db;
// 套接字描述符,当为 -1 时表示伪客户端
int fd;
// 客户名字
robj *name;
// 输入缓冲区,客户发来的请求命令会暂时的保存在这个简单字符串缓存中
sds querybuf;
// 命令参数,由服务器对 querybuf 解析得出
robj **argv;
// 命令参数长度,由服务器对 querybuf 解析得出
int argc;
// 命令类型,指向一个具体的解决函数,由服务器对 querybuf 解析得出
struct redisCommand *cmd;
// 回复缓存,服务器的回复,暂时存放
char buf[REDIS_REPLY_CHUNK_BYTES];
// ...
} redisClient;
clients
链表连接起多个客户端状态, 新添加的客户端状态会被放到链表的末尾。flags
属性使用不同标志来表示客户端的角色, 以及客户端当前所处的状态,例如集群中任何一太服务器可能都是一个客户端,执行 AOF 文件时也需要一个伪客户端。argv
和 argc
属性里面, 而 cmd
属性则记录了客户端要执行命令的实现函数。现在,我们终于可以来看看客户键入命令后到收到回复这段时间发生了什么,举个例子, 如果我们使用客户端执行以下命令:
redis> SET KEY VALUE
OK
SET KEY VALUE
时,redis-cli 会将命令封装成符合 redis 协议的命令,例如*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n
。+ok\r\n1
会被转换成ok\r\n
。重点回顾:
serverCron
函数默认每隔 100
毫秒执行一次, 它的工作主要包括更新服务器状态信息, 处理服务器接收的 SIGTERM
信号, 管理客户端资源和数据库状态, 检查并执行持久化操作, 等等。复制通常是针对主从复制的,主服务器需要向从服务器发送写命令,而从服务器也可以要求主服务器进行同步复制,前者是命令传播,直接传播命令即可,后者是我们主要研究的。
早期版本的 SYNC 命令十分暴力,主服务器会发送所有的 RDB 文件给从服务器,复制是异步的,在发送的这段时间,所有的数据变更被积累在一个缓冲区中,发送完成后在同步将缓冲区的数据变更全部发送即可完成复制。
SYNC 命令的缺陷十分明显,即不能实现增量更新,即部分复制,新版的 PSYNC 解决了这个缺陷,PSYNC 拥有完全同步和部分同步两个语义。
在 PSYNC 的实现中,部分重同步通过复制偏移量、复制积压缓冲区、服务器运行 ID 三个部分来实现。
在命令传播时,主从服务器各自维护复制语句的偏移量,例如如果主服务器偏移量为 100,而从服务器偏移量为 50,服务器应该补偿 50 ~ 100 字节的数据给服务器,RDB 肯定无法做到这一点,因为 RDB 存储的是整个数据库数据,没办法抽离,因此需要引入复制积压缓冲区.
主服务器传播命令时还会将命令写入复制积压缓冲区,命令会有一个对应的偏移量为标识,如果从服务器发来的偏移量能够在复制积压缓冲区找到,那么就执行部分复制,否则执行完全同步
若想让一台服务器成为从服务器,只需要在配置文件中加入:
# 假设 192.168.88.111 为主节点ip,6379为端口
slaveof 192.168.88.111 6379
然后以该配置文件启动 redis 即可:redis-server redis.conf
一致性与共识就是经典的 Raft 算法了,不过 redis 做到巧妙的一点是,每个 Sentinel 都订阅同一个频道,它们会以每两秒一次的频率, 通过该频道发送消息来向其他 Sentinel 宣告自己的存在,这就使得每个 Sentinel 不必互相见面就能知道彼此的存在。
启动一个 Sentinel 需要在配置文件加上:
# sentinel monitor [master-group-name] [ip] [port] [quorum]
# 该行的意思是:监控的master的名字叫做mymaster (可以自定义),地址为192.168.88.111:6379
# 行尾最后的一个2代表在sentinel集群中,多少个sentinel认为master死了,才能真正认为该master不可用了。
sentinel monitor my_master 192.168.88.111 6379 2
可以使用命令启动:
$ redis-sentinel redis.conf
或者命令:
$ redis-server redis.conf --sentinel
这两个命令的效果完全相同。
当一个 Sentinel 启动时, 它需要执行以下步骤:
__sentinel__:hello
频道发送消息来向其他 Sentinel 宣告自己的存在。__sentinel__:hello
频道中接收其他 Sentinel 发来的信息, 并根据这些信息为其他 Sentinel 创建相应的实例结构, 以及命令连接。一个 Redis 集群通常由多个节点(node)组成, 在刚开始的时候, 每个节点都是相互独立的, 它们都处于一个只包含自己的集群当中, 要组建一个真正可工作的集群, 我们必须将各个独立的节点连接起来, 构成一个包含多个节点的集群。
连接各个节点的工作可以使用 CLUSTER MEET 命令来完成, 该命令的格式如下:
CLUSTER MEET
向一个节点 node
发送 CLUSTER MEET 命令, 可以让 node
节点与 ip
和 port
所指定的节点进行握手(handshake), 当握手成功时, node
节点就会将 ip
和 port
所指定的节点添加到 node
节点当前所在的集群中。
不过,如果想要进入集群模式,还必须要将配置中的 cluster-enabled 选项配置为 true。
16384
个槽可以分别指派给集群中的各个节点, 每个节点都会记录哪些槽指派给了自己, 而哪些槽又被指派给了其他节点。槽位是针对数据库中的所有的 key 而言的,redis 会将所有的 key 尽可能均匀映射到 0 ~ 16384,用户请求相关key时,会根据key的槽位选择对应的节点。槽位信息必须在集群中传播,节点可以通过 bitset 来快速查看相关槽位是否是自己负责的。MOVED
错误, MOVED
错误携带的信息可以指引客户端转向至正在负责相关槽的节点,客户端会自动完成重定向,这对用户是不可见的。i
至节点 B , 那么当节点 A 没能在自己的数据库中找到命令指定的数据库键时, 节点 A 会向客户端返回一个 ASK
错误, 指引客户端到节点 B 继续查找指定的数据库键。MOVED
错误表示槽的负责权已经从一个节点转移到了另一个节点, 而 ASK
错误只是两个节点在迁移槽的过程中使用的一种临时措施。MOVED
错误, MOVED
错误携带的信息可以指引客户端转向至正在负责相关槽的节点,客户端会自动完成重定向,这对用户是不可见的。i
至节点 B , 那么当节点 A 没能在自己的数据库中找到命令指定的数据库键时, 节点 A 会向客户端返回一个 ASK
错误, 指引客户端到节点 B 继续查找指定的数据库键。MOVED
错误表示槽的负责权已经从一个节点转移到了另一个节点, 而 ASK
错误只是两个节点在迁移槽的过程中使用的一种临时措施。MEET
、 PING
、 PONG
、 PUBLISH
、 FAIL
五种。