前言
Redis 数据库里面的每个键值对(key-value)都是由对象(object)组成的:
数据库键总是一个字符串对象
数据库的值则可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象这五种对象中的其中一种
这篇博客要讨论的主要是这五种对象所使用的底层数据结构
Redis底层数据结构有以下数据类型:
- 简单动态字符串
- 链表
- 字典
- 跳跃表
- 整数集合
- 压缩列表
一、简单动态字符串
Redis构建了一种名为简单动态字符串(SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。在Redis里面,C字符串只会作为字符串字面量用在一些无须对字符串值进行修改的地方(比如打印日志)。当Redis需要一个可以被修改的字符串值时,Redis就会使用SDS来表示字符串
SDS还被用作缓冲区:AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区,都是由SDS实现的
1、SDS的定义
struct sdshdr {
// 记录buf数组中已使用字节的数量
// 等于SDS所保存字符串的长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
2、SDS与C字符串的区别
1)、常数复杂度获取字符串长度
因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符串进行计数,直到遇到代表字符串结尾的空字符串为止,这个操作的时间复杂度为O(N)
因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1)
通过使用SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降低到了O(1)
2)、杜绝缓冲区溢出
C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出
假设程序中有两个在内存中紧邻着的字符串s1和s2,其中s1保存了字符串“redis”,而s2则保存了字符串“MongoDb”:
将s1的内容修改为“Redis Cluster”,但忘记了为s1分配足够的空间,就会导致s1的数据将溢出到s2所在的空间中,导致s2保存的内容被意外地修改
当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题
3)、减少修改字符串时带来的内存重分配次数
因为C字符串并不记录自身的长度,所以对于一个包含N个字符的C字符串来说,这个C字符串的底层实现总是一个N+1个字符长的数组(额外的一个字符串空间用于保存空字符)。因为C字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重分配操作:
- 如果程序执行的是增长字符串的操作,程序需要先通过内存重分配来扩展底层数组组的空间大小,如果忘了这一步就会产生缓冲区溢出
- 如果程序执行的是缩短字符串的操作,程序需要通过内存重分配来释放字符串不再使用的那部分空间,如果忘了这一步就会产生内存泄露
SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录
通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略
1)空间预分配
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须的空间,还会为SDS分配额外的未使用空间
- 如果对SDS进行修改之后,SDS的长度(也就是len属性的值)将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同
- 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间
通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数
在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无须执行内存重分配
2)惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用
4)、二进制安全
C字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,这些限制使得C字符串只能保存文本数据
SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤或者假设,数据在写入时是什么样的,它被读取时就是什么样
通过使用二进制安全的SDS,而不是C字符串,使得Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据
5)、兼容部分C字符串函数
6)、总结
C 字符串 | SDS |
获取字符串长度的复杂度为O(N) | 获取字符串长度的复杂度为O(1) |
API是不安全的,可能会造成缓冲区溢出 | API是安全的,不会造成缓冲区溢出 |
修改字符串长度N次必然需要执行N次内存重分配 | 修改字符串长度N次最多执行N次内存重分配 |
只能保存文本数据 | 可以保存二进制数据和文本文数据 |
可以使用所有<string.h>库中的函数 | 可以使用一部分<string.h>库中的函数 |
二、链表
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度
链表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现
此外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区
1、链表和链表节点的实现
每个链表节点使用一个adlist.h/listNode
结构来表示:
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
}listNode;
多个listNode可以通过prev和next指针组成双端链表
使用adlist.h/list
来持有链表
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;
Redis的链表实现的特性可以总结如下:
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
- 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)
- 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)
- 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值
三、字典
字典(映射)是一种用于保存键值对的抽象数据结构
在字典中,一个键可以和一个值进行关联,这些关联的键和值就称为键值对,字典中的每个键都是独一无二的
Redis的数据库就是使用字典来作为底层实现的,对数据库的CRUD操作也是构建在对字典的操作之上的
字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现
1、字典的实现
Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表就保存了字典中的一个键值对
1)、哈希表
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于size-1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
}dictht;
table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry
结构的指针,每个dictEntry结构保存
着一个键值对。size属性记录了哈希表的大小,也即是table数组的大小,而used属性则记录了哈希表目前已有节点的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面
一个大小为4的空哈希表结构图如下:
2)、哈希表节点
哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存着一个键值对:
typeof struct dictEntry{
// 键
void *key;
// 值
union{
void *val;
uint64_t u64;
int64_t s64;
}
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
}dictEntry;
key属性保存着键值对中的键,而v属性则保存着键值对中的值
next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,以此来解决键冲突的问题
通过next指针将两个索引值相同的键k1和k0连接在一起
3)、字典
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privedata;
// 哈希表
dictht ht[2];
// rehash 索引
// 在rehash不在进行时,值为-1
int trehashidx;
}dict;
- type属性是一个指向dictType结构的指针,每个dictType结构保存了一组用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数
- privedata属性则保存了需要传给那些类型特定函数的可选参数
- ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用
- trehashidx属性记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1
一个普通状态下(没有进行rehash)的字典如下:
2、哈希算法
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新建值对的哈希表节点放到哈希表数组的指定索引上面
Redis计算哈希值和索引值的方法如下:
# 使用字典设置的哈希函数,计算键key的哈希值
hash = dict -> type -> hashFunction(key);
# 使用哈希表的sizemask属性和哈希值,计算出索引值
# 根据情况不同,ht[x]可以是ht[0]或者ht[1]
index = hash & dict -> ht[x].sizemask;
Redis使用Murmurhash2算法来计算键的哈希值,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快
3、解决键冲突
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突
Redis的哈希表使用链地址法来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题
因为dictEntry节点组成的链表没有指向链表表尾的指针,所以为了速度考虑,程序总是将新节点添加到链表的表头位置(复杂度为O(1)),排在其他已有节点的前面
使用链表解决k2和k1的冲突:
4、rehash
为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩
扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:
1)、为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也就是ht[0].used属性的值)
- 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used * 2的
- 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的
2)、将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新就按键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
3)、当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备)
哈希表的扩展与收缩:
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
- 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
哈希表的负载因子计算公式:
# 负载因子=哈希表已保存节点数量/哈希表大小
load_factor = ht[0].used / ht[0].size
在执行BGSAVE命令或者BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免必要的内存写入操作,最大限度地节约内存
当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
5、渐进式rehash
为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]
哈希表渐进式rehash的详细步骤:
1)、为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
2)、在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
3)、在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序出了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash至ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一
4)、随着字典操作的不断进行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成
因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除、查找、更新等操作会在两个哈希表上进行。而新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作