写在前面
感觉自己弱了,就多读点书,最近感觉对redis的理解有些弱,所以,找了一本书, 黄健宏的 《Redis设计与实现》,写了一些读书笔记与摘抄, 与各位牛马们共勉
底层模型
字符串
SDS模型
- SDS是redis基于C语言自己实现的一种字符串结构,相比于C语言的字符串做了一些优化
struct sdshdr {
//记录buf数组中已使用字节的数量
//等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
};
SDS的优势
- 常数复杂度获取字符串长度
- 如果遍历获取长度复杂度为O(n),直接记录则为O(n)
- 杜绝缓冲区溢出
- 因为记录了未使用的字节数量,在字符串拼接的时候,可以先计算一下空间是否够用,如果不够用扩容之后再做拼接
- 减少修改字符串时带来的内存重分配次数
- 字符串增长的操作,底层会先去做一下内存的重新分配,如果不做,容易产生缓冲区溢出
- 缩短字符串的操作, 底层会在执行操作后, 释放一部分空间, 如果不做,则容易产生内存泄漏
- SDS实现优化策略
- 空间预分配策略
- 当SDS的API对一个SDS进行修改,API会先检查未使用空间是否够用,如果够用则直接使用,无需分配。如果不够则需要对SDS进行空间扩展,这时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间
- 策略
- 如果对于SDS进行修改后, SDS长度小于1M, 程序会分配和len属性同样大小的未使用空间
- 如果对于SDS进行修改后, SDS长度大于等于1M, 程序会分配1M未使用空间
- 通过空间预分配策略, Redis可以减少连续执行字符串增长操作的内存分配次数
- 惰性空间释放策略
- 当SDS的API需要缩短SDS保存的字符串时, 程序并不立即使用内存重分配来回收缩短的字节, 而是使用free属性将这些字节数量记录下来,等待之后使用。
- 与此同时, 当我们在需要时,真正释放SDS未使用的空间,不用担心惰性空间释放策略会造成内存浪费
- 空间预分配策略
- 二进制安全
- C字符串存在的问题: C字符串中的字符必须符合某种编码(例如ASCII), 并且除了字符串多末尾不能包含空字符, 否则最先被程序读入的空字符会被误认为是字符串结尾, 所以使用C字符串只能保存文本数据, 不能保存图片、视频、音频、压缩文件等二进制数据
- SDS 的API是二进制安全的
- redis用 buf属性来保存二进制数据, 而不是字符
- SDS不是用空字符来判断字符串的结束,而是用len字段来判断
- 兼容部分C字符串函数
- 虽然SDS的API是二进制安全的,但是也遵循C字符串以空字符结尾的惯例
SDS对比C字符串
C字符串 | SDS |
---|---|
获取字符串长度O(n) | 获取字符串长度复杂度O(1) |
API是不安全的,可能造出缓冲区溢出 | API是安全的,不会造出缓冲区溢出 |
修改字符串长度N必然要执行N次内存重分配 | 修改字符串长度N最多要执行N次内存重分配 |
只能存储文本数据 | 可以保存文本或者二进制数据 |
可以使用所有<string.h>库中函数 | 可以使用一部分<string.h>库中函数 |
链表(双端链表)
- 链表节点
- 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;
-
dup、free和match成员则是用于实现多态链表所需的类型特定函数
- dup函数用于复制链表节点所保存的值
- free函数用于释放链表节点所保存的值
- match函数则用于对比链表节点所保存的值和另一个输入值是否相等
-
Redis链表的特性
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
- 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
- 带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
- 多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
字典
- 又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。
- Redis的字典使用哈希表作为底层实现
哈希表
- dict.h/dictht
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
} dictht;
- table是一个数组, 数组中每个元素都是一个哈希表节点指针dictEntry
- dictEntry 结构保存一个键值对
- size 记录哈希表大小(table数组大小)
- used 记录哈希表已有节点数
- sizemask值属性总是等于 size-1, 这个属性和哈希值一起决定一个键应该放在table数组中哪个索引上。
哈希表节点
typedef struct dictEntry {
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
} v;
//指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
- key 键值对的键
- v 键值对的值
- 可以是 指针
- 可以是 uint64_t整数
- 可以是 int64_t整数
- next 指向另一个哈希表节点,将多个哈希值相同的数据存成链表,用来解决哈希冲突
字典结构
typedef struct dict {
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
// rehash索引
//当rehash不在进行时,值为-1
in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
- type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
- privdata属性则保存了需要传给那些类型特定函数的可选参数。
- ht属性是一个包含两个项的数组, 一般情况下字典只使用ht[0]哈希表,ht[1]只会在对ht[0]哈希表进行rehash时候使用
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;
哈希算法
- redis 使用 MurmurHash2 算法来计算哈希值
- MurmurHash的优点在于,即使输入的键很有规律,但是算法仍然能给出一个很好的随机分布性,且算法的速度也非常快
- MurmurHash目前最新版为 MurmurHash3,可参考MurmurHash
解决hash冲突
- 链地址法
rehash(重新散列)操作
-
rehash步骤
- 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)
- 双倍扩容: 如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2 n(2的n次方幂);
- 等量扩容: 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2n
- 将保存在ht[0]中的所有键值对rehash到ht[1]上面,rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
- 当ht[0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备
- 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht[0].used属性的值)
-
rehash条件(满足其一就会触发)
- 服务器目前没有执行BGSAVE 命令或 BGREWRITEAOF命令, 且哈希表的负载因子大于等于1
- 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
- 当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作
-
根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
-
负载因子计算公式
#负载因子=哈希表已保存节点数量/哈希表大小
load_factor = ht[0].used / ht[0].size
渐进式rehash
-
过程
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表
- 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始
- 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一
- 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成
-
好处
- 分而治之,计算工作均摊到对字典的每个添加、删除、查找和更新操作,避免了集中式rehash而带来的庞大计算量
-
rehash期间的hash表做操作
- 在两个hash表中操作
- 新添加的键值对一律保存到ht[1]
跳跃表
- 跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针
跳跃表节点
- redis.h/zskiplistNode
typedef struct zskiplistNode { //层 struct zskiplistLevel { //前进指针 struct zskiplistNode *forward; //跨度 unsigned int span; } level[]; //后退指针 struct zskiplistNode *backward; //分值 double score; //成员对象 robj *obj; } zskiplistNode;
-
层
- level数组, 每一个元素都包涵一个指向其他节点的指针,层的数量越多,访问其他节点的速度就越快
- 创建一个新的跳跃表节点时,根据幂次定律(power law,越大的数出现的概率越小),随机生成一个1~32的值作为level数组的大小,这个大小就是层的高度
-
前进指针
- 每个层都有一个指向表尾方向的前进指针(level[i].forward属性),用于从表头向表尾方向访问节点
-
跨度
- 层的跨度(level[i].span属性)用于记录两个节点之间的距离
- 两个节点之间的跨度越大,它们相距得就越远。
- 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点
- 跨度实际上是用来计算排位(rank)
-
后退指针
- 节点的后退指针(backward属性)用于从表尾向表头方向访问节点
-
分值
- 节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序
-
成员对象
- 节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。
- 各个节点保存的成员对象必须是唯一的
-
跳跃表
typedef struct zskiplist {
//表头节点和表尾节点
structz skiplistNode *header, *tail;
//表中节点的数量
unsigned long length;
//表中层数最大的节点的层数
int level;
} zskiplist;
- header和tail指针分别指向跳跃表的表头和表尾节点
- length属性来记录节点的数量
- evel属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量, 表头节点的层高并不计算在内
跳跃表重点回顾
- 跳跃表是有序集合的底层实现之一
- Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(比如表头节点、表尾节点、长度),而zskiplistNode则用于表示跳跃表节点
- 每个跳跃表节点的层高都是1至32之间的随机数
- 在同一个跳跃表中,多个节点可以包含相同的分值,但每个节点的成员对象必须是唯一的
- 跳跃表中的节点按照分值大小进行排序,当分值相同时,节点按照成员对象的大小进行排序
整数集合
- 当一个集合只包含整数值元素,并且这个集合的元素数量不多时,redis就会使用整数集合作为集合键的底层实现
- 可以保存类型为int16_t、int32_t或者int64_t的整数值
整数集合实现
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
-
整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项
-
contents数组的真正类型取决于encoding属性的值
-
升级
- 升级步骤
- 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间
- 将底层数字现在有的所有元素转换成与新元素相同的类型,并且将转换后的数据元素放在正确的位置上,在放置元素的过程中,需要维持底层数组的有序性质不变
- 将新元素添加到底层数组里面
- 如果新元素小于所有元素, 放在索引为0的位置上
- 如果新元素大于所有元素, 放在索引为len-1的位置
- 优点
- 提升了整数集合的灵活性
- C语言为静态语言, 为了避免类型错误,通常不会将两种不同类型的值放在一个数据结构中
- 但是,整数集合可以自动升级来适应新元素, 所以可以将不通类型的整数添加到集合中,不必担心出现类型错误
- 尽可能的节约内存
- 如果为了保证int16\int32\int64三种类型的值都可以保存,直接使用int64就会实现,但是这样就会造成内存饿的一个浪费,自动升级的目的就是只有在需要使用的时候,才会用到相应的编码,从而尽可能的节省内存
- 提升了整数集合的灵活性
- 升级步骤
-
降级
- 不支持降级,一旦升级编码就会维持升级后的状态
-
优势
- 升级操作,为整数集合带来了操作上的灵活,并且尽可能的节约了内存
压缩列表
- 当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现
- 当一个哈希键只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希键的底层实现。
redis> HMSET profile "name" "Jack" "age" 28 "job" "Programmer"
OK
redis> OBJECT ENCODING profile
"ziplist"
- 压缩列表结构
- 是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构
- zlbytes
- 类型
- uint32_t
- 长度
- 4字节
- 用途
- 记录整个压缩列表占用的内存字节数
- 在对压缩列表进行内存重分配,或者计算zlend的位置时使用
- 记录整个压缩列表占用的内存字节数
- 类型
- zltail
- 类型
- uint32_t
- 长度
- 4字节
- 用途
- 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节
- 通过这个偏移量,程序无需便利整个压缩列表就可以确定表尾节点的地址
- 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节
- 类型
- zllen
- 类型
- uint16_t
- 长度
- 2字节
- 用途
- 记录压缩列表包含的节点数量
- 当这个属性的值小于UINT16_MAX(65535)时, 这个属性的值就是压缩列表包含节点的数量;
- 当这个属性的值等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算出来
- 记录压缩列表包含的节点数量
- 类型
- entryX
- 类型
- 列表节点
- 长度
- 不定
- 用途
- 压缩列表包含的各个节点, 节点的长度由节点保存的内容决定
- 类型
- zlend
- 类型
- uint8_t
- 长度
- 1字节
- 用途
- 特殊值0xFF(十进制255), 用于标记压缩列表的末端
- 类型
- 压缩列表节点的构成
- 可以保存一个字节数组或者一个整数值
- 字节数组可以是三种长度
- 63(2^6-1)
- 16383(2^14-1)
- 4294967295(2^32-1)
- 整数值可以是以下六种长度的一种:
- 4位长, 介于0~12之间无符号整数
- 1字节长的有符号整数
- 3字节长的有符号整数
- int16_t类型整数
- int32_t类型整数
- int64_t类型整数
- 每个压缩列表节点的构成
- previous_entry_length
- 作用
- 记录了压缩列表中前一个节点的长度
- 长度可以为1字节或者5字节
- 如果前一节点的长度小于254字节,长度为1字节
- 保存前一节点的长度
- 如果前一节点的长度大于等于254字节,长度为5字节
- 其中属性的第一字节会被设置为0xFE(十进制值254),而之后的四个字节则用于保存前一节点的长度
- 如果前一节点的长度小于254字节,长度为1字节
- 作用
- encoding
- 记录了节点的content属性所保存数据的类型以及长度
- content
- 负责保存节点的值,可以是一个字节数组或者整数
- 值的类型和长度由节点的encoding属性决定
- 负责保存节点的值,可以是一个字节数组或者整数
- previous_entry_length
- 表结尾节点向表头节点进行便利的完整过程
- 指向表尾节点可以通过entry4起始地址的指针p1(指向表尾节点通过指向压缩列表起始地址的指针加上zltail属性的值得出)
- 通过用p1减去entry4节点previous_entry_length属性的值,我们得到一个指向entry4前一节点entry3起始地址的指针p2
- 通过用p2减去entry3节点previous_entry_length属性的值,我们得到一个指向entry3前一节点entry2起始地址的指针p3;
- 通过用p3减去entry2节点previous_entry_length属性的值,我们得到一个指向entry2前一节点entry1起始地址的指针p4,entry1为压缩列表的表头节点;
- 最终完成了从表尾向头节点遍历了整个列表
- 连锁更新问题
- 对于previous_entry_lenght 属性记录前一个节点的长度, 小于254 1个字节,大于等于254 用5个字节
- 情景一:
- 在一个压缩列表中,多个连续且长度介于250~253的节点, 这时,我们将一个长度大于等于254的节点设置为表头,原来的表头节点e1 的previous_entry_lenght 会从1字节重新分配为5字节,导致e1的长度大于等于254,从而导致e1后面的节点e2也会重新分配previous_entry_lenght,以此类推,程序会不断的对压缩列表进行空间分配,直致eN为止。
- 情景二:
- 在一个压缩列表中,从e2~eN多个连续且长度介于250~253的节点,头节点为大于254的节点e1,e2的previous_entry_lenght 为 5字符, e3~eNprevious_entry_lenght 为 1字符,当把e2删除掉时,后面e3~eN也会连续的扩展空间。
- 这种连续多次空间扩展的操作叫做:连锁更新
- 虽然连锁更新的复杂较高,但是造成性能问题的几率很低
- 压缩列表中恰好多个连续且长度介于250~253之间的节点, 连锁更新才会发生,这种情况的概率很低
- 如果出现连锁更新,只要节点不多,也不会对性能有太大的影响