深入浅出Redis:一文掌握Redis底层数据结构与实现原理

1. Redis的数据结构

Redis之所以高效,不仅在于它的内存操作,更在于巧妙的数据结构设计。本文带你揭开Redis底层数据结构的神秘面纱,包括动态字符串SDS如何解决C语言字符串问题、IntSet如何高效处理整数集合、Dict哈希字典如何快速实现键值对存储与扩容、ZipList如何节省空间但又引发"连锁更新"的问题、QuickList如何兼具空间高效与操作性能、SkipList跳表如何做到类似红黑树的查询效率,以及RedisObject如何统一不同类型的数据存储。在阅读完本文后,你将对Redis数据结构的设计思想与源码实现原理有更深入的理解,从而更有效地使用和优化Redis。

1.1 动态字符串 SDS

我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。

不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:

  • 获取字符串长度的需要通过运算
  • 非二进制安全(如果字符中出现 '\0',会导致字符串提前结束)
  • 不可修改
  • Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。

例如,我们执行命令set name Jack,那么Redis 将在底层创建两个 SDS,其中一个是包含"name"的SDS,另一个是包含"Jack"的SDS。Redis是C语言实现的,其中SDS是一个结构体,源码如下:

例如,一个包含字符串"name"的 SDS 结构如下:

SDS 之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为"hi"的 SDS:

假如我们要给SDS追加一段字符串",Amy",这里首先会申请新内存空间:

  • 如果新字符串小于 1M,则新空间为扩展后字符串长度的两倍+1;加 1 是因为申请的时候需要预留 '\0' 位
  • 如果新字符串大于 1M,则新空间为扩展后字符串长度+1M+1。称为**内存预分配**。申请内存这件事本省是耗时的,所以 Redis 采用的是预分配,减少内存申请的次数

源码如下:

c 复制代码
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[];
};

SDS 的优点:

  1. 获取字符串的长度的时间复杂度是 O(1)
  2. 遍历字符串的时候是根据 len 来运算的,所以是二进制安全的
  3. 支持动态扩容
  4. 减少内存的分配次数

1.2 IntSet

IntSet 是 Redis 中 Set 集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征

其中的 encoding 包含三种模式,表示存储的整数大小不同:

为了方便查找,Redis 会将 IntSet 中所有的整数**按照升序**依次保存在 contents 数组中,结构如图:

现在,数组中每个数字都在int16_t的范围内,因此采用的编码方式是INTSET_ENC_INT16,每部分占用的字节大小为:

  • encoding:4 字节
  • length:4 字节
  • contents:2 字节 * 3 = 6 字节

我们向该其中添加一个数字:50000,这个数字超出了 int16_t 的范围,IntSet 会自动升级编码方式到合适的大小

以当前案例来说流程如下:

  • 升级编码为 INTSET_ENC_INT32, 每个整数占 4 字节,并按照新的编码方式及元素个数扩容数组
  • **倒序依次将数组中的元素拷贝**到扩容后的正确位置
  • 将待添加的元素放入数组末尾
  • 最后,将 IntSet 的 encoding 属性改为 INTSET_ENC_INT32,将 length 属性改为 4

源码如下:

c 复制代码
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
    uint8_t valenc = _intsetValueEncoding(value); // 获取当前值编码(也就是获取value需要的字节数)
    uint32_t pos; // 要插入的位置
    if (success) *success = 1;

    // 判断编码是否超出了当前 intset 的编码
    if (valenc > intrev32ifbe(is->encoding)) { // 超出编码,需要升级
        return intsetUpgradeAndAdd(is, value);
    } else {
        // 在当前 intset 中查找值与 value 一样的元素的角标 pos
        if (intsetSearch(is, value, &pos)) {
            if (success) *success = 0; // 如果找到了,则无需插入,直接结束并返回失败
            return is;
        }
        // 数组扩容
        is = intsetResize(is, intrev32ifbe(is->length) + 1);
        // 移动数组中 pos 之后的元素到 pos + 1,给新元素腾出空间
        if (pos < intrev32ifbe(is->length))
            intsetMoveTail(is, pos, pos + 1);

        // 插入新元素
        _intsetSet(is, pos, value);
        // 重新置元素长度
        is->length = intrev32ifbe(intrev32ifbe(is->length) + 1);
        return is;
    }
}
c 复制代码
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
    // 获取当前 intset 编码
    uint8_t curenc = intrev32ifbe(is->encoding);
    // 获取新编码
    uint8_t newenc = _intsetValueEncoding(value);
    // 获取元素个数
    int length = intrev32ifbe(is->length);
    // 判断新元素是大于0还是小于0,小于0插入队首,大于0插入队尾
    int prepend = value < 0 ? 1 : 0;

    // 重新编码为新编码
    is->encoding = intrev32ifbe(newenc);
    // 重新置数组大小
    is = intsetResize(is, intrev32ifbe(is->length) + 1);

    // 倒序遍历,逐个搬运元素到新位置,intsetGetEncoded 按照旧编码方式查找旧元素
    while (length--)
        _intsetSet(is, length + prepend, intsetGetEncoded(is, length, curenc));

    /* 插入新元素,prepend 决定是队首还是队尾 */
    if (prepend)
        _intsetSet(is, 0, value);
    else
        _intsetSet(is, intrev32ifbe(is->length), value);

    // 修改数组长度
    is->length = intrev32ifbe(intrev32ifbe(is->length) + 1);
    return is;
}

