Redis Value 数据结构详解

143 阅读9分钟

image.png

redis 的 value 一共有5种数据结构。其中 String 类型有3种内部编码实现。4种集合类型有5种底层的实现方式,redis对每种集合类型有默认的实现方式,也可以指定某个集合的实现方式。

对于 value 具体使用的编码,我们可以通过 object encoding 命令来查看

user_name> object encode key_name
"embstr"

1. String

  • int:8个字节的长整型
  • embstr:小于等于39个字节的字符串
  • raw:大于39个字节的字符串
❯ redis-cli
127.0.0.1:6379> set str 123
OK
127.0.0.1:6379> object encoding str
"int"
127.0.0.1:6379> set str "sunxy"
OK
127.0.0.1:6379> object encoding str
"embstr"
127.0.0.1:6379> set str "sunxynnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"
OK
127.0.0.1:6379> object encoding str
"embstr"
127.0.0.1:6379> set str "sunxynnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn"
OK
127.0.0.1:6379> object encoding str
"raw"
127.0.0.1:6379>

redis 内部使用的字符串结构体

struct sdshdr{
     //记录buf数组中已使用字节的数量
     //等于 SDS 保存字符串的长度 4byte
     int len;
     //记录 buf 数组中未使用字节的数量 4byte
     int free;
     //字节数组,用于保存字符串 字节\0结尾的字符串占用了1byte
     char buf[];
}

c语言默认的字符串实现

image.png

对比:

C字符串SDS
获取字符串长度的复杂度为O(N)获取字符串长度的复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出API是安全的,不会造成缓冲区溢出
只能保存文本数据可以保存文本或者二进制数据
修改字符串长度N次必然需要执行N次内存重分配修改字符串长度N次最多需要执行N次内存重分配

image.png

2. redis底层数据结构

2.1 HashTable

在 redis 内部叫 dict

typedef struct dictEntry {
    void *key;
    void *val;
    struct dictEntry *next;
} dictEntry;

typedef struct dictType {
    unsigned int (*hashFunction)(const void *key);
    void *(*keyDup)(void *privdata, const void *key);
    void *(*valDup)(void *privdata, const void *obj);
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    void (*keyDestructor)(void *privdata, void *key);
    void (*valDestructor)(void *privdata, void *obj);
} dictType;

typedef struct dict {
    dictEntry **table;
    dictType *type;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
    void *privdata;
} dict;

2.2 LinkedList

双向链表

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned long len;
} list;

list对象存放头尾节点, 可以双向遍历——从后向前,从前向后。

这样也可以用O(1)的时间复杂度,实现list的lpop、lpush、rpop、rpush的需求。

2.3 ZipList

压缩列表。ziplist 采用了一段连续的内存来存储数据,本质上就是一个字节数组,相比 linkedlist 和 hashtable减少了内存碎片,和指针的内存占用。而且当节点较少时,ziplist更容易被加载到CPU缓存中。

area        |<---- ziplist header ---->|<----------- entries ------------->|<-end->|

size          4 bytes  4 bytes  2 bytes    ?        ?        ?        ?     1 byte
            +---------+--------+-------+--------+--------+--------+--------+-------+
component   | zlbytes | zltail | zllen | entry1 | entry2 |  ...   | entryN | zlend |
            +---------+--------+-------+--------+--------+--------+--------+-------+
                                       ^                          ^        ^
address                                |                          |        |
                                ZIPLIST_ENTRY_HEAD                |   ZIPLIST_ENTRY_END
                                                                  |
                                                         ZIPLIST_ENTRY_TAIL

2.3.1 各个字段的含义

