由于C字符串存在大量问题,所以在Redis中,并没有使用C风格字符串,而是自己构建了一个简单动态字符串即SDS(simple dynamic string)
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 数据空间
char buf[];
};
为解决C字符串缓冲区溢出问题以及长度计算问题,SDS中引入了len来统计当前已使用空间长度,free来计算剩余的空间长度
C字符串的主要缺陷就是因为它没有记录自己的长度,而如果在需要了解长度时,就只能通过O(N)的效率进行一次遍历
并且因为C字符串没有统计剩余空间的字段,也没有容量字段,所以很容易就会因为strcat等函数造成缓冲区的溢出,为弥补这一缺陷,redis在sds中增加了free字段
通过标记剩余空间,当对SDS进行插入操作时,就会提前判断当前剩余空间是否足够,如果不足则会先进行空间的拓展,再进行插入,这样就解决了缓冲区溢出的问题
由于Redis作为一个高效的内存数据库,用于速度要求严苛,插入删除频繁
的场景,为了提高内存分配的效率,防止大量使用内存重分配而调用系统函数导致的性能损失问题(用户态和内核态的切换),Redis主要依靠空间预分配和惰性空间释放来解决这个问题
为减少空间分配的次数,当需要进行空间拓展时,不仅仅会为SDS分配修改所必须要的空间,并且会为SDS预分配额外的未使用空间。
预分配未使用空间的策略如下
当我们对SDS进行删除操作时,并不会立即回收删除后空余的空间,而是将空余空间以free字段记录下来,以备后面使用。
这样做的目的在于防止因为空间缩短后因为再度插入导致的空间拓展问题。
并且如果有需求需要真正释放空间,Redis也提供了对应的API,所以不必担心会因为惰性的空间释放而导致的内存浪费问题。
比起 C 字符串, SDS 具有以下优点:
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
/*
* 双端链表迭代器
*/
typedef struct listIter {
// 当前迭代到的节点
listNode *next;
// 迭代的方向
int direction;
} listIter;
/*
* 双端链表结构
*/
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;
从上面的结构可以看出,Redis的链表是一个带头尾的双端无环链表,并且通过len字段记录了链表节点的长度
同时为了实现多态与泛型,链表中还提供了dup,free,match属性来设置相关的函数,使得链表支持不同类型的值的存储
Redis的字典底层采用了哈希表来进行实现。
首先看看字典底层哈希表的结构
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
哈希表中记录了当前的总长度,已有节点,以及当前索引大小(用于哈希函数来计算节点位置)
为解决哈希冲突,Redis字典采用了链地址法来构造了哈希桶的结构,也就是哈希数组中的每个元素都是一个链表。
下面来看看哈希节点的结构
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
可以看到,为保证键值对适用于多重类型,key值使用的时void的形式,而value使用了64位有符号整型和64位无符号整型,void指针的一个联合体,每个节点使用next来链接成一个链表
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
} dictType;
为保证字典具有多态及泛型,dictType中提供了如哈希函数以及K-V的各种操作函数,使得字典适用于多重情景
/*
* 字典
*/
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
从字典的结构中,我们可以看到里面同时存放了两个哈希表,以及一个rehashidx属性。
这就牵扯到了字典的核心之一,rehash。
Redis作为一个插入频繁且对效率要求高的数据库,当插入的数据过多时,就会因为哈希表中的负载因子过高而导致查询或者插入的效率降低,此时就需要通过rehash来进行重新扩容并重新映射。
但是如果只是用一个哈希表,映射时就会导致数据库暂时不可用,作为一个使用频繁的数据库,短期的停机几乎是不可容许的问题,所以Redis设计时采用了双哈希的结构,并采用了渐进式rehash的方法来解决这个问题。
rehash的步骤如下
由于数据库中可能存在大量的数据,而rehash的时候又过长,为了避免因为rehash造成的服务器停机,rehash的过程并不是一次完成的,而是一个多次的,渐进式的过程。
在渐进式rehash的时候,由于数据不断的进行迁移,无法确定数据处于哪一个表上, 此时如果进行插入、删除、查找的操作时就会在两个表上进行,如果在一个表中没找到对应数据,就会到另一个表中继续查找。
并且如果此时新插入节点,都会统一的防止在新表ht[1]中,防止对ht[0]的rehash造成干扰,保证ht[0]节点的只减少不增加
跳表是一个较为少见的数据结构,如果不了解的可以看看我之前的博客
看了这篇博客,还敢说你不懂跳表吗?
由于跳表的实现简单且性能可与平衡树相媲美,对于大量插入删除的数据库来说,跳表只需要进行简单的链表插入和索引的选拔,而不像平衡树一样需要进行整体平衡的维持。并且由于在范围查找上的效率远远强于平衡树,所以Redis底层选取跳表来作为有序集合的底层之一。
typedef struct zskiplistNode {
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
跳跃表的查询从最顶层出发,通过前进指针来往后查找,通过比较节点的分数来判断当前节点是否与索引匹配,如果查找不到则进入下层继续查找,并记录下跨越的层数span来进行排位。
同时为了处理特殊情况,还准备了一个后退指针来进行从表尾到表头的遍历,但是与前进不同,后退指针并不存在跳跃,而是只能一个一个向后查询
/*
* 跳跃表
*/
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
跳跃表通过保存表头和表尾节点,来快速访问表头和表尾。并且保存了节点的数量来实现O(1)的长度计算
为了避免因为层数过高导致的大量空间损失,Redis跳跃表的节点高度最高位32层。
整数集合时集合键的底层实现之一,当集合中的元素全部都是整数值的时候,并且集合中元素不多时,Redis就会使用整数集合来作为集合键的底层结构。整数集合具有去重且排序的特性
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
在上面的结构中,虽然content数组的类型是一个8bit的整型,但是数据真正存储的方式并不是这个类型,而是根据encoding来决定具体的类型,8bit只是作为一个基本单位来进行使用。
例如此时encoding设置为INTSET_ENC_INT16时,数组存储的格式就有每个元素16bit
如果encoding设置为INTSET_ENC_INT64时,数组存储的格式就有每个元素64bit
升级的流程
新元素的插入位置
由于会引起升级的元素的类型都必顶比数组中的所有数据都大,所以也就决定了其要么比所有数据都大,要么比所有数据都小(负数),所以插入位置只能是首部和尾部
在整数列表中升级是一个不可逆的过程,即使将所有高类型的数据删除了,也不会进行降级。
理由是防止因为降级后再次升级带来的大量数据挪动的问题,在保证了效率的同时,也带来了一定程度上的空间浪费(非必要时尽量不要升级)
升级带来的好处
压缩列表(ziplist)是列表键和哈希键的底层实现之一。
当一个列表键只包含少量列表项, 并且每个列表项要么就是小整数值, 要么就是长度比较短的字符串, 那么 Redis 就会使用压缩列表来做列表键的底层实现。主要核心就是为了节约空间
例如这张图,可以看出当前包含三个节点,总空间为0x50(十进制80),到尾部的偏移量为0x3c(十进制60),节点数量为0x3(十进制3)
整数的类型可以是以下六种之一:
字节数组可以是以下三种之一:
而压缩列表节点又有三个属性组成,分别是previous_entry_length,encoding,content。
previous_entry_length
这个属性记录了压缩列表前一个节点的长度,该属性根据前一个节点的大小不同可以是1个字节或者5个字节。
小于254字节时的表示
大于等于254字节时的表示
为什么要这样设计呢?
由于压缩列表中的数据以一种不规则的方式进行紧邻,无法通过后退指针来找到上一个元素,而通过保存上一个节点的长度,用当前的地址减去这个长度,就可以很容易的获取到了上一个节点的位置,通过一个一个节点向前回溯,来达到从表尾往表头遍历的操作
encoding
encoding通过以下规则来记录content的类型
content
content属性负责保存节点的值,值的具体类型由上一个字段encoding来决定。
例如存储字节数组,00表示类型为字节数组,01011表示长度为11
存储整数值,表示存储的为整数,类型为int16_t
当添加或删除节点时,可能就会因为previous_entry_length的变化导致发生连锁的更新操作。
假设e1的previous_entry_length只有1个字节,而新插入的节点大小超过了254字节,此时由于e1
的previous_entry_length无法该长度,就会将previous_entry_length的长度更新为5字节。
但是问题来了,假设e1原本的大小为252字节,当previous_entry_length更新后它的大小则超过了254,此时又会引发对e2的更新。
顺着这个思路,一直更新下去
同理,删除也会引发连锁的更新
从上图可以看出来,在最坏情况下,会从插入位置一直连锁更新到末尾,即执行了N次空间重分配, 而每次空间重分配的最坏复杂度为 O(N) , 所以连锁更新的最坏复杂度为 O(N^2) 。
即使存在这种情况,但是并不影响我们使用压缩列表