1、概述

Redis的RedisObject的数据结构:

typedef struct redisObject {
    // 对外的类型 string list set hash zset等 4bit
    unsigned type:4;
    // 底层存储方式 4bit
    unsigned encoding:4;
    // LRU 时间 24bit
    unsigned lru:LRU_BITS; 
    // 引用计数  4byte
    int refcount;
    // 指向对象的指针  8byte
    void *ptr;
} robj;

redis 存储 二进制 redis存储byte_redis

Redis 内部使用一个 redisObject 对象来表示所有的 key 和 value。

redisObject 最主要的信息如上图所示:type 表示一个 value 对象具体是何种数据类型,encoding 是不同数据类型在 Redis 内部的存储方式。

比如:type=string 表示 value 存储的是一个普通字符串,那么 encoding 可以是 raw 或者 int。

2、字符串

字符串是最基础的类型,因为所有的键都是字符串类型,且字符串之外的其他几种复杂类型的元素也是字符串。

字符串长度不能超过512MB。

2.1、结构

Redis 中的字符串是可以修改的字符串,在内存中它是以字节数组的形式存在的。

Redis 的字符串叫着「SDS」,也就是 Simple Dynamic String。它的结构是一个带长度信息的字节数组

struct SDS<T> {  
  T capacity; // 数组容量,所分配数组的长度; 1byte
  T len; // 数组长度,字符串的实际长度; 1byte
  byte flags; // 特殊标识位; 1byte
  byte[] content; // 数组内容,存储了真正的字符串内容 
 }

修改字符串时,如果数组没有冗余空间,那么追加操作必然涉及到分配新数组,然后将旧内容复制过来,最后修改为新数据;
如果字符串的长度非常长,这样的内存分配和复制开销就会非常大

Redis 规定字符串的长度不得超过 512M 字节。创建字符串时 len 和 capacity 一样长,不会多分配冗余空间,这是因为绝大多数场景下我们不会使用 append 操作来修改字符串

SDS 结构使用了范型 T,为什么不直接用 int 呢,这是因为当字符串比较短时,len 和 capacity 可以使用 byte 和short 来表示,Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。

2.2、内部编码

int: 存储的字符串全是数字时使用;
embstr: 存储的字符串长度小于等于44个字符时使用;
raw: 存储的字符串长度大于44个字符时使用。

为什么分界线是 44 ?

通过上面RedisObject结构,可知RedisObject对象占用16 字节的存储空间;

SDS 结构体的大小,在字符串比较小时,SDS 对象头的大小是capacity+3,至少是 3。

所以分配一个字符串的最小空间占用为 19 字节 (16+3);

redis 存储 二进制 redis存储byte_redis_02


而内存分配器 jemalloc/tcmalloc 等分配内存大小的单位都是 2、4、8、16、32、64 等等,为了能容纳一个完整的 embstr 对象,jemalloc 最少会分配 32 字节的空间,如果字符串再稍微长一点,那就是 64 字节的空间。如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。

当内存分配器分配了 64 空间时,SDS 结构体中的 content 中的字符串是以字节\0 结尾的字符串,之所以多出这样一个字节,是为了便于直接使用 glibc 的字符串处理函数,以及为了便于字符串的调试打印输出。 看上面这张图可以算出,留给 content 的长度最多只有 45(64-19)字节了。字符串又是以\0 结尾,所以 embstr 最大能容纳的字符串长度就是 44

扩容策略:

字符串在长度小于等于1M 之前,扩容空间采用加倍策略,也就是保留 100% 的冗余空间;
当长度超过 1M 之后,为了避免加倍后的冗余空间过大而导致浪费,每次扩容只会多分配 1M 大小的冗余空间。

SDS优势:
1、长度获取:存在len这个属性,所以可以在O(1)的时间复杂度下获得长度;
2、避免频繁的内存分配:上面的“扩容策略”;
3、缓冲区溢出:sds的修改函数在修改前会判断内存,动态的分配内存,杜绝了缓冲区溢出的可能性;
4、二进制安全:sds不是通过空字符串来判断字符串是否已经到结尾,而是通过len这个字段的值。所以说,sds还具备二进制安全这个特性,即可以安全的存储具有特殊格式的二进制数据。

