Redis底层数据结构详解
简单动态字符串
在Redis中字符串是用简单动态字符串(SDS)存储的,并不是C中的字符串,后面会对比二者的区别。
1. SDS的定义
struct sdsstr{
int len; //buf数组已使用字节的数量
int free; //buf数组未使用字节的数量
char buf[];//字节数组,用于保存字符串
}
注:buf数组存的还是C字符串,要有一个空字符’\0’作为结尾。
2. SDS与C字符串的区别
- 常数复杂度获取字符串长度
C字符串获取字符串长度:O(n)
SDS获取字符串长度:O(1)
这有什么好处呢?
Redis把获取字符串长度的复杂度降低到了O(1),这保证了获取字符串长度的操作不会成为Redis的性能瓶颈。 - 杜绝缓冲区溢出
C字符串不会记录自身长度,就会很容易造成缓冲区溢出。
举个例子:char *strcat(char *dest, const char *src)
,这个函数是拼接两个字符串用的,例如s1=“pipilong”,s2=“keji”,这两个字符串相邻:
执行前内存:p i p i l o n g \0 k e j i \0 然后执行strcat(s1," Cluster"),s1没有分配足够的空间,执行后将会导致数据溢出到s2的空间中。
执行后内存:p i p i l o n g C l u s t e r \0 直接将s2的内容给覆盖掉了,如果程序调用s2,则会出现错误。
SDS的API中有一个拼接字符串的函数sdscat,它会先检查给的SDS的空间是否足够,如果不够的话进行扩容。扩容的具体操作,会在下一小节具体讲解。 - 减少修改字符串时带来的内存重分配次数(SDS的扩容机制)
首先我们先看下C字符串,如果每次增长或缩减C字符串,程序要对保存的这个C字符串数组进行一次内存重分配操作:
- 如果是增长字符串的操作,程序会通过内存重分配扩展底层数组空间大小,如果没有这一步,将会产生缓冲区溢出。
- 如果是缩短字符串的操作,程序会通过内存重分配释放掉字符串不再使用的那部分空间,如果没有这一步,将导致内存泄漏。
内存重分配涉及到复杂的算法,可能要执行系统调用,是一个比较耗时的操作。
Redis中通常会有很频繁的字符串修改操作,那么在频繁的修改场合中,每变一次字符串长度都要执行一次内存重分配操作,这样会对系统性能造成严重影响。
SDS通过实现空间预分配和惰性空间释放两种策略来解决这个问题:
1. 空间预分配:
2. 惰性空间释放:
- 二进制安全
C字符串不能保存像图片、音频这样的二进制数据。C字符串是通过’\0’来分割每一个字符串的,那么程序在读入空字符串时会被误认为字符串结尾。SDS会通过存在len属性的长度读取字符串,就不会产生二进制安全问题了。
链表
链表作为一种很常用的数据结构,在Redis中使用也是很广泛,例如:发布订阅、客户端的状态信息等都用到了链表。Redis中构建了自己的链表实现,我们讨论一下Redis中是怎么实现链表的。
1. 链表和链表节点的定义
- 链表节点定义:
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;
2. Redis的链表实现的特征
- 双端:链表节点有prev和next指针。
- 无环:表头节点的prev指针和表尾节点的next指针都为null,链表的终点都是null。
- 有表头指针的表尾指针:获取链表的表头节点和表尾节点的复杂度为O(1)。
- 带链表长度计数器:获取链表中节点数量的复杂度为O(1)。
- 多态:链表节点用void* 保存节点值,并且dup、free、match这三个函数的参数和返回值都为void * 类型,所以链表能保存各种不同类型的值。
字典
就是我们经常说的映射(map),它是一种保存键值对的数据结构,Redis的数据库底层就是用到了这种数据结构,C中是没有内置这种数据结构的,Redis构建了自己的字典实现。
1. 哈希表节点、哈希表和字典的定义
- 哈希表节点定义:
typedef struct dictEntry{
void *key; //键
union{
void *val;
uint64_t u64;
int64_t s64;
} v; //值
struct dictEntry *next; //指向下一个哈希表节点,用来解决hash冲突问题
} dictEntry;
- 哈希表定义:
typedef struct dictht{
dictEntry **table; //哈希表的数组
unsigned long size; //哈希表大小
unsigned long sizemask; //哈希表大小掩码,用来计算下标索引用的
unsigned long used; //该哈希表已经有的节点数量
} dictht;
- 字典定义:
typedef struct dict{
dictType *type; //类型特定函数
void *privdata; //私有数据
dictht ht[2]; //哈希表,两个,为什么两个后续会讲
int trehashidx; //rehash索引,rehash用的,不在rehash时,为-1,rehash是什么后文会讨论
} dict;
- dictType定义:
typedef struct dictType{
unsigned int (*hashFunction)(const void *key); //计算hash值的函数
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); //销毁值的函数
}
2. 哈希算法
当往字典中添加一个新的键值对的时候,首先会计算键的哈希值,用字典中自带的函数计算,然后根据计算出的哈希值得出索引。
举个例子:加入要把k0,v0加入到字典中
- hash = dict->type->hashFunction(k0);
- index = hash&dict->ht[0].sizemask
字典是作为数据库的底层实现的,Redis使用MurmurHash2算法来计算键的哈希值。
3. 解决键冲突
当有两个及以上的键值对分配到同一个哈希桶上,就会产生哈希冲突问题。Redis的哈希表是通过拉链法来解决哈希冲突问题的。Redis的哈希表中因为没有指向链表表尾的指针,所以采用头插法插入新的节点,复杂度是O(1)
4. rehash
随着操作不断地进行,哈希表中的键值对会逐渐增多或减少,为了让哈希表的负载因子维持在一个合理的范围内,当哈希表保存的键值对太多或太少时,就会对哈希表的大小进行相应的扩展或收缩。
rehash主要有三步:
- 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作和ht[0]当前含有的键值对。
- 扩展操作:ht[1] 的大小等于第一个大于等于ht[0].used*2的2n幂
- 收缩操作:ht[1] 的大小等于第一个大于等于ht[0].used的2n幂
- 将保存在ht[0]中的所有键值对rehash到ht[1]上,rehash是重新计算哈希值和索引值。
- 当ht[0]的所有键值对都迁移到ht[1]中后,释放ht[0],将ht[1]设置为ht[0],为ht[1]创建一个空的哈希表,为下一次rehash做准备
哈希表扩展或收缩的条件:
- 服务器当前没有执行bgsave命令或者bgrewriteaop命令时,并且哈希表的负载因子大于等于1
- 服务器当前正在执行bgsave命令或者bgrewriteaop命令时,并且哈希表的负载因子大于等于5
为什么要根据bgsave命令或bgrewriteaop命令是否执行,服务器执行操作所需要的负载因子不同呢?
因为在执行bgsave或bgrewriteaop命令时,Redis会创建一个子进程来执行这些操作,重点是有很多操作系统会使用写时复制技术优化子进程的使用效率,服务器会通过提高负载因子,避免在存在子进程时进行哈希表rehash操作,最大限度的节约内存。
如果哈希表的负载因子小于0.1,程序会自动开始进行收缩操作。
5. 渐进式rehash
哈希表扩展或收缩会将ht[0]中的所有键值对rehash到ht[1]中。如果一次性全部将这些键值对rehash到ht[1]中,这么大的计算量可能会让Redis服务器一段时间内暂停服务,为了避免rehash对服务器带来的影响,这个操作是分多次、渐进式进行。
渐进式rehash操作:
- 为ht[1]分配空间
- 维护一个索引计数变量rehashidx,将它设置为0,表示rehash正式开始
- 在rehash进行期间,每次对字典的操作,程序除了执行这个指定的操作外,还会顺便将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],rehash操作完成后,rehashidx值加一
- 随着字典操作不断进行,在某个时间点ht[0]的所有键值对都会rehash到ht[1]上,这时将rehashidx的值设为-1,表示完成
这样就避免了集中式rehash带来的庞大的计算量。
删除、查找、更新操作会在两个表上都进行。而增加操作只在ht[1]上进行。
跳表
跳表是什么?它是一种有序的数据结构,它通过在每个节点中维护多个指向其它节点的指针,从而达到快速访问的目的,它的访问时间复杂度为:O(logn)。
1. 跳表节点和跳表的定义
- 跳表节点的定义:
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 lnog length; //表中节点的数量
int level; //表中层数最大的节点的层数
} zskiplist;
2. 对zskiplistNode结构中每个属性详解
- zskiplistNode
- 层:level数组中有多个元素,每个元素都包含了一个指向其它节点的指针,通过这些指针来加快访问速度。层数越多访问其它节点的速度就越快。每次创建一个新的跳表节点的时候,根据幂次定律,随机生成一个1~32大小之间的level数组,这个大小也就是层的高度。
- 前进指针:每层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点。
- 跨度:记录两个节点之间的距离。跨度越大,相距的距离就越远。
- 后退指针:用于从表尾向表头方向访问节点,每个节点只有一个后退指针,每次只能往后退到前一个结点。
- 分值和成员:是一个double类型的浮点数,跳表中所有节点都按照这个分值从小到大进行排序。成员对象是一个指针,它指向字符串对象,字符串对象保存着一个SDS值。
整数集合
整数集合是集合键的底层实现之一,当一个集合中全部都是整数时,并且这个集合的元素不多时,Redis就会用整数集合这个数据结构当作集合键的底层实现。
1. 整数集合的定义
typedef struct intset {
uint32_t encoding; //编码方式
uint32_t length; //集合包含的元素长度
int8_t contents[]; //保存元素的数组
} intset;
contents数组是整数集合的底层实现,整数集合的每个元素都是contents数组的一个数据项,各个项在数组中按照值的大小从小到大有序地排列,并且数组中不能有重复项。
2. 升级
我们要添加一个新的元素到整数集合中,并且这个新元素的类型比整数集合中所有元素的类型都要长,整数集合需要进行升级,才能把这个元素放进去。
升级整数集合分为三步进行:
- 根据新元素的类型,扩展底层数组的空间大小,并为新元素分配空间。
- 将底层数组中所有元素都转换成与新元素相同的类型,将转换后的元素放到正确位置上,并且要保证元素间相对位置不变。
- 将新元素添加到底层数组里面。
升级的好处:
- 提升灵活性:Redis是用C写的,C中一般一个数组中的元素不允许有不同类型的元素,所以我们只会将一种元素放到数组中。我们通过升级的方式让整数集合适应新元素的类型。
- 节约内存:只有在我们需要的时候才进行升级,这样一个数组既能保存16、32位,也能保存64位的数据,保证了没有32、64位数据的情况下,数组只为每个元素分配16位大小就可以了,很好的节省了内存。
3. 降级
整数集合不支持降级,一旦对数据进行了升级,编码就要一直保持升级的状态。
压缩列表
压缩列表是列表键和哈希键的底层实现之一。压缩列表就是为了节省内存而开发的数据结构,是一系列特殊编码的连续内存块组成的顺序型数据结构,它可以有多个节点,每个节点可以是一个字节数组或整数值。
1. 压缩列表和压缩列表节点的定义
- 压缩列表节点
previous_entry_length | encoding | content - 压缩列表
zlbytes | zltail | zllen | entry1 | entry2 | entryN | zlend
2. 解析压缩列表节点的每个字段
- previous_entry_length:
该字段记录了前一个压缩列表节点的长度。
这个字段的长度是1字节或5字节:
1字节情况:前一个节点的长度小于254字节时,该字段为1字节。
5字节情况:前一个节点的长度大于等于254字节时,该字段为5字节,第一个字节为0xfe,后面四个字节保存前一个节点的长度。
因为每个节点保存了前一个节点的长度,就很容易计算出前一个节点的起始地址,压缩列表从表尾向表头遍历操作就是基于这个原理的。表尾节点位置很好找到,用zltail属性,然后再根据每个节点的previous_entry_length计算前一个节点的起始位置。
2. encoding:
该属性保存值的编码及长度。
前两位是00、01、10是字节数组编码。前两位是11是整数编码。
3. content:
这个属性负责保存节点 的值。
3. 连锁更新
我们在压缩列表中增加节点或删除节点可能会导致连锁更新。这个连锁更新会涉及到我们前面讨论的previous_entry_length属性。
举个例子:加入我们有一些节点,每个节点的大小为250~253字节,那么当在列表头部插入一个大于等于254字节的节点时,它的下一个节点因为previous_entry_length为一字节,不能保存其长度,所以要扩容为5字节,则扩容后该节点变为254~257字节之间,同理后面会连续扩容,导致连锁更新。
删除也可能产生连锁更新,有这么一种情况:一个大的节点大于等于254,它后面一个小的节点小于254,再后面都是250~253字节之间的节点,那么当删除这个小的节点后,后面因为其previous_entry_length属性都是1字节,所以要扩容,同理一次都要扩容,发生连锁更新。
连锁更新最坏的情况下可能发生n次空间重分配,每次空间重分配最坏复杂度为O(n),所以连锁更新最坏的复杂度为O(n2) 。
对象
上面介绍了Redis中实现的多种数据结构,每种对象都最少用到了一种数据结构。在不同的场景下,不同的对象可以应用不同的数据结构,从而优化了对象在不同场景下的使用效率。
Redis对象基于引用计数技术的内存回收机制。
Redis的对象有访问时间的记录信息,这个信息可以用于计算键的空转时长。
1. 对象的结构
typedef struct redisObject{
unsigned type:4; //类型
unsigned encoding:4; //编码
void *ptr; //指向底层实现数据结构的指针
}
2. 类型
类型常量 | 对象的名称 |
---|---|
pedis_string | 字符串对象 |
pedis_list | 列表对象 |
pedis_hash | 哈希对象 |
pedis_set | 集合对象 |
pedis_zset | 有序集合对象 |
3. 编码
编码常量 | 编码对应的底层数据结构 |
---|---|
pedis_encoding_int | long类型的整数 |
pedis_encoding_embstr | embstr编码的sds |
pedis_encoding_raw | 简单动态字符串sds |
pedis_encoding_hashtable | 字典 |
pedis_encoding_linkedlist | 双端链表 |
pedis_encoding_ziplist | 压缩列表 |
pedis_encoding_intset | 整数集合 |
pedis_encoding_skiplist | 跳跃表和字典 |
字符串
字符串对象的编码可以是int、raw、embstr
- 如果字符串对象保存是一个整数,则编码为int
- 如果字符串对象保存是一个字符串且长度大于39字节,则编码为raw
- 如果字符串对象保存是一个字符串且长度小于等于39字节,则编码为embstr
列表
列表对象的编码可以是ziplist或linkedlist
- 列表中所有字符串元素长度小于64字节并且数量小于512个,使用ziplist编码
- 不满足上面两个条件其一,使用linkedlist编码
哈希
哈希对象的编码可以是ziplist或hashtable
- 哈希对象中所有键值对的键和值的字符串长度都小于64字节,并且数量小于512个,用ziplist编码
- 不满足上面两个条件其一,使用hashtable编码
集合
集合对象的编码可以是intset或hashtable
- 所有元素都是整数且数量不超过512个,使用intset编码
- 不满足上面两个条件其一,使用hashtable编码
有序集合
有序集合的编码可以是ziplist或skiplist
- 有序集合保存的元素数量小于128个且所有元素长度都小于64字节,使用ziplist编码
- 不满足上面两个条件其一,使用skiplist编码
参考
- 《Redis设计与实现》黄键宏 著