字段名含义
zlbytes是一个无符号 4 字节整数,保存着 ziplist 使用的内存数量。通过 zlbytes,程序可以直接对 ziplist 的内存大小进行调整,无须为了计算 ziplist 的内存大小而遍历整个列表
zltail压缩列表 最后一个 entry 距离起始地址的偏移量,占 4 个字节。这个偏移量使得对表尾的 pop 操作可以在无须遍历整个列表的情况下进行
zllen压缩列表的节点 entry 数目,占 2 个字节。当压缩列表的元素数目超过 2^16 - 2 的时候,zllen 会设置为2^16-1,当程序查询到值为2^16-1,就需要遍历整个压缩列表才能获取到元素数目。所以 zllen 并不能替代 zltail
entryX压缩列表存储数据的节点,可以为字节数组或者整数
zlend压缩列表的结尾

2.3.2 entry节点的结构

area        |<------------------- entry -------------------->|

            +------------------+----------+--------+---------+
component   | pre_entry_length | encoding | length | content |
            +------------------+----------+--------+---------+
entry字段含义
pre_entry_length记录了前一个节点的长度,通过这个值,可以进行指针计算,从而跳转到上一个节点
encoding 和 lengthencoding 和 length 两部分一起决定了 content 部分所保存的数据的类型(以及长度)
contentcontent 部分保存着节点的内容,类型和长度由 encoding 和 length 决定。

2.3.3 压缩链表的主要操作

a. 将节点添加到 ziplist 的末端 O(n)

  1. 根据新节点要保存的值,计算出编码这个值所需的空间大小,以及编码它前一个节点的长度所需的空间大小,然后对 ziplist 进行内存重分配。
  2. 设置新节点的各项属性: pre_entry_length 、 encoding 、 length 和 content 。
  3. 更新 ziplist 的各项属性,比如记录空间占用的 zlbytes ,到达表尾节点的偏移量 zltail ,以及记录节点数量的 zllen 。

b. 将节点添加到某个/某些节点的前面。

这个比添加到末尾要复杂的多, 因为这种操作除了将新节点添加到 ziplist 以外, 还可能引起后续一系列节点的改变

  1. 将一个新节点 new 添加到节点 prev 和 next 之间。
  2. next 现在的前驱节点是 new, 但是里面记录的 pre_entry_length 还是 prev 节点的, 所以必须更新 next 节点的 pre_entry_length ,这个过程中可能产生 next 节点的扩容。
  3. 如果 next 节点发生扩容,那就必须又更新 next 的下一个节点。
  4. 这就是说, 在某个/某些节点的前面添加新节点之后, 程序必须沿着路径挨个检查后续的节点,是否满足新长度的编码要求, 直到遇到一个能满足要求的节点(如果有一个能满足,则这个节点之后的其他节点也满足), 或者到达 ziplist 的末端 zlend 为止, 这种检查操作的复杂度为 O(N^2) 。

2.4 IntSet

typedef struct intset {

    // 保存元素所使用的类型的长度
    uint32_t encoding;

    // 元素个数
    uint32_t length;

    // 保存元素的数组
    int8_t contents[];

} intset;

如果在一个 intset 里面, 最长的元素可以用 int16_t 类型来保存, 那么这个 intset 的所有元素都以 int16_t 类型来保存。

另一方面, 如果有一个新元素要加入到这个 intset , 并且这个元素不能用 int16_t 类型来保存 —— 比如说, 新元素的长度为 int32_t , 那么这个 intset 就会自动进行“升级”: 先将集合中现有的所有元素从 int16_t 类型转换为 int32_t 类型, 接着再将新元素加入到集合中。

根据需要, intset 可以自动从 int16_t 升级到 int32_t 或 int64_t , 或者从 int32_t 升级到 int64_t 。

2.5 SkipList

原始数据就是一个单向链表: list.png

为了方便查找我们加一层索引: list1.png

索引层元素多了以后我们再加一层索引,一直重复。最后就得到了下面的跳表数据结构: list2.png

跳表的利用空间换时间的思想,实现了二分查找。主要操作,时间复杂度都是O(logN)。

操作时间复杂度
FINDO(logN)
ADDO(logN)
DELETEO(logN)
RANGE_FINDO(logN)

3. 四种集合类型的实现

