本文来源:
【2024 最新版,redis7】redis 底层的 10 种数据结构_redis 最新版本中的数据结构-CSDN 博客 Redis 数据结构浅析 | ES Blog

本文 redis 版本:7.2.4

redisObject 对象

redis/src/server.h at 7.4 · redis/redis · GitHub

Redis 中数据对象 redisObject 的定义如下:

typedef struct redisObject {
 unsigned type:4;
 unsigned encoding:4;
 unsigned lru:24; /* lru time (relative to server.lruclock) */
 int refcount:32;
 void *ptr;
} robj;
// 一个redisObject占用的存储空间:4+4+24+32+64=128bit/8=16字节

其中 type 对应了 Redis 中的五种基本数据类型:

#define OBJ_STRING 0
#define OBJ_LIST 1
#define OBJ_SET 2
#define OBJ_ZSET 3
#define OBJ_HASH 4

encoding 对应了 Redis 中的 13 种编码方式:

/* 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 */
#define OBJ_ENCODING_LISTPACK_EX 12 /* Encoded as listpack, extended with metadata */

ptr 指针指向对象。

Redis 数据结构

Redis 五种数据类型的应用场景:

  • 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
  • List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
  • Hash 类型:缓存对象、购物车等。
  • Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
  • Zset 类型:排序场景,比如排行榜、电话和姓名排序等。

Redis 后续版本又支持四种数据类型,它们的应用场景如下:

  • BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
  • Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息 ID,支持以消费组形式消费数据。

查看编码方式的方法

object encoding key 这个命令可以返回 key 在 redis 中内部的编码方式。

String 底层数据结构

int 类型

127.0.0.1:6379> set test 1234567890
OK
127.0.0.1:6379> object encoding test
"int"
127.0.0.1:6379> set test 1234567890123456789
OK
127.0.0.1:6379> object encoding test
"int"
127.0.0.1:6379> set test 12345678901234567890
OK
127.0.0.1:6379> object encoding test
"embstr"
127.0.0.1:6379> set test 0111
OK
127.0.0.1:6379> object encoding test
"embstr"
127.0.0.1:6379> set test 123456789012345678901234567890123456789012345
OK
127.0.0.1:6379> strlen test
(integer) 45
127.0.0.1:6379> get test
"123456789012345678901234567890123456789012345"
127.0.0.1:6379> object encoding test
"raw"

embstr 类型

embstr 类型是 redis 专门用来保存短字符串的一种优化编码,其结构如下图所示

embstr底层编码方式

该结构中,redisObject 是必须有的,每个 redis 对象都有。embstr 使用时,只分配一次内存空间。因为 redisObject 和 sdshdr 是连续的。

所以在一起分配,当 embstr 字符串扩大时,也就照成了要重新给 embstr 类型的字符串重新分配内存空间,所以 redis 中为了杜绝这种情况,embstr 字符串是只读的,不能修改。

raw 类型

raw 类型是 redis 用来保存,可变长的,可修改的字符串,其结构如下图所示:

raw编码的字符串对象

如图所示,raw 方式保存字符串时,会进行两次申请内存空间,第一次是给 redisObject 申请,第二次是给 sdshdr 申请。

这样的方式,虽然比 embstr 多申请一次内存空间,但是这种方式当字符串长度发生扩展时,只用重新申请 sdshdr 的内存就行了,然后修改 ptr 指针,可以有效防止内存碎片。

SDS 简单动态字符串

3.2 版本之前

SDS 的结构定义在 sds.h 文件中,以前版本的 SDS 比较简单,有三个属性,分别是 len, free, buf 数组

// 3.0
struct sdshdr {
    // 记录buf数组中已使用字节的数量,即SDS所保存字符串的长度
    unsigned int len;
    // 记录buf数据中未使用的字节数量
    unsigned int free;
    // 字节数组,用于保存字符串
    char buf[];
};

3.2 版本及之后

在 3.2 版本之后,根据字符串长度的不同,分别初始化不同的 sds

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

3.2 版本之后,会根据字符串的长度来选择对应的数据结构

static inline char sdsReqType(size_t string_size) {
    if (string_size < 1<<5)  // 32
        return SDS_TYPE_5;
    if (string_size < 1<<8)  // 256
        return SDS_TYPE_8;
    if (string_size < 1<<16)   // 65536 64k
        return SDS_TYPE_16;
    if (string_size < 1<<32)  // 4294967296 4G
        return SDS_TYPE_32;
    return SDS_TYPE_64;
}

以下是 raw 编码方式下 sdshdr8 的一个字符串示例:

SDS 动态字符串与 C 语言字符串的区别