IntSet 可以看做是特殊的整数数组,具备一些特点:

  • Redis 会确保 IntSet 中的元素唯一、有序
  • 具备类型升级机制,可以节省内存空间
  • 底层采用**二分查找方式来查询**

1.3 Dict

我们知道 Redis 是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过 Dict 来实现的

Dict 由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

当我们向 Dict 添加键值对时,Redis 首先根据 Key 计算出** hash 值(h)**,然后利用 **h & sizemask **来计算元素应该存储到数组中的哪个索引位置


为什么这里采用是 h & sizemark,而不是 h % size 呢?

其实 h & sizemark 和 h % size 的效果是一样的,首先必须要保证 h 是 2 的 n 次幂。因为 sizemark = size - 1,在二进制的视角看 sizemark 就是除了最高位是 0 其余都是 1 的一串 bit,当进行 **h & sizemask **计算时,其实就是提取 h 中除了最高位其他位时 1 的 bit。例如 size 的 bit 是 100 (4),sizemark 的 bit 就是 011 ,那么当一个 hash 值的 bit 是 010(2)时,我们只需要得知它的后两位有几个 1 就行了,与运算之后得到 010 也就是 2,但 hash 中的 bit 时 100(4) 时,与运算得到的就是 000 也就是 0


DictEntry 的 key 指针指向的什么?

实际总是指向 SDS 结构(无论你加的是数字还是字符串),不会再封装为 RedisObject


DictEntry 发生冲突的时候,采用的链表结构是 ZipList 吗?

DictEntry 的 next 指针类型是 dictEntry,所以链表结构是一个普通类型的单向链表


Dict 中的 HashTable 就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低

Dict 在每次新增键值对时都会检查负载因子(LoadFactor = used / size) ,满足以下两种情况时会触发哈希表扩容:

  1. 哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
  2. 哈希表的 LoadFactor > 5 ;
c 复制代码
static int _dictExpandIfNeeded(dict *d) {
    // 如果正在 rehash,则返回 ok
    if (dictIsRehashing(d)) return DICT_OK;

    // 如果哈希表为空,则初始化哈希表为默认大小 4
    if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    // 当负载因子(used/size)达到1以上,并且当前没有进行 bgrewrite 等子进程操作
    // 或者负载因子超过5,则执行 dictExpand,也就是扩容
    if (d->ht[0].used >= d->ht[0].size &&
        (dict_can_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)) {
        // 扩容大小为 used + 1,底层会对扩容大小做判断,实际找的是第一个大于等于 used+1 的 2^n
        return dictExpand(d, d->ht[0].used + 1);
    }
    return DICT_OK;
}
c 复制代码
int htNeedsResize(dict *dict) {
    long long size, used;
    // 哈希表大小
    size = dictSlots(dict);
    // entry 数量
    used = dictSize(dict);
    // size > 4(哈希表初识大小)并且负载因子低于0.1
    return (size > DICT_HT_INITIAL_SIZE && (used*100/size < HASHTABLE_MIN_FILL));
}
c 复制代码
// t_hash.c # hashTypeDeleted()
if (dictDelete((dict*)o->ptr, field) == C_OK) {
    deleted = 1;
    // 删除成功后,检查是否需要重置 Dict 大小,如果需要则调用 dictResize 重置
    /* Always check if the dictionary needs a resize after a delete. */
    if (htNeedsResize(o->ptr)) dictResize(o->ptr);
}
c 复制代码
int dictResize(dict *d) {
    unsigned long minimal;
    // 如果正在做 bgsave 或 bgwriteof 或 rehash,则返回错误
    if (!dict_can_resize || dictIsRehashing(d)) return DICT_ERR;
    // 获取 used,也就是 entry 个数
    minimal = d->ht[0].used;
    // 如果 used 小于4,则重置为4
    if (minimal < DICT_HT_INITIAL_SIZE)
        minimal = DICT_HT_INITIAL_SIZE;
    // 重置大小为 minimal,实际上是第一个大于等于 minimal 的 2^n
    return dictExpand(d, minimal);
}

