Redis 基本数据结构(上)

446 阅读13分钟

Redis 有 5 种基础数据结构,string(字符串,sds)、list(列表)、set(集合)、hash(哈希)、zset(有序集合)。

字符串(string)

Redis 虽然底层是用的 C 编写,但是 Redis 并没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将 SDS 用作 Redis 的默认字符串表示。

熟悉一门技术最开始的方法,就是 "Hello World",我们使用Redis来保存一个 "Hellow World" 的msg。使用 redis-cli执行下面这个命令。

redis> set msg "hello world"
OK

返回 OK 表示执行成功。Redis将在数据库中创建一个新的键值对:

  • 键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串 "msg" 的 SDS。
  • 键值对的值也是一个字符串对象,对象的底层实现是一个保存着字符串 "hello world" 的SDS。

《Redis 设计与实现》 中对SDS的结构作了详细的解释,但是在新版本当中(5.x),Redis 对 SDS 结构做了优化。

2.x SDS 结构

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

sdschar* 的别名,sdshdr 是 redis 字符串的底层数据结构

5.x SDS 结构

typedef char *sds;

// __attribute__((__packed__)) 告诉GCC编译器,不要对此结构做内存对齐
// sdshdr5/sdshdr8/sdshdr16/sdshdr32/sdshdr64 是给不同的字符串长度做预留的
// free 字段没有了,增加了 alloc 和 flags 字段

/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
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[];
};

len : 字符串的真正长度

alloc : 字符串的最大容量(不包括最后的空字符)

flags : 占用一个字节,低3位表示 sds 的类型

buf : 一个字符数组,字符数组的长度等于最大容量+1。通常情况下,字符串的真实长度小于最大容量。在真正的字符串数据之后,是空余的字节(用字节0填充),允许在不重新分配内存的情况下向后做有限的扩展。还有一个NULL结束符,即'\0'。是为了能够使用c语言中的字符串方法。

一个 sds 字符串的完整结构,由在内存地址上前后相邻的两部分组成: header 和 buf 。header 包括了 len 、alloc 、flags。

//获取字符串长度
static inline size_t sdslen(const sds s) {
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        ....
    }
}
//获取可用空间
static inline size_t sdsavail(const sds s) {
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        ....
    }
}
//设置len
static inline void sdssetlen(sds s, size_t newlen) {
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        ....
    }
}
....

可以看到 Redis 字符串常见方法都会先去拿到 flags , s[-1] 表示向低地址方向便宜1个字节的位置,得到flags字段。前面说过 flags 低3位地址表示类型,就可以得到 sds header 的类型(sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64) 。

在各个header的类型定义中,有几个地方需要注意下:

  • 在各个header的定义中使用了__attribute__((packed)), 是为了让编译器以紧凑模式来分配内存。如果没有这个属性,编译器可能会为struct的字段做对齐优化,在其中填充空字节。那样的话,就不能保证header和sds的数据部分紧紧前后相邻,也不能按照固定向低地址方向便宜1个字节的方式来获取flags字段了。
  • 在各个header的定义中最后有一个 char buf[] 。我们注意到这是一个没有指明长度的字符数组,这是C语言中定义字符数组的一种特殊写法,称为柔性数组(flexible array member),只能定义一个结构体的最后一个字段上。它这里只是起到一个标记的作用,表示在flags字段后面就是一个字符数组,或者说,它指明了紧跟在flags字段后面的这个字符数据在结构体中的偏移位置。而程序在为header分配内存的时候,它并不占用内存空间。如果计算sizeof(struct sdshdr16)的值,那么结果是5个字节,其中没有buf字段。

列表(list)

列表是用来存储多个有序的字符串。在3.2之前使用了linkedlist(双向链表) 和 ziplist(压缩列表)作为其底层实现,创建新列表时 redis 默认使用 ziplist , 满足以下任一条件时,列表会转换成使用 linkedlist :

  • 列表新增一个字符串值,这个字符串的长度超过 server.list_max_ziplist_value(默认值为64)
  • ziplist 包含的节点超过 server.list_max_ziplist_entries(默认值为512)

当然,这两个条件可以在配置文件中修改,redis.conf :

list-max-ziplist-value 64

List-max-ziplist-entries 512

在3.2之后使用了quicklist作为其底层实现。

Redids 5.0.7
> object encoding books
"quicklist" 
Redis 2.8.0
> object encoding books
"ziplist"