3、字典

dict 是 Redis 服务器中出现最为频繁的复合型数据结构;

hash 结构的数据会用到字典;
整个 Redis 数据库的所有 key 和 value 也组成了一个全局字典
带过期时间的 key 集合也是一个字典;
zset 集合中存储 value 和 score 值的映射关系也是通过 dict 结构实现的。

3.1、结构

struct RedisDb {     
	dict* dict; // all keys  key=>value     
	dict* expires; // all expired keys key=>long(timestamp)     
} 
 
struct zset {    
	dict *dict; // all values  value=>score     
	zskiplist *zsl; 
}

redis 存储 二进制 redis存储byte_字符串_03


dict 结构内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的。

但是在 dict 扩容缩容时,需要分配新的 hashtable,

然后进行渐进式搬迁,这时候两个 hashtable 存储的分别是旧的 hashtable 和新的 hashtable。

待搬迁结束后,旧的 hashtable 被删除,新的 hashtable 取而代之。

struct dict { 
	...     
	dictht ht[2]; 
}

redis 存储 二进制 redis存储byte_Redis_04


hashtable 的结构和 Java 的 HashMap 几乎是一样的,都是通过分桶的方式解决 hash 冲突。第一维是数组,第二维是链表。数组中存储的是第二维链表的第一个元素的指针。

3.2、渐进式rehash

就是把拷贝节点数据的过程平摊到后续的操作中,而不是一次性拷贝。
原因: 当hash表里的值太多时,那么要一次性将这些键值对全部 rehash 到 ht[1] 的话, 庞大的计算量可能会导致服务器在一段时间内停止服务。

步骤:
1、为 ht[1] 分配空间(如上面扩容的图);

2、在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始

3、在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] ,当 rehash 工作完成之后, 程序将 rehashidx 属性的值增1(即把ht[0]上的数据一个一个搬到ht[1]);

4、直到 ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成

执行期间的哈希表操作
因为数据是从ht[0]搬到ht[1]的,所以字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行;但是新增的数据会直接加到ht[1];

3.3、特性

hash攻击:
如果 hash 函数存在偏向性,黑客就可能利用这种偏向性对服务器进行攻击。使得数据在链表上的分布极为不均匀,导致查找复杂度从 O(1)退化到 O(n),从而拖垮服务器。

扩容条件:
当 hash 表中元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是原数组大小的 2 倍。

不过如果 Redis 正在做 bgsave为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容 (dict_can_resize);
但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍(dict_force_resize_ratio),说明 hash 表已经过于拥挤了,这个时候就会强制扩容

缩容:
是元素个数低于数组长度的 10%。缩容不会考虑 Redis 是否正在做 bgsave。

扩容时考虑bgsave是因为,扩容需要申请额外的很多内存,且会重新链接链表(如果会冲突的话), 这样会造成很多内存碎片,也会占用更多的内存,造成系统的压力
缩容过程中,由于申请的内存比较小,同时会释放掉一些已经使用的内存,不会增大系统的压力,因此不用考虑是否在进行bgsave操作。

set结构:
Redis 里面 set 的结构底层实现也是字典,只不过所有的 value 都是 NULL,其它的特性和字典一模一样。

4、压缩列表(ziplist)

Redis 为了节约内存空间使用,zset 和 hash 容器对象在元素个数较少的时候,采用压缩列表 (ziplist) 进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。

4.1、结构

struct ziplist<T> {     
	int32 zlbytes; // 整个压缩列表占用字节数     
	int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点     
	int16 zllength; // 元素个数     
	T[] entries; // 元素内容列表,挨个挨个紧凑存储     
	int8 zlend; // 标志压缩列表的结束,值恒为0xFF 
}

压缩列表为了支持双向遍历,所以才会有ztail_offset 这个字段,用来快速定位到最后一个元素,然后倒着遍历。

redis 存储 二进制 redis存储byte_redis_05


entry结构

struct entry {     
	int<var> prevlen; // 前一个 entry 的字节长度     
	int<var> encoding; // 元素类型编码     
	optional byte[] content; // 元素内容 
}