Dict 的 rehash:

不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的 size 和 sizemask 变化,而 key 的查询与 sizemask 有关。因此必须对哈希表中的每一个 key 重新计算索引,插入新的哈希表,这个过程称为 rehash。过程是这样的:

  1. 计算新 hash 表的 realeSize ,值取决于当前要做的是扩容还是收缩:
    • 如果是扩容,则新 size 为第一个大于等于 dict.ht[0].used + 1的 2^n
    • 如果是收缩,则新 size 为第一个大于等于 dict.ht[0].used 的 2^n (不得小于4)
  2. 按照新的 realeSize 申请内存空间,创建 dictht,并赋值给 dict.ht[1]
  3. 设置 dict.rehashidx = 0 ,标示开始rehash
    • dict.ht[0] 中的每一个 dictEntry 都 rehash 到 dict.ht[1],将 dict.ht[1] 赋值给 dict.ht[0],给 dict.ht[1] 初始化为空哈希表,释放原来的 dict.ht[0] 的内存
    • 每次执行新增、查询、修改、删除操作时,都检查一下 dict.rehashidx 是否大于 -1,如果是则将dict.ht[0].table[rehashidx] 的 entry 链表 rehash 到 dict.ht[1],并且将 rehashidx++直至dict.ht[0] 的所有数据都 rehash 到 dict.ht[1](判断依据是 rehashidx >= ht[0].size
  4. 将 rehashidx 赋值为 -1,代表 rehash 结束
  5. 在 rehash 过程中,新增操作,则直接写入 ht[1],查询、修改和删除则会在 dict.ht[0] 和 dict.ht[1] 依次查找并执行。这样可以确保 ht[0] 的数据只减不增,随着 rehash 最终为空

整个过程可以描述成:

Dict 的结构:

  • 类似 Java 的 HashTable,底层是数组加链表来解决哈希冲突
  • Dict 包含两个哈希表,ht[0] 平常用,ht[1] 用来 rehash

Dict 的伸缩:

  • 当 LoadFactor 大于 5 或者 LoadFactor 大于 1 并且没有子进程任务时,Dict 扩容
    • 扩容大小为第一个大于等于 used + 1 的 2^n
  • 当 LoadFactor 小于 0.1 时,Dict 收缩,收缩的最小容量是 4
    • 收缩大小为第一个大于等于 used 的 2^n
  • Dict 采用**渐进式 rehash**,每次访问 Dict 时执行一次 rehash
  • rehash 时 ht[0] 只减不增,新增操作只在 ht[1] 执行,其它操作在两个哈希表

1.4 ZipList

DIct 因为大量使用指针**(一个指针占 8 字节)**,会浪费内存空间,并且也会造成内存碎片。进而有了 ZipList,ZipSet 是一种特殊的"双端链表" ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)

属性 类型 长度 用途
zlbytes uint32_t 4 字节 记录整个压缩列表占用的内存字节数
zltail uint32_t 4 字节 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。
zllen uint16_t 2 字节 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。
entry 列表节点 不定 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlend uint8_t 1 字节 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

ZipListEntry:ZipList 中的 Entry 并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用 16 个字节,浪费内存。而是采用了下面的结构:

  1. previous_entry_length :前一节点的长度,占 1 个或 5 个字节
    • 如果前一节点的长度小于 254 字节,则采用 1 个字节来保存这个长度值****
    • 如果前一节点的长度大于 254 字节,则采用 5 字节来保存这个长度值,第一个字节为 0xFE(254)表示扩展标志后四个字节才是真实长度数据
  2. encoding:编码属性,记录 content 的数据类型(字符串还是整数)以及长度,占用 1 个、2 个或 5个字节
  3. contents :负责保存节点的数据,可以是字符串或整数