C 语言使用长度 N+1 的字符数组来表示长度为 N 的字符串,字符数组的最后一位是’\0’,用以表示字符串的结尾,但是这种方式不能满足 redis 对字符串的安全性,效率和功能上的要求,所以 redis 在字符串上面使用了 sds 动态字符串来保存字符串,相比与 c 语言字符串,sds 动态字符串有以下优点:

C 语言字符串SDS 动态字符串SDS 的优点
C 语言中没有保存字符串长度,当想获取字符串长度时,需要遍历字符串,统计长度,时间复杂度 O(n)SDS 结构中保存了字符串的长度 len, 当程序想获取字符串的长度时,直接访问 len 属性即可,时间复杂度为 O(1),确保了获取字符串长度不会成为 redis 的性能瓶颈常数复杂度获取字符串长度
C 语言中如果对字符串进行操作,如果是增长字符串,会导致内存的重新分配。
如果是截取字符串,未截取的地方需要被释放掉,如果没有释放,就会导致内存溢出。
SDS 结构中,对字符串数组采用预申请,len 属性记录已经使用的长度,alloc 属性记录所有的长度(在 3.0 版本使用 free 记录未使用空间,3.2 版本则改为 alloc 记录总长度),sds 采用这种空间预分配惰性空间释放两种策略,解决了字符串拼接和截取两种场景下的空间问题。杜绝缓冲区溢出

减少修改字符串带来的重新分配内存的次数
只能保存文本数据可以保存文本或二进制数据
可以使用所有的 <string.h> 中的函数可以使用部分 <string.h> 中的函数
  • 空间预分配:当对一个 SDS 字符串进行增长操作且内存不够用时,SDS 会进行内存的申请,申请时,如果 len≤1MB,那么就申请 len+len+1 的内存,也就是额外申请已使用内存 len 的两倍+1。如果申请时 len>1MB, 那么就申请 len+1MB+1 的内存,每次扩增 1MB。

  • 惰性空间释放:用于优化 SDS 字符串缩短操作,当 SDS 的字符串进行缩短时,程序不会释放其内存,而是使用 free 字段记录下还有多少未使用的空间,等待以后使用

List 底层数据结构

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

ziplist 类型(压缩列表)

在说 Redis 的 ziplist 之前,先看一下什么是压缩列表。压缩列表是一种紧凑型的数据结构,目的就是为了节省内存空间,他借鉴了数组的存储思想,占用一块连续的内存空间,但又与数组有些许区别。因为数组要求每个元素占用相同的存储空间,且每个存储空间的大小依据最大的那个元素来计算。

上面图中所表示,前 4 个元素都浪费了存储空间,所以为了保证占用内存空间的连续性和节省更多的内存空间,可以对数组进行压缩,使每个元素的大小为实际存储的大小。但这样的话,不知道每个元素具体的大小了,代码不能判断下一个元素的起点,所以还要为每个元素增加一个字段用于记录每个元素占据的位置大小,用于标识当前元素的大小,这样代码在遍历的时候,就能判断下一个元素的起点,这就构成了最简单的压缩链表

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

redis 的 ziplist 就是基于上面的压缩列表做了自己的封装,接下来我们看 redis 中 ziplist 具体的实际结构如何:

Redis-2 数据结构的编码方式.png

ziplist 最大的好处就是节省了内存空间,但他的缺点也很多。

  • 不能存储太多的元素,否则遍历效率极低
  • 当新增或修改某个元素时,会重新分配内存,甚至会引起连锁更新的问题。

连锁更新:连锁更新就是我只更新了一个元素,但由于某个原因,导致所有元素都进行了内存重分配,导致所有元素都进行了更新,就会降低效率。

那么 ziplist 如何出现连锁更新问题的呢?这就要看 entry 节点中 prevlen 属性占用空间大小了,prevlen 属性记录是上一个节点的大小,当上一个节点大小小于 256 字节时,prevlen 属性需要 1 字节的空间来保存这个长度大小,当上一个节点大小超过 256 字节时,prevlen 属性就需要 5 字节的空间来保存这个长度大小。如果当上一个节点增大超过 256 字节时,就会引发下一个节点的 prevlen 属性内存空间增大,需要为下一个节点重新分配额外的 4 字节空间。循环往复,有可能每一个下个节点都被导致重新分配内存空间,直至最后一个节点,就会导致连锁更新。

因此:ziplist 只适合保存结点数量不多的场景。

linkedlist 类型(双向链表)

redis 中的 linkedlist 就是基于双向链表实现的。以下是 redis 中 linkedlist 具体结构图:

可以看到 linkedlist 中包含了 3 个属性:

  • head:用于指向链表中的第一个结点
  • tail:用于指向链表中的最后一个结点
  • len:用于记录链表中所有结点的数量

除此之外,还包含了 3 个函数:

  • dup:结点复制函数,用于复制节点
  • free:结点释放函数,用于释放节点的内存空间
  • match:结点比较函数,用于比较节点之间的值
linkedlist 的优点linkedlist 的缺点
可以直接获取到头结点和尾结点,时间复杂度 O(1)双向链表中的每个结点内存空间都是不连续的,无法很好的利用 cpu 缓存
每个结点中都有 next 和 prev 属性,可以方便的获取下一个结点和上一个结点,时间复杂度也是 O(1)双向链表遍历的时间复杂度为 O(n)
linkedlist 中维护了链表的长度 len,可以直接获取链表长度,时间复杂度也是 O(1)

quicklist 类型

由于 ziplistlinkedlist 都存在着不可避免的缺陷,所以在 redis3.2 版本之后,引入了一种新的数据结构:QuickList(快速列表)。List 对象的底层数据结构,也由 ziplistlinkedlist 变为 QuickList

QuickList 结合了 ziplistlinkedlist 的优点,它是一个压缩列表 ziplist 为结点的双向链表,以下是它的数据结构图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

可以看到 quicklist 的数据结构和 linkedlist 总体上比较相似,都是包含了一个双向链表,并且都维护了双向链表的 head 结点和 tail 结点,以及结点的数量,最大的区别就是在双向链表中存储的 value 值发生了变化,linkedlist 中存储的值是实际的值,而 quicklist 中存储的值是一个指向 ziplist 的指针。该 ziplist 的最大大小由 quicklist 的 fill 属性进行限制,当某个节点的 ziplist 大小超出了该限制,就会在链表中新建一个 ziplist 保存到新的 quicklist 结点中。

listpack 类型(紧凑列表)

QuickList 使用 QuickListNode 结构指向每个 ZipList,增加了内存开销。为了减少内存开销,并进一步避免 ZipList 连锁更新问题,Redis 在 5.0 版本中,就设计实现了 ListPack 结构。ListPack 结构沿用了 ziplist 紧凑型的内存布局,把每个元素都紧挨着放置。ListPack 中每个列表项不再包含前一项的长度了,因此当某个列表项中的数据发生变化,导致列表项长度变化时,其他列表项的长度是不会受影响的,因而这就避免了 ZipList 面临的连锁更新问题。

Redis-2 数据结构的编码方式-1.png

在 listpack 中,因为每个列表项只记录自己的长度,而不会像 ziplist 中的列表项那样,会记录前一项的长度。所以,当我们在 listpack 中新增或修改元素时,实际上只会涉及每个列表项自己的操作,而不会影响后续列表项的长度变化,这就避免了连锁更新。

Set 底层数据结构

intset

intset 适用于保存都是整数的集合,根据编码的不同可以保存不同大小范围的整数,如果三种编码都无法保存这个整数,或者有元素不是整数,就会升级为 hashtable 的编码方式进行编码。

hashtable

HashTable(哈希表)是一个保存 key-value 键值对的结构体,它与 Java 中的 HashMap 相似,每个 key 都是唯一的,可以通过 key 去查询和修改其 value 值。
在 redis 中,hashtable 底层实现是基于数组的,数组的每个元素相当于一个 bucket 桶,当通过哈希散列函数计算出 key 对应的 hash 值(也就是数组下标)时,就会将该元素放入在数组中对应的下标位置,当发生 hash 冲突时,就会采用链地址法,在对应的 bucket 桶下形成一个链表来保存这些数据。

常见的解决 hash 冲突的方式还有以下几种:

  1. 链地址法:形成一个 Entry 链表来保存数据
  2. 二次 hash:用另一种函数重新计算 hash 值,尝试映射到其他的 bucket 中
  3. 公共溢出区:将产生 Hash 冲突的元素统一存放到一个公共的区域
  4. 开放定址法:冲突的 Entry 按照一定的规则(线性探测、平方探测)在 hash 表中找到下一个空闲的 bucket 存放

下面是 hashtable 的结构图:

从结构中可以看到,映射到相同数组下标位置的元素,会形成链表,当 redis 再次查询时,会先计算数组下标位置,然后再沿着链表进行遍历查找,时间复杂度为 O(1)+O(n)=O(n).

哈希表使用了链式哈希法虽然很好的解决了哈希冲突的问题,但随着结点越来越多,链表上的结点元素也越来越多,就会照成查询时的成本变高,会大大降低查询效率。

