本文来源于《Redis的设计与实现》第一章节的学习,是这本书的简要读书笔记,仅作为记录所用,希望以后能常常温故知新~~~
简单动态字符串被广泛用于redis内部的数据结构中,例如redis的键值对相应的键是SDS实现,值的相关也由SDS保存实现;AOF模块中的AOF缓冲区,以及客户端状态下的输入缓冲区都是由SDS实现的;它的结构如下:
/*
* 保存字符串对象的结构
*/
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 数据空间
char buf[];
};
由于有free,即未使用空间的存在,SDS实现了空间预分配以及惰性空间释放两种优化策略;兼容二进制数据;并兼容部分C字符串函数。
额外分配的未使用空间数量由以下公式决定:
(1) 如果对SDS 进行修改之后,SDS 的长度(也即是len 属性的值) 将小于1MB,那么程序分配和len 属性同样大小的未使用空间,这时SDS len 属性的值将和 free 属性的值相同。举个例子,如果进行修改之后,SDS 的len 将变成13 字节,那么程序也会分配13 字节的未使用空间,SDS 的 buf 数组的实际长度将变成13+13+1=27 字节(额外的一字节用于保存空字符)。
(2) 如果对SDS 进行修改之后,SDS 的长度将大于等于1MB,那么程序会分配1MB 的未使用空间。举个例子,如果进行修改之后,SDS 的len 将变成30MB,那么程序会分配1MB 的未使用空间,SDS 的buf 数组的实际长度将为30 MB + 1MB + 1byte。
在扩展SDS 空间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无须执行内存重分配。通过这种预分配策略,SDS 将连续增长N 次字符串所需的内存重分配次数从必定N 次降低为最多N 次~~~
惰性空间释放用于优化SDS 的字符串缩短操作:当SDS 的API 需要缩短SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free 属性将这些字节的数量记录起来,并等待将来使用。
链表作为一种常用的数据结构,被内置在很多高级编程语言中。Redis中链表是双端链表,被广泛用于实现Redis 的各种功能,比如列表键、发布与订阅、慢查询、监视器等。它定义在adlist.h/list中:
/*
* 双端链表结构
*/
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 节点值复制函数,用于复制链表节点所保存的值
void *(*dup)(void *ptr);
// 节点值释放函数,用于释放链表节点所保存的值
void (*free)(void *ptr);
// 节点值对比函数,用于对比链表节点所保存的值和另一个输入值是否相等。
int (*match)(void *ptr, void *key);
// 链表所包含的节点数量
unsigned long len;
} list;
/*
1. 双端链表节点结构
*/
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
哈希表用于实现redis中的字典,字典通常由一个哈希表数组组成,哈希表节点使用 dictEntry 结构表示,每个dictEntry都对应一个键值对,结构如下:
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
当要将一个新的键值对添加到字典里面时, 程序需要先根据键值对的键计算出哈希值和索引值, 然后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。Redis 计算哈希值和索引值的方法如下:
# 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 属性和哈希值,计算出索引值 # 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;
Redis解决键冲突的时候,当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时, 我们称这些键发生了冲突(collision)。
Redis的哈希表使用链地址法解决键冲突:每个哈希表节点都有一个 next 指针, 多个哈希表节点可以用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用这个单向链表连接起来, 这就解决了键冲突的问题。为了方便起见,程序总是会将新节点添加到链表表头的位置,排在其他已有节点前面;
举个例子, 假设程序要将键值对 k2 和 v2 添加到哈希表里面, 并且计算得出 k2 的索引值为 2 , 那么键 k1 和 k2 将产生冲突, 而解决冲突的办法就是使用 next 指针将键 k2 和 k1 所在的节点连接起来,如下图所示。
因为 dictEntry 节点组成的链表没有指向链表表尾的指针, 所以为了速度考虑, 程序总是将新节点添加到链表的表头位置(复杂度为 O(1)), 排在其他已有节点的前面。
首先,为字典中的哈希表分配空间,空间大学取决于执行的操作,以及当前哈希表包含的键值对数量:
其次,将h[0]所有键值对rehash到h[1]的指定位置上;
最后,将h[1]设置为h[0],新建一个空白的哈希表,为下一次rehash做准备;
那么,什么情况下会进行哈希表的扩展与收缩呢?
扩展
# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size
收缩
当redis哈希表中的键值对数量过多的时候,在扩展与收缩的时候,rehash是渐进式的,分多次完成;它的优点是:将rehash键值对所需的计算工作均摊到对字典的添加,删除,查找与更新操作上,避免了集中式rehash带来的庞大计算量;
跳跃表是有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的;跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点;它是有序集合键的底层实现之一。如果一个有序集合包含的元素数量比较多,或者有序集合中元素的成员是较长的字符串是,就会作为有序集合键的底层实现;
/*
* 跳跃表节点
*/
typedef struct zskiplistNode {
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
/*
* 跳跃表
*/
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
整数集合(intset)是集合键的底层实现之一: 当一个集合只包含整数值元素, 并且这个集合的元素数量不多时, Redis 就会使用整数集合作为集合键的底层实现。
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
需要注意的是,虽然intset结构中声明contents是int8_t类型的,但实际上contents数组不保存任何int8_t类型的值,它的真正类型取决于encoding属性的值;例如:如果encoding 属性的值为 INTSET_ENC_INT16 , 那么 contents 就是一个 int16_t 类型的数组, 数组里的每个项都是一个 int16_t 类型的整数值 (最小值为 -32,768 ,最大值为 32,767 )。例如:
每次将一个新元素添加到整数集合里面, 并且新元素的类型比整数集合现有所有元素的类型都要长时, 整数集合需要先进行升级(upgrade), 然后才能将新元素添加到整数集合里面。
升级整数集合并添加新元素共分为三步进行:
因为每次向整数集合添加新元素都可能会引起升级, 而每次升级都需要对底层数组中已有的所有元素进行类型转换, 所以向整数集合添加新元素的时间复杂度为 O(N) 。
需要注意的是,redis中的整数集合并不支持降级操作~~~
压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry), 每个节点可以保存一个字节数组或者一个整数值。
它通常被作为列表键和哈希键的底层实现之一,当添加新节点到压缩列表, 或者从压缩列表中删除节点, 可能会引发连锁更新操作, 但这种操作出现的几率并不高;
redis使用对象来表示数据库中的键与值,当在redis的数据库中新创建一个键值对的时候,至少会创建两个对象,一个对象用于键值对的键,另一个用于键值对的值;
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// ...
} robj;
其中,type记录了对象类型,可以是字符串对象,列表对象,哈希对象,集合对象以及有序集合对象之一;键类型与值类型可以不相同,对某个数据库键执行type命令,返回的结果应该是数据库键对应的值对象类型;ptr指针指向对象的底层实现数据结构,这些数据结构由encoding属性决定;
需要注意的是,redis字符串对象中不单可以存储是string类型的值,它可以用long类型保存的整数,可以是long double类型保存的浮点数,也可以是字符串值;
它的编码可以是ziplist,也可以是linkedlist,ziplist编码的对象列表使用压缩列表为底层实现,每一个压缩列表节点保存了一个列表元素,而linkedlist编码的列表对象使用双端链表作为底层实现,每一个链表节点都保存了一个字符串对象,而每一个字符串对象都保存了列表元素;
它的编码可以是ziplist,也可以是hashtable;
那么什么情况下会进行编码转换呢???
使用ziplist编码时,哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节;并且哈希表中保存的键值对数量<512个;无法满足条件即需要用hashtable编码
它的编码可以是intset,也可以是hashtable;
intset 编码的集合对象使用整数集合作为底层实现, 集合对象包含的所有元素都被保存在整数集合里面。另一方面, hashtable 编码的集合对象使用字典作为底层实现, 字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为 NULL;
那么什么情况下会进行编码转换呢???
当集合对象可以同时满足以下两个条件时, 对象使用 intset 编码:1. 集合对象保存的所有元素都是整数值;2. 集合对象保存的元素数量不超过 512 个;无法满足条件即需要用hashtable编码;
它的编码可以是ziplist,也可以是skiplist;
ziplist 编码的有序集合对象使用压缩列表作为底层实现, 每个集合元素使用两个紧挨在一起的压缩列表节点来保存, 第一个节点保存元素的成员(member), 而第二个元素则保存元素的分值(score)。压缩列表内的集合元素按分值从小到大进行排序, 分值较小的元素被放置在靠近表头的方向, 而分值较大的元素则被放置在靠近表尾的方向。
skiplist编码的有序集合对象使用zset结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表:
typedef struct zset {
zskiplist *zsl;
dict *dict;
} zset;
zsl 跳跃表按分值从小到大保存了所有集合元素, 每个跳跃表节点都保存了一个集合元素: 跳跃表节点的 object 属性保存了元素的成员, 而跳跃表节点的 score 属性则保存了元素的分值。dict 字典为有序集合创建了一个从成员到分值的映射, 字典中的每个键值对都保存了一个集合元素: 字典的键保存了元素的成员, 而字典的值则保存了元素的分值。 通过这个字典, 程序可以用O(1)复杂度查找给定成员的分值;
有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现, 但无论单独使用字典还是跳跃表, 在性能上对比起同时使用字典和跳跃表都会有所降低。如果我们只使用字典来实现有序集合, 那么虽然以O(1)复杂度查找成员分值,但字典会以无序状态存储元素,排序时至少需要O(NlogN)的时间复杂度以及O(N)的内存空间 (因为要创建一个数组来保存排序后的元素),另一方面, 如果我们只使用跳跃表来实现有序集合, 那么跳跃表执行范围型操作的所有优点都会被保留, 但因为没有了字典, 所以根据成员查找分值这一操作的复杂度将从O(1)上升到O(logN);
那么什么情况下会进行编码转换呢???
当有序集合对象同时满足以下两个条件时,对象使用ziplist编码:1. 有序集合保存的元素数量小于 128 个;2. 有序集合保存的所有元素成员的长度都小于 64 字节;无法满足条件就会使用skiplist来进行编码;
redis中用于操作键的命令基本上可以分为两种类型,其中一种命令对任何类型的键执行,例如:del命令,expire命令,type命令等;另一种只能对特定类型的键执行,例如set 与 get只能对字符串键执行;
类型检查则是通过redisObject 结构的 type 属性来实现的: 在执行一个类型特定命令之前, 服务器会先检查输入数据库键的值对象是否为执行命令所需的类型, 如果是的话, 服务器就对键执行指定的命令;否则, 服务器将拒绝执行命令, 并向客户端返回一个类型错误。
redis 在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制, 通过这一机制, 程序可以通过跟踪对象的引用计数信息, 在适当的时候自动释放对象并进行内存回收。
typedef struct redisObject {
// ...
// 引用计数
int refcount;
// ...
} robj;
对象的引用计数信息会随着对象的使用状态而不断变化:
redis的对象共享只共享整数值对象;
举例,如果A键创建了一个包含整数值100的字符串对象作为值对象,而B也要创建100的字符串对象,那么为了节约内存,可以让多个键共享使用一个对象;在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:1. 将数据库键的值指针指向一个现有的值对象;2. 将被共享的值对象的引用计数增一。目前来说, Redis 会在初始化服务器时, 创建一万个字符串对象, 这些对象包含了从 0 到 9999 的所有整数值, 当服务器需要用到值为 0到 9999 的字符串对象时, 服务器就会使用这些共享对象, 而不是新创建对象;
那为什么redis不共享包含字符串的对象呢?当服务器考虑将一个共享对象设置为键的值对象时, 程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同, 只有在共享对象和目标对象完全相同的情况下, 程序才会将共享对象用作键的值对象, 而一个共享对象保存的值越复杂, 验证共享对象和目标对象是否相同所需的复杂度就会越高, 消耗的 CPU 时间也会越多;如果共享对象是保存整数值的字符串对象, 那么验证操作的复杂度为 O(1);如果共享对象是包含了多个值(或者对象的)对象, 比如列表对象或者哈希对象, 那么验证操作的复杂度将会是O(N2);
redisObject 结构包含的最后一个属性为 lru 属性, 该属性记录了对象最后一次被命令程序访问的时间,这个时间可以用于计算对象的空转时间。