Redis 是高性能内存数据库,我们一直都说 Redis 很 “快”,那为什么快呢?首先 Redis 是内存操作(内存随机读写速度是纳秒级的,磁盘随机读写是毫秒级的),其次在网络 IO 处理方面采用多路复用的技术(一个线程处理多个连接),单线程处理读写操作既保证线程安全又能省去线程切换带来的开销,最后就是 Redis 采用高性能的数据结构了,今天就来了解下 Redis 底层的数据结构。
一、Redis 全局 hash 表
在 Redis 中,所有的数据都是以**「键值对」**的方式存在,我们都知道 Redis 中有五大基本数据结构:String、List、Hash、Set 和 Sorted Set
但是这些只是值的保存形式。我们先从全局看,键和值采用什么数据结构组织?
Redis 采用**「哈希表」来存储所有的键值对。哈希表其实就是一个数组**,每个元素就可以看作是一个哈希桶,我们只需要通过特定的 hash 函数计算键的 hash 值并对数组长度进行取模运算,就可以得到在数组中该键的位置进行读写,先不看对集合类型值的操作来看时间复杂度为 O(1) 。
全局 Hash 表的结构大概如下:

每个 hash 桶内的 entry 元素里会存储 key 和 value 数据的指针。
我们知道 hash 取模是一种损失精度的算法,必定会出现计算出索引值重复的情况,这种情况被称作 hash 冲突。
Redis 如何解决 Hash 冲突?
Redis 是采用的 链表法 解决 hash 冲突。原理也很简单,就是当出现 hash 冲突时将元素以链表的方式加在 hash 桶的尾部,结构如下:

加入链表后读写操作就变成:计算出 hash 桶的位置后再遍历桶上的链表和 key 进行匹配,直到匹配成功或链表遍历完。
当 hash 表里的数据越写越多,出现的 hash 冲突也会越来越多,那链表就可能会越来越长,进而导致查找元素的耗时变长效率变低(链表查找元素的时间复杂度为O(n))。
最简单粗暴的方式就是 rehash,也就是扩容并重新分配 hash 桶,Redis 也是这么做的,为了使 rehash 更加高效,Redis 会默认初始化两个全局哈希表:哈希表 1 和 哈希表 2。最开始插入数据使用的是 哈希表 1,哈希表 2 此时是没有分配空间的,当达到一定条件时(键值对数量大于 hash 桶的数量)会进行 rehash 操作,过程分为三步:
1. 分配给哈希表 2 以哈希表 1 两倍的空间(默认);
2. 把哈希表 1 的数据重新映射拷贝到哈希表2中;
3. 释放哈希表 1 的空间。
然后我们的读写就可以切换到哈希表 2,原来的哈希表 1 用作下一次扩容。
整个过程看似很合理,但是第二步会拷贝大量的数据,如果一次性将数据拷贝完会耗费大量的时间并且这一段时间内无法对外提供读写服务。
为了解决这个问题,Redis 采用了渐进式 rehash 的方式进行数据迁移。
渐进式 rehash 就是利用分片分批执行的思想,当有读写请求时会顺带迁移一个元素(从第一个元素开始往后),迁移数据的同时又不会占用很多时间。除了根据键值对操作顺带进行数据迁移,Redis 后台还有个定时任务执行 rehash,会周期性的迁移一批数据,这样可以缩短整个rehash的过程。
二、Redis 底层数据结构概览
了解完Redis键值对的全局组织结构后,我们再看下键值对的局部数据结构;Redis 的 value 存储结构主要有五种:String、List、Hash、Set 和 Sorted Set;底层实现的数据结构为七种:双向链表、哈希表、整数数组、简单动态字符串、压缩列表、快速列表和跳表。

我们按照5种基本数据类型结构来梳理一下Redis底层数据类型。
String的 SDS/int/embstr/raw
Redis的String底层数据结构使用的是SDS,但是SDS有两种存储方式,一种是embstr,一种是raw。
字符串内容可转为 long,采用 int 类型;否则长度<39(3.2版本前是39,3.2版本后分界线是44) 用embstr,其他用 raw。
C语言中也有字符串类型,Redis是C语言写的,却不用C的String,为啥要造个轮子SDS。😶带着疑问咱们看看SDS的结构体:

大致的数据结构如下图所示:

先剧透一个知识点:字符串长度如果小于39的话,则采取embstr存储,否则采取raw类型存储。为啥是39? 原因:对象头占16字节,空的sdshdr占用9字节,也就是一个数据至少占用16+9=25字节。
其次操作系统使用jmalloc和tmalloc进行内存的分配,而内存分配的单位都是2的N次方,所以是2,4,8,16,32,64等字节,但是redis如果采取32的话,那么32-25=7,也太他妈少了,所以Redis采取的是64字节,所以: 64-25=39。
为什么 Redis 不使用 C 语言原生的字符串而要自己实现呢?
SDS 使用的是空间换时间的思想。主要有这么几个优化的地方:
优化获取字符串长度
C语言要想获取字符串长度必须遍历整个字符串的每一个字符,然后自增做累加,时间复杂度为O(n);
SDS直接维护了一个len变量,时间复杂度为O(1)。
减少内存分配
当我们对一个字符串类型进行追加的时候,可能会发生两种情况:
当前剩余空间(free)足够容纳追加内容时,我们就不需要再去分配内存空间,这样可以减少内存分配次数。
当前剩余空间不足以容纳追加内容,我们需要重新为其申请内存空间。
比如下面的sds的方式,free还有三个空余空间呢,你插入的是hi两个字符,所以足够,不需要调用函数重新分配,提升效率。

而C语言字符串在进行字符串的扩充和收缩的时候,都会面临着内存空间的重新分配问题。如果忘记分配或者分配大小不合理还会造成数据污染问题。
那么 SDS 的free空间哪来的呢?字符串扩容策略!
当给 SDS 的值追加一个字符串,而当前的剩余空间不够时,就会触发 SDS 的扩容机制。扩容采用了空间预分配的优化策略,即分配空间的时候:
- 如果 SDS 值大小< 1M,则增加一倍;
- 反之如果>1M,则当前空间加1M作为新的空间。
当 SDS 的字符串缩短了,SDS 的buf内会多出来一些空间,这个空间并不会马上被回收,而是暂时留着以防再用的时候进行多余的内存分配。这个是惰性空间释放策略。
防止缓冲区溢出
其实和减少内存分配是成套的,都是因为SDS预先检查内存自动分配来做到防止缓冲区溢出的。比如:
程序中有两个在内存中紧邻着的 字符串 s1 和 s2,其中s1 保存了字符串“redis”,s2 则保存了字符串“MongoDB”:

如果我们现在将 s1 的内容修改为"redis cluster",但是又忘了重新为 s1 分配足够的空间,这时候就会出现以下问题:

我们可以看到,原本 s2 中的内容已经被 s1 的内容给占领了,s2 现在为 "cluster",而不是“MongoDB”。造成了缓冲区溢出,也是数据污染。
Redis中SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:
当我们需要对一个SDS 进行修改的时候,Redis 会在执行拼接操作之前,预先检查给定SDS 空间是否足够,如果不够,会先拓展SDS 的空间,然后再执行拼接操作

二进制安全
在C语言中通过判断当前字符是否为 '\0' 来确定字符串是否结束,而在 SDS 结构中,只要遍历长度没有达到 len,即使遇到 '\0' ,也不会认为字符串结束。比如下面内存,C语言的字符串类型会丢失 g123 这四个字符,因为他遇到 '\0' 就结束了,而 SDS 不会存在此问题。

#include <stdio.h>
int main() {
char str[] = "Hello, World!";
// 打印原始字符串
printf("原始字符串: %s\n", str);
// 将字符串截断为 "Hello"
str[5] = '\0';
// 打印截断后的字符串
printf("截断后的字符串: %s\n", str);
return 0;
}
int/embstr/raw
int
如果一个字符串内容可转为 long,那么该字符串会被转化为 long 类型,redisObject的对象 ptr 指向该long,并将 encoding 设置为 int,这样就不需要重新开辟空间,算是长整形的一个优化。
embstr/raw
上面的SDS只是字符串类型中存储字符串内容的结构,Redis中的字符串分为两种存储方式,分别是embstr和raw,当字符串长度特别短(redis3.2之前是39字节,redis3.2之后是44字节)的时候,Redis使用embstr来存储字符串,而当字符串长度超过39(redis3.2之前)的时候,就需要用raw来存储。

