Redis 数据结构底层实现
String
- 如果一个字符串对象保存的是整数值,并且这个整数值可以用
long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int - 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于
39字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw - 如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于
39字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值
List
- 列表对象的编码可以是
ziplist或者linkedlist - 列表对象保存的所有字符串元素的长度都小于
64字节并且保存的元素数量小于512个,使用ziplist编码;否则使用linkedlist
Hash
- 哈希对象的编码可以是
ziplist或者hashtable - 哈希对象保存的所有键值对的键和值的字符串长度都小于
64字节并且保存的键值对数量小于512个,使用ziplist编码;否则使用hashtable
Set
- 集合对象的编码可以是
intset或者hashtable - 集合对象保存的所有元素都是整数值并且保存的元素数量不超过
512个,使用intset编码;否则使用hashtable
Sorted Set
- 有序集合的编码可以是
ziplist或者skiplist - 有序集合保存的元素数量小于
128个并且保存的所有元素成员的长度都小于64字节。使用ziplist编码;否则使用skiplist
简单动态字符串(SDS)
Redis自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型, 并将SDS用作Redis的默认字符串表示;
key 和 value 底层都是用SDS来实现的
SDS 的结构:
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串,最后一个字节则保存了空字符 '\0' 兼容C语言
char buf[];
};为什么不直接使用C语言的字符串呢?SDS对比C语言的字符串有如下优势:
SDS获取字符串长度时间复杂度O(1)
因为
SDS通过len字段来存储长度,使用时直接读取就可以;C语言要想获取字符串长度需要遍历整个字符串,时间复杂度O(N)
SDS能杜绝缓冲区的溢出
因为当
SDS API要对SDS进行修改时,会先检查SDS的空间是否足够,如果不够的话SDS会自动扩容,所以不会造成缓冲区溢出。而C语言及其容易造成缓冲区溢出问题;
比如用strcat(),在用这个函数之前必须要先给目标变量分配足够的空间,否则就会溢出
SDS能减少修改字符串时带来的内存重分配次数
空间预分配:当
SDS扩容时不只是会增加需要的空间大小,还会额外的分配一些未使用的空间。分配的规则是:如果分配后SDS的长度小于1MB,那么会分配所需双倍大小的空间,简单说就是,SDS动态分配后是16KB,那么就会多分配16KB的未使用空间;
如果大于 1MB,那么就分配申请的空间大小。
惰性空间释放:用于优化
SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,并不会立即内存重分配来回收多出来的字节,而是用free来记录未使用空间。
C字符串的每次变更(曾长或缩短)都会对数组作内存重分配。同样,如果是缩短,没有处理好多余的空间,也会造成内存泄漏
链表
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度。链表在
Redis中的应用非常广泛,
比如List的底层实现之一链表,当一个List包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为List的底层实现。
除了用作List的底层实现之外,发布与订阅、慢查询、监视器等动能也用到了链表,Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区
结构:
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode多个 listNode 可以通过 prev 和 next 指针组成双端链表, 如下图所示:

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;由一个 list 结构和三个 listNode 结构组成的链表:

list特性总结:
- 双端
链表节点带有
prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
- 无环
表头节点的
prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
- 带表头指针和表尾指针
通过
list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)
- 带链表长度计数器
程序使用
list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)
- 多态
链表节点使用
void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值
字典
Redis数据库底层就是用字典实现的,对数据库的增、删、改、查操作都是构建在对字典的操作之上;字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现
哈希表
结构:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;一个大小为 4 的空哈希表:
哈希表节点
结构:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表,用于解决hash冲突(链地址法)
struct dictEntry *next; // 单链表结构
} dictEntry;举个例子, 下图就展示了如何通过 next 指针, 将两个索引值相同的键 k1 和 k0 连接在一起:
字典
结构:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;type 属性和 privdata 属性是针对不同类型的键值对,为创建多态字典而设置的:
type 属性是一个指向 dictType 结构的指针,每个 dictType 结构保存了一簇用于操作特定类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数; 而 privdata
属性则保存了需要传给那些类型特定函数的可选参数
ht 属性是一个包含两个项的数组,数组中的每个项都是一个 dictht 哈希表,一般情况下,字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用
除了 ht[1] 之外,另一个和 rehash 有关的属性就是 rehashidx ,它记录了 rehash 目前的进度, 如果目前没有在进行 rehash , 那么它的值为 -1
下面展示了一个普通状态下(没有进行 rehash)的字典:

哈希算法
当将一个新的键值对插入到字典中,需要计算索引值,Redis 计算索引值的方法是:
# 使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
# 使用哈希表的 sizemask 属性和哈希值,计算出索引值
# 根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1]
index = hash & dict->ht[x].sizemask;rehash
随着不断的操作,
hash表中的键值对可能会增多或减少,为了让哈希表的负载因子保持在一个范围内,需要对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的 2^n
- 如果执行的是扩展操作,那么
将保存在
ht[0]中的所有键值对rehash到ht[1]上面;·rehash· 指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上当
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另一方面,当哈希表的负载因子小于 0.1 时,程序自动开始对哈希表执行收缩操作
- 渐进式
rehash
rehash 时会将 ht[0] 所有的键值对迁移到 ht[1] 中,但这个动作不是一次性的,而是分多次、渐进式地完成。
这样的原因是:当数据量大的时候一次性迁移会造成服务器在一段时间内停止服务(类似一些语言执行GC时的STW)。为了避免发生这样的事就出现了 渐进式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 期间,字典的删改查操作都是同时作用在 ht[0] 以及 ht[1] 上的。 如查找一个键,会现在 ht[0] 查找,找不到就去 ht[1] 查找,注意的是增加操作,新增的键值对只会保存到 ht[1]
上,不会保存到 ht[0] 上,这一措施保证了 ht[0] 的键值只减不增,随着 rehash 操作 ht[0] 最终会变成空表
跳跃表
跳跃表(
skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。 跳跃表支持平均O(log N)最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点
在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树
skiplist与AVL对比:
- 从算法实现难度上来比较,
skiplist比平衡树要简单得多。 - 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而
skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。 - 查找单个key,
skiplist和平衡树的时间复杂度都为O(log n),大体相当。 - 在做范围查找的时候,平衡树比
skiplist操作要复杂。 skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的
Redis 使用跳跃表作为有序集合键的底层实现之一: 如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时,Redis 就会使用跳跃表来作为有序集合键的底层实现
跳跃表节点结构:
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;跳跃表结构:
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量,也即是跳跃表目前包含节点的数量(表头节点不计算在内)
unsigned long length;
// 表中层数最大的节点的层数,层数最大的那个节点的层数(表头节点的层数不计算在内)
int level;
} zskiplist;示例:
位于 zskiplist 结构右方的是四个 zskiplistNode 结构,该结构包含以下属性:
层(level)
节点中用 L1 、L2 、L3 等字样标记节点的各个层,L1 代表第一层,L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。
前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行
跳跃表节点的 level 数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快
每次创建一个新跳跃表节点的时候,程序都根据幂次定律 (power law,越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间的值作为 level 数组的大小,这个大小就是层的“高度”
跨度
层的跨度(level[i].span 属性)用于记录两个节点之间的距离,两个节点之间的跨度越大,它们相距得就越远,指向 NULL 的所有前进指针的跨度都为 0 ,因为它们没有连向任何节点
初看上去,很容易以为跨度和遍历操作有关,但实际上并不是这样,遍历操作只使用前进指针就可以完成了,跨度实际上是用来计算排位(rank)的,在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位
后退(backward)指针
节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用 节点的后退指针(
backward属性)用于从表尾向表头方向访问节点,跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点
分值(score)
各个节点中的 1.0 、 2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排列
节点的分值(score 属性)是一个 double 类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序
成员对象(obj)
各个节点中的 o1 、o2 和 o3 是节点所保存的成员对象
节点的成员对象(obj 属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个 SDS 值
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的
分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)
整数集合
整数集合(
intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现
它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素
结构:
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但实际上 contents 数组并不保存任何 int8_t 类型的值,contents 数组的真正类型取决于 encoding 属性的值:如果 encoding 属性的值为 INTSET_ENC_INT16 ,那么 contents 就是一个 int16_t
类型的数组,数组里的每个项都是一个 int16_t 类型的整数值 (最小值为 -32,768 ,最大值为 32,767 )
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面
升级整数集合并添加新元素共分为三步进行:
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变
- 将新元素添加到底层数组里面
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态
压缩列表
当一个列表键、哈希键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表来做列表键的底层实现
redis> RPUSH lst 1 3 5 10086 "hello" "world"
(integer) 6
redis> OBJECT ENCODING lst
"ziplist"redis> HMSET profile "name" "Jack" "age" 28 "job" "Programmer"
OK
redis> OBJECT ENCODING profile
"ziplist"压缩列表的构成
压缩列表是 Redis 为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构
一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值

ziplist没有明确定义结构体,这里只作大概的演示:
typedef struct entry {
/*前一个元素长度需要空间和前一个元素长度*/
unsigned int prevlengh;
/*元素内容编码*/
unsigned char encoding;
/*元素实际内容*/
unsigned char *data;
}zlentry;entry结构体里面有三个重要的字段:
previous_entry_length这个字段记录了ziplist中前一个节点的长度,什么意思?就是说通过该属性可以进行指针运算达到表尾向表头遍历,这个字段还有一个大问题下面会讲。encoding记录了数据类型(int16? string?)和长度。data/content记录数据
上面有说到,previous_entry_length这个字段存放上个节点的长度,那默认长度给分配多少呢?Redis是这样分的,如果前节点长度小于254,就分配1字节,大于的话分配5字节,那问题就来了;
如果前一个节点的长度刚开始小于254字节,后来大于254,那不就存放不下了吗? 这就涉及到previous_entry_length
的更新,但是改一个肯定不行阿,后面的节点内存信息都需要改。所以就需要重新分配内存,然后连锁更新包括该受影响节点后面的所有节点; 除了增加新节点会引发连锁更新、删除节点也会触发
typedef struct ziplist{
/*ziplist分配的内存大小*/
uint32_t zlbytes;
/*达到尾部的偏移量*/
uint32_t zltail;
/*存储元素实体个数*/
uint16_t zllen;
/*存储内容实体元素*/
unsigned char* entry[];
/*尾部标识*/
unsigned char zlend;
}ziplist;压缩列表的各个组成部分:
| 属性 | 类型 | 长度 | 用途 |
|---|---|---|---|
| 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 ),用于标记压缩列表的末端 |
quicklist
一个由
ziplist组成的双向链表。但是一个quicklist可以有多个quicklist节点,它很像B树的存储方式。是在redis3.2版本中新加的数据结构,用在列表的底层实现

表头结构:
typedef struct quicklist {
//指向头部(最左边)quicklist节点的指针
quicklistNode *head;
//指向尾部(最右边)quicklist节点的指针
quicklistNode *tail;
//ziplist中的entry节点计数器
unsigned long count; /* total count of all entries in all ziplists */
//quicklist的quicklistNode节点计数器
unsigned int len; /* number of quicklistNodes */
//保存ziplist的大小,配置文件设定,占16bits
int fill : 16; /* fill factor for individual nodes */
//保存压缩程度值,配置文件设定,占16bits,0表示不压缩
unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;节点结构:
typedef struct quicklistNode {
struct quicklistNode *prev; //前驱节点指针
struct quicklistNode *next; //后继节点指针
//不设置压缩数据参数recompress时指向一个ziplist结构
//设置压缩数据参数recompress指向quicklistLZF结构
unsigned char *zl;
//压缩列表ziplist的总长度
unsigned int sz; /* ziplist size in bytes */
//ziplist中包的节点数,占16 bits长度
unsigned int count : 16; /* count of items in ziplist */
//表示是否采用了LZF压缩算法压缩quicklist节点,1表示压缩过,2表示没压缩,占2 bits长度
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
//表示一个quicklistNode节点是否采用ziplist结构保存数据,2表示压缩了,1表示没压缩,默认是2,占2bits长度
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
//标记quicklist节点的ziplist之前是否被解压缩过,占1bit长度
//如果recompress为1,则等待被再次压缩
unsigned int recompress : 1; /* was this node previous compressed? */
//测试时使用
unsigned int attempted_compress : 1; /* node can't compress; too small */
//额外扩展位,占10bits长度
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;Redis相关配置
list-max-ziplist-size -2
list-compress-depth 0list-max-ziplist-size参数
当取正值的时候,表示按照数据项个数来限定每个quicklist节点上的ziplist长度。比如,当这个参数配置成5的时候,表示每个quicklist节点的ziplist最多包含5个数据项。
当取负值的时候,表示按照占用字节数来限定每个quicklist节点上的ziplist长度。这时,它只能取-1到-5这五个值,每个值含义如下:
-5: 每个quicklist节点上的ziplist大小不能超过64 Kb。(注:1kb => 1024 bytes)
-4: 每个quicklist节点上的ziplist大小不能超过32 Kb。
-3: 每个quicklist节点上的ziplist大小不能超过16 Kb。
-2: 每个quicklist节点上的ziplist大小不能超过8 Kb。(默认值)
list-compress-depth参数
这个参数表示一个quicklist两端不被压缩的节点个数。 注:这里的节点个数是指quicklist双向链表的节点个数,而不是指ziplist里面的数据项个数。实际上,一个quicklist节点上的ziplist
,如果被压缩,就是整体被压缩的
取值含义如下:0: 是个特殊值,表示都不压缩。这是Redis的默认值。1: 表示quicklist两端各有1个节点不压缩,中间的节点压缩。2: 表示quicklist两端各有2个节点不压缩,中间的节点压缩。3: 表示quicklist两端各有3个节点不压缩,中间的节点压缩。 依此类推
Redis对于quicklist内部节点的压缩算法,采用的LZF(一种无损压缩算法)