为什么 zlend 中 0xFF 不会和 previous_entry_length 中的 0xFF 混稀?

  1. 因为在 entry 结构体的 previous_entry_length 字段里,只有在"用 5 字节编码"时才可能出现 0xFF,但它只是"长度数据"的一部分,不会被解析为结束符 ,也就是说 0xFF 出现在previous_entry_length 时不会单独解析 0xFF,而是会用后面 4 字节解码出完整长度。
  2. 但是当解析下一个 entry 时,**一开始就遇到了 0xFF,**那就说明 ZipList 已经到结尾了

为什么 previous_entry_length 一个字节时只能表示 254 个字节,而不是 255 个字节?

  • 是因为 **0xFE(254)**被规定为"扩展标志",一旦前面 entry 超过 253 字节,这 1 字节就写 0xFE然后后面追加 4 字节来存真正的长度,也就是下面的情况二使用 5 个字节表示长度

ZipList 中所有存储长度的数值均采用**小端字节序**,即低位字节在前,高位字节在后。例如:数值 0x1234,采用小端字节序后实际存储值为:0x3412


Encoding 编码:

ZipListEntry 中的 encoding 编码分为字符串和整数两种:

字符串:如果 encoding 是以**"00" "01"或者 "10"开头,则证明 content 是字符串**

编码 编码长度 字符串大小
00pppppp 1 bytes <= 63 bytes
01pppppp qqqqqqqq 2 bytes <= 16383 bytes
10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt 5 bytes <= 4294967295 bytes

例如,我们要保存字符串:"ab"和 "bc"

最终采用 16 进制的小端字节序进行存放:


ZipListEntry 中的 encoding 编码分为字符串和整数两种:

  • 整数:如果 encoding 是以**"11"开始,则证明 content 是 整数**,且 encoding 固定只占用 1 个字节
编码 编码长度 整数类型
11000000 1 int16_t(2 bytes)
11010000 1 int32_t(4 bytes)
11100000 1 int64_t(8 bytes)
11110000 1 24 位有符整数(3 bytes)
11111110 1 8 位有符整数(1 bytes)
1111xxxx 1 直接在 xxxx 位置保存数值,****范围从 0001~1101,减 1 后结果为实际值

例如,一个 ZipList 中包含两个数字:"2" 和 "5"

ZipList的连锁更新问题:

ZipList 的每个 Entry 都包含 previous_entry_length 来记录上一个节点的大小,长度是 1 个或 5 个字节:

如果前一节点的长度小于 254 字节,则采用 1 个字节来保存这个长度值

如果前一节点的长度大于等于 254 字节,则采用5个字节来保存这个长度值,第一个字节为 0xfe,后四个字节才是真实长度数据

现在,假设我们有N个连续的、长度为** 250~253 **字节之间的 entry,因此 entry 的 previous_entry_length 属性用 1 个字节即可表示,如图所示:

如果此时在 ZipList 的头部插入一个 Entry,且 Entry 的字节大小为 254 字节,那么整个 ZipLIst 都需要更新 previous_entry_length 属性,也就是说整个 ZipList 都需要做重新申请内存,然后做数据迁移

ZipList 这种特殊情况下产生的连续多次空间扩展操作称之为**连锁更新(Cascade Update)**。新增、删除都可能导致连锁更新的发生


ZipList特性:

  • 压缩列表的可以看做一种连续内存空间的"双向链表"
  • 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低
  • 如果列表数据过多,导致链表过长,可能影响查询性能
  • 增或删较大数据时有可能发生连续更新问题

1.6 QuickList

问题1:ZipList 虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?

为了缓解这个问题,我们必须限制 ZipList 的长度和 entry 大小

问题2:但是我们要存储大量数据,超出了 ZipList 最佳的上限该怎么办?

我们可以创建多个 ZipList 来分片存储数据**(分片思想)**

问题3:数据拆分后比较分散,不方便管理和查找,这多个 ZipList 如何建立联系?

Redis 在 3.2 版本引入了新的数据结构** QuickList**,它是一个**双端链表**,只不过链表中的每个节点都是一个 ZipList

为了避免 QuickList 中的每个 ZipList 中 entry 过多,Redis 提供了一个配置项:list-max-ziplist-size

  • list-max-ziplist-size为正,则代表 ZipList 的允许的 entry 个数的最大值
  • list-max-ziplist-size为负,则代表 ZipList 的最大内存大小,分 5 种情况:
    • -1:每个 ZipList 的内存占用不能超过 4kb
    • -2:每个 ZipList 的内存占用不能超过 8kb**(list-max-ziplist-size 默认值 为 -2)**
    • -3:每个 ZipList 的内存占用不能超过 16kb
    • -4:每个 ZipList 的内存占用不能超过 32kb
    • -5:每个 ZipList 的内存占用不能超过 64kb

