Redis底层数据结构剖析

118 阅读19分钟

编者注:本文参考《Redis设计与实现 - 黄健宏》的前1~8章

Redis数据结构

字符串类型(string)

SDS格式

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

底层_sds.jpg

SDS与C字符串区别

  1. SDS是O1复杂度获取字符串长度,C需要On;

  2. 避免缓存区溢出;C拼接字符串需要事先开辟好足够的内存,不然会溢出;SDS自动检测内存并有扩容机制;

  3. 减少字符串修改时带来的内存重分配次数;SDS实现了空间预分配惰性空间释放两种优化;

    预分配:当修改后SDS长度小于1MB,那会额外分配和len属性同样大小+1byte(空字符)的未使用空间;

    当修改后SDS长度大于等于1MB,那额外分配1MB+1byte未使用空间;

    惰性删除

    使用free字段记录回收空间的大小;

  4. 二进制安全;C的字符串字符都是符合某种编码且以\0结尾,使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据;SDS保存二进制数据,通过len来确定字符串大小,并且为了和C保持通用,其结尾也会加空字符;

链表类型(list)

结构

底层_链表.jpg

// 双端链表结点
typedef struct listNode {
	// 前置节点
	struct listNode * prev;
	// 后置节点
	struct listNode * next;
	// 节点的值
	void * value;
}listNode;
// 链表封装
typedef struct list {
	// 表头节点
	listNode * head;
	// 表尾节点
	listNode * tail;
	// 链表所包含的节点数量
	unsigned long len;
	// 节点值复制函数
	void *(*dup)(void *ptr);
	// 节点值释放函数
	void (*free)(void *ptr);
	// 节点值对比函数
	int (*match)(void *ptr,void *key);
} list;

dup函数用于复制链表节点所保存的值;

free函数用于释放链表节点所保存的值;

match函数则用于对比链表节点所保存的值和另一个输入值是否相 等;

链表特点

链表被广泛用于实现Redis的各种功能,比如列表键、发布与订 阅、慢查询、监视器等;

  1. 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1);
  2. 无环:表头节点的prev指针和表尾节点的next指针都指向NULL, 对链表的访问以NULL为终点;
  3. 带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1);
  4. 带链表长度计数器:程序使用list结构的len属性来对list持有的链表 节点进行计数,程序获取链表中节点数量的复杂度为O(1);
  5. 多态:链表节点使用void*指针来保存节点值,并且可以通过list结 构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值;

字典(dict)

场景

> set msg helloworld
OK
> OBJECT ENCODING msg
embstr
> type msg
string

此处Redis会在字典里记录key=msg, value=helloworld的记录;

除此之外,还有hash类型,对于数据较多下会使用字典,后续会详细说明;

结构

# hash表
typedef struct dictht {
 // 哈希表数组
 dictEntry **table;
 // 哈希表大小
 unsigned long size;
 // 哈希表大小掩码,用于计算索引值,总是等于size-1
 unsigned long sizemask;
 // 该哈希表已有节点的数量
 unsigned long used;
} dictht;

table属性是一个数组,数组中的每个元素都是一个指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对;

sizemask属性的值总是等于 size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面;

size 属性记录了哈希表的大小,也即是table数组的大小;

used属性则记录了哈希表目前已有节点(键值对)的数量;

# hash表节点(键值对)
typedef struct dictEntry {
	// 键
	void *key;
	// 值
	union{
		void *val;
		uint64_tu64;
		int64_ts64;
	} v;
	// 指向下个哈希表节点,形成链表
	struct dictEntry *next;
} dictEntry;

key属性保存着键值对中的键;

v属性保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一 个int64_t整数;

next属性是指向另一个哈希表节点的指针,目的是节点链化;

底层_字典结构.jpg