embstr的存储方式是将RedisObject对象头和SDS结构放在内存中连续的空间位置,也就是使用malloc方法一次分配;
而raw需要两次malloc,分别分配对象头和SDS的空间。
释放空间也一样embstr释放一次,raw释放两次,所以embstr是一种优化,但是为什么是39字节才采取embstr呢?39哪来的?
这个问题在上面SDS里已经说过了
原因:对象头占16字节,空的sdshdr占用9字节,也就是一个数据至少占用16+9=25字节。其次操作系统使用jmalloc和tmalloc进行内存的分配,而内存分配的单位都是2的N次方,所以是2,4,8,16,32,64等字节,但是redis如果采取32的话,那么32-25=7,也太他妈少了,所以Redis采取的是64字节,所以: 64-25=39。
仅限redis3.2版本之前。Redis3.2版本之后的SDS结构发生了变化【最小的是sdshdr5,空的话占用3字节+1个空白=4字节,16+4=20,64-20=44】。
List的 ZipList/LinkedList/QuickList
Redis3.0之前:ZipList/LinkedList
Redis3.0之后:QuickList
ZipList
ZipList是一个经过特殊编码的双向链表,它的设计目标就是为了提高存储效率。

-
zlbytes:ZipList的长度,32位无符号整数。
-
zltail:ZipList最后一个节点的偏移量,反向遍历ZipList或者pop尾部节点的时候用来提升性能。
-
zllen:ZipList的entry(节点)个数。
-
entry:节点,并不是一个数组,然后里面存的值,而是一个数据结构。下面说。
-
zlend:值为255,用于标记ZipList的结尾。
entry的布局
由三部分组成:
- prevlength:记录上一个节点的长度,为了方便反向遍历ZipList
- encoding:当前的编码规则,记录了节点的content属性所保存数据类型以及长度
- data: 保存节点的值。可以是字符串或者数字,值的类型和长度由encoding决定
如果前一节点的长度小于254字节,那么 previous_entry_length 属性的长度为1字节,前一节点的长度就保存在这一个字节里面。
如果前一个节点的长度大于等于254,那么 previous_entry_length 属性的长度为5字节,其中属性的第一字节会被设置为0xFE(十进制254),而之后的四个字节则用于保存前一节点的长度。用254不用255(11111111)作为分界是因为255是zlend的值,它用于判断ziplist是否到达尾部。
利用此原理即当前节点位置减去上一个节点的长度即得到上一个节点的起始位置,压缩列表可以从尾部向头部遍历,这么做很有效地减少了内存的浪费。

总结
- ZipList是为节省内存空间而生的。
- ZipList是一个为Redis专门提供的底层数据结构之一,本身可以有序也可以无序。当作为List和Hash的底层实现时,节点之间没有顺序;当作为ZSet的底层实现时,节点之间会按照大小顺序排列。
- ZipList的弊端也很明显,对于较多的entry或者entry长度较大时,需要大量的连续内存,并且节省的空间比例相对不再占优势,就可以考虑使用其他结构了。
LinkedList
就是双向链表,对首尾节点的定位很快,O(1)复杂度。在首位前后插入节点也是O(1)。
Redis的list类型什么时候会使用ZipList编码,什么时候又会使用LinkedList编码呢?
当列表对象可以同时满足下列两个条件时,列表对象采用ZipList编码,否则采用LinkedList编码。
- 列表对象保存的所有字符串元素的长度都小于64字节;
- 列表元素保存的元素数量小于512个;
上述两个参数可以更改配置进行自定义。
QuickList
Redis3.0版本开始对list数据结构采取QuickList了,抛弃了之前的ZipList和LinkedList。quicklist 是一个双向链表,并且是一个ZipList的双向链表,也就是说一个QuickList由多个quicklistNode组成,每个quicklistNode指向一个ZipList,一个ZipList包含多个entry元素,每个entry元素就是我们push的list的元素。ZipList本身也是一个能维持数据项先后顺序的列表,而且数据项保存在一个连续的内存块中,意味着QuickList结合了ZipList和LinkedList的特点!更为优化了,还省去了ZipList和LinkedList之间转换的步骤了。