QuickList 的和 QuickListNode 的结构源码:

compress 参数有三种选择方式:0 代表不压缩;1 代表压缩首尾两个节点;2 代表压缩首尾四个节点;

流程图展示结构,除了首位节点,其他的 QucikListNode 节点都被压缩了:

QuickList 的特点:

  • 是一个节点为 ZipList 的双端链表
  • 节点采用 ZipList,解决了传统链表的内存占用问题
  • 控制了 ZipList 大小,解决连续内存空间申请效率问题
  • 中间节点可以压缩,进一步节省了内存

总而言之,QucikList 兼具了 ZipList 的优点,又解决了连续空间申请的效率问题

1.7 SkipList

ZipList 和 QuickList 查询首尾确实非常快,还节约内存,但是当查询的是中间节点的某一个元素时,查询性能就不是很好了

SkipList(跳表)也是一个链表结构,但与传统链表相比有几点差异:

  • 元素按照升序排列存储**(根据 Score 值进行排序,节点存放的元素值为 ele,其实这就是 Zset 的底层结构)**
  • 每一个节点可能包含多个指针,指针跨度不同

SkipList 和 SkipListNode 的结构源码:

SkipList 的特点:

  • 跳跃表是一个双向链表,每个节点都包含 score 和 ele 值
  • 节点按照 score 值排序,score 值一样则按照 ele 字典排序
  • 每个节点都可以包含多层指针,层数是 1 到 32 之间的随机数(用一个算法来决定需要多少层)
    • 一般来说高层的节点数是底层节点数的 1/2,最高层的节点最少为 2,所以有,所以时间复杂度为 O(logN)
    • 增删改查效率与红黑树基本一致,实现却更简单
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大

1.8 RedisObject

Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做 Redis 对象,源码如下:

RedisObject 可以看作时数据的对象头,一个 RedisObject 所占用的空间大小为** 16 字节**:

  • type:4bit
  • encoding:4bit
  • LRU:24bit
  • refcount:32bit
  • ptr:8 字节

什么是 RedisObject?为什么 Redis 的数据需要被封装为 RedisObject?

从 Redis 的使用者的角度来看,⼀个 Redis 节点包含多个 database(非 cluster 模式下默认是16个,cluster 模式下只能是1个),而一个 database 维护了从 key space 到 object space 的映射关系。这个映射关系的 key 是string 类型,而 value 可以是多种数据类型,比如:string, list, hash、set、sorted set 等。我们可以看到,key 的类型固定是 string,而 value 可能的类型是多个\ 而从 Redis 内部实现的角度来看,database 内的这个映射关系是用⼀个 Dict 来维护的。Dict 的 Key 固定用⼀种数据结构来表达就够了,这就是动态字符串 SDS。而 value 则比较复杂,为了在同⼀个 Dict 内能够存储不同类型的 value,这就需要⼀个通⽤的数据结构,这个通用的数据结构就是 ROBJ,全名是 RedisObject


Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含 11 种编码方式

编号 编码方式 说明
0 OBJ_ENCODING_RAW raw 编码动态字符串
1 OBJ_ENCODING_INT long 类型的整数的字符串
2 OBJ_ENCODING_HT hash 表(字典dict)
3 OBJ_ENCODING_ZIPMAP 已废弃
4 OBJ_ENCODING_LINKEDLIST 双端链表
5 OBJ_ENCODING_ZIPLIST 压缩列表
6 OBJ_ENCODING_INTSET 整数集合
7 OBJ_ENCODING_SKIPLIST 跳表
8 OBJ_ENCODING_EMBSTR embstr 的动态字符串
9 OBJ_ENCODING_QUICKLIST 快速列表
10 OBJ_ENCODING_STREAM Stream流

Redis 中会根据存储的数据类型不同,选择不同的编码方式。**Redis 五种数据类型**的使用的编码方式如下:

数据类型 编码方式
OBJ_STRING int / embstr(SDS) / raw(SDS)
OBJ_LIST LinkedList + ZipList(3.2以前) / QuickList(3.2以后)
OBJ_SET IntSet / HT
OBJ_ZSET ZipList / HT+SkipList
OBJ_HASH ZipList / HT

2. Redis 的数据类型

2.1 String