# 字典
typedef struct dict {
 // 类型特定函数
 dictType *type;
 // 私有数据
 void *privdata;
 // 哈希表
 dictht ht[2];
 // rehash 索引:当rehash不在进行时,值为-1
 int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;

type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的;

  • type属性是一个指向dictType结构的指针,每个dictType结构保存了 一簇用于操作特定类型键值对的函数,Redis会为不同对象类型的字典设置不同类型的特定函数。
  • privdata属性则保存了需要传给那些类型特定函数的可选参数。

ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0] 哈希表进行rehash时使用。 除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。

底层_字典整体.jpg

# 字典类型(可以理解为C++的接口,毕竟这是C写的,没有接口,只能用结构体保存函数指针)
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;

Rehash说明

字典保存了一个翻转数组,其中一个保存hash表,另一个节点的hash表为空;目的是,当hash表需要扩容的时候,通过写时拷贝和扩容机制,将空的节点升级为扩容后的hash表,避免竞争问题;

扩容机制

满足其一即可扩容

  • 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1;
  • 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5;
缩容机制
  • 当哈希表的负载因子小于0.1时,程序自动开始对哈希 表执行收缩操作。
# 负载因子=哈希表已保存节点数量/哈希表大小
load_factor = ht[0].used / ht[0].size
渐进式扩容
  1. 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。

  2. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置 为0,表示rehash工作正式开始。

  3. 在rehash进行期间,每次对字典执行删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在 rehashidx索引上的所有键值对 rehash 到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一;

    在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两 个哈希表,所以在渐进式rehash进行期间,字典的删除(delete)、查找 (find)、更新(update)等操作会在两个哈希表上进行。

    另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被 保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了 ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。

  4. 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表 示rehash操作已完成。

渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对 所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式rehash而带来的庞大计算量。

跳表(skiplist)

跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可 以通过顺序性操作来批量处理节点。

底层_跳表.jpg

结构

# 跳表   
typedef struct zskiplist {
 // 表头节点和表尾节点
 structz skiplistNode *header, *tail;
 // 表中节点的数量
 unsigned long length;
 // 表中层数最大的节点的层数
 int level;
} zskiplist;

通过zskiplist结构来持有跳表节点,可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点, 或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息。

# 跳表节点
typedef struct zskiplistNode {
  // 层
  struct zskiplistLevel {
    // 前进指针
    struct zskiplistNode *forward;
    // 跨度
    unsigned int span;
  } level[];
  // 后退指针
  struct zskiplistNode *backward;
  // 分值
  double score;
  // 成员对象
  robj *obj;
} zskiplistNode;
  • 层:跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指 向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度, 一般来说,层的数量越多,访问其他节点的速度就越快;

    每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为 level数组的大小,这个大小就是层的“高度”;

  • 前进指针:每个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点;

  • 跨度:用于记录两个节点之间的距离;

  • 后退指针:用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点;

  • 分值:节点的分值(score属性)是一个double类型的浮点数,跳跃表中的 所有节点都按分值从小到大来排序;

  • 成员对象:节点的成员对象(obj属性)是一个指针,它指向一个字符串对 象,而字符串对象则保存着一个SDS值。当分数一致时,按照对象字典序排列;

整数集合(intset)

当一个集合只包含整 数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。

结构

typedef struct intset {
 // 编码方式
 uint32_t encoding;
 // 集合包含的元素数量
 uint32_t length;
 // 保存元素的数组 单字节数组
 int8_t contents[];
} intset;

整数集合(intset)是Redis用于保存整数值的集合抽象数据结构, 它可以保存类型为int16_t(2字节)、int32_t(4字节)或者int64_t(8字节)的整数值,并且保证集合中不会出现重复元素;

  • contents数组是整数集合的底层实现:整数集合的每个元素都是 contents数组的一个数组项(item)按照单字节声明数组长度,实际保存的元素按照encoding决定元素占用大小,各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项;
  • length属性记录了整数集合包含的元素数量,也即是contents数组的长度;
  • encoding属性的值为INTSET_ENC_INT16,表示整数集合的底层实 现为int16_t类型的数组,而集合保存的都是int16_t类型的整数值;
  • length属性的值为5,表示整数集合包含五个元素;