总结
LinkedList:
-
双端链表便于在表的两端进行push和pop操作,但是它的内存开销比较大;
-
双端链表每个节点上除了要保存数据之外,还要额外保存两个指针(pre/next);
-
双端链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片;
ZipList:
-
ZipList由于是一整块连续内存,所以存储效率很高;
-
ZipList不利于修改操作,每次数据变动都会引发一次内存的realloc;
-
当ZipList长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能;
quicklist:
-
空间换时间,之前LinkedList需要两个指针,浪费空间,现在不用LinkedList,用ZipList,然后上面封装一层quicklistnode,底层存储还是ZipList,只是空间上多了一层指针用于检索。
-
结合了双端链表和压缩列表的优点。
Hash的ZipList/HashTable
ZipList
ziplist在上面list结构里介绍了。这里只说下hash是怎么用ziplist的。
ziplist 编码的哈希对象使用压缩列表作为底层实现, 每当有新的键值对要加入到哈希对象时, 程序会先将保存了键的压缩列表节点推入到压缩列表表尾, 然后再将保存了值的压缩列表节点推入到压缩列表表尾, 因此保存了同一键值对的两个节点总是紧挨在一起,保存键的节点在前,保存值的节点在后;先添加到哈希对象中的键值对会被放在压缩列表的表头方向,而后来添加到哈希对象中的键值对会被放在压缩列表的表尾方向。
例如, 我们执行以下 HSET 命令, 那么服务器将创建一个列表对象作为 profile 键的值:

profile 键的值对象使用的是 ziplist 编码, 其中对象所使用的压缩列表结构如下图所示:

HashTable
基础原理
类比HashMap,哈希对象中的每个键值对都使用一个字典键值对来保存:
- 字典的每个键都是一个字符串对象,对象中保存了键值对的键。
- 字典的每个值都是一个字符串对象, 对象中保存了键值对的值。
比如上面ziplist的hset案例如果用hashtable来存储的话如下面这个样子:

详细如下:
hashtable的结构是:dict指向dictht,dictht包含多个dictEntry,dictEntry包含next指针,指向下一个entry,形成一个链表,key冲突的话就会形成链表(理解成hashmap里的hash碰撞)。如下:

可以看到dict包含ht,ht是个数组,包含ht[0]和ht[1]两部分。ht[0]是我们数据真实存储的地方,ht[1]是为了伸缩容量时候进行rehash用的。目前没有rehash,所以指向null。
key冲突的图示如下(在dictEntry下标为2的位置发生了key冲突,采取链表的方式解决):

总结
- 字典 ht 属性是包含两个哈希表项的数组,一般情况下, 字典只使用 ht[0], ht[1] 哈希表只会在对ht[0] 哈希表进行 rehash时使用
- 哈希表使用链表形式来解决键冲突
- 键值对添加到字典的过程, 先根据键值对的键计算出哈希值和索引值, 然后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面
ziplist和hashtable怎么选择?
当哈希对象可以同时满足以下两个条件时, 哈希对象使用 ziplist 编码,不能满足这两个条件的哈希对象需要使用 hashtable 编码。
- 哈希对象保存的所有键值对的键和值的字符串长度都小于 64 字节
- 哈希对象保存的键值对数量小于 512 个。
上述两个参数可以更改配置进行自定义。
说说ReHash
ReHash就是HashTable容量需要进行伸缩容
什么时候需要ReHash?
当以下条件中的任意一个被满足时, 程序会自动开始对哈希表执行扩展操作:
(1)服务器没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于 1 ; (2)服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于5 ;
其中哈希表的负载因子可以通过公式计算:
$load_factor = \frac {ht[0].used} {ht[0].size}$
怎么ReHash?
-
为字典(dict)的 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 做准备。
但是整个rehash的过程是渐进式的,因为如果几十万条记录一次性rehash的话,扛不住,浪费性能,所以演进出了渐进式rehash。
以下是哈希表渐进式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 键值对所需的计算工作均摊到对字典的每个增删改查操作上,从而避免了集中式 rehash 而带来的庞大计算量。
图示渐进式rehash




因为在进行渐进式 rehash 的过程中,字典会同时使用 ht[0] 和 ht[1] 两个哈希表,所以在渐进式rehash 进行期间,字典的删改查 (没有增)等操作会在两个哈希表上进行。
比如说,要在字典里面查找一个键的话,程序会先在 ht[0] 里面进行查找,如果没找到的话,就会继续到 ht[1] 里面进行查找诸如此类。
另外,在渐进式rehash 执行期间,新添加到字典的键值对一律会被保存到 ht[1]里面,而 ht[0]则不再进行任何添加操作:这一措施保证了 ht[0] 包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。
Set的intset/hashtable
Redis的底层数据结构-Set&Zset(IT枫斗者) ()
intset
intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。比如:

hashtable
字典的每个键都是一个字符串对象, 每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为NULL 。