String 是 Redis 中最常见的数据存储类型,value 的编码方式有三种

  1. 其**基本编码方式是 RAW**,基于简单动态字符串(SDS)实现,存储上限为 512MB
  2. 如果存储的字符串长度**小于 44 字节**,则会采用 embstr 编码,此时 RedisObject 的头信息与 SDS 是一段连续空间,申请内存时只需要调用一次内存分配函数,效率更高
  3. 如果**存储的是字符串是整数值且可转成 long long 范围**,例如 "255" 可以用整数 255 表示,则会采用 int 编码,此时 value 值由 ptr 指针进行存储,不再需要申请其他内存空间,因为 ptr 为 8 字节,所以 int 表示的范围是 -2^63 到 2^63 - 1
    • 底层是通过<font style="color:rgb(51, 51, 51);">string2ll</font>函数进行判断的
    • 如果存储的字符串是整数值,并且大小在 LONG_MAX 范围内,则会采用INT编码:直接将数据保存在RedisObject的 ptr 指针位置(刚好8字节),不再需要SDS了
c 复制代码
int string2ll(const char *s, size_t slen, long long *value) {
    const char *p = s;
    size_t plen = 0;
    int negative = 0;
    unsigned long long v;

    /* A zero length string is not a valid number. */
    if (plen == slen)
        return 0;

    /* Special case: first and only digit is 0. */
    if (slen == 1 && p[0] == '0') {
        if (value != NULL) *value = 0;
        return 1;
    }

    /* Handle negative numbers: just set a flag and continue like if it
     * was a positive number. Later convert into negative. */
    if (p[0] == '-') {
        negative = 1;
        p++; plen++;

        /* Abort on only a negative sign. */
        if (plen == slen)
            return 0;
    }

    /* First digit should be 1-9, otherwise the string should just be 0. */
    if (p[0] >= '1' && p[0] <= '9') {
        v = p[0]-'0';
        p++; plen++;
    } else {
        return 0;
    }

    /* Parse all the other digits, checking for overflow at every step. */
    while (plen < slen && p[0] >= '0' && p[0] <= '9') {
        if (v > (ULLONG_MAX / 10)) /* Overflow. */
            return 0;
        v *= 10;

        if (v > (ULLONG_MAX - (p[0]-'0'))) /* Overflow. */
            return 0;
        v += p[0]-'0';

        p++; plen++;
    }

    /* Return if not all bytes were used. */
    if (plen < slen)
        return 0;

    /* Convert to negative if needed, and do the final overflow check when
     * converting from unsigned long long to long long. */
    if (negative) {
        if (v > ((unsigned long long)(-(LLONG_MIN+1))+1)) /* Overflow. */
            return 0;
        if (value != NULL) *value = -v;
    } else {
        if (v > LLONG_MAX) /* Overflow. */
            return 0;
        if (value != NULL) *value = v;
    }
    return 1;
}

OBJ_ENCODING_INT 编码方式:

OBJ_ENCODING_EMBSTR 编码方式:

OBJ_ENCODING_RWA 编码方式:

string 的操作命令:

  1. 在对 string 进行 **<u><font style="color:#DF2A3F;">INCR</font></u>**, **<u><font style="color:#DF2A3F;">DECR</font></u>** 等操作的时候

如果它内部是OBJ_ENCODING_INT编码,那么可以直接行加减操作;如果它内部是OBJ_ENCODING_RAWOBJ_ENCODING_EMBSTR编码,那么 Redis 会先试图把 SDS 存储的**字符串转成 long 型,如果能转成功,再进行加减操作**

那为什么执行INCR,DECR时 Redis 还要尝试去转换?难道不是一开始就采用了OBJ_ENCODING_INT的编码方式吗?

  1. 有些值虽然最初是数字,但被修改过(如后面 APPEND setbit``getrange变成非数字字符串)
  2. 有些值一开始就不是数字(如 SET key abc)
  3. 还有些值通过其它命令写入(如 MSET、RESTORE、AOF 恢复、RDB 加载等)未必保证都是 INT 编码

所以 Redis 的编码方式时动态的,并不是死认"INT 编码"

  1. 对⼀个内部表示成 long 型的 string 执行**<u><font style="color:#DF2A3F;">append</font></u>** **<u><font style="color:#DF2A3F;">setbit</font></u>**, **<u><font style="color:#DF2A3F;">getrange</font></u>**这些命令