list 支持如下一些操作:

  • lpush : 在左侧(即列表头部)插入数据

    > lpush books java android
    (integer)2
    > lrange books 0 -1    //(O(n) 复杂度,慎用)
    1) "android"
    2) "java"
    
  • lpop : 在左侧(即列表尾部)删除数据

    > lpop books
    "android"
    
  • rpush : 在右侧(即列表尾部)插入数据

    > rpush books android
    (integer)2
    > lrange books 
    1) "java"
    2) "android"
    
  • rpop : 在右侧(即列表尾部)删除数据

    > rpop books
    "android"
    

这几个操作都是 O(1) 时间复杂度,redis 也支持任意位置的存取,lindexlinsert 但是因为内部用的是链表的数据ji结构,所以需要遍历,时间复杂度较高。

linkedlist 大家使用Java应该挺熟的,这里就不分析了。这里主要分析下 ziplistquicklist

ziplist

虽然说3.2之后,列表的底层数据结构改成了quicklist ,但是ziplist并没有被抛弃,依然是 hash 类型的底层实现方式之一。

Redis 官方对 ziplist 的定义(ziplist.c)

The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values,where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time. However, because every operation requires a reallocation of the memory used by the ziplist, the actual complexity is related to the amount of memory used by the ziplist.

zipilist 是一个经过特殊编码的双向链表,它的设计目标就是为了提高存储效率。ziplist 可以用于存储字符串或整数,其中整数是按真正的二进制表示进行编码的,而不是编码成字符串序列。它能以O(1)的时间复杂度在表的两端提供 pushpop 操作。

大体上看,ziplist 的结构如下:

...

entry 的结构:

  • prevlen

    以字节为单位,记录了前一个entry的长度。可以是1个字节或者5个字节。

    • 如果前一个entry的长度小于254字节,那么prevlen属性的长度就为1个字节,前一个entry的长度就保存在这个1个字节中
    • 如果前一个entry的长度大于等于254字节,那么prevlen属性的长度就为5个字节,其中prevlen属性的第一个字节为0xFE,之后的4个字节保存前一个entry的长度。

    因为prevlen保存了前一个entry属性的长度,所以程序可以通过指针运算,根据当前节点的起始,找到前一个entry的起始地址。压缩列表的从表尾向表头遍历的过程就是运用了这个原理。

  • encoding

    记录了 entry-data属性所保存数据的类型和长度:

    • 一字节、二字节长或五字节长,最高位为00、01或者10表示entry-data是数组类型。数组的长度为除去encoding编码最高两位之后的其它位记录。

    • 一字节长,最高位以11开头的是整数编码,表示entry-data是整数类型,整数值类型和长度为除去encoding编码最高两位之后的其它位记录。(整数值的类型可能为int16_t、int32_t、int64_t、有符号整数)

      编码 entry-data整数值类型
      11000000 Int16_t
      11010000 Int32_t
      11100000 Int64_t
      11110000 24位有符号整数
      11111110 8位有符号整数
      1111xxxx (xxxx between 0000 and 1101) 所以能表示的值在0~12之间,无需entry-data属性

      备注:11111111(0xFF),用于标记列表的结尾。

  • entry-data

    保存节点的值,可能为一个字节数组,也可能为整数。类型和长度由encoding属性决定。

    eg:

    • encoding:00001011 ;entry-data:"hello world" 。最高位"00"表示类型为字节数组,"001101"表示长度为11
    • encoding:11000000 ;entry-data:10086。最高位"11"表示存储的是一个int16_t类型的整数值。
ziplist api

ziplist 没有使用 struct 来表达,就是用 unsigned char *。因为ziplist 是一段连续的内存,而且内部结构是动态的,没法使用一个固定的数据结构来表达。

/**
** 创建一个空的ziplist
*/
unsigned char *ziplistNew(void);
/**
** 合并两个ziplist
*/
unsigned char *ziplistMerge(unsigned char **first,unsigned char **second);
/**
** 在表头或者表尾插入一段数据。
*/
unsigned char *ziplistPush(unsigned char *zl,unsigned char *s,unsigned int slen,int where);
/**
**	返回index 指定的数据项的内存位置。index可以是负数,表示从尾部向前索引
*/
unsigned char *ziplistIndex(unsigned char *zl,int index);
/**
** 返回下一个项
*/
unsigned char *ziplistNext(unsigned char *zl,unsigned char *p);
/**
**返回前一项
*/
unsigned char *ziplistPrev(unsigned char *zl,unsigned char *p);
/**
**指定位置插入一个新的数据项
*/
unsigned char *ziplistInsert(unsigned char *zl,unsigned char *p,unsigned char *s,unsigned int slen);

