这一章节我们将深入理解Redis底层数据结构,也就是尝试真正去了解我们指定的set k1 v1这样的指令,是怎么执行的,数据是怎么保存的。
开始之前,做两个简单声明:
第一:作为Java程序员,我们研究Redis底层结构的目的,只有一个**:面试!**也就是体现你对Redis的理解深度,而并不是要你去写一个Redis。因此,我们接下来主要分析常用的几种数据类型的底层结构,中间必然会涉及到一些Redis底层的C源码。对于这些源码,我只抽取其中部分精华,用做知识点的佐证。如果之间有逻辑断层,或者你想要了解一些其他的数据类型,可以自行看源码补充。
第二:Redis的底层数据结构其实是经常变化的,不光Redis6到Redis7这样的大版本,就算同样大版本下的不同小版本,底层结构也是经常有变化的。对于讲到的每种数据结构,我会尽量在Redis源码中进行验证。如果没有说明,Redis的版本是目前最新的7.2.5。
⼀ 、整体理解Redis底层数据结构
1、Redis数据在底层是什么样的?
在应用层面,我们熟悉Redis有多种不同的数据类型,比如string,hash,list,set,zset等。但是这些数据在Redis的底层是什么样子呢?实际上Redis提供了一个指令OBJECT可以用来查看数据的底层类型。
127.0.0.1:6379> OBJECT HELP
1) OBJECT <subcommand> [<arg> [value] [opt] ...]. Subcommands are:
2) ENCODING <key>
3) Return the kind of internal representation used in order to store the value
4) associated with a <key>.
5) FREQ <key>
6) Return the access frequency index of the <key>. The returned integer is
7) proportional to the logarithm of the recent access frequency of the key.
8) IDLETIME <key>
9) Return the idle time of the <key>, that is the approximated number of
10) seconds elapsed since the last access to the key.
11) REFCOUNT <key>
12) Return the number of references of the value associated with the specified
13) <key>.
14) HELP
15) Print this help.
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> OBJECT ENCODING k1
"embstr"
可以看到, k1 v1这个<k,v>键值对 ,他在底层的数据类型就是 embstr 。Redis在底层, 其 实是这样描述这些数据类型的。
< server.h 880⾏>
/* Objects encoding. Some kind of objects like Strings and Hashes can be
* internally represented in multiple ways. The 'encoding ' field of the object
* is set to one of this fields for this object. */
#define OBJ_ENCODING_RAW 0 /* Raw representation */
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#define OBJ_ENCODING_HT 2 /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3 /* No longer used: old hash encoding. */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 /* No longer used: old list/hash/zset encoding. */
#define OBJ_ENCODING_INTSET 6 /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of listpacks */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
#define OBJ_ENCODING_LISTPACK 11 /* Encoded as a listpack */
这⾥也能看到有些类型已经不再使⽤了 。⽐如ZIPLIST 。如果你看过---些以前的Redis 的⽂章, 就会知道, ZIPLIST是在Redis6中经常使⽤的---个重要的数据类型 。但是现在已经不再使⽤了 。在Redis7中, 基本已经使⽤list pack替代了zip list。
然后, 在上⾯的注释中还可以看到 。这些编码⽅式都是使⽤在Object的encoding字段⾥ 的 。这个Object是什么东东呢?
<server.h 900⾏>
struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned l ru:LRU_BITS; /* LRU time (relative to global l ru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
};
Redis是一个<k,v>型的数据库,其中key通常都是string类型的字符串对象,而value在底层就统一是redisObject对象。
而这个redisObject结构,实际上就是Redis内部抽象出来的一个封装所有底层数据结构的统一对象。这就类似于Java的面向对象的设计方式。
这里面几个核心字段意义如下:
-
type:Redis的上层数据类型。比如string,hash,set等,可以使用指令type key查看。
-
encoding: Redis内部的数据类型。
-
lru:当内存超限时会采用LRU算法清除内存中的对象。关于LRU与LFU,在redis.conf中有描述
LRU means Least Recently Used
LFU means Least Frequently Used
-
refcount:表示对象的引用次数。可以使用OBJECT REFCOUNT key 指令查看。
-
*ptr:这是一个指针,指向真正底层的数据结构。encoding只是一个类型描述。实际数据是保存在ptr指向的具体结构里。
2、Redis常见数据类型的底层数据结构总结
我们已经知道了Redis有上层的应用类型,也有底层的数据结构。那么这些上层数据类型和底层数据结构是怎么对应的呢?
127.0.0.1:6379> set k1 v1 OK
127.0.0.1:6379> type k1 string
127.0.0.1:6379> object encoding k1
"embstr"
这就是---种对应关系 。也就是说,在应⽤层⾯,我们操作的是string这样的数据类型 ,但是 Redis在底层, 操作的是embstr这样---种数据结构 。但是, 这些上层的数据类型和底层的数 据结构之间, 是不是就是简单的一一对应的关系呢?
127.0.0.1:6379> set k2 1 OK
127.0.0.1:6379> type k2 string
127.0.0.1:6379> object encoding k2
"int"
127.0.0.1:6379> set k3
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
OK
127.0.0.1:6379> type k3
string0
127.0.0.1:6379> OBJECT ENCODING k3
"raw"
从这⾥能够看到, 每---种上层数据类型对应底层多种不同的数据结构, 也就是说, 同样的 ---个数据类型, Redis底层的处理⽅式是不同的。
Redis提供了---个指令, 可以直接调试某---个key的结构信息 。但是这种⽅式默认是关闭的。
127.0.0.1:6379> DEBUG Object k1
(error) ERR DEBUG command not allowed. If the enable-debug-command option is set to "local", you can run it from a local connection, otherwise you need to set this option in the configuration file, and then restart the server.
按照要求, 修改配置⽂件, 重启Redis服务后, 就可以看到每---个key的内部结构
127.0.0.1:6379> DEBUG object k1
Value at:0x7f0e36264c80 refcount:1 encoding:embstr serializedlength:3
l ru:7607589 l ru_seconds_idle:23
现在搞明⽩ encoding是什么了之后, 问题就到了下---步, 这个ptr指针到底指向了哪些数据 结构呢?
下⾯直接列出了Redis中上层数据类型和底层真正存储数据的数据结构的对应关系。
| Redis版本 | string | set | zset | list | hash |
| Redis 6 | SDS(动态字符串) | intset+hashtable | skiplist+ziplist | quicklist+ziplist | hashtable+ziplist |
Redis 7 | SDS | intset+listpack+hashtable | skiplist+listpack | quicklist+listpack | hashtable+list |
---|
这个列表⾥的这些数据结构, 如果不理解, 先直接记住 。 这是Redis---个⽐较⾼频的⾯试题 (⾼级职位) 。⾄于具体的细节, 后⾯会慢慢分析。
另外, 其他的数据类型, 包括---些扩展模块的数据类型, ⾯试中基本不太可能问得太深, ⾃⾏理解。
Redis6和Redis7最⼤的区别就在于Redis7已经⽤list pack替代了zip list 。只不过为了保证兼容性, Redis7中并没有移除zip list的代码以及配置 。list pack与zip list的区别也是---个⾼频的⾯试题, 后⾯会逐步介绍。
⼆ 、String数据结构详解
从之前的简单实验中已经看到, string数据, 在底层对应了int ,embstr,raw 三种不同的数据结构 。他们到底是什么呢?下⾯分⼏个问题逐步深⼊ 。
1 、string数据是如何存储的?
先上结论,再验证。 string数据的类型,会根据value的类型不同,有以下几种处理方式
- int : 如果value可以转换成一个long类型的数字,那么就用int保存value。只有整数才会使用int,如果是浮点数,Redis内部其实是先将浮点数转化成字符串,然后保存
- embstr : 如果value是一个字符串类型,并且长度小于44字节的字符串,那么Redis就会用embstr保存。代表embstr的底层数据结构是SDS(Simple Dynamic String 简单动态字符串)
- raw :如果value是一个字符串类型,并且长度大于44字节,就会用raw保存。
源码验证:
在客户端执行一个 set k1 v1 这样的指令,会进入<t_string.c>的setComand方法处理。
<t_string.c 295行>

这个tryObjectEncoding的方法实现,在object.c中
<object.c 614行>的*tryObjectEncodingEx方法。 关键部分如下:

1 、从这⾥可以看到, 对于数字⻓度超过20的⼤数字, Redis是不会⽤int保存的。
2 、OBJ_SHARED_INTEGER = 1000 。对于1000以内的数字, 直接指向内存。
<object.c 685行>

2 、string类型对应的int,emb str,raw有什么区别?
(1)int类型
就是尽量在对应的robj中的ptr指向⼀个缓存数据对象。

(2)embstr类型
如果字符串类型⻓度⼩于44, 就会创建---个embstr的对象 。这个创建的⽅法是这样的:
<object.c 92⾏>

embstr字⾯意思就是内嵌字符串 。 所谓内嵌的核⼼, 其实就是将新创建的SDS对象直接分配在对象⾃⼰的内存后⾯ 。这样内存读取效率明显更⾼ 。
这⾥有---段介绍, SDS其实是---段不可修改的字符串 。这意味着如果使⽤APPEND之 类的指令尝试修改---个key的值, 那么就算value的⻓度没有超过44,Redis也会使⽤ --- 个新创建的raw类型, ⽽不再使⽤原来的SDS。

这个SDS是什么呢?其实他就是Redis底层对于String的一种封装。
<sds.h 45行>

Redis根据字符串⻓度不同, 封装了多种不同的SDS结构 。通常 ,保存字符串,⽤---个buf[] 就够了 。但是Redis在这个数组的基础上, 封装成了SDS结构 。通过添加的这些参数, 可以更⽅便解析字符串。
例如, 如果⽤数组⽅式保存字符串, 那么读取完整字符串就只能遍历数组⾥的各个字节数据, 时间复杂度O(N) 。但是SDS中预先记录了len后, 就可以直接读取---定⻓度的字节, 时间复杂度O(1), 效率更⾼ 。 另外, C语⾔中通常⽤字节数组保存字符串, 那么还需要定义 ---个特殊的结束符\0表示这---个字符串结束 。但是在Redis中, 如果value中就包含\0这样的 字符串, 就会产⽣歧义 。但是有SDS后, 直接读取完整字节, 也就不⽤管这些歧义了。
(3)raw类型
从之前分析可以看到, raw类型其实相当于是兜底的---种类型 。特殊的数字类型和⼩字符串 类型处理完了后, 就是raw类型了 。raw类型的处理⽅式就是单独创建---个SDS, 然后将robj的ptr指向这个SDS。

3 、string底层数据结构总结
对于string类型的---系列操作, Redis内部会根据⽤户给的不同键值使⽤不同的编码⽅式, ⾃适应地选择最优化的内部编码⽅式 。这些逻辑, 对于⽤户是完全隔离的。
对于string类型的数据, 如果value可以转换为数字, Redis底层就会使⽤int类型 。在
RedisObject中的ptr指针中 ,会直接复制为整数数据, 不再额外创建指针指向整数, 节省 了指针的空间开销 。并且, 如果数字⽐较⼩ ,⼩于1000, 将会直接使⽤预先创建的缓存对 象, 连创建对象的内存空间也节省了。
如果value是字符串且⻓度⼩于44字节, Redis底层就会使⽤embstr类型 。embstr类型会调 ⽤内存分配函数, 分配---块连续的内存空间保存对应的SDS 。这样使⽤连续的内存空间,
不光可以提⾼数据的读取速度, ⽽且可以避免内存碎⽚ 。
如果value是字符串类型 ,但是⼤于44字节, 那么RedisObject和SDS就会分开申请内存。 通过RedisObject的ptr指针指向新创建的SDS。
三、HASH类型数据结构详解
1 、hash数据是如何存储的
还是先上结论, 再源码验证 。hash类型的数据, 底层存储时, 有两种存储格式 。hashtable 和list pack
127.0.0.1:6379> hset user:1 id 1 name roy (integer) 2
127.0.0.1:6379> type user:1 hash
127.0.0.1:6379> OBJECT ENCODING user:1 "listpack"
127.0.0.1:6379> config set hash-max-listpack-entries 3 OK
127.0.0.1:6379> config set hash-max-listpack-value 8 OK
127.0.0.1:6379> hset user:1 name royaaaaaaaaaaaaaaaa (integer) 0
127.0.0.1:6379> OBJECT ENCODING user:1 "hashtable"
127.0.0.1:6379> hset user:2 id 1 name roy score 100 age 18 (integer) 4
127.0.0.1:6379> OBJECT ENCODING user:2 "hashtable"
简单来说,就是hash型的数据,如果value里的数据比较少,就用listpack。如果数据比较多,就用hashtable。
如何判断value里的数据少,涉及到两个参数。hash-max-listpack-entries 限制value里键值对的个数(默认512),hash-max-listpack-value 限制value里值的数据大小(默认64字节)。
从这两个参数里可以看到,对于hash类型数据,大部分正常情况下,都是使用listpack。所以,对于hash类型数据,主要是要理解listpack是如何存储的。至于hashtable,正常基本用不上,面试也就很少会问。
但是hash类型的底层数据,只用ziplist和listpack,其实是很像的。Redis6里也有ziplist相关的这两个参数。
但是hash类型的底层数据, 只⽤ zip list和list pack, 其实是很像的 。Redis6⾥也有 zip list相关的这两个参数。
2、hash底层数据结构详解
首先理解hash数据底层数据存储的基础结构
hash数据的value,是一系列的键值对。 这些<k,v>键值对底层封装成了一个dictEntry结构。然后,整个这些键值对,又会被封装成一个dict结构。这个dict结构就构成了hash的整个value。
(1)hashtable
对于hashtable,早期版本中会有一个专门的数据结构dictht,现在就是这个dict了。
<dict.h 84行>

dictEntry的结构体定义在dict.c中
<dict.c 63⾏>

然后,来看redis底层是如何执⾏⼀个hset key field1 value1 field2 value2 这样的指令的 Redis底层处理hset指令的⽅法在
<t_hash.c 606⾏>

接下来这个hashTypeTryConversion⽅法就会尝试进⾏编码转换 。 这就验证了hash类型数 据根据那两个参数选择⽤list pack还是hashtable的。

接下来,到底什么是list pack?
(2)list pack
list pack是zip list的升级版, 所以,谈到list pack就不得不谈zip list 。zip list字⾯意义是压缩
列表 。怎么压缩呢?
zip list最⼤的特点, 就是他被设计成---种内存紧凑型的数据结构, 占⽤---块连续的内存空 间, 不仅可以利⽤CPU缓存, ⽽且会针对不同⻓度的数据, 进⾏响应的编码 。这种⽅法可 以及有效的节省内存开销。
在redis6中, zip list是Redis底层⾮常重要的---种数据结构, 不⽌⽀持hash, 还⽀持list等其 他数据类型
zip list是由连续内存块组成的顺序性数据结构, 整个结构有点类似于数组 。可以在任意---端 进⾏push/pop操作, 时间复杂度都是O(1) 。整体结构如下:

这些entry就可以认为是保存hash类型的value当中的一个键值对。
然后,每一个entry结构又分为三个部分。

- previous_entry_length:记录前一个节点的长度,占1个或者5个字节。如果前一个节点的长度小于254字节,则采用一个字节来保存长度值。如果前一个节点的长度大于等于254字节,则采用5个字节来保存这个长度值。第一个字节是0xfe,后面四个字节才是真实长度数据
为什么要这样?因为255已经用在了ziplist的最后一个zlend。
- encoding:编码属性,记录content的数据类型。表明content是字符串还是整数,以及content的长度。
- contents:负责保存节点的数据,可以是字符串或整数。
ziplist后面的list通常是指链表数据结构。而典型的双向链表是在每个节点上通过两个指针指向前和后的相邻节点。而ziplist这种数据结构,就不再保存指针,只保留长度。极致压缩内存空间。这也是关于ziplist紧凑的一种表现。
在这种结构下,对于一个ziplist,要找到对列的第一个元素和最后一个元素,都是比较容易的,可以通过头部的三个字段直接找到。但是,如果想要找到中间某一些元素(比如Redis 的list数据类型的LRANGE指令),那么就只能依次遍历(从前往后单向遍历)。所以,ziplist不太适合存储太多的元素。
然后,为什么要用listpack替换ziplist呢?
redis的作者antirez的github上提供了listpack的实现。里面有一个md文档介绍了listpack。文章地址: https://github.com/antirez/listpack/blob/master/listpack.md
listpack的整体结构跟ziplist是差不多的,只是做了一些小调整。最核心的原因是要解决ziplist的连锁更新问题。
下面介绍连锁更新问题,这个了解即可。
连锁更新问题的核心就是在enty的previous_entry_length记录方式。如果前一个节点的长度小于254字节,那么previous_entry_length只有1个字节。如果大于等于254字节,则previous_entry_length需要扩展到5个字节。
这时假设我们有这样一个ziplist,每个entry的长度都是在250~253字节之间,previous_entry_length都只要一个字节。

这时, 如果将---个⻓度⼤于等于254字节的新节点加⼊到压缩列表的表头节点, 也就是e1的头部。

这时, 因为e1的previous_entry_length只有1个字节, ⽆法保存新节点的⻓度 ,此时就需要 扩充previous_entry_length到5个字节 。这样e1的整体⻓度就会超过254字节 。⽽ e1---旦⻓ 度扩展, 意味着e2的previous_entry_length也需要从1扩展到5字节 。接下来, 后续每---个 entry都需要重新调整空间。
这种特殊情况下产⽣的连续多次空间扩展操作, 就称为连锁更新 。连锁更新造成的空间连 续变动, 是⾮常不安全的, 同时效率也是⾮常低的 。正是因为连锁更新问题, 才造成
Redis7中使⽤新的list pack结构替代zip lists。
list pack的整体结构如下:

3 、hash底层数据结构总结
最后, 对于hash类型的底层数据结构, 做---个总结:
1 、hash底层更多的是使⽤list pack来存储value。
2 、如果hash对象保存的键值对超过512个, 或者所有键值对的字符串⻓度超过64字节, 底 层的数据结构就会由list pack升级成为hashtable。
3 、对于同---个hash数据, list pack结构可以升级为hashtable结构 ,但是hashtable结构不 会降级成为list pack。