当集合对象可以同时满足以下两个条件时,对象使用 intset 编码,不能满足这两个条件的集合对象需要使用 hashtable 编码。
- 集合对象保存的所有元素都是整数值。
- 集合对象保存的元素数量不超过 512 个。
第二个条件是可以通过配置自定义的。
Zset的ziplist/zskiplist
「跳表」是 Redis 实现有序集合(Sorted Set)的基本数据结构之一;我们在有序数组里查询某个数据,可以根据二分查找的方式以 O(logN) 的时间复杂度找到,但是如果是增删操作就有拷贝数组的开销,总的时间复杂度就为O(logN + L) ( L 为总长度);链表的查找时间复杂度为 O(N),增删为 O(1);而跳表就是取二者优点可以进行 “二分查找” 的链表。
跳表是由 William Pugh 发明的,最早出现于他在 1990 年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》;简单来说跳表就是 链表 + 多级索引。
我们先来看下普通的链表是如何进行数据查找的。

想要查找数据 31,我们只能乖乖从链表头部往后遍历并判断,最终遍历了 8个节点才找到对应节点。
现在我们对这个链表每隔一个节点抽出来一个索引,形成一个一级索引:

想要查找数据 31,我们先在一级索引上进行遍历,查找路径如下:1 -> 5 -> 9 -> 20 -> 42;
当发现 31 (target) < 42 时,从上一个节点回到原链表,再次往后遍历,发现 31 匹配,找到目标节点(后续进行增删改操作);
增加了一级索引,总共遍历了 6 个节点找到了目标节点,相比没有索引的链表少遍历了两个节点;这么看不是很明显,我们将链表加长并多加几层索引看看效果。

假设目标值为 45,如果没有多级索引,查找目标值需要在原始链表遍历 14 个节点;现在加入了多级索引我们的查找路径是这样的:1 -> 21 -> 41 -> 61,发现目标值 45 小于 61,层级下移,找到目标节点45,一共遍历 5 个节点;可见性能提升还是很明显的,**查找的时间复杂度和二分查找基本一致:O(logN)**。
后续的增删操作只需 O(1) 复杂度的指针操作即可完成。
跳表索引的动态更新
当我们不停往链表里插入数据时,如果不更新索引,极端情况下,跳表会退化成链表(两个索引间的数据越来越多),性能会下降的很厉害。
和平衡二叉树、红黑树类似,跳表也有自己的方式来维护索引和原始链表大小的平衡;如果链表节点增多,索引自然也需要增加一些,避免复杂度退化。
跳表在插入数据时,会通过一个随机函数决定插入到哪些索引层中,假设函数生成了 k,则将这个节点添加到第 1 到第 k 级索引中;假设我们现在添加一个节点 38,生成的随机数 k 为 2,对应添加的结构如下(红色节点为添加部分):

当然,这个随机函数也是有说法的,如果真的只是普通的随机函数是无法保证良好的性能,我们期望的是级别越高的索引应该越少。
Redis 的索引随机函数伪代码如下:
p = 1/4
MaxLevel = 32
randomLevel()
level := 1 // random()返回一个[0...1)的随机数
while random() < p and level < MaxLevel do
level := level + 1
return level
该函数清晰明了,返回的 level(也就是上面说的k)生成的越大概率越小,p 的值默认是 1/4,这个值决定着下级索引数量基本是上级索引数量的 4 倍;我们也可以调整 p 的值来平衡执行效率和内存消耗。
**跳表其实也是利用空间换时间的方式提高性能(多了一些指针的开销,但不多)**。
红黑树或平衡树增删改查的性能和跳表一样都是 O(logN),那为什么 Redis 使用跳表实现有序集合而不是红黑树或平衡树?
Redis 的作者 antirez 也回答过该问题:
There are a few reasons:
- They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
- A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
- They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
总结一下就是:
-
跳表的内存占用没有树那么密集,跳表较灵活,可以根据配置来减少或增加索引的数量,有效的平衡执行效率和内存消耗。
-
对于区间查找的操作,跳表能以 O(logN) 的时间复杂度定位到区间的起点,然后在原始链表中顺序遍历即可,Redis 跳表中的原始链表是双向链表,为了支持倒序;红黑树和平衡树的区间查询就没有那么高效了。
-
跳表实现起来更简单。
跳跃表维持结构平衡的成本是比较低的,完全是依靠随机,相比二叉查找树,在多次插入删除后,需要Rebalance来重新调整结构平衡
















