了解Redis的朋友都知道,Redis数据库是以键值对的形式存储所有数据的,Redis还能通过自身支持的五种数据类型来更自由地存储数据,只需要用对应的命令就能以这五种数据类型的形式将数据存进Redis数据库,但是Redis里的具体实现并没有看起来这么简单,这篇文章就自顶向下地来讲讲Redis是如何存储数据的
Redis存储结构
Redis数据库中键值对数据是以下图的方式存储在Redis服务器的,具体每个结构在下面会逐一分析
redisServer
Redis启动时会在main函数中初始化一个redisServer作为Redis服务器的实体,db数组的每一项都是redisDb结构,即Redis数据库,Redis默认是有16个数据库的
// server.h
struct redisServer {
...
redisDb *db;
...
int dbnum; /* Total number of configured DBs */
...
}
redisDb
redisDb就是Redis的具体某个数据库,可以用SELECT命令切换当前使用的数据库,redisDb内部有两个dict结构的指针,dict用于存储所有的数据(包括有以及没有超时属性的数据),expires只存储具有超时属性的数据
// server.h
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
...
int id; /* Database ID */
...
} redisDb;
dict
dict内部包含ht数组,存储两个dictht结构,即两个哈希表,ht[0]用于存储数据,ht[1]用于rehash,rehashidx值是用来记录扩容进度的,如果为-1则表示当前没有进行扩容,type属性为dictType结构体,里面包含计算哈希值等几个函数,所以一个Redis数据库的数据存储结构其实就是Redis实现的哈希表
dict进行rehash时会先给ht[1]的表分配内存空间,然后将ht[0]的所有数据重新计算索引值放到h[1]中,再释放ht[0]并将ht[1]的哈希表放到ht[0],再最后创建一个空的哈希表放到ht[1],而且为了减小rehash函数对服务器性能造成的影响,Redis没有一次把ht[0]的哈希表数据全部放到ht[1]中,而是渐进式地分多次把数据慢慢地放到ht[1]中
// dict.h
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
dictht
dictht内部包含哈希表结构,为一个dictEntry数组,使用链地址法解决哈希冲突,还有表的容量等信息
// dict.h
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
dictEntry
dictEntry是哈希表的节点,我们存储的每一个键值对都是存储在对应的dictEntry结构中的,dictEntry内部包含了key和value的指针(均为无类型指针),还有一个next指针指向另一个dictEntry,当发生哈希冲突时,可以通过这个指针将冲突的节点组成一条链表解决冲突
// dict.h
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
Redis数据类型
五种数据类型
Redis有五种数据类型,分别是String、List、Set、Zset、Hash,但起始Redis实际上并没有实现这五种数据类型,只是定义了五个宏来表示这五种数据类型,因为每一种数据类型都不止一种底层数据结构,所以Redis中的五种数据类型只是一个抽象的概念
// server.h
/* The actual Redis Object */
#define OBJ_STRING 0 /* String object. */
#define OBJ_LIST 1 /* List object. */
#define OBJ_SET 2 /* Set object. */
#define OBJ_ZSET 3 /* Sorted set object. */
#define OBJ_HASH 4 /* Hash object. */
redisObject
Redis实现五种抽象数据类型存储数据的原理其实很简单,就是通过redisObject结构体来存储键值对数据的值,redisObject结构体成员type表示数据类型,encoding表示编码方式,这是Redis具体实现的数据结构,后面会提到,lru表示当前redisObject最后一次被访问的时间,refcount表示当前redisObject被引用的次数,ptr为指向底层存储结构的指针
// server.h
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
所以redisObject中没有存储数据,只是存储数据的属性和指向底层数据结构的指针,Redis的五种数据类型也只不过是一个属性而已,这样做的好处是Redis可以根据数据的大小灵活地改变底层数据结构,更高效地使用内存空间和存储数据,Redis还能通过redisObject统一地进行数据存储和内存管理、实现共享对象(Redis默认创建0~9999的字符串作为共享对象)
Redis数据结构
十种数据编码方式
Redis存储数据时会根据数据的类型和数据的大小来选择合适的编码方式,每一种编码方式的底层数据结构都是确定的,这里提到的底层数据结构只是简单说明,在后面还会详细分析这些底层数据结构的实现原理
// server.h
/* Objects encoding. Some kind of objects like Strings and Hashes can be
* internally represented in multiple ways. The 'encoding' field of the object
* is set to one of this fields for this object. */
#define OBJ_ENCODING_RAW 0 /* Raw representation */
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#define OBJ_ENCODING_HT 2 /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3 /* Encoded as zipmap */
#define OBJ_ENCODING_LINKEDLIST 4 /* No longer used: old list encoding. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6 /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
五种数据类型与十种编码方式的对应关系如下:
type | encoding | encoding | encoding |
---|---|---|---|
string | INT | EMBSTR | RAW |
list | ZIPLIST | LINKEDLIST(< 3.2) | QUICKLIST(>=3.2) |
hash | ZIPLIST | HT | |
set | INTSET | HT | |
zset | ZIPLIST | SKIPLIST |
OBJ_ENCODING_INT
INT编码方式以整数来保存字符串数据,但是字符串长度得小于或等于20,在tryObjectEncoding函数中,如果字符串长度小于或等于20并且字符串可以非溢出地解析成long值,则会将字符串用long值进行存储,还有如果没有设置maxmemory内存限制的话并且字符串解析的long值小于最小共享值的话会直接返回共享对象(默认创建0~9999为共享对象)
// object.c
robj *tryObjectEncoding(robj *o) {
long value;
sds s = o->ptr;
size_t len;
...
/* Check if we can represent this string as a long integer.
* Note that we are sure that a string larger than 20 chars is not
* representable as a 32 nor 64 bit integer. */
len = sdslen(s);
if (len <= 20 && string2l(s,len,&value)) {
/* This object is encodable as a long. Try to use a shared object.
* Note that we avoid using shared integers when maxmemory is used
* because every object needs to have a private LRU field for the LRU
* algorithm to work well. */
if ((server.maxmemory == 0 ||
!(server.maxmemory_policy & MAXMEMORY_FLAG_NO_SHARED_INTEGERS)) &&
value >= 0 &&
value < OBJ_SHARED_INTEGERS)
{
// 没有设置maxmemory内存限制的话并且字符串解析的long值小于最小共享值
decrRefCount(o);
incrRefCount(shared.integers[value]);
// 返回共享对象
return shared.integers[value];
} else {
if (o->encoding == OBJ_ENCODING_RAW) {
sdsfree(o->ptr);
o->encoding = OBJ_ENCODING_INT;
o->ptr = (void*) value;
return o;
} else if (o->encoding == OBJ_ENCODING_EMBSTR) {
decrRefCount(o);
return createStringObjectFromLongLongForValue(value);
}
}
}
...
}
OBJ_ENCODING_EMBSTR
创建字符串对象时如果字符串长度没有超过44则以EMBSTR编码方式创建,否则以raw编码方式创建
// object.c
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
return createEmbeddedStringObject(ptr,len);
else
return createRawStringObject(ptr,len);
}
从zmalloc函数可以看出redisObject和sds(Redis的字符串结构)是一起分配内存的,他们的地址是连续的,这样可以减少内存分配的次数和避免内存碎片的出现
// object.c
robj *createEmbeddedStringObject(const char *ptr, size_t len) {
robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr8)+len+1);
struct sdshdr8 *sh = (void*)(o+1);
o->type = OBJ_STRING;
o->encoding = OBJ_ENCODING_EMBSTR;
o->ptr = sh+1;
...
if (ptr == SDS_NOINIT)
sh->buf[len] = '\0';
else if (ptr) {
memcpy(sh->buf,ptr,len);
sh->buf[len] = '\0';
} else {
memset(sh->buf,0,len+1);
}
return o;
}
OBJ_ENCODING_RAW
当字符串长度大于44时会使用RAW编码方式存储字符串,redisObject和sds是分别申请内存的,然后再将sds的指针赋给redisObject的ptr成员变量
// object.c
robj *createRawStringObject(const char *ptr, size_t len) {
return createObject(OBJ_STRING, sdsnewlen(ptr,len));
}
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
o->type = type;
o->encoding = OBJ_ENCODING_RAW;
o->ptr = ptr;
...
return o;
}
// sds.c
sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen);
/* Empty strings are usually created in order to append. Use type 8
* since type 5 is not good at this. */
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
int hdrlen = sdsHdrSize(type);
unsigned char *fp; /* flags pointer. */
sh = s_malloc(hdrlen+initlen+1);
...
return s;
}
OBJ_ENCODING_ZIPLIST
ZIPLIST编码方式会创建一个ziplist数据结构来存储数据,list、hash、zset类型的数据在成员较少、成员值较小的时候都会采用ziplist的编码方式
// object.c
robj *createZiplistObject(void) {
unsigned char *zl = ziplistNew();
robj *o = createObject(OBJ_LIST,zl);
o->encoding = OBJ_ENCODING_ZIPLIST;
return o;
}
robj *createHashObject(void) {
unsigned char *zl = ziplistNew();
robj *o = createObject(OBJ_HASH, zl);
o->encoding = OBJ_ENCODING_ZIPLIST;
return o;
}
robj *createZsetZiplistObject(void) {
unsigned char *zl = ziplistNew();
robj *o = createObject(OBJ_ZSET,zl);
o->encoding = OBJ_ENCODING_ZIPLIST;
return o;
}
这两个临界值是可以在Redis的配置文件中指定的
# redis.conf
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
OBJ_ENCODING_LINKEDLIST
Redis3.2版本之前,list类型的数据使用LINKEDLIST编码方式,底层为adlist.h中的list数据结构
OBJ_ENCODING_QUICKLIST
Redis3.2版本之后,list类型的数据使用QUICKLIST编码方式,底层为quicklist.c中quicklist数据结构
// object.c
robj *createQuicklistObject(void) {
quicklist *l = quicklistCreate();
robj *o = createObject(OBJ_LIST,l);
o->encoding = OBJ_ENCODING_QUICKLIST;
return o;
}
OBJ_ENCODING_INTSET
当set类型的数据都是整数并且数量比较少时会使用INSET编码方式,底层为intset.h中的intset数据结构
// object.c
robj *createIntsetObject(void) {
intset *is = intsetNew();
robj *o = createObject(OBJ_SET,is);
o->encoding = OBJ_ENCODING_INTSET;
return o;
}
这个临界值同样可以通过Redis的配置文件进行指定
# redis.conf
set-max-intset-entries 512
OBJ_ENCODING_HT
hash类型的数据使用HT编码方式,底层为dict.h中的dict数据结构,在hashTypeTryConversion函数中检查hash对象,如果存储的字符长度超过临界值,就把ziplist存储结构转换为dict存储结构
// t_hash.c
void hashTypeTryConversion(robj *o, robj **argv, int start, int end) {
int i;
if (o->encoding != OBJ_ENCODING_ZIPLIST) return;
for (i = start; i <= end; i++) {
if (sdsEncodedObject(argv[i]) &&
sdslen(argv[i]->ptr) > server.hash_max_ziplist_value)
{
hashTypeConvert(o, OBJ_ENCODING_HT);
break;
}
}
}
set类型的数据也是使用HT编码方式,创建的dict结构的值为NULL
// object.c
robj *createSetObject(void) {
dict *d = dictCreate(&setDictType,NULL);
robj *o = createObject(OBJ_SET,d);
o->encoding = OBJ_ENCODING_HT;
return o;
}
OBJ_ENCODING_SKIPLIST
zset类型的数据使用SKIPLIST编码方式,底层为zset.h中的zset数据结构,在createZsetObject函数中在创建zset的同时还需要创建一个dict和zskiplist数据结构并将它们的指针赋给zset的成员变量
// object.c
robj *createZsetObject(void) {
zset *zs = zmalloc(sizeof(*zs));
robj *o;
zs->dict = dictCreate(&zsetDictType,NULL);
zs->zsl = zslCreate();
o = createObject(OBJ_ZSET,zs);
o->encoding = OBJ_ENCODING_SKIPLIST;
return o;
}
底层数据结构
上面说到的数据编码方式使用的对应数据结构都是Redis实际存储值的地方,现在就来看看Redis是怎么实现这些底层数据结构的,dict在一开始介绍Redis存储结构的时候已经提到,这里就略过这个数据结构了
sds
C语言的char[]数组存储字符串比较局限,所以Redis使用自定义的sds结构体(Simple Dynamic String)来存储字符串,sds结构体中包含字符串长度len,分配的空间大小alloc(已使用和未使用),用于表示结构体类型信息的标志位flags(除了sdshdr5都是低三位表示类型,高五位表示未使用的位),存储字符串的柔性数组buf[](C语言结构体特性,只是占位符,不占用空间)
// sds.h
typedef char *sds;
/* 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[];
};
Redis根据字符串长度定义多种sdshdr结构体,比如根据前面的分析可以知道EMBSTR编码方式是直接使用sdshdr8结构体,RAW编码方式是调用sdsnewlen函数根据字符串的长度选择不同类型的结构体来创建字符串,sdshdr5一般不会被用到
ziplist
ziplist是一系列特殊编码的连续内存块组成的顺序型数据结构,用来节约内存,Redis没有定义ziplist的结构体,就是一个unsigned char *,本质上相当于一个字节数组,只定义了宏来表示ziplist内部的各个部分,每个部分的长度也是不同的,ziplist的内存分布:
[zlbytes] [zltail] [zllen] [zlentry] … [zlentry] [zlend]
// ziplist.c
#define ZIP_END 255
#define ZIPLIST_BYTES(zl) (*((uint32_t*)(zl)))
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t))))
#define ZIPLIST_LENGTH(zl) (*((uint16_t*)((zl)+sizeof(uint32_t)*2)))
...
unsigned char *ziplistNew(void) {
unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
unsigned char *zl = zmalloc(bytes);
ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
ZIPLIST_LENGTH(zl) = 0;
zl[bytes-1] = ZIP_END;
return zl;
}
Redis定义了ziplist节点的结构体zlentry,但是根据注释了解到这个结构体并不是数据在ziplist的实际编码方式,Redis只是将ziplist中的节点信息按照规则解析到zlentry中,方便后续计算,ziplist节点的内存分布:
[previous_entry_length] [encoding] [content]
// ziplist.c
typedef struct zlentry {
unsigned int prevrawlensize; /* 用来编码前一节点长度的字节数,就是previous_entry_length的长度 */
unsigned int prevrawlen; /* 前一节点的长度,就是previous_entry_length的值 */
unsigned int lensize; /* 用来编码节点长度和数据编码信息的字节数,就是encoding的长度 */
unsigned int len; /* 节点数据的长度,就content的长度 */
unsigned int headersize; /* prevrawlensize + lensize. */
unsigned char encoding; /* 节点数据编码 */
unsigned char *p; /* 节点数据 */
} zlentry;
/* Return a struct with all information about an entry. */
void zipEntry(unsigned char *p, zlentry *e) {
ZIP_DECODE_PREVLEN(p, e->prevrawlensize, e->prevrawlen);
ZIP_DECODE_LENGTH(p + e->prevrawlensize, e->encoding, e->lensize, e->len);
e->headersize = e->prevrawlensize + e->lensize;
e->p = p;
}
list
list就是一个很基础的双向链表
// adlist.h
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;
quicklist
quicklist也是一个双向链表,只是它的节点是用ziplist存储数据的(就是下面的unsigned char *),所以节点和链表的基本信息也会比较多,为什么要设计成这样呢,这是因为链表和压缩列表都具有局限性,链表在节点插入和删除操作上很方便,但是内存空间不连续,内存开销很大,压缩列表在一块连续的内存上,存储效率高,但是数据较多时对于节点插入和删除操作的性能都特别差,因为可能需要移动大量的内存数据,所以合理控制压缩列表存放元素个数并将其作为链表的节点可以让这两种数据结构互相弥补各自的短板,使list性能更佳
// quicklist.h
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;
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;
intset
intset是一个整数集合,存储结构为contents数组,数组的元素不会重复并且是有序的,contents数组存储的整数类型取决于encoding值,所以只会存储int16_t、int32_t和int64_t类型的整数,intset查看元素是否在集合使用二分查找法
// intset.h
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
// intset.s
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))
zskiplist
zskiplist为跳跃表,是一种高效查询的数据结构,元素在表中都是有序的,实现了平均O(logN),最坏O(N)时间复杂度的查找,是一种空间换时间的实现方式,跳跃表的底层结构就是一个多层链表(zskiplist用数组来构造层),最底层的链表存储完整数据,其他每一层的节点数都不同,是从下层到上层递减的,查找元素的时候是从最上层链表开始并跳跃着往后遍历查找节点,这样就实现了不用遍历位于所查节点前的所有节点就能得到所查节点,有点类似于基于有序数组的二分查找
// server.h
typedef struct zskiplistNode {
sds ele;
double score;
// 后退指针,用于从后向前遍历
struct zskiplistNode *backward;
// 用数组实现分层
struct zskiplistLevel {
// 每层都有前进指针,用于从前向后遍历
struct zskiplistNode *forward;
// 节点与前一个节点之间的跨度
unsigned long span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
// 最大的节点层数
int level;
} zskiplist;
zset
zset是一个有序的集合,存储的元素都是唯一的,元素根据score值进行排序,Redis用dict和zskiplist都构造zset结构,zskiplist中按照score值从小到大存放了所有集合元素,dict中则是维护了所有集合元素与分值的映射,这是因为同时使用这两种数据结构相比单独使用其中一种数据结构(只使用其中任意一种也可以实现zset的)可以更高效地完成zset的操作,比如查找指定元素的分值(ZSCORE命令)是使用dict的,时间复杂度是O(1),查找指定范围内的所有元素就用有序的zskiplist,而且zset的dict和zskiplist是共享元素的成员和分值的,所以不会出现重复数据而浪费内存的情况,所以使用这两个数据结构各自的特性完成对应的操作可以最大化zset的性能
// server.h
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;