redis 为了解决 hash 冲突,在数据量达到一定阈值的时候,就会对 hash 表进行 rehash(重新计算 hash)操作,会将数组进行扩容,对每个元素重新计算 hash,然后重新映射到新位置。
触发 rehash 的条件有两个:

  1. 负载因子 ≥1,并且服务器没有执行 bgsave 命令或者 bgrewriteaof 命令,就会执行 rehash 操作,但并一定 100%执行
  2. 负载因子 ≥5,说明此时的 hash 表冲突已经很严重了,将强制执行 rehash 操作

负载因子 = hash 表中 entry 结点的总数量 / 数组大小

redis 为了解决 hash 冲突,在进行 rehash 的时候,也设计了一种 dict 结构,dict 中包含了两个 dictht 哈希表 ht[0]ht[1],默认情况下,这两个哈希表只使用一个。以下是 dict 的结构:

当我们第一次新增数据的时候,元素结点会存放在 ht[0] 中,当 ht[0] 中的结点负载因子超过阈值时,就会重新计算结点的 hash 值,将其放入到 ht[1] 中。

rehash 步骤:

  1. 给哈希表 ht[1] 分配空间,分配空间的大小为 2n 中第一个 > ht[0].used _ 2 的 n 值。比如上图中 ht[0].used _ 2=22,而 25>22>24,所以 n=5,即分配的空间大小为 32.
  2. ht[0] 中的 entry 结点经过 rehash 重新运算后,迁移到 ht[1]
  3. 迁移完成后,释放 ht[0] 的内存空间,并把 ht[1] 设置为 ht[0]
  4. 创建一个新的 ht[1],为下次扩容做准备

通过扩容操作,并进行 rehash 操作,可以有效地解决 hash 冲突的问题,但当 hash 表中的元素很大时,就会导致 rehash 动作耗时很长,而 redis 是单线程的,一旦耗时过长影响到后续的操作,那就是得不偿失的。因此在 redis 中没有采用直接全部 rehash,而是采用渐进式 rehash 操作来进行。

渐进式 rehash:不一次性把所有的数据 rehash 到新的哈希表中,而是在每次对哈希表的元素进行增删查改的时候,顺带把某个数组下标的链表上所有的结点进行 rehash 操作,直至把所有的元素 rehash。在新增数据时,对于新增的数据将其放入 ht[1] 中,对于查询数据时,先去查询 ht[0],再去查询 ht[1]。在渐进式 rehash 操作时,两个 dict 中都会存储数据,但 ht[0] 中的数据会越来越少,直至全部 rehash 进入到 ht[1] 中。

以下是 rehash 的结构图:

ZSet 底层数据结构

ziplist 类型

ziplist 的具体结构在 4.1 节已经介绍过了。但 ZSet 中具体的做法是将元素和其对应的分值分别保存到两个相邻的 entry 的 content 中。

当 ziplist 作为 zset 的底层数据结构时,每个集合元素使用两个紧挨在一起的压缩列表结点来保存。第一个结点保存元素成员,第二个结点保存结点的分值。

image.png

redis 选择 ziplist 作为 zset 的底层数据结构也是有限制的:

  1. 有序集合中保存的元素数量 < 128 个
  2. 有序集合保存的所有元素的长度小于 64 字节

否则就会使用 skiplist 作为 zset 的底层实现。

skiplist 类型

ziplist 俗称跳表,跳表是一个基于有序链表实现的可以快速查找元素的数据结构。下面是一个普通的有序链表的结构图:

该链表中如果想找到靠后的结点,就需要挨个遍历结点,时间复杂度为 O(n)。那么有没有办法提高查询效率呢,当然是有的。既然我们的链表是有序的,我们可不可以跳过前面的结点呢?这时候我们的初级链表就出来了(一个含有两层链表的跳表, 相当于带了一层索引):

这样我们查询 12 的话:

  1. 只有一层链表:1368912,需要遍历 6 次,时间复杂度 O(n)
  2. 有两层链表:16912,需要遍历 4 次,时间复杂度 O(n 2 \frac{n}{2}2n​)
  3. 同理,对于一个 n 层链表来说,时间复杂度为 O(l o g n lognlogn)

在上面的结构中,跳跃表遵守上一层结点是下一层结点的一半,但是在发生大量插入元素的时候,就会发生频繁调整跳跃表这个操作。

所以在 redis 中设计了一种升层算法,对于每一个新增的结点,初始化其层数为 1,然后循环以下算法步骤:算法算出一个 0~1 之间随机数,如果随机数< 0.25,则层数增加一层,然后循环这个步骤,直到生成的随机数> 1 4 \frac{1}{4}41​,如果循环到第三次退出循环时,那么这个新增节点的层数就是 2。这个算法对于每个结点来说,新增一层的概率是 0.25,层数越高,概率越小。对于新增的结点,只需要修改其前向指正和后向指针即可,其它结点不受影响。

