深入浅出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 上的查找需要进行遍历

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

相关源码

相关推荐
短剑重铸之日11 分钟前
《SpringBoot4.0初识》第一篇:前瞻与思想
java·开发语言·后端·spring·springboot4.0
it_czz30 分钟前
LangSmith vs LangFlow vs LangGraph Studio 可视化配置方案对比
后端
蓝色王者32 分钟前
springboot 2.6.13 整合flowable6.8.1
java·spring boot·后端
花哥码天下1 小时前
apifox登录后设置token到环境变量
java·后端
程序员小寒2 小时前
从一道前端面试题,谈 JS 对象存储特点和运算符执行顺序
开发语言·前端·javascript·面试
hashiqimiya2 小时前
springboot事务触发滚动与不滚蛋
java·spring boot·后端
ChineHe3 小时前
Redis数据类型篇001_数据类型梳理与选择指南
数据库·redis·缓存
TeamDev3 小时前
基于 Angular UI 的 C# 桌面应用
前端·后端·angular.js
PPPHUANG3 小时前
一次 CompletableFuture 误用,如何耗尽 IO 线程池并拖垮整个系统
java·后端·代码规范
用户8356290780513 小时前
用Python轻松管理Word页脚:批量处理与多节文档技巧
后端·python