类型升级

比如原本数组的元素都是int16的,现在放入int32的元素,那么数组中每个元素都需要进行升级成int32的;大体分为3步:

  1. 重新分配内存空间;
  2. 已有元素进行类型转换;
  3. 添加新元素;

升级的优势:

  1. 灵活保存元素;可以实时根据元素的大小选择合理的类型去保存;
  2. 节约内存空间;

降级

若对数组进行了升级,编码就一直保持升级后的状态,不会再降级;

压缩列表(ziplist)

当一个列 表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是 长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。

底层_压缩列表.jpg

压缩表节点(entry)

底层_压缩链表.jpg

可表示的值

表格中的下划线“_”表示留空,而b、x等变量则代表实际 的二进制数据;

类型大小编码编码长度(字节)
字符串长度小于等于63(2^6–1)字节的字节数组00 bbbbbb1
长度小于等于16383(2^14–1)字节的字节数组01 bbbbbb xxxxxxxx2
长度小于等于4294967295(2^32–1)字节的字节数组10 ______ aaaaaaaa bbbbbbbb cccccccc dddddddd5
整型4长,介于0至12之间的无符号整数1111xxxx (xxxx四位已经保存了0~12的值,无需content属性)1
1字节长的有符号整数11 1111101
3字节长的有符号整数11 1100001
int16_t类型整数11 0000001
int32_t类型整数11 0100001
int64_t类型整数11 1000001

结构

previous_entry_length:长度为1字节或5字节。

条件长度字节值含义
前一节点的长度<254字节1前一节点长度
前一节点的长度>=254字节5其中属性的第一字节会被设置为0xFE(十进制值 254),而之后的四个字节则用于保存前一节点的长度

压缩列表的从表尾向表头遍历操作是将一个指向某个节点起始地址的指针减去这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。

encoding:节点的encoding属性记录了节点的content属性所保存数据的类型以及长度;长度为1字节,2字节,5字节;上方列表已列出;

最高位为00、01或者10的是 字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录;

最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值。整数值的类型和长度由编码除去最高两位之后的其他位记录;

连锁更新

由于我们每个节点会记录前一个节点的长度,因此当在压缩节点中插入一个值后,有可能会引起后续节点的连锁更新。比如每个节点的长度都是253个字节,因此每个节点的previous_entry_length长度都是1个字节,当向第一个位置插入一个254字节的值,那么后面的每个节点previous_entry_length长度都要更新成5字节;

注意:添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引 发连锁更新操作,但这种操作出现的几率并不高。

Redis对象系统

Redis使用一个对象Object来封装五种不同类型的对象,Redis可以在执行命令之前,根据对 象的类型来判断一个对象是否可以执行给定的命令。使用对象的另一个好处是,我们可以针对不同的使用场景,为对象设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。

RedisObject

结构

Redis底层实际用的数据结构由对象类型和编码决定

typedef struct redisObject {
 // 类型
 unsigned type:4;
 // 编码
 unsigned encoding:4;
 // 指向底层实现数据结构的指针
 void *ptr;
 // ...
} robj;

Redis5大对象类型

对象含义对象类型TYPE命令的输出
字符串对象REDIS_STRINGstring
列表对象REDIS_LISTlist
哈希对象REDIS_HASHhash
集合对象REDIS_SETset
有序集合对象REDIS_ZSETzset

Redis对象列表,编码,数据结构

从redisObject的结构可以看出,Redis底层实际用的数据结构由对象类型和编码决定

