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[];
}
sds
是 char*
的别名,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 也支持任意位置的存取,lindex
和 linsert
但是因为内部用的是链表的数据ji结构,所以需要遍历,时间复杂度较高。
linkedlist
大家使用Java应该挺熟的,这里就不分析了。这里主要分析下 ziplist
和 quicklist
。
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)的时间复杂度在表的两端提供 push
和 pop
操作。
大体上看,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)。
感谢
- zhangtielei.com/posts/blog-…
- 《Redis设计与实现》