.....
linkedlist vs ziplist
  • 双向链表便于在表的两端进行push和pop操作,但是它的内存开销比较大。首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。
  • ziplist由于是一整块连续内存,所以存储效率很高。但是,它不利于修改操作,每次数据变动都会引发一次内存的realloc。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能。
quicklist

quicklist.h

/* quicklistNode is a 32 byte struct describing a ziplist for a quicklist.
 * We use bit fields keep the quicklistNode at 32 bytes.
 * count: 16 bits, max 65536 (max zl bytes is 65k, so max count actually < 32k).
 * encoding: 2 bits, RAW=1, LZF=2.
 * container: 2 bits, NONE=1, ZIPLIST=2.
 * recompress: 1 bit, bool, true if node is temporarry decompressed for usage.
 * attempted_compress: 1 bit, boolean, used for verifying during testing.
 * extra: 10 bits, free for future use; pads out the remainder of 32 bits */
typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
/* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
 * 'sz' is byte length of 'compressed' field.
 * 'compressed' is LZF data with total (compressed) length 'sz'
 * NOTE: uncompressed length is stored in quicklistNode->sz.
 * When quicklistNode->zl is compressed, node->zl points to a quicklistLZF */
typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/ //压缩后的ziplist大小
    char compressed[]; //柔性数组,存放压缩后的ziplist字节数组
} quicklistLZF;
/* quicklist is a 40 byte struct (on 64-bit systems) describing a quicklist.
 * 'count' is the number of total entries.
 * 'len' is the number of quicklist nodes.
 * 'compress' is: -1 if compression disabled, otherwise it's the number
 *                of quicklistNodes to leave uncompressed at ends of quicklist.
 * 'fill' is the user-requested (or default) fill factor.
 * 'bookmakrs are an optional feature that is used by realloc this struct,
 *      so that they don't consume memory when not used. */
typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : QL_FILL_BITS;              /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;

quicklist 是一个双向链表,而且是一个ziplist的双向链表。quicklistNode 表示的是链表都能知道,那么 ziplist 是用什么来表示的呢?看到上面这张图,一个 quicklistNode 指向一个 ziplist , 说明 ziplist 是 quicklistNode 的属性,就是上面代码中的 zl 字段。如果当前节点的数据没有压缩,那么它指向一个 ziplist 结构;否则,指向一个 quicklistLZF 结构。encoding 字段表示的是否压缩字段 1:没有压缩;2:压缩(LZF算法)。

quicklist 结合了 linkedlist 和 ziplist 的优点。但是有一个问题没说清楚,怎么搭配?

  • ziplist 越短,内存碎片越多,存储效率降低。极端情况下,每个ziplist都只包含一个数据项,就会退化成一个普通的linkedlist。
  • ziplist 越长,分配大块连续内存空间的难度就越大。可能出现,有很多的小块的内存空间,但是却找不到足够大的连续的空闲空间,从而降低存储效率。极端情况下,整个quicklist 只包含一个节点,所有的数据项都包含在这仅有的一个节点的ziplist里面。就退化成一个ziplist了。

ziplist 要维持一个合理的长度,redis 提供了一个配置参数 list-max-ziplist-size,可以取正值,也可取负值。

  • 正值,表示每个节点上的ziplist数据项
  • 负值,按照占用字节数来限定每个quicklist节点上的ziplist长度。这时,只能取-1到-5这五个值
    • -5 : 每个节点上的ziplist大小不能超过64Kb
    • -4 : 每个节点上的ziplist大小不能超过32Kb
    • -3 : 每个节点上的ziplist大小不能超过16Kb
    • -2 : 每个节点上的ziplist大小不能超过8Kb (默认值)
    • -1 : 每个节点上的ziplist大小不能超过4Kb

这个只是解决了一个节点上的ziplist大小的问题,还有一个问题是节点过多的问题。redis 提供了一个配置参数list-compress-depth ,表示quicklist两端不被压缩的节点个数,把中间的数据节点进行压缩。

  • 0 : 不压缩 (默认值)
  • 1 : 两端各一个节点不压缩,中间的节点压缩
  • 2 : 两端各两个节点不压缩,中间的节点压缩
  • 以此类推....

上图是一个quicklist的结构图示例。

list-max-ziplist-size 3
list-compress-depth 2

在插入头节点或者尾节点的时候,会判断ziplist的大小,如果超了,就会新建一个 quicklistNode ,如果没有超,直接拼接在ziplist后面(ziplistPush)。新建quicklistNode之后,会对老的quicklistNode 进行压缩(根据list-compress-depth)。

感谢