如果它内部是OBJ_ENCODING_EMBSTROBJ_ENCODING_ROW编码,那么可以直接进行上述操作;针对的仍然是 string 的值 (即⼗进制表示的字符串),而不是针对内部表⽰的 long 型进⾏操作。比如字符串"32",如果按照字符数组来解释,它包含两个字符,它们的 ASCII 码分别是 0x33 和 0x32。当我们执行命令setbit key 7 0的时候,相当于把字符 0x33 变成了 0x32,这样字符串的值就变成了 "22"。⽽如果将字符串 "32" 按照内部的 64 位 long 型来解释,那么它是 0x0000000000000020,在这个基础上执⾏setbit位操作,结果就完全不对了。因此,在这些命令的实现中,会把 long 型先转成字符串再进行相应的操作

2.2 List

哪一个数据结构能满足LPUSH``RPUSH``LRANGE``LPOP``RPOP等操作?

  • LinkedList :普通链表,可以从双端访问内存占用较高,内存碎片较多
  • ZipList :压缩列表,可以从双端访问内存占用低存储上限低
  • QuickListLinkedList + ZipList可以从双端访问内存占用较低 ,包含多个ZipList,存储上限高

Redis 的 List 结构类似一个双端链表,可以从首、尾操作列表中的元素。

在 3.2 版本之前,Redis 采用 ZipList 和 LinkedList 来实现 List,当元素数量小于 512 并且元素大小小于 64 字节时采用 ZipList 编码,超过则采用LinkedList 编码

在3.2版本之后,Redis 统一采用 QuickList 来实现 List

Redis 的 List 类型可以从首、尾操作列表中的元素,例如 LIst 的 Push 操作

java 复制代码
// client:当前的客户端连接对象,包含命令参数等信息;where:决定插入头部还是尾部;int xx:是否只在 key 已存在时才推入元素
void pushGenericCommand(client *c, int where, int xx) {
    int j;
    // 命令参数第0个是命令名,第1个是 key,从第2个参数开始是要插入的元素
    for (j = 2; j < c->argc; j++) {
        if (sdslen(c->argv[j]->ptr) > LIST_MAX_ITEM_SIZE) {
            addReplyError(c, "Element too large");
            return;
        }
    }
    // 获取 Key 对应的 List 对象
    robj *lobj = lookupKeyWrite(c->db, c->argv[1]);
    // 通过 RedisObject 中的 type 值,检查 lobj 是不是 OBJ_LIST
    if (checkType(c,lobj,OBJ_LIST)) return;
    // 检查是否为空
    if (!lobj) {
        if (xx) {
            addReply(c, shared.czero);
            return;
        }
        // 为空,创建一个新的 QucikList
        lobj = createQuicklistObject();
        // 设置 ZipList 的限制
        quicklistSetOptions(lobj->ptr, server.list_max_ziplist_size,
                            server.list_compress_depth);
        // 添加元素
        dbAdd(c->db,c->argv[1],lobj);
    }

    for (j = 2; j < c->argc; j++) {
        listTypePush(lobj,c->argv[j],where);
        server.dirty++;
    }

    addReplyLongLong(c, listTypeLength(lobj));

    char *event = (where == LIST_HEAD) ? "lpush" : "rpush";
    signalModifiedKey(c,c->db,c->argv[1]);
    notifyKeyspaceEvent(NOTIFY_LIST,event,c->argv[1],c->db->id);
}
java 复制代码
robj *createQuicklistObject(void) {
    quicklist *l = quicklistCreate();
    robj *o = createObject(OBJ_LIST,l);
    o->encoding = OBJ_ENCODING_QUICKLIST;
    return o;
}

List 的内存结构图:

2.3 Set

Set 是 Redis 中的单列集合,满足下列特点:

  • **不保证**有序性,说明在某种情况下,能出现有序的序列
  • 保证元素唯一
  • 求交集、并集、差集

常用命令为SADDSISMENMBERSINTER,可以看出,Set 对查询元素的效率要求非常高,那什么样的数据结构可以满足?

  1. HashTable,也就是 Redis 中的 Dict ,不过 Dict 是双列集合(可以存键、值对),Java 中 HashSet 底层是通过 HashMap 实现的,只利用了 HashMap 的 Key,而 Value 为 null,所以我们也可以参考 Java 的实现思路。所以 Redis 的 Set 数据类型的实现方式之一就是通过 HT 编码(Dict)实现的,同时也保证了查询效率和唯一性。Dict 中的 Key 用来存储元素,Value 统一为 null。最终 Set集合不一定确保元素有序,但可以满足元素唯一、查询效率也高
  2. 当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,Set 会采用 IntSet 编码 ,以节省内存,当采用 IntSet 编码的时候,能够满足元素的有序性**,**但是 Set 集合对外还是要说明是不保证元素的有序性的****

