参考文献
- redis数据结构分析
- Skip List(跳跃表)原理详解
- redis 源码分析之内存布局
- Redis 基础数据结构与对象
- Redis设计与实现-第7章-压缩列表
在redis中构建了自己的底层数据结构:动态字符,双端链表,字典,压缩列表,整数集合和跳跃表等。通过这些数据结构,redis构造出字符串对象,列表对象,哈希对象,集合对象和有序集合对象这5种我们常用的数据结构。接下来将从底层数据结构开始,一步步介绍redis的数据结构的实现
动态字符串
在redis中并没有使用c语言原生的字符串,而是自己构建了一个动态字符串。使用这个动态字符串有以下的优势:
- o(1)复杂度获取字符串长度
- 减少修改字符串的时候内存分配次数。在字符串长度小于1M的时候,分配和len一样大小的空间,如果大于1M则分配1M未使用的空间。
- 二进制安全
struct sdshdr {
unsigned int len;
unsigned int free;
char buf[];
};
链表
链表在redis中被广泛的应用,例如list的底层实现的方法之一就是链表。除了列表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,Redis服务器本身还使用了链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区。
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;
字典
字典在redis中应用相当广泛,比如redis的数据库就是使用字典实现的。而字典又由hashtable来组成,一个空白的hashtable 如下所示:
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值
unsigned long used; // 已有节点数量
} dictht;
每个dictEntry的结构如下所示:
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
key保存键值对中的键,v属性保存值信息,值可以是一个指针/uint64_t整数/int64_t整数。next指向下一个哈希表节点指针,解决键值对冲突问题。
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int iterators; /* number of iterators currently running */
} dict;
type属性和privdata属性是针对不同类型的键值对,为创建可以存储多种类型的字典而设置的。
type属性是一个指向dictType结构的指针,每个dictType结构保存了一组用于操作特定类型键值对的函数,Redis会为不同用途的字典设置不同的特定函数。privdata属性则保存了需要传给那些特定函数的可选参数。
ht属性包含2项,每一项都是一个dictht哈希表,一般情况下字典只使用ht[0],ht[1]只在对ht[0]哈希表进行rehash时使用。
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;
整数集合
整数集合是集合键的底层实现之一,当一个集合只包含整数元素时,并且每个集合的元素数量不多时,Redis就会使用整数集合作为集合建的底层实现。
typedef struct intset {
uint32_t encoding; // 16/32/64编码
uint32_t length; // 数组长度
int8_t contents[];
} intset;
整数集合是Redis中用于保存整数的集合抽象数据结构,它可以保存int16_t/int32_t/int64_t的值,并且保证集合中元素不会重复。
contents数组用于存储整数,数组中的值按照值的大小从小到大有序排列,并且不会包含重复项。当encoding编码的是int型整数的话,那么contents数组中每4项用于保存一个int型整数。
因为contents数组可以保存int16/int32/int64的值,所以可能会出现升级现象,也就是本来是int16编码方式,需要升级到int32编码方式,这时数组会扩容,然后将新元素添加到数组中,这期间数组始终会保持有序性。一旦整数集合进行了升级操作,编码就会一直保持升级后的状态,也就是不会出现降级操作。
跳跃表
跳跃表是一种有序数据结构,它通过在每个节点维持多个指向其他节点的指针来达到快速访问节点的目的。Redis使用跳跃表作为有序集合的底层实现之一,如果一个有序集合包含的元素数量较多,或者有序集合元素是比较长的字符串,Redis就会使用跳跃表作为有序集合的底层实现。
Redis中的跳跃表由zskiplistNode和zskiplist两个结构体定义,其中zskiplistNode表示跳跃表节点,zskiplist表示跳跃表信息。
typedef struct zskiplistNode {
robj *obj; // Redis对象
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
span属性用于记录两个节点之间的距离,指向NULL的forward值都为0。节点的分值score是一个浮点数,跳跃表中所有节点都按照分值从小到大排列。obj属性必须指向一个字符串对象,而字符串则保存着一个sds。
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
header和tail指针分表指向跳跃表的表头和表尾节点,通过length属性记录表长度,level属性用于保存跳跃表中层高最大的节点的层高值。每个跳跃表节点层高都是1~32的随机值,在同一个跳跃表中,多个节点可以包含相同的分值,但是每个节点的成员对象必须是唯一的。当分值相同时,节点按照成员对象的大小排序。
压缩列表
压缩列表是列表键和哈希表键的底层实现之一,当一个列表键只包含少量列表项,并且每个列表项是小整数或者短的字符串,那么会使用压缩列表作为列表键的底层实现。压缩列表是为了节约内存而开发的,由一系列特殊编码的连续内存块组成的顺序性数据结构。一个压缩列表可以包含多个节点,每个节点保存一个字节数组或者一个整数值。
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
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、encoding、content三个部分组成。
整数编码可以保存的类型:
- 4位长,介于0-12之间的无符号整数
- 1字节长的有符号整数
- 3字节长的有符号整数
- uint16_t类型
- uint32_t类型
- uint64_t类型
previous_entry_length
节点的previous_entry_length属性以字节为单位,记录了压缩列表中前一个节点的长度。因为有了这个长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的。
encoding
节点的encoding属性记录了节点的content属性所保存数据的类型以及长度
- 一字节、两字节或者五字节,值的最高位为00、01或者10的是字节数组编码。
- 一字节,值的最高位以11开头的是整数编码。
content
节点的content属性负责保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
连锁更新
由于previous_entry_length可能是一个或者五个字节,所有插入和删除操作带来的连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所有连锁更新的最坏复杂度为O(N^2)。
但连锁更新的条件比较苛刻,而且压缩列表中的数据量也不会太多,因此不需要注意性能问题,平均复杂度仍然是O(N)。
Redis 对象
redis提供了5中基本的数据结构:字符串,列表,哈希表,集合和有序集合。而实现这个5种数据结构,都至少使用了前面介绍的对象中的一种。Redis对象还实现了引用计数技术的内存回收技术,当不再使用某个对象时,可以及时释放其内存;通过了引用计数实现了对象共享机制,节约内存;Redis的对象带有访问时间记录信息,该信息可用于计算该对象空转时间,在启动了maxmemroy功能下,空转时间较长的键优先被删除。
Redis中使用对象表示键和值,当新建一个键值对时,Redis至少创建2个对象,一个是键对象,另一个是值对象。
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
int refcount;
void *ptr;
} robj;
type表示对象类型,对于Redis键值对来说,键永远都是字符串,值可以是字符串、列表、哈希表、集合、有序集合中的一种。encoding表示对象编码,也就是该对象使用什么底层数据结构实现。ptr指向对象的底层数据结构。
类型常量 | 对象名称 |
---|---|
REDIS_STRING | 字符串对象 |
REDIS_LIST | 列表对象 |
REDIS_HASH | 哈希对象 |
REDIS_SET | 集合对象 |
REDIS_ZSET | 有序集合对象 |
编码常量 | 编码所对应的底层数据结构 |
---|---|
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 | 跳跃表 |
一般不同的数据类型可以对应多种实现
类型 | 编码 | 对象 |
---|---|---|
REDIS_STRING | REDIS_ENCODING_INT | 使用整数值实现的字符串对象 |
REDIS_STRING | REDIS_ENCODING_EMBSTR | 使用embstr编码的简单动态字符串实现的字符串对象 |
REDIS_STRING | REDIS_ENCODING_RAW | 使用简单动态字符串实现的字符串对象 |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST | 使用双端链表实现的列表对象 |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的列表对象 |
REDIS_HASH | REDIS_ENCODING_HT | 使用字典实现的哈希表对象 |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的哈希表对象 |
REDIS_SET | REDIS_ENCODING_INTSET | 使用整数集合实现的集合对象 |
REDIS_SET | REDIS_ENCODING_HT | 使用字典实现的集合对象 |
REDIS_ZSET | REDIS_ENCODING_SKIPLIST | 使用跳跃表实现的有序集合对象 |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用字典实现的有序集合对象 |
字符串对象
字符串对象的实现方式可以是int,raw或者embstr。如果标识一个字符串整数的时候,并且可以用long来标识,那么编码格式就是int。如下图所示:
如果字符串长度大于了39字节,就使用动态字符串编码。如下图所示:
embstr是比较特殊的编码方式,redisObject结构和sdshdr结构在一块内存中,使用embstr对象只需要调用一次内存分配函数即可,而raw方式需要调用2次。因为在同一块内存中,所以对缓存是友好的。
列表对象
列表对象的编码可以是压缩列表或者是链表。列表对象保存的所有字符串长度都小于64字节并且列表保存的元素数量小于512个时使用ziplist编码实现。
如果不满足条件则使用linkedlist编码实现。注意这个512的值是可以修改的,具体参见配置项list-max-ziplist-value和list-max-ziplist-entries选项。
哈希对象
哈希对象的编码可以是压缩列表或者是哈希表对象。ziplist编码的哈希对象使用压缩列表作为底层实现,当有新的键值对要加入哈希对象时,会先将保存了键的压缩列表节点推入到压缩列表表尾,再将保存了值的压缩列表节点推入到列表表尾。这样的话,一对键值对总是相邻的,并且键节点在前值节点在后。如图所示:
如果hashtable编码的哈希对象使用字典作为底层实现,则哈希对象中的每个键值对都是字典键值对来保存,此时哈希对象如下:
哈希象保存的所有字符串长度都小于64字节并且列表保存的元素数量小于512个时使用ziplist编码实现,否则使用hashtable编码实现。注意这个512的值是可以修改的,具体参见配置项hash-max-ziplist-value和hash-max-ziplist-entries选项。
集合对象
集合对象的编码可以是整数集合或者是哈希表对象。intset编码的集合对象使用整数集合作为底层实现,所有元素都保存在整数集合中。如图所示:
另外集合对象也可以使用hashtable的集合对象使用字典作为底层实现,字典中每个键都是一个字符串对象,即一个集合元素,而字典的值都是NULL的。如下图所示:
当集合对象所有的元素都是整数值并且集合对象数量不超过512个时使用intset实现,否则使用hashtable实现。注意,这里的512值是可以修改的,具体参见配置项set-max-intset-entries选项。
有序集合
有序集合对象的编码可以是ziplist和skiplist。ziplist编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨着的压缩列表节点保存,第一个保存集合元素,第二个保存集合元素对应的分值。压缩列表内集合元素按照分值大小进行排序,分值较小的在前,分值大的在后。如图所示:
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
zset中的zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点保存一个集合元素,跳跃表节点的object属性保存元素的成员,score属性保存元素的分值。通过该跳跃表,可以对有序集合进行范围型操作,比如zrank、zrange命令就是基于跳跃表实现的。
zset中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素,字典的键保存集合元素的成员,字典的值保存集合成员的分值。通过该字典,可以O(1)复杂度查找到特定成员的分值,zscore命令就是根据这一特性来实现的。通过字典+skiplist作为底层实现,各取所长为我所用。
对象的其他特性
对象空转时长
redisObject结构中有一项(unsigned lru;)是记录对象最后一次访问的时间,使用命令object idletime key可以显示对象空转时长。
当Redis打开maxmemory选项时,并且Redis用于回收内存的算法为volatile-lru或者allkey-lru时,那么当Redis占用内存超过了maxmemory选项设定的值时,空转时长较高的那部分键会优先被Redis释放,从而回收内存。
内存回收
C不具备内存回收功能,Redis在自己对象机制上实现了引用计数功能,达到内存回收目的,每个对象的引用计数值在redisObject中的(int refcount;)来记录。当创建一个对象或者该对象被重新使用时,它的引用计数++;当一个对象不再被使用时,它的引用计数--;当一个对象的引用计数为0时,释放该对象内存资源。
对象共享
对象的应用计数另外一个功能就是对象的共享,当一个对象被另外一个地方使用时,可以直接在该对象引用计数上++就行。注意:Redis只对包含整数值的字符串对象进行共享。