Redis有五种基础数据类型:字符串(String)、列表(List)、集合(Set)、有序集合(ZSet)和哈希(Hash)。
了解这些基础数据类型底层使用的数据结构,我们就能够更加清楚这些数据类型如何存储数据尽量节省内存,以及为何能够做到高效的增删查改操作。同时,能够更好的了解这些基础数据类型的特性。
下面介绍6种基础的数据结构:简单动态字符串SDS、整数集合IntSet、字典Dict、压缩列表ZipList、快速列表QuickList和跳表SkipList。
简单动态字符串SDS
Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组),而是使用自己的字符串实现SDS。
在Redis中C字符串只会用作字符串字面量(string literal),在一些无需对字符串修改的地方,比如打印日志:
c
redisLog(REDIS_WARNING,"Redis is now ready to exit, bye bye...");
当Redis需要一个可以修改的字符串值时,就会使用SDS。比如,在Redis的数据库中,包含字符串的键值对在底层都是使用SDS实现的。
除了用来保存数据库中的字符串外,SDS还被用作缓冲区(buffer):AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区,都是由SDS实现的。
SDS的结构
Redis是用C语言实现的,它的本质是一个结构体,源码如下:
c
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* buf已保存的字符串字节数,不包含结束标识 */
uint8_t alloc; /* buf申请的总的字节数,不包含结束标识 */
unsigned char flags; /* 不同的SDS头类型,用来控制SDS的头大小 */
char buf[];
}
这里解释下几个参数:
- len:用来记录字符串的字节数。这里len的类型为uint8_t,表示是一个占用8bit的无符号整数。那么其可以表示的长度为2的8次方 减1,即255字节。
- alloc:记录buf数组申请的空间大小。
看到这里我们会发现,buf只能存储255字节的字符串,不够用。其实,Redis还定义了好几种结构:
c
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags;
char buf[];
}
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len;
uint8_t alloc;
unsigned char flags;
char buf[];
}
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
}
struct __attribute__ ((__packed__)) sdshdr8 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
}
这样,我们就明白了。根据要保存的字符串大小会选择使用对应的SDS结构体。
flags
变量就是表示当前使用的是哪种SDS结构。几种定义如下:
c
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
SDS遵循C字符串以空字符串结尾的管理,保存空字符串的1字节空间不计入SDS的len属性里面。这样做的好处是,SDS可以直接复用一部分C字符串函数库里面的函数。
SDS与C字符串的区别
- 获取字符串长度的操作高效。 C语言中的字符串并不会记录自己的长度。因此获取长度的时候需要遍历计算,时间复杂度为O(n)。SDS维护了len属性,将时间复杂度减低到了O(1)。
- 防止缓冲区溢出。 C字符串不记录自身长度带来的另一个问题就是容易造成缓冲区溢出(buffer overflow)。C语言两个字符串的拼接,需要保证分配了足够的空间,否则会产生缓冲区溢出。而SDS在进行修改之前,会判断是否有足够的空间,不够则先进行扩容再进行修改。
- 减少修改字符串带来的内存重分配次数。 C字符串每次修改都要进行内存重分配。而SDS通过空间预分配和惰性空间释放两种策略,减少内存重分配次数。空间预分配 ,分配内存时分配额外的空间。惰性空间释放,当SDS字符串缩短时,不立即回收释放的空间。
- 二进制安全。 C字符串必须以空字符结尾,因此只能存储文本数据,而不能存储图片、音频、视频、压缩文件等二进制数据。SDS通过len属性来记录字符串长度,因此可以存储二进制数据。
SDS的扩容公式
当SDS保存的字符串内容需要修改,且修改后的字符串大小大于申请的空间大小。那么SDS就需要扩容,重新申请新的内存空间:
- 如果新字符串小于1MB,则新空间为扩展后字符串长度的两倍 + 1。
- 如果新字符串大于等于1MB,则新空间为扩展后字符串长度 + 1MB + 1。
字典 Dict
Redis是一个键值型的数据库,而键与值的映射关系正是通过Dict来实现的。
Redis的字典使用哈希表作为底层实现,每个哈希表里面可以有多个哈希节点,而每个哈希表就保存了字典中的一个键值对。
字典的实现
Dict由三部分组成:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)。
哈希表定义如下:
c
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小的掩码,用于计算索引值
// 总是等于size-1
unsigned long sizemask;
// 哈希表已有节点的数量
unsigned long used;
} dictht;
table属性是一个数组,数组中的每个元素都是指向dictEntry的指针。每个dictEntry都保存了一个键值对。
哈希节点定义如下:
c
typedef struct dictEntry {
void *key; // 键
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // 值
// 下一个entry的指针
struct dictEntry *next;
} dictEntry;
key保存键值对的键,v保存键值对的值。键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。
next是指向另一个哈希节点的指针,形成链表,解决哈希冲突的问题。
字典定义如下:
c
typedef struct dict {
dictType *type; // dict类型,内置不同的hash函数
void *privdata; // 私有数据,在做特殊hash运算时使用
dictht ht[2]; // 一个Dict包含两个哈希表,其中一个是当前数据,另一个一般是空,rehash时使用
long rshashidx; // rehash的进度,-1表示未进行。
}
type属性是一个指向dictType的指针。每个dictType保存一簇用于操作特定类型键值对的函数。Redis会为用途不用的字典设置不同的类型特定函数。
ht属性是一个包含两个项的数组。一般情况下,字典只会使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]进行rehash时使用。
rehashidx记录当前hash进度,如果当前没有进行rehash,值为-1。
数据存放步骤
当要往Dict添加键值对时,Redis首先会根据key计算出hash值(h),然后利用h&sizemask计算出元素应该存储到数组的索引位置。
Redis保证size的数量一定为2的n次方。因此h&sizemask的操作等价于h跟size取余的操作,具体原理这里不展开介绍。
当发生哈希冲突时,通过链地址法(separate chaining)解决哈希冲突。
哈希表的扩展和收缩
Dict就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,那么查询效率就会降低。
Dict在每次新增键值对时都会检查负载因子LoadFactor,以下两种情况都会触发哈希表扩容:
- 哈希表的LoadFactor >= 1,并且服务器没有执行BGSAVE或者BGREWRITEOF等后台进程。
- 哈希表的LoadFactor >= 5。
根据BGSAVE或BGWRITEOF命令是否正在执行,哈希表扩展所需的负载因子不同。这是因为在执行BGSAVE或BGWRITEOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy on write)技术来优化子进程的使用效率。所以在子进程存在期间,服务器会提高执行操作所需的负载因子,从而尽量避免在子进程存在期间执行哈希表扩展操作,避免不必要的内存写入操作,最大限度的节省内存。
每次删除元素时,也会对负载因子做检查,当LoadFactor < 0.1时,会做哈希表收缩。
rehash
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key计算新的索引,插入到新的哈希表,这个过程叫做rehash。
同时,Dict的rehash是分多次、渐进式的完成,因此也叫做渐进式rehash。这是因为若Dict中存在数百万的entry,若要一次rehash完成,极有可能导致主线程阻塞。
过程如下:
- 计算新hash表的realSize,值取决于当前操作是扩容还是收缩。如果是扩容,那么新的size为第一个大于等于dict.ht[0].used + 1的 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 n 2^n </math>2n;如果是收缩,则新size为第一个大于等于dict.ht[0].used的 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 n 2^n </math>2n(不得小于4)。
- 按照新的realSize申请内存空间,创建dictht,并赋值给dict.ht[1]。
- 设置dict.rehashidx = 0,标识开始rehash。
- 每次执行增删查改操作时,都检查一下dict.rehash是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehsh到dict.ht[1],并将rehasidx++。直到dict.ht[0]的所有数据都rehash到dict.ht[1]。
- 将dict.ht[1]赋值给dict.h[0],将dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内容。
- 将rehashidx赋值为-1,表示rehash结束。
在rehash过程中,新增操作,直接写入ht[1];查询、修改和删除操作则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只增不减。
跳表 SkipList
跳表是一种有序数据结构,通过在节点中维护多个指向其他节点的指针,从而达到快速访问节点的目的。
跳表实现
跳表由zskiplistNode和zskiplist两个结构定义,其中zskiplistNode结构表示跳表节点,而zskiplist结构用于保存跳表的节点的相关信息,如节点的数量,以及指向表头和表尾节点的指针等。
跳表节点定义如下:
c
typedef struct zskiplistNode {
// 值
sds ele;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned long span;
} level[];
} zskiplistNode;
参数介绍:
- 层。跳表节点的level数组,保存了指向其他节点的指针,程序可以通过这些节点提高访问其他节点的速度。每次创建跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个1和32之间的值作为level数组的大小,这个大小就是层的"高度"。
- 前进指针。指向下一个节点的指针,用来从表头到表尾方向遍历。
- 跨度。记录两个节点之间的距离。
- 后退指针。用于从表尾到表头方向遍历。
- 分值。跳跃表中的所有节点都按分值从小到大排序。若分值相同,则按照字典序排序。
跳表定义如下:
c
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 层数最大的节点的层数
int level;
} zskiplist;
header和tail节点分别指向表头和表尾节点。length记录节点数量。level记录表中层高最大的那个节点的数量,表头节点的层高不计算在内。
整数集合 IntSet
IntSet是Redis中Set集合的底层实现之一。当一个集合只包含整数值元素,并且这个集合的元素不多时,Redis就会使用整数集合作为Set集合的实现。
IntSet的实现
结构如下:
c
typedef struct intset {
uint32_t encoding; /* 编码方式,支持存放16位、32位、64整数 */
uint32_t length; /* 元素个数 */
int8_t contents[]; /* 整数数组,保存集合数据 */
}
contents数组是整数集合的底层实现:用来存放元素。元素在数组是有序的,并且不会重复。虽然intset结构将从contents声明为int8_t类型的数组,但实际上content数组并不存放任何int8_t类型的值,contents数组的真正类型由encoding决定。
encoding是用来指定contents数组中存放元素的大小的,有三种模式:
c
#define INTSET_ENC_INT16 (sizeof(int16_t)) /* 2字节整数 */
#define INTSET_ENC_INT32 (sizeof(int32_t)) /* 4字节整数 */
#define INTSET_ENC_INT64 (sizeof(int64_t)) /* 8字节整数 */
IntSet升级
现假设有一个IntSet,里面存储了元素为 {5,10,20},采用的编码是INTSET_ENC_INT16。
我们要添加一个元素50000,这个数字超过了int16_t能表示的范围,那么IntSet会自动升级编码到合适的大小。
具体流程如下:
- 升级编码到INTSET_ENC_INT32,并按照新的编码和元素数量扩容数组。(数组中所有元素的编码都要升级)
- 倒序将数组中的元素拷贝到扩容后的正确位置。(倒序是因为拷贝元素时不会导致其他元素的值被覆盖)
- 将待添加的元素放入数组末尾。
升级之后新元素的摆放位置:
引发升级的新元素要么比所有元素都大,要么比所有元素都小。新元素大于所有元素,新元素会被放到底层数组的最开头。新元素小于所有元素,新元素放到底层数组的末尾。
升级的好处:
- 提升灵活性。C语言是静态语言,为了避免类型错误,我们通常不会把两种不同类型的值放到同一个数据结里面。整数集合可以通过升级来适应元素,因此我们可以随意的把int16_t、int32_t或int64_t类型的整数添加到集合中,并不用担心类型错误,这种做法很灵活。
- 节约内存。整数集合的升级机制使得可以同时保存3种不同类型的值,并且只在有需要的时候才进行升级,这样可以节省内存。
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
压缩列表 ZipList
压缩列表是Redis为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。
压缩列表的构成
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4字节 | 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配,或者计算zlend的位置时使用。 |
zltail | uint32_t | 4字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节;通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。 |
zllen | uint16_t | 2字节 | 记录了压缩列表包含的节点数量:当这个属性的值小于UINT16_MAX(65535)时,这个属性的值就是压缩列表包含节点的数量。当这个值等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算出来。 |
entryX | 列表节点 | 不定 | 压缩列表包含的节点,节点的长度由节点保存的内容决定。 |
zlend | uint8_t | 1字节 | 特殊值0xFF(十进制255),用于标记压缩列表的末端。 |
压缩列表节点的构成
- previous_entry_length,记录前一个节点的长度。previous_entry_length属性的长度可以是1字节或者5字节。如果前一个节点的长度小于254字节,那么用1字节来记录;如果前一个节点的长度大于等于254字节,则使用5字节记录,其中第一个字节固定为0xFE,后四个字节才是真实长度。
- encoding,记录节点的content属性所保存数据的类型和长度。一字节、两字节或者五字节长,值的最高位为00、01、10的是字节数组编码,表示节点的content保存的是字节数组,长度为编码除去最高两位之后的其他位记录。;1字节长,值的最高位为11的是整数编码,表示content属性保存的是整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录。
连锁更新问题
前面讲到每个节点的previous_entry_length保存了前一个节点的长度。
先考虑一种情况,在一个压缩列表中有多个连续的,且长度在250字节到253字节之间的节点e1 - en。这些节点的长度都小于254字节,因此占用1字节空间。接着,节点e1的内容修改后长度为255字节。那么e2节点的previous_entry_length需要从原来的1字节到占用5字节,那么e2节点的长度又会大于等于254字节,又会导致后续节点的更新。
Redis将这种在特殊情况下产生的连续多次空间扩展操作称为连锁更新(cascade update)。
除了添加新节点可能导致连锁更新外,删除节点也可能导致连锁更新。
连锁更新在最坏情况需要对压缩列表执行N次空间重分配操作,每次空间重分配的最坏复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n),所以连锁更新的最坏复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)。
需要注意的是,尽管连锁更新的复杂度较高,但它发生的几率是很低的:
- 压缩列表要恰好有多个连续的、长度介于250-253之间的节点才可能触发连锁更新。在实际中,是很少见的。
- 即使触发了连锁更新,只要被更新的节点不多,就不会对性能造成影响。
对象
Redis中的每一个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type属性、enccoding属性和ptr属性。
c
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// ...
}
type属性记录对象的类型。 这个属性的值可以是下表中常量的一个:
类型常量 | 对象的名称 |
---|---|
REDIS_STRING | 字符串对象 |
REDIS_LIST | 列表对象 |
REDIS_HASH | 哈希对象 |
REDIS_SET | 集合对象 |
REDIS_ZSET | 有序集合对象 |
对象的ptr指针指向对象的底层实现数据结构,而这些数据结构由对象的encoding属性决定。
encoding属性的值可以是下表中常量的一个:
编码常量 | 编码所对应的底层数据结构 |
---|---|
REDIS_ENCODING_INT | long类型的整数 |
REDIS_ENCODING_EMBSTR | embstr编码的简单动态字符串 |
REDIS_ENCODING_RAW | 简单动态字符串 |
REDIS_ENCODING_HT | 字典 |
REDIS_ENCODING_LINKEDLIST | 双端链表 |
REDIS_ENCODING_ZIPLIST | 压缩列表 |
REDIS_ENCODING_INTSET | 整数集合 |
REDIS_ENCODING_SKIPLIST | 跳跃表和字典 |