结构如下,其中 DictEntry 中的 Key 指针指向的是 SDS 结构

2.4 ZSET

ZSet 也就是 SortedSet,其中每一个元素都需要指定一个 score 值和 member 值:

  • 可以根据 score 值**排序**
  • member 必须**唯一**
  • 可以根据 member** 查询分数**ZSCORE

常用命令为ZADDZRANKZSCORE,ZSet 底层数据结构必须满足**键值存储**、** 键必须唯一**、**可排序**这几个需求。之前学习的哪种编码结构可以满足?

  • SkipList:可以排序,并且可以同时存储 score 和 ele 值(member)
  • HT(Dict):可以键值存储,并且可以根据 key 找 value

ZSet 结合 SkipList 和 Dict 实现****:

内存结构图:

当元素数量不多时,HT 和 SkipList 的优势不明显,而且更耗内存。因此** ****ZSet 还会采用 ZipList 结构来节省内存**

不过需要同时满足两个条件:

  1. 元素数量小于zset_max_ziplist_entries,默认值 128
  2. 每个元素都小于zset_max_ziplist_value字节,默认值 64

ZipList 本身没有排序功能,而且没有键值对的概念,因此需要 ZSet 通过业务逻辑进行实现:

  • ZipList 是连续内存,因此** score 和 element 是紧挨在一起的两个 entry: element 在前,score 在后**
  • score 越小越接近队首,score 越大越接近队尾,按照 score 值升序排列

ZSet 的创建流程,以及 ZSet 添加元素的流程:

2.5 Hash

Hash 结构与 Redis 中的 ZSet 非常类似:

  • 都是键值存储
  • 都需求根据键获取值
  • 键必须唯一

区别如下:

  • ZSet 的键是 member,值是 score;Hash 的键和值都是任意值
  • ZSet 要根据 score 排序;Hash 则无需排序

Hash 的常用命令HSETHGETHDELHKEYSHVALS

ZipList 本来就设计为各个数据项挨在⼀起组成连续的内存空间,这种结构并不擅长做修改操作。⼀旦数据发⽣改动,就会引发内存 realloc,可能导致内存拷贝。Hash 底层采用的编码与 ZSet 也基本一致,只需要把排序有关的 SkipList 去掉即可

Hash 结构默认采用 ZipList 编码,用以节省内存。** ZipList 中相邻的两个 entry 分别保存 field 和 value**

当数据量较大时,Hash 结构会转为 HT 编码,也就是 Dict,触发条件有两个:

  1. ZipList 中的元素数量超过了hash-max-ziplist-entries(默认512)
  2. ZipList 中的任意 entry 大小超过了hash-max-ziplist-value(默认64字节)

当满足上面两个条件其中之⼀的时候,Redis 就使⽤Dict 字典来实现 Hash。Redis 的 Hash 之所以这样设计,是因为当 ZipList 变得很⼤的时候,它有如下几个缺点:

  • 每次插⼊或修改引发的 realloc 操作会有更⼤的概率造成内存拷贝,从而降低性能
  • ⼀旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更⼤的⼀块数据
  • 当 ZipList 数据项过多的时候,在它上⾯查找指定的数据项就会性能变得很低,因为 ZipList 上的查找需要进行遍历

两种实现方式的内存结构体:

相关源码

相关推荐
kk在加油28 分钟前
Redis基础数据结构
数据结构·数据库·redis
SoniaChen3340 分钟前
Rust基础-part3-函数
开发语言·后端·rust
全干engineer1 小时前
Flask 入门教程:用 Python 快速搭建你的第一个 Web 应用
后端·python·flask·web
没有bug.的程序员1 小时前
JAVA面试宝典 -《Spring Boot 自动配置魔法解密》
java·spring boot·面试
William一直在路上1 小时前
SpringBoot 拦截器和过滤器的区别
hive·spring boot·后端
慌糖1 小时前
CentOS 安装 Redis 简明指南
linux·redis·centos
小马爱打代码2 小时前
Spring Boot 3.4 :@Fallback 注解 - 让微服务容错更简单
spring boot·后端·微服务
旷世奇才李先生2 小时前
奇哥面试记:SpringBoot整合RabbitMQ与高级特性,一不小心吊打面试官
spring boot·面试·java-rabbitmq
mrsk2 小时前
🧙‍♂️ CSS中的结界术:BFC如何拯救你的布局混乱?
前端·css·面试
曾曜2 小时前
PostgreSQL逻辑复制的原理和实践
后端