Redis数据类型及存储结构

众所周知,redis是有名的哈希存储,从而实现从键对值的快速访问。它使用了一个哈希表来保存所有键值对。

实际上为了应对哈希冲突,entry里面还有一个next的指针,指向下一个entry

redis值的基本数据类型包含五大类:String、List、Set、Sorted Set、Hash。除String外,其它4种类型因为value值都是一组集合,所以后4中类型也叫做集合类型。它们所对应的底层数据结构如下:

图片来源:极客时间,这是我们常看到的redis的数据结构。实际上有些许变化

因为redis有多种数据类型,不同的数据类型有一些元数据需要记录(如LRU,被引用次数等),所以redis会用一个redisObject对象来保存这些元数据,并用指针指向实际数据。redisObject的定义如下:(https://github.com/redis/redis/blob/unstable/src/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;

type表示对象的类型,占4个bit。string,list,hash等
encoding:对象的编码类型,占4个bit。如string类型有int、raw、embstr三种编码。
refcount:*记录该对象被引用的次数,占用4字节。
ptr:指向具体数据的指针,占用8字节。
所以一个redisObjects的大小为16个字节。

综合起来,redis的存储结构大概如下:


redis存储示意图.png

关于key存储的是SDS还是redisObject,一定要注意。redis在开始时把key和value都转成了redisObject。但在最后进行db存储操作的时候,又只取出了redisObject对象中ptr所指向的SDS结构体。看源码时看到db中的setKey之后没细看,纠结了我好久。

好了,接下来就开始逐一了解一下这五种数据类型以及他们的数据结构。首先是String。

-----------------------------------------String---------------------------------------
String就是我们经常用到的典型的键-单值,例如:

127.0.0.1:6379> SET 'name' 'huan'
OK

String类型的数据,redis会用简单动态字符串(Simple Daynamic String,SDS)结构体来保存数据。

SDS结构体.png

buffer: 字节数组,保存实际数据。为表示数据结束,会在最后加一个'\0'
Len: buffer的已用长度。会使用sdsReqType方法据长度的大小和系统选择合适的字节数(1/2/4/8)。
Alloc: 实际的分配长度,会取离len最近的2的幂次方。一般会大于len。和len一样根据长度的大小选择合适的字节数。
flags: 标记字段,目前只使用了3bits

sdshdr8结构体定义如下:

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[];
};

当我们设置一个简单的字符串键值对(<6bytes),计算使用内存为80bytes.

before_mem = get_memory_info()
r.set("key1","key1")
after_mem = get_memory_info()
print("used:{}".format(after_mem-before_mem))

#输出
已使用的内存为1109120B
已使用的内存为1109200B
used:80

但如果key和value都是字符串形式的整型, 则只需要使用64bytes.

before_mem = get_memory_info()
r.set("1001","100202")
after_mem = get_memory_info()
print("used:{}".format(after_mem-before_mem))

#输出
已使用的内存为1109056B
已使用的内存为1109120B
used:64

get_memory_info方法:

def get_memory_info()->(float,float):
    info_mem = r.info(section='memory')
    used_memory = info_mem['used_memory']
    print("已使用的内存为{}B".format(used_memory))
    return used_memory

这是因为String有三种编码方式int,embstr, raw,所对应的底层存储方式不同。


三种编码方式.png

当字符串为长整型时,直接将redisObject的指针ptr直接存储为整型数据。
当字符串小于等于44字节时,redisObject的指针ptr与它所指向的SDS结构连续存储。
当字符串大于44字节时,SDS不再和redisObject连续存储了,而是单独地分配空间,通过ptr指针指向。
这样做的目的是为了更好地利用内存,减少内存碎片。

-----------------------------------------Hash---------------------------------------
Hash类型包含key、filed、value三部分,如:

127.0.0.1:6379> hset website google google.cn
(integer) 0
127.0.0.1:6379> hget website google
"google.cn"

即一个key下面还有域的概念,一个key将对应多组field:value。这样对于一些具有相同前缀的key我们可以进行切割,把key的相同部分提取出来作为key,不同部分为field,既可以进行一个分类区分,又可以节省内存空间。
Hash的底层结构使用紧凑列表listpack(改进版的压缩列表ziplist)哈希表两种方式进行存储。