3.1 Hash

3.1.1 ZipList 实现 Hash

当value元素比较少且数据类型是 Hash 的时候,redis 会优先使用 ziplist 来存放 Hash 元素。

127.0.0.1:6379> hset test_hash key1 one
(integer) 1
127.0.0.1:6379> hset test_hash key2 two
(integer) 1
127.0.0.1:6379> hset test_hash key3 three
(integer) 1
127.0.0.1:6379> object encoding test_hash
"ziplist"

这时查找元素,并不能保证O(1)的时间复杂度。因为 ziplist 要遍历查找元素。但是由于这时本身元素就比较少,所以速度并不是很慢。

默认情况下,一个哈希对象超过配置的阈值(键和值的长度有>64byte OR 键值对个数>512个)时,会转换成哈希表(hashtable)。

3.2 List

早期同Hash一样,元素少用 ziplist,元素多用 linkedlist。 在 redis 3.2版本之后,使用 快速列表 实现 List。

127.0.0.1:6379> lpush test_list 1
(integer) 1
127.0.0.1:6379> lpush test_list 2
(integer) 2
127.0.0.1:6379> lpush test_list 3
(integer) 3
127.0.0.1:6379> object encoding test_list
"quicklist"

快速链表定义

    typedef struct quicklistNode {
        struct quicklistNode *prev;
        struct quicklistNode *next;
        unsigned char *zl;         
        unsigned int sz;             /* ziplist 占用的字节总数 */
        unsigned int count : 16;     /* ziplist 的元素个数 */
        unsigned int encoding : 2;   /* 是否被压缩,2表示被压缩,1表示原生 */
        unsigned int container : 2;  
        unsigned int recompress : 1; 
        unsigned int attempted_compress : 1; 
        unsigned int extra : 10; 
    } quicklistNode;
    
    typedef struct quicklist {
        quicklistNode *head;
        quicklistNode *tail;
        unsigned long count;        /* 所有 ziplists 的元素总和 */
        unsigned long len;          /* quicklistNodes 的个数 */
        int fill : 16;              
        unsigned int compress : 16; 
    } quicklist;

quicklist 其实就是一个以 ziplist 为节点(quicklistNode 中存放指向 ziplist 的指针)的 adlist。quicklist 平衡了 ziplist 和 adlist 的优缺点:

  • 双向链表便于在表的两端进行 push 和 pop 操作,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片,不利于内存管理。

  • ziplist 由于是一整块连续内存,所以存储效率很高。但是,它不利于修改操作,每次数据变动都会引发一次内存的 realloc。特别是当 ziplist 长度很长的时候,一次 realloc 可能会导致大批量的数据拷贝,进一步降低性能。

3.3 Set

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。 默认情况下,如果一个集合:

  1. 只保存着整数元素;
  2. 元素的数量不多; 会优先使用 IntSet 保存数据。否则,就会转为HashTable。
127.0.0.1:6379> object encoding test_list
"quicklist"
127.0.0.1:6379> SADD test_set 1
(integer) 1
127.0.0.1:6379> SADD test_set 2
(integer) 1
127.0.0.1:6379> object encoding test_set
"intset"
127.0.0.1:6379> SADD test_set "redis"
(integer) 1
127.0.0.1:6379> object encoding test_set
"hashtable"

3.4 ZSet

Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。 不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。 有序集合的成员是唯一的,但分数(score)却可以重复。

默认未满足下列两个条件时,Redis 会使用 ziplist 实现 有序集合

  • 当sorted set中的元素个数,即(数据, score)对的数目超过128的时候,也就是ziplist数据项超过256的时候。
  • 当sorted set中插入的任意一个数据的长度超过了64的时候。
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

当数据多的时候,sorted set是由一个叫zset的数据结构来实现的,这个zset包含一个dict + 一个skiplist。dict用来查询数据到分数(score)的对应关系,而skiplist用来根据分数查询数据(可能是范围查找)。