一、数据结构
1、简单动态字符串
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
2、链表
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
} list;
3、字典
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
根据节点key计算索引值(桶位置):index = hash(key) & dict->ht[x].sizemask
解决key冲突:链地址法;为了速度考虑, 程序总是将新节点添加到链表的表头位置(复杂度为 O(1))
rehash:利用字段中的两个哈希表
哈希表渐进式 rehash 的详细步骤:
- 为
ht[1]分配空间, 让字典同时持有ht[0]和ht[1]两个哈希表。 - 在字典中维持一个索引计数器变量
rehashidx, 并将它的值设置为0, 表示 rehash 工作正式开始。 - 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将
ht[0]哈希表在rehashidx索引上的所有键值对 rehash 到ht[1], 当 rehash 工作完成之后, 程序将rehashidx属性的值增一。 - 随着字典操作的不断执行, 最终在某个时间点上,
ht[0]的所有键值对都会被 rehash 至ht[1], 这时程序将rehashidx属性的值设为-1, 表示 rehash 操作已完成。
查找一个键的话, 程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找
新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作
4、跳表
层带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离
后退指针在程序从表尾向表头遍历时使用。
节点的分值(score 属性)是一个 double 类型的浮点数, 跳跃表中的所有节点都按分值从小到大来排序。
节点的成员对象(obj 属性)是一个指针, 它指向一个字符串对象, 而字符串对象则保存着一个 SDS 值。
在同一个跳跃表中, 各个节点保存的成员对象必须是唯一的, 但是多个节点保存的分值却可以是相同的: 分值相同的节点将按照成员对象在字典序中的大小来进行排序
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
5、整数集合
整数集合(intset)是 Redis 用于保存整数值的集合抽象数据结构, 它可以保存类型为 int16_t 、 int32_t 或者 int64_t 的整数值, 并且保证集合中不会出现重复元素
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
插入新数据,可能带来数据类型升级
共分为三步进行:
- 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
两个好处, 一个是提升整数集合的灵活性, 另一个是尽可能地节约内存
6、压缩列表
压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构
属性
类型
长度
用途
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 ),用于标记压缩列表的末端。
一个压缩列表可以包含任意多个节点(entry), 节点都由 previous_entry_length 、 encoding 、 content 三个部分组成:
根据previous_entry_length从表尾向表头遍历操作
content 属性负责保存节点的值, 节点值可以是一个字节数组或者整数, 值的类型和长度由节点的 encoding 属性决定
encoding 属性记录了节点的 content 属性所保存数据的类型以及长度
添加新节点,如果新节点太长会引发连锁更新(连续存储+previous_entry_length导致的),连锁更新的最坏复杂度为 O(N^2) 。 尽管连锁更新的复杂度较高, 但它真正造成性能问题的几率是很低的
二、对象
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// ...
} robj;
每次当我们在 Redis 的数据库中新创建一个键值对时, 我们至少会创建两个对象, 一个对象用作键值对的键(键对象), 另一个对象用作键值对的值(值对象)。
type 属性记录了对象的类型:
类型常量
对象的名称
REDIS_STRING
字符串对象
REDIS_LIST
列表对象
REDIS_HASH
哈希对象
REDIS_SET
集合对象
REDIS_ZSET
有序集合对象
redis键值对中:键总是一个字符串对象, 而值则可以是字符串对象、列表对象、哈希对象、集合对象或者有序集合对象的其中一种;对一个数据库键执行 TYPE 命令时, 命令返回的结果为数据库键对应的值对象的类型
ptr 指针指向对象的底层实现数据结构, 而这些数据结构由对象的 encoding 属性决定
类型
编码
对象
REDIS_STRING
REDIS_ENCODING_INT
使用整数值实现的字符串对象。
REDIS_STRING
REDIS_ENCODING_EMBSTR
使用 embstr 编码的简单动态字符串实现的字符串对象。
REDIS_STRING
REDIS_ENCODING_RAW
使用简单动态字符串实现的字符串对象。
REDIS_LIST
REDIS_ENCODING_ZIPLIST
使用压缩列表实现的列表对象。
REDIS_LIST
REDIS_ENCODING_LINKEDLIST
使用双端链表实现的列表对象。
REDIS_HASH
REDIS_ENCODING_ZIPLIST
使用压缩列表实现的哈希对象。
REDIS_HASH
REDIS_ENCODING_HT
使用字典实现的哈希对象。
REDIS_SET
REDIS_ENCODING_INTSET
使用整数集合实现的集合对象。
REDIS_SET
REDIS_ENCODING_HT
使用字典实现的集合对象。
REDIS_ZSET
REDIS_ENCODING_ZIPLIST
使用压缩列表实现的有序集合对象。
REDIS_ZSET
REDIS_ENCODING_SKIPLIST
使用跳跃表和字典实现的有序集合对象。
每种类型的对象都至少使用了两种不同的编码
使用 OBJECT ENCODING 命令可以查看一个数据库键的值对象的编码
1、字符串对象
字符串对象的编码可以是 int 、 raw 或者 embstr 。
如果一个字符串对象保存的是整数值, 并且这个整数值可以用 long 类型来表示, 那么字符串对象会将整数值保存在字符串对象结构的 ptr 属性里面(将 void* 转换成 long ), 并将字符串对象的编码设置为 int
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度大于 39 字节, 那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值, 并将对象的编码设置为 raw
如果字符串对象保存的是一个字符串值, 并且这个字符串值的长度小于等于 39 字节, 那么字符串对象将使用 embstr 编码的方式来保存这个字符串值。embstr 编码是专门用于保存短字符串的一种优化编码方式, 这种编码和 raw 编码一样, 都使用 redisObject 结构和 sdshdr 结构来表示字符串对象, 但 raw 编码会调用两次内存分配函数来分别创建 redisObject 结构和 sdshdr 结构, 而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间, 空间中依次包含 redisObject 和 sdshdr 两个结构
使用 embstr 编码的字符串对象来保存短字符串值有以下好处:
embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为一次。- 释放
embstr编码的字符串对象只需要调用一次内存释放函数, 而释放raw编码的字符串对象需要调用两次内存释放函数。 - 因为
embstr编码的字符串对象的所有数据都保存在一块连续的内存里面, 所以这种编码的字符串对象比起raw编码的字符串对象能够更好地利用缓存带来的优势。
对于 `int` 编码的字符串对象来说, 如果我们向对象执行了一些命令, 使得这个对象保存的不再是整数值, 而是一个字符串值, 那么字符串对象的编码将从 `int` 变为 `raw` 。
embstr 编码的字符串对象实际上是只读的: 当我们对 embstr 编码的字符串对象执行任何修改命令时, 程序会先将对象的编码从 embstr 转换成 raw , 然后再执行修改命令; 因为这个原因, embstr 编码的字符串对象在执行修改命令之后, 总会变成一个 raw 编码的字符串对象
字符串对象是 Redis 五种类型的对象中唯一一种会被其他四种类型对象嵌套的对象
2、列表对象
列表对象的编码可以是 ziplist 或者 linkedlist
ziplist 编码的列表对象使用压缩列表作为底层实现, 每个压缩列表节点(entry)保存了一个列表元素。
linkedlist 编码的列表对象使用双端链表作为底层实现, 每个双端链表节点(node)都保存了一个字符串对象, 而每个字符串对象都保存了一个列表元素
当列表对象可以同时满足以下两个条件时, 列表对象使用 ziplist 编码:
-
列表对象保存的所有字符串元素的长度都小于
64字节; -
列表对象保存的元素数量小于
512个;
不能满足这两个条件的列表对象需要使用 linkedlist 编码
3、哈希对象
哈希对象的编码可以是 ziplist 或者 hashtable 。
ziplist 编码的哈希对象使用压缩列表作为底层实现, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾, 因此:
-
保存了同一键值对的两个节点总是紧挨在一起, 保存键的节点在前, 保存值的节点在后;
-
先添加到哈希对象中的键值对会被放在压缩列表的表头方向, 而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
-
hashtable编码的哈希对象使用字典作为底层实现, 哈希对象中的每个键值对都使用一个字典键值对来保存: -
字典的每个键都是一个字符串对象, 对象中保存了键值对的键;
-
字典的每个值都是一个字符串对象, 对象中保存了键值对的值
当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist 编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于
64字节; - 哈希对象保存的键值对数量小于
512个;
不能满足这两个条件的哈希对象需要使用 hashtable 编码
4、集合对象
集合对象的编码可以是 intset 或者 hashtable 。
intset 编码的集合对象使用整数集合作为底层实现, 集合对象包含的所有元素都被保存在整数集合里面。
hashtable 编码的集合对象使用字典作为底层实现, 字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL
当集合对象可以同时满足以下两个条件时, 对象使用 intset 编码:
- 集合对象保存的所有元素都是整数值;
- 集合对象保存的元素数量不超过
512个;
不能满足这两个条件的集合对象需要使用 hashtable 编码
5、有序集合对象
有序集合的编码可以是 ziplist 或者 skiplist 。
ziplist 编码的有序集合对象使用压缩列表作为底层实现, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。
压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。
skiplist 编码的有序集合对象使用 zset 结构作为底层实现, 一个 zset 结构同时包含一个字典和一个跳跃表:
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;
zset 结构中的 zsl 跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点都保存了一个集合元素: 跳跃表节点的 object 属性保存了元素的成员, 而跳跃表节点的 score 属性则保存了元素的分值。 通过这个跳跃表, 程序可以对有序集合进行范围型操作, 比如 ZRANK 、 ZRANGE 等命令就是基于跳跃表 API 来实现的。
除此之外, zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射, 字典中的每个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 通过这个字典, 程序可以用 O(1) 复杂度查找给定成员的分值, ZSCORE 命令就是根据这一特性实现的, 而很多其他有序集合命令都在实现的内部用到了这一特性。
当有序集合对象可以同时满足以下两个条件时, 对象使用 ziplist 编码:
- 有序集合保存的元素数量小于
128个; - 有序集合保存的所有元素成员的长度都小于
64字节;
不能满足以上两个条件的有序集合对象将使用 skiplist 编码。
6、类型检查与命令多态
Redis 中用于操作键的命令基本上可以分为两种类型。
其中一种命令可以对任何类型的键执行, 比如说 DEL 命令;
而另一种命令只能对特定类型的键执行, 比如说: SET ;类型特定命令所进行的类型检查是通过 redisObject 结构的 type 属性来实现的:
- 在执行一个类型特定命令之前, 服务器会先检查输入数据库键的值对象是否为执行命令所需的类型
Redis 除了会根据值对象的类型来判断键是否能够执行指定命令之外, 还会根据值对象的编码方式, 选择正确的命令实现代码来执行命令。
7、内存回收、对象共享
Redis 在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制, 通过这一机制, 程序可以通过跟踪对象的引用计数信息, 在适当的时候自动释放对象并进行内存回收。
每个对象的引用计数信息由 redisObject 结构的 refcount 属性记录:
typedef struct redisObject {
// ...
// 引用计数
int refcount;
// ...
} robj;
对象的引用计数信息会随着对象的使用状态而不断变化:
- 在创建一个新对象时, 引用计数的值会被初始化为
1; - 当对象被一个新程序使用时, 它的引用计数值会被增一;
- 当对象不再被一个程序使用时, 它的引用计数值会被减一;
- 当对象的引用计数值变为
0时, 对象所占用的内存会被释放。
对象的整个生命周期可以划分为创建对象、操作对象、释放对象三个阶段。
作为例子, 以下代码展示了一个字符串对象从创建到释放的整个过程:
// 创建一个字符串对象 s ,对象的引用计数为 1
robj *s = createStringObject(...)
// 对象 s 执行各种操作 ...
// 将对象 s 的引用计数减一,使得对象的引用计数变为 0
// 导致对象 s 被释放
decrRefCount(s)
让多个键共享同一个值对象需要执行以下两个步骤:
- 将数据库键的值指针指向一个现有的值对象;
- 将被共享的值对象的引用计数增一。
Redis 会在初始化服务器时, 创建一万个字符串对象, 这些对象包含了从 0 到 9999 的所有整数值,供共享使用
举个例子, 如果我们创建一个值为 100 的键 A , 并使用 OBJECT REFCOUNT 命令查看键 A 的值对象的引用计数, 我们会发现值对象的引用计数为 2 :
redis> SET A 100
OK
redis> OBJECT REFCOUNT A
(integer) 2
- 如果共享对象是保存整数值的字符串对象, 那么验证操作的复杂度为 O(1) ;
- 如果共享对象是保存字符串值的字符串对象, 那么验证操作的复杂度为 O(N) ;
- 如果共享对象是包含了多个值(或者对象的)对象, 比如列表对象或者哈希对象, 那么验证操作的复杂度将会是 O(N^2) 。
因此, 尽管共享更复杂的对象可以节约更多的内存, 但受到 CPU 时间的限制, Redis 只对包含整数值的字符串对象进行共享。
8、对象的空转时长
lru 属性, 该属性记录了对象最后一次被命令程序访问的时间:
typedef struct redisObject {
// ...
unsigned lru:22;
// ...
} robj;
OBJECT IDLETIME 命令可以打印出给定键的空转时长, 这一空转时长就是通过将当前时间减去键的值对象的 lru 时间计算得出的
键的空转时长还有另外一项作用: 如果服务器打开了 maxmemory 选项, 并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru , 那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存