prevlen 字段表示前一个 entry 的字节长度,当压缩列表倒着遍历时,需要通过这个字段来快速定位到下一个元素的位置。它是一个变长的整数,当字符串长度小于 254(0xFE) 时,使用1个字节表示;如果达到或超出 254(0xFE) 那就使用 5 个字节来表示。第一个字节是 0xFE(254),剩余四个字节表示字符串长度。你可能会觉得用 5 个字节来表示字符串长度,是不是太浪费了。我们可以算一下,当字符串长度比较长的时候,其实 5 个字节也只占用了不到(5/(254+5))<2%的空间。

encoding 字段存储了元素内容的编码类型信息,ziplist 通过这个字段来决定后面的 content 内容的形式。

redis 存储 二进制 redis存储byte_字符串_06

4.2、IntSet 小整数集合

当 set 集合容纳的元素都是整数并且元素个数较小时,Redis 会使用 intset 来存储结合元素。intset 是紧凑的数组结构,同时支持 16 位、32 位和 64 位整数。

struct intset<T> {     
	int32 encoding; // 决定整数位宽是 16 位、32 位还是 64 位; 4bytes
	int32 length; // 元素个数; 4bytes 
	int<T> contents; // 整数数组,可以是 16 位、32 位和 64 位 
}

现当 set 里面放进去了非整数值时,存储形式立即从 intset 转变成了 hash 结构。

4.3、特性

扩容:
因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。
如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist
不适合存储大型字符串,存储的元素也不宜过多。

级联更新:
由于中间某个元素的更新导致扩容,使后续的元素都需要依次调整。同样删除元素的时候也会导致级联更新。

5、快速列表(quicklist)

Redis 早期版本(redis3.2版本之前)存储 list 列表数据结构使用的是压缩列表 ziplist 和普通的双向链表 linkedlist,也就是元素少时用 ziplist元素多时用 linkedlist

// 链表的节点 
struct listNode<T> { 
    listNode* prev;     
    listNode* next;     
    T value; 
} 
// 链表 
struct list {     
    listNode *head;     
    listNode *tail;     
    long length; 
}

考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化影响内存管理效率。后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist。

5.1、结构

quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来。

redis 存储 二进制 redis存储byte_redis_07

5.2、特性

每个 ziplist 存多少元素?
quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个 ziplist。ziplist 的长度由配置参数 list-max-ziplist-size 决定。

压缩深度:
quicklist 默认的压缩深度是 0,也就是不压缩。压缩的实际深度由配置参数 listcompress-depth 决定。
为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。如果深度为 2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第二个 ziplist 都不压缩。

6、跳跃列表(skiplist)

Redis 的 zset 是一个复合结构,一方面它需要一个 hash 结构来存储 value 和 score 的对应关系,另一方面需要提供按照 score 来排序的功能,还需要能够指定 score 的范围来获取 value 列表的功能,这就需要另外一个结构「跳跃列表」。

zset 的内部实现是一个 hash 字典加一个跳跃列表 (skiplist)。

redis 存储 二进制 redis存储byte_字符串_08

6.1、结构

struct zslnode {   
	string value;   
	double score;   
	zslnode*[] forwards;  // 多层连接指针   
	zslnode* backward;  // 回溯指针 
} 
 
struct zsl {   
	zslnode* header;  // 跳跃列表头指针   
	int maxLevel;  // 跳跃列表当前的最高层   
	map<string, zslnode*> ht;  // hash 结构的所有键值对
}

下图中每一个 kv 块对应的结构如代码中的 zslnode 结构,kv header 也是这个结构,只不过 value 字段是 null 值——无效的,score 是 Double.MIN_VALUE,用来垫底的;

redis 存储 二进制 redis存储byte_redis_09

1、由多层组成,最底层为第1层,次底层为第2层,以此类推。层数不会超过一个固定的最大值(Redis 的跳跃表共有 64 层)。
2、每层都是一个有头节点的有序链表,第1层的链表包含跳表中的所有元素。
3、如果某个元素在第k层出现,那么在第1~k-1层也必定都会出现,但会按一定的概率p在第k+1层出现。

6.2、操作

插入数据:

redis 存储 二进制 redis存储byte_字符串_10


查找数据:

例如查找35

redis 存储 二进制 redis存储byte_redis_11


删除过程

删除过程和插入过程类似,都需先把这个「搜索路径」找出来。然后对于每个层的相关节点都重排一下前向后向指针就可以了。同时还要注意更新一下最高层数 maxLevel。

更新过程
假设这个新的 score 值不会带来排序位置上的改变,那么就不需要调整位置,直接修改元素的 score 值就可以了;

但是如果排序位置改变了,那就要调整位置;一个简单的策略就是先删除这个元素,再插入这个元素,需要经过两次路径搜索。Redis 就是这么干的。

不过 Redis 遇到 score 值改变了就直接删除再插入,不会去判断位置是否需要调整。

若score值相同呢?
zset 的排序元素不只看 score 值,如果 score 值相同还需要再比较 value 值 (字符串比较)。

元素排名是如何计算的
Redis 在 skiplist 的 forward 指针上进行了优化,给每一个 forward 指针都增加了 span 属性,span 是「跨度」的意思,表示从前一个节点沿着当前层的 forward 指针跳到当前这个节点中间会跳过多少个节点。Redis 在插入删除操作时会小心翼翼地更新 span 值的大小。

struct zslforward {   
	zslnode* item;   
	long span;  // 跨度 
} 
 
struct zsl {   
	String value;   
	double score;   
	zslforward*[] forwards;  // 多层连接指针  
	zslnode* backward;  // 回溯指针 
}

这样当我们要计算一个元素的排名时,只需要将「搜索路径」上的经过的所有节点的跨度 span 值进行叠加就可以算出元素的最终 rank 值。

7、紧凑列表(listpack)

Redis 5.0 又引入了一个新的数据结构 listpack,它是对 ziplist 结构的改进,在存储空间上会更加节省,而且结构上也比 ziplist 要精简。

7.1、结构

redis 存储 二进制 redis存储byte_字符串_12


listpack 元素长度的编码可以是 1、2、3、4、5 个字节。同 UTF8 编码一样,它通过字节的最高为是否为 1 来决定编码的长度。

redis 存储 二进制 redis存储byte_redis_13

7.2、特性

级联更新
listpack 的设计彻底消灭了 ziplist 存在的级联更新行为,元素与元素之间完全独立,不会因为一个元素的长度变长就导致后续的元素内容会受到影响。

8、基数树(rax)

它是一个有序字典树 (基数树 Radix Tree),按照 key 的字典序排列,支持快速地定位、插入和删除操作。rax 是一棵比较特殊的 radix tree,它在结构上不是标准的 radix tree。

8.1、结构

struct raxNode {     
	int<1> isKey;  // 是否没有 key,没有 key 的是根节点     
	int<1> isNull;  // 是否没有对应的 value,无意义的中间节点     
	int<1> isCompressed;   // 是否压缩存储,这个压缩的概念比较特别     
	int<29> size;   // 子节点的数量或者是压缩字符串的长度 (isCompressed)     
	byte[] data;   // 路由键、子节点指针、value 都在这里 
}

redis 存储 二进制 redis存储byte_字符串_14

8.2、应用

1、人员档案信息:
它的 key 是每个人的身份证号,value 是这个人的履历。
因为身份证号的编码的前缀是按照地区进行一级一级划分的,这点和单词非常类似。
可以快速地定位出人员档案,还可以快速查询出某个小片区都有哪些人。

2、 Web 服务器的 Router
这棵树上挂满了 URL 规则,每个 URL 规则上都会附上一个请求处理器。当一个请求到来时,我们拿这个请求的 URL 沿着树进行遍历,找到相应的请求处理器来处理。因为 URL 中可能存在正则 pattern,而且同一层的节点对顺序没有要求,所以它不算是一棵严格的 radix tree。

3、Redis Stream 结构里面用于存储消息队列
在 Stream 里面消息 ID 的前缀是时间戳 + 序号,这样的消息可以理解为时间序列消息。使用 Rax 结构进行存储就可以快速地根据消息 ID 定位到具体的消息,然后继续遍历指定消息之后的所有消息