对象类型编码数据结构数据结构缩写OBJECT ENCODING
REDIS_STRINGREDIS_ENCODING_INT整数实现int
REDIS_ENCODING_EMBSTRembstr编码sdsembstr
REDIS_ENCODING_RAWsdssdsraw
REDIS_LISTREDIS_ENCODING_ZIPLIST压缩表ziplistziplist
REDIS_ENCODING_LINKEDLIST双端列表listlinkedlist
REDIS_HASHREDIS_ENCODING_ZIPLIST压缩表ziplistziplist
REDIS_ENCODING_HT字典dicthashtable
REDIS_SETEDIS_ENCODING_INTSET整数集合intsetintset
EDIS_ENCODING_HT字典dicthashtable
REDIS_ZSETEDIS_ENCODING_ZIPLIST压缩表ziplistziplist
EDIS_ENCODING_SKIPLIST跳表skiplistskiplist

字符串对象(REDIS_STRING)

字符串对象的编码可以是int、raw或者embstr。

输入字符串值保存方式
可用long保存的整数值将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int
长度>32字节的字符串以及(doule long)编码使用raw保存
长度<=32字节字符串以及(doule long)编码使用embstr保存

embstr编码是专门用于保存短字符串的一种优化编码方式。这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象。

raw编码会调用两次内存分配函数来分别创建redisObject结构和 sdshdr结构;

embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构。

同理:释放embstr编码的字符串对象只需要调用一次内存释放函数,而释放raw编码的字符串对象需要调用两次内存释放函数;

编码转换

例如一个整数使用追加函数,会将int转为raw,再进行追加;

对于embstr,每次追加都需先转成raw再进行追加;

结构

底层_字符串对象.jpg

列表对象(REDIS_LIST)

列表对象的编码可以是ziplist或者linkedlist。

编码转换

当列表对象可以同时满足以下两个条件时,列表对象使用ziplist编码

  • 列表对象保存的所有字符串元素的长度都小于64字节; 【判定标准:每个大小】
  • 列表对象保存的元素数量小于512个;不能满足这两个条件的列表对象需要使用linkedlist编码。【判定标准:整体数量】

注意:以上两个条件的上限值是可以修改的,具体请看配置文件中关于 list-max-ziplist-value选项和list-max-ziplist-entries选项的说明。

结构

底层_列表对象.jpg

哈希对象(REDIS_HASH)

哈希对象的编码可以是ziplist或者hashtable;

ziplist编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾。

类型转化

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;【判定标准:每个大小】
  • 哈希对象保存的键值对数量小于512个;不能满足这两个条件的哈 希对象需要使用hashtable编码;【判定标准:整体数量】

注意:这两个条件的上限值是可以修改的,具体请看配置文件中关于hashmax-ziplist-value选项和hash-max-ziplist-entries选项的说明。

结构

底层_hash对象.jpg

集合对象(REDIS_SET)

集合对象的编码可以是intset或者hashtable;

结构转换

当集合对象可以同时满足以下两个条件时,对象使用intset编码:

  • 集合对象保存的所有元素都是整数值;
  • ·集合对象保存的元素数量不超过512个。

不能满足这两个条件的集合对象需要使用hashtable编码;

注意:第二个条件的上限值是可以修改的,具体请看配置文件中关于setmax-intset-entries选项的说明;

结构

底层_集合对象.jpg

有序集合对象(REDIS_ZSET)

有序集合的编码可以是ziplist或者skiplist;

结构转换

当有序集合对象可以同时满足以下两个条件时,对象使用ziplist编码:

  • 有序集合保存的元素数量小于128个;
  • 有序集合保存的所有元素成员的长度都小于64字节;

不能满足以上两个条件的有序集合对象将使用skiplist编码。

注意:以上两个条件的上限值是可以修改的,具体请看配置文件中关于 zset-max-ziplist-entries选项和zset-max-ziplist-value选项的说明。

结构

底层_有序集合对象.jpg

# zset结构包含一个跳表和字典结构
typedef struct zset {
    // 可以快速进行范围查询
	zskiplist *zsl;
    // 用字典可以快速查询成员分数
	dict *dict;
} zset;

zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素:跳跃表节点的object属性保存了元素的成员,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE 等命令就是基于跳跃表API来实现的。

zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存 了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程可以用O(1)复杂度查找给定成员的分值,ZSCORE命令就是根据这一 特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。