int hashTypeSet(robj *o, sds field, sds value, int flags) {
 if (o->encoding == OBJ_ENCODING_LISTPACK) {
      ...
    } else if (o->encoding == OBJ_ENCODING_HT){
      ...
}

Hash类型默认使用压缩列表存储,但它设置了用压缩列表保存数据时的两个阈值,如果超过了这两个阈值,则使用哈希表保存数据。两个阈值分别是:
hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数
hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。
我们可以在redis.conf中设置这两个值,默认设置为:

hash-max-ziplist-entries 512
hash-max-ziplist-value 64

我们看一下内存存储情况

before = get_memory_info()
r.hset('website','baidu','baidu.com')
after = get_memory_info()
print(after-before)

before = get_memory_info()
r.hset('website','google','google.cn')
after = get_memory_info()
print(after-before)

#输出
已使用的内存为1090160B
已使用的内存为1090288B
128 此时我的redis里面是没有数据的,如果非空,此值为96
已使用的内存为1090288B
已使用的内存为1090304B
16

如果key不存在,需要分配dictEntry等,实际数据所占的内存比比较小,而一旦key已存在,所使用的内存就小了。那就看看压缩列表是怎样存储以减少内存的吧

压缩列表
因为listpack是ziplist的改进版,我们就先从压缩列表看起吧。压缩列表的文档相对详细很多。我们可以看下他们两个的entry的定义,基本一样的:

/* Each entry in the ziplist is either a string or an integer. */
typedef struct {
  /* When string is used, it is provided with the length (slen). */
  unsigned char *sval;
  unsigned int slen;
  /* When integer is used, 'sval' is NULL, and lval holds the value. */
  long long lval;
} ziplistEntry;
/* Each entry in the listpack is either a string or an integer. */
typedef struct {
    /* When string is used, it is provided with the length (slen). */
    unsigned char *sval;
    uint32_t slen;
    /* When integer is used, 'sval' is NULL, and lval holds the value. */
    long long lval;
} listpackEntry;
压缩列表存储结构 .png

zlbytes:4字节,指整个压缩列表的长度
zltail:4字节,最后一个entry的偏移量。主要是用于pop的时候可以直接定位而不需要遍历。(listpack没有此字段
zllen:2字节,entry的个数。当超过2^16-2 个时,设置为2^16-1,此时需要遍历才能知道有多少个。
zlend:ziplist的结束标志。0xFF。ziplist中其它字段都不会存在0xFF。

entry:
entry里面可以存字符串,可以存整型。主要包括以下几部分:
prev_len:前一个数据的长度。当长度小于254的时候,只需1个字节;当长度大于等于254的时候,占5个字节,第一个字节设置为FE,后面4个字节表示长度。
encoding:2个bit。表示数据是字符串还是整型等,以及是多少长度的。如:
00|pppppp:表示长度小于或等于63(6bits)的字符串。后面6bits表示字符串长度
len数据的长度
结合源码中给出的注释例子:

 * The following is a ziplist containing the two elements representing
 * the strings "2" and "5". It is composed of 15 bytes, that we visually
 * split into sections:
 *
 *  [0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
 *        |             |          |       |       |     |
 *     zlbytes        zltail    entries   "2"     "5"   end

看完上面,为什么说ziplist节省空间呢?因为ziplist是按顺序存储数据的,相当于数组。但是又不同于数据,数据只需要多大的空间,它存分配多少给存取,因为它有记录长度的字段。而不像数组,它每一个数据都需要按数组的最大长度的数据分配空间。而它不能存储过大的数据,也是因为它是顺序存取,所以读取的时候是要遍历的,如果数据过多,就会影响其性能。当其超过其阈值时,就要转换成其它的数据结构存储了。

-----------------------------------------List---------------------------------------
示例

r.lpush('op','on')
r.lpush('op','off')
r.linsert('op','AFTER','on','color')
r.lrange('op',0,r.llen("op"))

许多地方都说列表的底层存储结构为压缩列表双向链表。实际上,redis后面也做了优化,用的是快速列表quicklist。根据它的描述可得知是一个listpack的链表。即综合了压缩列表和双向链表。

#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of listpacks */

整数数组的结构定义如下:

typedef struct intset {
    uint32_t encoding; #有16B/32B/64B
    uint32_t length; #整型数组中数据的个数
    int8_t contents[]; #一个柔性数组,用于存取具体的集合数据
} intset;

-----------------------------------------Set---------------------------------------
示例

SADD bbs "discuz.net"

Set集合的底层存储结构为整数数组哈希表。在redis的配置中有一个参数可配置整型数组中元素的最大个数,默认为512。最大不能超过1G,即使配置超过1G了,代码也会进行修正。当超过最大个数时,就转成哈希表存储。

set-max-intset-entries 512
/* Convert to regular set when the intset contains
                 * too many entries. */
                size_t max_entries = server.set_max_intset_entries;
                /* limit to 1G entries due to intset internals. */
                if (max_entries >= 1<<30) max_entries = 1<<30;
                if (intsetLen(subject->ptr) > max_entries)
                    setTypeConvert(subject,OBJ_ENCODING_HT);

-----------------------------------------Sorted Set---------------------------------
sorted set 底层使用紧凑列表listpack和跳表skiplist两种结构保存数据。同时为了插入和删除的时间复杂度为O(log(n)),它还使用哈希表保存数据。

ZSETs are ordered sets using two data structures to hold the same elements in order to get O(log(N)) INSERT and REMOVE operations into a sorted data structure.
The elements are added to a hash table mapping Redis objects to scores. At the same time the elements are added to a skip list mapping scores to Redis objects (so objects are sorted by scores in this "view").

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

它同样有两个配置选项控制是使用压缩列表还是跳表

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

zset-max-ziplist-entries: 压缩列表中元素的最大个数
zset-max-ziplist-value:压缩列表中单个元素的最大长度

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容