我们都知道Redis是用C语言编写的内存数据库。但是由于C几乎没有提供任何数据结构的封装,所以Redis为了实现更快,更安全的操作,自己在内部封装了一系列的数据结构。
其中包括了简单动态字符串、链表、字典、跳跃表、整数集合、压缩列表,下面来一一介绍(画的图有点丑。。)。
简单动态字符串(SDS)
SDS定义
在redis中,只有字符串字面量才会用C字符串来表示(比如打印日志),其它都使用SDS来表示(比如键值对的键都是用SDS表示的字符串)。
SDS的结构:
struct sdshdr {
// 记录buf数组已使用的字节数,也就是SDS字符串的长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
SDS为了可以重用C字符串函数库里的函数,所以遵循了用空字符结尾,但这个空字符不计入len属性中。
SDS的特点
- 常数复杂度获取字符串长度。C字符串如果要获取字符串长度,必须从头到尾遍历整个字符串,所以导致复杂度为O(N)。但是SDS本身在属性中记录了长度,所以获取SDS长度的复杂度为O(1)。
- 杜绝缓冲区溢出。C字符串如果在拼接字符串操作时,已分配的内存空间不足以放下拼接后的字符串,那么将会造成缓冲区溢出。但是SDS会根据所需空间和自身空间来动态扩展空间大小。
- 通过未使用空间减少了内存重分配次数。C字符串在每次拼接或截断操作时,都要重新分配内存空间以防止缓冲区溢出或内存泄漏。而SDS通过未使用空间实现了空间预分配和惰性空间释放两种优化策略来减少内存重分配次数。
- 空间预分配:如果SDS修改之后,长度将小于1MB,那么将会分配和SDS长度同样大小的未使用空间。如果长度将大于1MB,那么将直接分配1MB的未使用空间。
- 惰性空间释放:如果SDS的长度缩短时,多余的空间并不会被立即释放,而是用未使用空间将他们留在SDS中,未以后可能的增加预留空间。当然,SDS也可以通过手动调用API来释放未使用空间,以免造成内存泄漏。
- 二进制安全 。由于C字符串会将遇到的第一个空字符判断为字符串结尾,所以导致C字符串只能保存文本,而不能保存像图片、视频等二进制数据,所以C字符串被称为字符数组。而SDS会以处理二进制的方式来处理SDS存放再buf数组里的数据,SDS不是以空字符判断结尾的,而是通过len属性的值来判断字符串是否结束。所以SDS的API是二进制安全的,可以存放各种数据,所以SDS被称为字节数组。
- 兼容部分C字符串的函数。因为SDS与C字符串一样遵循以空字符结尾,所以可以让那些保存文本数据的SDS重用一部分C字符串函数库的函数。
链表
当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。同时,在发布与订阅、慢查询、监视器等功能也用到了链表。
链表和链表节点的实现
链表结构
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
void (*match)(void *ptr, void *key);
} list;
链表结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len。而dup、free和match则是用于实现多台所需的类型特定函数,从而实现可以保存各种不同类型的值。
链表节点结构
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
}listNode;
多个listNode可以通过prev和next指针组成双端链表。但是无环,因为表头节点的prev指针和表尾节点的next指针都指向NULL,所以对链表的访问以NULL为终点。
字典
Redis的数据库就是使用字典作为底层来实现的。可以把数据库中所有的对象都看作是键值对,而这个键值对就是保存在代表数据库的字典里的。另外,哈希键的底层也是通过字典实现的。
字典的实现
字典结构
typedef struct dict {
// 类型特定函数(我觉得这个应该是相当于Java中的泛型)
dictType *type;
// 私有数据
void *privdata;
// 哈希表数组,字典存储使用ht[0],ht[1]在rehash迁移字典数据时使用
dictht ht[2];
// rehash索引,当rehash不在进行时,值为-1
int trehashidx;
} dict;
type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的。
哈希表结构
typedef struct dictht {
// 哈希表节点数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值,总是等于size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
sizemask属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。
哈希表节点结构
typedef struct dictEntry {
// 键
void *key;
// 值,用union结构存储数据,用于压缩空间
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表(拉链法解决哈希冲突)
struct dictEntry *next;
} dictEntry;
哈希算法
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值,再根据哈希表的sizemask和哈希值计算出索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
Redis使用MurmurHash2算法来计算键的哈希值,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。
rehash
扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:
- 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也就是ht[0].used属性的值)
- 如果要执行的是扩展操作,那么ht[1] 的大小为第一个大于等于ht[0].used乘以2的2的n次方幂。比如ht[0].used的值为4,那么4乘以2等于8又等于2的三次方幂,所以ht[1]的大小将被分配为8.
- 如果要执行的是收缩操作,那么ht[1] 的大小为第一个大于等于ht[0].used的2的n次方幂。比如ht[0].used的值为4,那么4等于2的2次方幂,所以ht[1]的大小将被分配为2.
- 将保存在ht[0]中的所有键值对rehash到ht[1]上面,rehash指的是重新计算键的哈希值和索引值,然后将键值对按照索引值放到ht[1]对应的位置上。
- 当ht[0]中的所有键值对都迁移到了ht[1]之后,ht[0]的空间将会被释放,然后将ht[1]设置为ht[0],并再创建一个ht[1]空表,为下一次rehash做准备。
渐进式rehash
为了避免rehash对服务器性能造成影响,服务器并不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式的将ht[0]里面的键值对慢慢的rehash到ht[1]。这里就用到了rehashidx属性,当程序处理rehash期间时,rehashidx值被设置为0,当rehash操作完成时,又将它设置为-1.
渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个增删改查操作上,从而避免了集中式rehash带来的庞大计算量。
另外,在rehash期间,字典的删除、查找、更新操作会在两个哈希表上进行,如果在ht[0]没有找到的话,就回去ht[1]找。而添加操作则全部在ht[1]进行,即所有新添加的键值对都会存到ht[1]里面。
跳跃表
跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其它节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找,还可以通过顺序行操作来批量处理节点。在Redis中用跳跃表来作为有序集合的底层实现之一。
跳跃表的实现
跳跃表结构(zskiplist)
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
level属性用于在O(1)复杂度内获取跳跃表中层数最高的那个节点的层数,注意,表头节点的层高并不能算在里面。
跳跃表节点
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode * forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
- 层:每次创建一个新跳跃表节点的时候,程序都根据幂次定律(越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的高度。
- 前进指针:每个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点,当程序遍历跳跃表的时候,就是根据每个层的前进指针来移动的。
- 跨度:层的跨度用于记录两个节点之间的距离,这个是用于来计算节点的排位的。在查找某个节点的过程中,将沿途访过的所有层的跨度累加起来,得到的就是当前节点在跳跃表中的排位。
- 后退指针:节点的后退指针用于从表尾向表头方向访问节点,但后退指针每次只能后退至前一个节点,而不能跳跃多个节点。
- 分值和成员:节点的分值是一个double类型的浮点数,也就是代表着节点的排位。跳跃表中的所有节点都按分值从小到大排序。节点的成员对象是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。在一个跳跃表中,各个节点的成员对象必须是唯一的,但是分值可以相同。分值相同的节点按照成员对象在字典序中的大小来排序,成员对象较小的节点会排在前面(靠近表头的方向)。
整数集合
整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,且元素数量不多时,将会使用整数集合作为集合的底层实现。
整数集合的实现
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
encoding的类型可以是int16_t,int32_t或者int64_t。其中虽然contents被声明为int8_t,但实际上contents数组中不会保存int8_t类型的值,真正的类型还是取决于encoding属性的值。注意,如果contents数组中包含了不同整数类型的值,那么encoding将被设置为占用空间最大的那个类型。同时,其他值也将被升级编码为该类型。
升级
当我们要将一个新元素添加到整数集合里时,并且新元素的类型比整数集合现有元素的类型都要长时,我们将需要先将整数集合进行升级,才能将新元素添加进去。
升级整数集合并添加新元素分三步进行:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组的其他元素都转换为新类型,并保存与原来相同的顺序放置。
- 最后再将新元素添加到数组中。
压缩列表
压缩列表是列表建和哈希键的底层实现之一。当列表键或哈希键中的元素较少时,将会使用压缩列表来作为他们的底层实现。
压缩列表的实现
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序性数据结构。一个压缩列表可以包含任意多个节点,一个节点可以保存一个SDS或一个整数值。
- zlbytes:记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配或计算zlend的位置时使用。
- zltail:记录压缩列表表尾节点距离压缩列表的起始地址有多少个字节,通过zltail可以通过O(1)复杂度确定表尾节点的地址。
- zlen:记录压缩列表的节点数量,当这个值等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能得到。
- entryX:压缩列表包含的各个节点。
- zlend:特殊值0xFF,用于标记压缩列表的结尾。
压缩列表节点的实现
每个压缩列表节点可以保存一个字节数组或者一个整数值。压缩列表节点由三部分组成。
previous_entry_length
记录了压缩列表中前一个节点的长度。previous_entry_length属性自身的长度可以是1字节或5字节。
- 如果前一个节点的长度小于254字节,那么previous_entry_length属性的长度为1字节,前一节点的长度就保存在这1字节里。
- 如果前一个字节的长度大于等于254字节,那么previous_entry_length属性的长度为5字节。其中1字节将被设置为oxFE,而其它4字节用于保存前一节点的长度。
程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。压缩列表的从表尾向表头的遍历操作就是利用这一原理实现的。
encoding
- 记录了节点的content属性所保存数据的类型以及长度。一字节、两字节或五字节长、值的最高位为00、01、10的表示节点的content属性保存着字节数组,数组的长度为去掉encoding的最高两位之后的位记录。
- 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着的是整数值。
content
负责保存节点的值,节点值可以是字节数组或整数,具体由encoding决定。
连锁更新
如果当前压缩列表的节点长度都小于254字节,那么用于记录前一个字节长度的属性previous_entry_length只需要用一个字节保存,但是现在要新加一个字节长度大于254字节的节点到压缩列表中来,那么将会造成连锁更新,因为新加节点的后一个节点保存了这个节点的长度,需要将previous_entry_length扩展为5字节的,然后继续类似的扩展直到最后一个节点。
参考
Redis的设计与实现 黄建宏 著