综上所述,通过将有序集合的部分节点层,从最上层结点开始依次查找,如果本层的 next 结点大于我们想要找的结点,那么就自降一层再依次查找,如果找到了就返回其值,找不到就返回 null。如果跳跃表的层数很多的话,那么就会跳过很多节点,从而提升查询的效率,这就是跳跃表的思想。

跳跃表有以下性质:

  1. 跳跃表有很多层结构组成,最底层的结点个数为跳跃表的长度
  2. 跳跃表有一个头结点,头结点中默认有一个 32 层的结构(redis 默认层数最高 32 层),每层的结构都包含一个指向本层下一个元素的 next 指针
  3. 除了头结点外,有最高层的结点的层数称为跳跃表的高度
  4. 每层都是一个有序链表,随着数据 score 依次递增
  5. 除了头结点外,最底层的链表包含所有的元素

在跳跃表中,对于一个新增的元素来说,其新增结构如下:

Hash 底层数据结构

ziplist 类型

当元素较少时,使用 ziplist 保存 hash 结构的元素,当元素个数超过一定的限制后,使用 hashtable 来保存。

hashtable 类型

hashtable 在上述 5.2 章节中已经介绍。

listpack 类型

rehash 的一些细节

  • 分摊开销
    为了减少停顿,步骤 2 会分为多次渐进完成,将 rehash 键值对所需的计算工作,平均分摊到每个字典的增加、删除、查找、更新操作,期间会使用 dict.rehashidx 记录 dict.ht[0] 中已经完成 rehash 操作的 dictht.table 索引:
  • 每执行一次 rehash 操作,dict.rehashidx 计数器会加 1
  • 当 rehash 完成后,dict.rehashidx 会被设置为 -1
  • 触发条件
    计算当前负载因子:loader_factor = ht[0].used / ht[0].size
    收缩: 当 loader_factor < 0.1 时,执行 rehash 回收空闲空间
    扩展:
  1. 没有执行 BGSAVEBGREWRITEAOF 命令,loader_factor >= 1 执行 rehash
  2. 正在执行 BGSAVEBGREWRITEAOF 命令,loader_factor >= 5 执行 rehash

大多操作系统都采用了 写时复制copy-on-write 技术来优化子进程的效率:

父子进程共享同一份数据,直到数据被修改时,才实际拷贝内存空间给子进程,保证数据隔离

在执行 BGSAVEBGREWRITEAOF 命令时,redis 会创建子进程,此时服务器会通过增加 loader_factor 的阈值,避免在子进程存在期间执行不必要的内存写操作,节约内存

Redis 整体结构

每个数据库都是一个 redisDb 结构体:

typedef struct redisDb {
	dict *dict;  /* 据库的键空间 keyspace */
	dict *expires;  /* 设置了过期时间的 key 集合 */
	dict *blocking_keys; /* 客户端阻塞等待的 key 集合 (BLPOP)*/
	dict *ready_keys;  /* 已就绪的阻塞 key 集合 (PUSH) */
	dict *watched_keys; /* 在事务中监控受监控的 key 集合 */
	int id;  /* 数据库 ID */
	long long avg_ttl; /* 平均 TTL, just for stats */
	unsigned long expires_cursor; /* 过期检测指针 */
	list *defrag_later; /* 内存碎片回收列表 */
} redisDb;

redis 所有数据库都保存着 redisServer.db 数组中,redisServer.dbnum 保存了数据库的数量,简化后的内存布局大致如下:

+-------------+
| redisServer |
+-------------+ +------------+------+-------------+
| db | -> | redisDb[0] | .... | redisDb[15] |
+-------------+ +------------+------+-------------+
| dbnum | |
| 16 | |
+-------------+ | +---------+   +------------+
  +->| redisDb |  +-> | ListObject |
  +---------+ +------------+ | +------------+
  | dict | -> | StringObj | --+
  +---------+ +------------+ +------------+
  | expires | | StringObj | ----> | HashObject |
  +---------+ +------------+ +------------+
   | | StringObj | --+
   | +------------+ | +------------+
   |   +-> | StringObj |
   |   +------------+
   |
   | +------------+ +-------------+
   +----> | StringObj | -> | long |
    +------------+ +-------------+
    | StringObj | -> | long |
    +------------+ +-------------+

参考链接
Redis 数据结构与对象编码 (Object Encoding) - buttercup - 博客园