Redis中的数据结构
为《Redis设计与实现》笔记
SDS动态字符串
Redis使用自定义字符串
struct sdshdr {
// 字符串长度
int len;
// buf数组中剩余未使用字节的数目
int free;
char buf[];
}
额外增加了变量len来记录字符串长度,其存在以下几个好处
- 能够在常数复杂度下获取字符串长度
- 能够存储二进制数据。传统C字符串使用
'\0'
来判断字符串的结尾,但是这种方法无法用来保存二进制数据,若在字符串中间处出现'\0'
则被判定为结束会后面的字符被屏蔽
下面的图中C字符只能能读到"Redis"
SDS字符串缓冲区
在传统字符串中,使用strcat
等函数时不会考虑分配了多大的内存,容易造成缓冲区溢出
在SDS字符串中,会对字符串长度和缓冲区大小进行检查,若缓冲区大小小于字符串长度则进行扩容,,其火绒规则为:
- 若长度小于1MB,则每一次扩容为原长度的两倍+1个空字符位(
'\0'
) - 若长度大于等于1MB,则每次扩容增加1MB
在字符串缩短后,未使用的缓存依然保留,以免未来被使用上
链表
链表节点结构,使用双向节点
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void* val;
} listNode;
链表结构
typedef struct list {
listNode *head;
listHode *tail;
// 节点数目
unsigned long len;
//节点值复制函数
void *(dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
}
其list结构图如下所示
Redis链表特点:
- 双端,使用双向链表
- 无环,链表头节点的
prev
和为节点的next
都指向NULL
- 带有链表长度计数器,能快速获得链表长度
- 多态,使用
void*
存储节点值,并定义了三个函数指针报对不同值类型的节点进行操作
字典
哈希表结构
哈希表节点
typedef struct dictEntry {
// 键
void* key;
// 值
union {
void *val;
uint_t u64;
int64_t s64;
} v;
// 下一个节点
struct dictEntry *next;
} dictEntry;
哈希表结构
typedef struct dictht {
// 哈希表数组,为数组指针
dictEntry **table;
// 大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值,通常位size-1
unsigned long sizemask;
// 已使用的节点数
unsigned long used;
} dictht;
对于相同索引的节点使用单向链表的方式存储,其结构图如下
字典
字典结构如下
typedef struct dict {
// 针对哈希表存储类型的操作函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表,这里有两张哈希表,用于进行rehash操作
dictht ht[2];
// rehash索引,在未进行rehash时为-1
int trehashidx;
} dict;
dictType
保存了一系列针对指定类型的操作函数,其结构体定义如下:
typedef struct dictType {
// 计算哈希值的函数
insigned int (*hashFunction) (const void *key);
// 复制键的函数
void *(*keyDup) (void *privadata, const void *key);
// 复制值的函数
void *(*valDup) (void *pridata, const void *obj);
// 对比键的函数
void *(*keyCompare) (void *pridata, const void *key1, const void *key2);
// 销毁键的函数
void *(*keyDistructor) (void *privadata, const void *key);
// 销毁值的函数
void *(*valDistructor) (void *pridata, const void *obj);
} dictType;
当有新的键值对插入时,调用dict->type->hashFunction()
方法得到哈希值索引,并通过该值添加到dict->ht[0]
中
rehash
当哈希表中的数据过多或者过小,需要通过rehash操作重塑哈希表的结构,这个时候就对dict->ht[1]
进行操作,其扩展和缩小规则如下:
- 当服务器未执行
BGSAVE
和BGREWRITEAOF
时,哈希表的负载因子>=1则进行rehash - 当服务器正在执行
BGSAVE
和BGREWRITEAOF
时,哈希表的负载因子>=5则进行rehash - 负载因子计算公式:
load_factor = ht[0].used / ht[0].size()
- 当哈希表的负载因子<0.1时执行缩小操作
- rehash操作中,
ht[1]
的大小为第一个大于ht[0].used*2
的2的n次幂
渐进式rehash
当哈希表过大时,无法在短时间内一次性完成rehash,所以redis使用多次的渐进式的rehash操作,其操作步骤如下:
- 为
ht[1]
分配空间,同时使用ht[0]
和ht[1]
两张表 - 将
rehashidx
设置为0,表示rehash开始进行 - rehash开始后,在对字典执行增删改查的同时,顺带将
ht[0]
中的数据搬运到ht[1]
中,每搬运一次rehashidx
的值+1 - 当所有的数据都从
ht[0]
搬运到ht[1]
后,rehashidx
设置为-1,表示操作已完成 - 在rehash期间,新的键值对会被保存到
ht[1]
中,查找优先在ht[0]
中进行,找不到则在ht[1]
中继续查找
跳表
在Redis中,跳表只在两个地方有使用,一是用于实现有序集合键,二是在集群节点中用作内部数据结构
跳表节点
Redis中,节点包含层信息,即每一层中的信息都包含在同一个节点之中,以数组的形式进行保存,而不是每一层中各包含多少个节点
typedef struct zskiplistNode {
// 前向指针,指向前一个节点
struct zskiplistNode *backward;
// 分值,节点按照分值大小来排列
double score;
// 成员对象,为一个指针,指向一个字符串对象
robj *obj;
// 层
struct zskiplistLevel {
// 后向节点
struct zskiplistNode *forward;
// 跨度,记录该层中两个节点之间的距离
unsinged int span;
} level[];
} szkiplistNode;
跳表结构
typedef struct zskiplist {
//表头节点和表尾节点
struct skiplistNode *header, *tail;
// 节点数目
unsinged long length;
// 层数
int level;
} zskiplist;
整数集合
Redis中,若一个集合只包含整数值元素,并且元素数量不多时,就会使用整数集合作为集合键的底层
整数集合结构体为
typedef struct intset {
// 编码方式
uint32_r encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
}
对于encoding,有:
- 如果其值为
INTSET_ENC_INT16
,那么contents
就是一个int16_t
类型的数组 - 如果其值为
INTSET_ENC_INT32
,那么contents
就是一个int32_t
类型的数组 - 如果其值为
INTSET_ENC_INT64
,那么contents
就是一个int64_t
类型的数组
content
虽然定义的是int8_t
类型的数组,但是却不存储int_8
类型的数值
升级
当新添加的元素比集合中存储的元素对应的类型所占的空间要大时,需要先对整数集合进行升级,其分为三步:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并未新元素分配空间
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素防止到正确的位置上,并在放置元素的过程中,位置底层数组的有序性质不变
- 将新元素添加到底层数组中
使用升级策略而不是直接统一使用int64_t
类型数据是为了尽可能的节省存储空间
压缩列表
Redis中,压缩列表是列表键和哈希键的底层实现之一
当一个哈希键只包含少量键值对,并且每个键值对的键和值要么时小整数值,要么是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现
压缩列表结构
一个压缩列表包含多个节点(entry),每个节点保存一个字节数组或者整数值,其结构图如下:
其各部分的含义为:
属性 | 类型 | 含义 |
---|---|---|
zlbytes | uint32_t | 压缩列表占用的字节数 |
zltail | uint32_t | 偏移量,记录列表尾节点距离起始节点有多少个字节,起始地址加上偏移量为表尾节点地址的取值 |
zllen | uint16_T | 压缩列表锁包含的节点数量 |
entry | 列表节点 | |
zlend | uint8_t | 用于标记压缩列表的结尾,值为0xFF |
节点
节点用于保存字节数组或者整数值,其包含的属性和含义如下:
属性 | 含义 |
---|---|
previous_entry_length | 记录前一个节点的长度,以字节为单位 |
encoding | content属性所保存的数据对应的数据类型 |
content | 节点的值 |
previous_entry_length
的长度可以是1字节或者5字节:
- 当前一个节点长度小于254字节时,
previous_entry_length
长度为1字节,保存前一节点的长度 - 当前一节点的长度大于254字节时,
previous_entry_length
长度为5字节,第一个字节值为0xFE,只有四个字节用于保存前一个节点的长度
对象
Redis没有直接使用前面所说的数据结构来实现数据库,而是使用这些数据结构来构建出一个对象系统,每个对象系统包含至少一个数据结构。
当用户在Redis中创建了一个键值对时,会响应地生成一个键对象和一个值对象。
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构地指针
void *ptr;
// ...
} robj;
type
对象中type决定了该对象的类型,取值情况如下
类型常量 | 对应的类型 |
---|---|
REDIS_STRING | 字符串对象 |
REDIS_LIST | 列表对象 |
REDIS_HASH | 哈希对象 |
REDIS_SET | 集合对象 |
REDIS_ZSET | 有序集合对象 |
encoding
对象中encoding属性表示该对象的底层实现,其取值情况如下
编码常量 | 对应的数据结构 |
---|---|
REDIS_ENCODING_INT | long类型的整数 |
REDIS_ENCODING_EMBSTR | embstr编码的简单动态字符串 |
REDIS_ENCODING_RAW | 简单动态字符串 |
REDIS_ENCODING_HT | 字典 |
REDIS_ENCODING_LINKEDLIST | 双端链表 |
REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_ENCODING_INTSET | 整数集合 |
REDIS_ENCODING_SKIPLIST | 跳表和字典 |
不同对象的底层实现可能如下:
对象类型 | 底层实现 |
---|---|
REDIS_STRING | REDIS_ENCODING_INT,REDIS_ENCODING_EMBSTR,REDIS_ENCODING_RAW |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST,REDIS_ENCODING_ZIPLIST |
REDIS_HASH | REDIS_ENCODING_HT,REDIS_ENCODING_ZIPLIST |
REDIS_SET | REDIS_ENCODING_HT,REDIS_ENCODING_INTSET |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST,REDIS_ENCODING_SKIPLIST |
字符串对象
字符串对象的编码方式可以是int
,raw
或者embstr
,其条件如下:
值 | 编码 |
---|---|
可以用long类型保存的整数 | int |
可以用long double类型保存的浮点数 | embstr或raw |
字符串数值,或者长度太大的整数或浮点数 | embstr或raw |
raw
和embstr
两种类型均为sdshdr
字符串实现,不同点在于raw
两次分配内存,而embstr
值分配一次内存,RedisObject
和sdshdr
内存在相邻位置,两者的结构图如下
使用embstr
有以下好处:
- 只需要分配一次内存,同时页只需要释放一次内存
- 所有数据都放在一块连续的内存里,能够更好的利用缓存带来的优势
在实际使用中,只有最开始使用时可能为embstr
类型,raw
和int
类型不会转换为embstr
类型,所以相当于embstr
类型仅为只读类型,当进行修改后会转化为raw
类型
列表对象
列表对象的编码可以是ziblist
或者linkedlist
,当满足以下条件时使用ziplist
:
- 列表中中保存的所有字符串元素的长度都小于64字节
- 列表中保存的元素小于512个
哈希对象
哈希对象的编码可以是ziplist
或者hashtable
,使用ziplist
存储时键和值紧挨在一起,为相邻的两个entry
哈希对象编码的选择方式同列表对象
集合对象
集合对象的编码和意识intset
或者hashtable
,当满足以下条件时使用intset
进行存储:
- 集合对象保存的所有元素均为整数值
- 集合对象保存的元素数量不超过512个
有序集合对象
有序集合对象的编码可以是ziplist
或者skiplist
+hashtable
为了能够高效实现各种操作,有序集合对象同时使用skiplist
和hashtable
实现
当满足以下条件按时,有序集合对象使用ziplist
:
- 保存的元素不超过128个
- 所有元素的长度都小于64字节