简单动态字符串
Redis 并没有直接使用 C 语言里面的以空字符为结尾的字符数组构成的字符串表示,而是自己构建了简单动态字符串,简称 SDS,作为 Redis 的默认字符串表示。
SDS 的定义
在 Redis 里面一个 sdshdr 结构表示了一个 SDS 值:
struct sdshdr {
int len; // 用于记录 buf 数组中已使用的字节的数量也就是 SDS 保存的字符串的长度
int free; // 记录 buf 数组中未使用字节的数量
char buf[]; // 字节数组用来保存字符串
}
其中它的 char buf[] 遵循 C 语言的以空字符为结尾,好处是 SDS 可以直接复用一部分 C 字符串函数库里面的函数例如 printf 函数。
SDS 的好处
- 常数复杂度获取字符串长度
因为我们定义 SDS 的结构体中 len 属性记录了字符串的长度,普通的 C 字符串需要通过遍历的形式获取长度复杂度是 O(n)。 - 杜绝缓冲区溢出
在上面提到 SDS 记录了字符串长度而 C 语言没有,导致的问题就是 C 语言容易造成缓冲区溢出,举个示例:利用 stract 函数可以将一个字符串拼接到另一个字符串的末尾,在程序中有两个在内存中紧邻着的字符串 s1 和 s2,如图所示
如果我们使用函数 stract(s1, “Cluster”); 我们将 s1 修改成了 RedisCluster,但是没有在执行这个命令前给 s1 分配足够的空间,导致的结果就是 s1 的数据将溢出到 s2 所在的空间,导致 s2 意外的被修改,如图所示
那么 SDS 如果要修改字符串的话在拼接字符串之前,API 会先检查给定的 SDS 的空间是否足够,如果不满足的话,API 会自动扩展 SDS 的空间至执行修改所需的大小,再执行拼接操作。 - 减少修改字符串带来的内存重分配次数(空间预分配和惰性空间释放)
我们知道对于字符串来说,如果程序执行增长字符串操作,我们需要保证有足够的空间,要先通过内存重分配来拓展底层数组的空间大小,否则就会出现缓冲区溢出;同理在缩短字符串的时候,要通过内存重分配来回收那段不需要的空间,否则就会出现内存泄露。对于 SDS 来说,它通过 free 属性(未使用空间)实现减少修改字符串带来的内存重分配次数,对应的手段是空间预分配和惰性空间释放。
空间预分配用于优化字符串增长:当 SDS 的 API 对一个 SDS 进行修改,需要对一个字符串进行空间扩展时,程序除了为 SDS 分配修改所必须的空间之外, 还会为 SDS 分配额外的空间:如果修改之后,SDS的长度小于 1MB,则会分配和 len 属性同样大小的未使用空间。如果修改之后,SDS 的长度大于或等于1MB,会为 SDS 分配 1MB 的未使用空间。通过这种策略,SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。
惰性空间释放用于优化字符串缩短:当 SDS 的 API 需要执行缩短 SDS 保存的字符串时,程序并不立即回收多出来的空间,而是维护一个 free 属性,将这些字节的数量记录下来,等待将来使用。与此同时,SDS 也提供了相应的 API 可以让我们在有需要的时候释放 SDS 的未使用空间,所以不必担心惰性空间释放策略会造成内存浪费。 - 二进制安全
C 字符串必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则第一个空字符将被误以为是字符串的结尾,这就导致C字符串只能保存文本数据,而不能保存图片、音频、视频、压缩文件这样的二进制数据。但是 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里面的数据,使得 SDS 不仅可以保存文本数据还可以保存任意格式的二进制数据。
链表
Redis 是一个设计追求简单的数据库,链表这种简单实用的数据结构在很多地方都得到了应用(事实上应该说是双向链表)譬如:列表(List),慢查询,监视器,订阅与发布等。包括客户端状态也是用链表在服务器中进行保存的。
链表和链表节点的实现
先看链表节点的定义
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
链表的定义:
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 链表特性
- 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的复杂度都是 O(1)。
- 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点。
- 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表尾节点的复杂度为 O(1)。
- 带链表长度计数器:程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,程序获取链表中节点数量的复杂度为 O(1)。
- 多态:链表节点使用 void* 指针来保存节点值,并且可以通过 list 结构的 dup、free、match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
字典
它是一种用于保存键值对的数据结构,其实跟 Java 中的 HashMap 作用差不多。同时它是哈希键的底层实现。而它的底层实现是使用了哈希表,一个哈希表里面可以有多个哈希节点,每个哈希节点就保存着字典的一个键值对。
哈希表和哈希节点的定义
哈希表定义如下:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
table 属性是一个数组,数组中每个元素都是一个指向 dictEntry 结构的指针,每个 dictEntry 结构(也就是哈希表节点)保存着一个键值对。
哈希表节点定义如下:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表,来解决键冲突的问题
struct dictEntry *next;
} dictEntry;
看一个样例:
字典的定义
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;
这里的 type 属性指向 dictType 结构的指针,这个结构里面保存着操作特定类型键值对的函数,而 private 属性就保存着需要传给那些类型特定函数的可选参数。看一下 dictType 结构里面的函数:
/*
* 字典类型特定函数
*/
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;
ht 属性是一个包含两个哈希表的数组,一般只用 ht[0],ht[1] 哈希表使用在对 ht[0] 表 rehash 时。下面展示一个普通状态下的字典:
哈希算法和键冲突
我们在 dictType 结构中看到了 Redis 提供的函数 hashFunction 来计算哈希值。计算出的哈希值会和之前 dict 结构里定义的 sizemask 进行与运算得到这个哈希表节点的索引值。
那么如果有两个或以上数量的键被分配到哈希表数组的同一个索引上,我们称这些键发生了冲突,而 Redis 采用了链地址法解决键冲突,而且考虑插入速度,采用的是前插法。
rehash
首先我们定义一个概念叫负载因子:负载因子 = 哈希表已保存的节点数量/哈希表大小。那么随着操作的进行,为了让负载因子维持在一个合理的范围之内,哈希表的大小会进行对应的拓展和收缩,这个工作就会通过 rehash 来完成。Redis 对字典的哈希表进行 rehash 步骤如下:
- 为 ht[1] 分配空间:这个空间大小由要执行的操作和 ht[0] 的键值对数量决定的,如果是拓展操作,ht[1] 大小为第一个大于等于 ht[0].used * 2 的 2n;如果是收缩操作,那么 ht[1] 大小为第一个大于等于ht[0].used 的 2n
- 将 ht[0] 的键值对 rehash 到 ht[1]
- 当 ht[0] 全部迁移到 ht[1],释放 ht[0],让 ht[1] 成为新 ht[0]。
拓展和收缩的条件
负载因子大于 1 且没有执行 BGSAVE,BGREEWRITEAOF 命令或者正在执行 BGSAVE,BGREEWRITEAOF 命令负载因子大于5执行拓展操作;
负载因子小于 0.1 则执行收缩操作
渐进式rehash
rehash 的动作不是一次性集中式的完成,而是分多次,渐近行的完成,步骤如下:
- 为 ht[1] 分配空间,将 rehashidx 设为 0。
- 每次对字典进行增删查改,程序将同时将 rehashidx 索引上的所有键值对 rehash 到 ht[1] ,完成后rehashidx 加 1。
- 当 ht[0] 上的所有键值对全部完成 rehash,将 rehashidx 设为 -1。rehash 完成。
在 rehash 过程中,字典的删查改将在两个 hashtable 上进行(先查 ht[0] 再查 ht[1]),而增加操作只会在 ht[1] 进行。
跳跃表
首先我们知道跳跃表是一个有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。它的时间复杂度很优秀平均 O(logN),最坏 O(n) 的复杂度效率可以可以和平衡树相比较。跳跃表提高它的查找效率是通过在链表上建索引的方式,具体的效果图可以看这里。因此在 Redis 里面它也是作为有序集合键的底层实现。
Redis 的跳跃表由 zskiplistNode 和 skiplist 两个结构定义,其中 zskiplistNode 结构用于表示跳跃表节点,而 zskiplist 结构则用于保存跳跃表节
点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等。看一个跳跃表:
位于图片最左边的是 zskiplist 结构, 该结构包含以下属性:
typedefstruct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header,*tail;
// 表中节点的数量
unsignedlong length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
右边的则是跳跃表节点是四个 zskiplistNode 结构, 该结构包含以下属性:
typedefstruct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsignedint span;
} level[];
} zskiplistNode;
整数集合
它是 Redis 里面用来保存整数值的集合的数据结构,它可以保存类型为 int16_t,int32_t,int64_t 的整数值,并且保证集合中不会出现重复的元素,它的定义如下:
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
整数集合的元素都存在 contents 这个数组里面,各个项在数组里面按值从小到大有序的排列,并且不包含重复项。而且虽然这个数组定义为 int8_t 类型,但是它实际存储的类型是由 encoding 属性的值来确定的,就如果 encoding 属性值为 INTSET_ENC_INT16 这个数组每个项就是 int_16 类型。
升级
每当要添加的新元素到整数集合里面,如果添加的这个数的类型比现类型长时,需要先进行升级,然后再添加。就例如如果数组中现在的类型是 int16_t,然后我要存储了一个需要用 int64_t 类型来保存的数,那么这个数组中元素会先升级为 int64_t,再进行插入。具体步骤如下:
- 根据新元素类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
- 将底层数组现有的元素都转化成与新元素相同的类型,并将类型转化后的元素放置到正确的位上;且在放置元素的过程中,需要继续维持底层数组的有序性质不变
- 添加新元素
升级的好处:提升整数集合的灵活性;尽可能的节约内存。
而且整数集合不支持降级操作,即使把当前编码唯一一个元素删除了,也会保持当前编码,不会降下来。例如这个数组类型为 int64_t,里面实际只有一个元素 a 需要使用 int64_t 来保存,其他都可以用 int3_t
来保存,你删了 a,这个数组也不会降级为 int32_t 类型。
压缩列表
压缩列表是列表建和哈希键的底层实现之一:
列表键底层实现之一:当一个列表键只包含少量列表项,且每个列表项要么是小整数值,要么是长度比较短的字符串时,就使用压缩列表。
哈希键底层实现之一:当一个哈希键只包含少量键值对,且每个键值对的键、值要么是小整数值,要么是长度比较短的字符串时,就使用压缩列表。
使用它的目的就是节约内存。
压缩列表构成
它是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或一个整数值。
它的组成部分以及组成部分的介绍可以看下图:
压缩列表节点构成
每个压缩列表节点可以保存一个字节数组或一个整数值,它的组成部分又是由 previous_entry_length;encoding;content 三个部分组成。
previous_entry_length:记录了其前一个节点的长度,可以是 1 字节或 5 字节,就如果前一个节点的长度小于 254 字节就用 1 字节存,大于等于 254 字节的就用 5 字节。
encoding:记录了节点 content 属性所保存数据的类型以及长度,使用不同长度和最高位来区分。
content:保存节点的值,可以是一个字节数组或整数,值的类型和长度由节点 encoding 属性决定。
连锁更新
新增节点时:如果当前节点 e1、e2…eN 的长度介于 250 字节到 253 节点,插入 new 位 254 节点到表头,则程序需要对压缩列表执行空间重分配操作,且 e1 的 previous_entry_length 属性要扩展为 5 字节,e1 长度变为介于254 字节到 257 字节,则 e2 也要进行这样的操作,直到所有节点都完成。
复杂度分析:连锁更新在最坏情况下需要对压缩列表执行 N 次空间重分配操作,而每次空间重分配的最坏复杂度为 O(n),所以连锁更新的最坏复杂度为 O(n2)。
对象
我们知道 Redis 对外提供了五种数据类型:字符串,列表,哈希,集合,有序集合。这五种对象每种都用到了至少一种我们上面介绍到的数据结构。
使用对象的好处有:可以在执行命令之前,根据对象的类型来判断是否可以执行该命令(类型字段作用);可以针对不同的使用场景,为对象设置多种不同的数据结构实现,优化对象在不同场景下的使用效率。
Redis 中每个对象都是由一个 redisObject 结构表示的,该结构中包含三个属性:type 属性;encoding 属性和 ptr 属性:
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
} robj;
对象的 type 属性记录着对象的类型,也就是刚刚我们说到的五个对象的一种,这个属性的值可以是下表中的一个:
类型常量 | 对象的名称 |
---|---|
REDIS_STRING | 字符串对象 |
REDIS_LIST | 列表对象 |
REDIS_HASH | 哈希对象 |
REDIS_SET | 集合对象 |
REDIS_ZSET | 有序集合对象 |
对于 Redis 保存的键值对来说,键总是一个字符串对象,而值是五种对象中的一种。
TYPE 命令的实现方式也是这样,输出的值就是类型常量。
对象的 ptr 属性指向对象的底层实现数据结构,而这些数据结构是由对象的 encoding 属性决定的,encoding 属性记录了对象所使用的编码,属性值可以是下表中的一个:
每种类型的对象都至少使用了两种不同的编码,如下图:
使用 OBJECT ENCODING 命令可以查看一个数据库键的值对象的编码。
字符串对象
在上面我们可以看到字符串对象的编码有三种:int,raw 或者 embstr。
选择哪种编码方式的依据是:
- 可以用 long 类型保存的整数,用 int
- 超长数字、字符串长度大于 32 字节,用 raw
- 超长数字、字符串长度小于等于 32 字节,用 embstr
embstr 编码是专门用于保存短字符串的一种优化编码方式,编码和 raw 一样,都使用 redisObject 结构和 sdshdr 结构来表示字符串对象,但是 raw 编码会调用两次内存分配一次调用获取 redisObject,一次调用获取 sdshdr,而 embstr 编码则通过调用一次内存分配函数来分配一块连续的空间,空间一次包含 redisObject 和 sdshdr 两个结构:
而且关于浮点数在 Redis 中存储也是用字符串来进行存储的。
当然有几种的编码方式就会有编码的转换,在 Redis 中 raw 方式还是最重要的。
当一个对象编码方式是 int 时,如果执行了一些命令使得整个对象保存的不再是整数值而是一个字符串,那么对应的字符串对象编码就从 int 变为了 raw。
当一个对象编码方式是 embstr 时,如果对这个对象执行任何的修改命令,那么程序会将这个对象的编码方式转换为 raw 再执行修改命令,因为 embstr 编码的对象只能作为只读方式存在
列表对象
列表对象的编码可以是 ziplist 或者 linkedlist。
ziplist 编码的列表对象使用压缩列表作为底层实现,每个列表节点保存一个列表元素。
linkedlist 编码的列表对象使用双端链表作为底层实现,它的链表节点的指针是指向一个 StringObject(实际就是一个简单动态字符串)
那么列表对象的编码转换为:
当同时满足以下条件时,列表对象使用 ziplist 编码:
- 列表对象保存所有字符串元素的长度都小于64字节
- 列表对象保存的元素数量小于 512 个
不满足上述条件的都使用 linkedlist 来进行编码。
哈希对象
哈希对象类型可以用 ziplist 或 hashtable 进行实现。
对于压缩列表的存储方式呢会将键值对两个对象连续推入压缩列表尾部,也就是哈希对象键值对是连续存放的,键在前值在后。
HSET profile name "TOM";
HSET profile age 25;
HSET profile career "PROGRA";
得到的压缩列表如下:
当使用 hashtable 编码的哈希对象,使用字典作为底层实现:
- 字典的每个键都是一个字符串对象,对象中保存着键值对的键
- 字典的每个值都是一个字符串对象,对象中保存着键值对的值
看下面的图就了解了:
编码转换规则如下:当哈希对象同时满足一下两个条件时,哈希对象使用 ziplist 进行编码:
- 哈希对象保存的所有键值对的键和值的字符串长度都小小于 64 字节
- 哈希对象保存的键值对数量小于 512 个
不能满足这两个条件的哈希对象需要使用 hashtable 编码。
这两个条件的上限值是可以修改完的,具体的看配置文件中的 hash-max-ziplist-value 和 hash-max-ziplist-entries。
集合对象
集合对象的编码可以是 intset 或者是 hashtable。
intset 编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。实例如下:
hashtable 编码的集合对象使用字典作为底层实现,字典每个键都是一个字符串对象,字典的值都设置为NULL。实例如下:
编码转换的规则如下:当集合对象同时满足以下条条件时,对象使用 intset 编码:
- 集合对象保存的所有元素都是整数值
- 集合对象保存的元素数量不超过 512 个
不能同时满足这两个条件的集合对象需要使用 hashtable 编码
有序集合对象
有序集合对象实现可以是 ziplist 或者 skiplist。
ziplist 编码的有序集合使用压缩列表作为底层实现,元素成员和分值为连续存放在两个邻近的节点中。压缩列表的集合元素按分值从小到大排序。
skiplist 编码的有序集合使用 zset 作为底层实现,一个 zset 包含一个字典和一个跳跃表
*
* 有序集合
*/
typedef struct zset {
// 字典,键为成员,值为分值
// 用于支持 O(1) 复杂度的按成员取分值操作
dict *dict;
// 跳跃表,按分值排序成员
// 用于支持平均复杂度为 O(log N) 的按分值定位成员操作
// 以及范围操作
zskiplist *zsl;
} zset;
那为什么有序集合需要这两个数据结构来实现呢?
理论上一个数据结构就可以实现有序集合了,但是为了考虑性能,让有序集合的查找和范围性操作都尽可能快的执行。
编码转换的规则是:当有序集合对象同时满足以下两个条件时,对象使用 ziplsit 编码
- 有序结合保存的元素数量小于 128 个
- 有序集合保存的所有元素成员的长度都小于 64 字节
否则都将使用 skiplist 进行实现。
同理这两个数组也是可以通过配置文件进行配置的。
类型检查和命令多态
命令大体上分为两类:
一种是任何键都可以执行,如 DEL 命令,EXPIRE 命令,RENAME 命令,TYPE 命令,OBJECT 命令
但是有些类型的操作是需要特殊的命令,如:
SET,GET,APPEND,STRLEN 等命令只能对字符串键执行;
HDEL,HSET,HGET,HLEN 等命令只能对哈希键执行;
RPUSH,LPOP,LINSERT,LLEN 等命令只能对列表键执行;
SADD,SPOP,SINTER,SCARD 等命令智能化对集合键执行;
ZADD,ZCARD,ZRANK,ZSCORE 等命令只能对有序集合键执行;
当客户端发来一个命令时,服务器会用 redisObject 中的 type 属性进行初步检查,通过后再调用具体实现函数,否则返回一个错误。
内存回收和对象共享
Redis 中的内存回收是通过引用计数的方式来实现的,每个对象的引用技术信息是由 redisObject 结构的 refcount 属性记录,
计数器的原则如下:
- 在创建一个新对象时,引用计数的值会被初始化为 1。
- 当对象被一个新程序使用时,引用计数值会被增加 1。
- 当对象不再被一个程序使用时,它的引用计数值会被减 1。
- 当对象的引用计数值为 0 时,对象所占用的内存会被释放。
除了用于回收机制之外,引用计数的属性还有对象共享的作用,在 Redis 中实现对象共享有两步:
- 将数据库键的指针指向一个现有的值对象
- 将被共享的值对象的引用计数加 1
Redis 在初始化服务器的时候会创建一万个字符串对象,包含从 0 到 9999 所有的整数值。
那为什么不共享包含字符串的对象呢?
因为程序要先检查给定的共享对象和键想创建的目标对象是否完全一样,这时候整数值的验证操作复杂度为 O(1), 而共享字符串的验证操作复杂度为 O(N),一个共享对象保存的的值越复杂验证花费的 CPU 时间也就越多。
对象的空转时长
在 redisObjet 中还有一个 lru 属性,用来记录对象最后一次被访问的时间,我们可以通过 OBJECT IDLETIME 命令得到 robj 的空转时间,计算方式是用当前时间减去 lru 属性来得出。
这个属性在服务器打开 maxmemory 选项且回收算法是 volatile-lru 或者 allkeys-lru 时,当服务器占用的内存总数超过了 maxmemory 的上限值,空转时长比较高的那部分键就会优先被服务器释放,从而回收内存。