Redis用到的底层数据结构有:简单动态字符串、双端链表、字典、压缩列表、整数集合、跳跃表等,Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,这个系统包括字符串对象、列表对象、哈希对象、集合对象和有序结合对象共5种类型的对象。
比如执行如下命令时:
Redis将在数据库中创建一个新的键值对,其中键是一个字符串,一个保存着"name"的sds;值是一个字符串,一个保存着"intrack"的sds。
Redis使用简单字符串(sds)作为字符串显示,有以下优势:
- 常数复杂度获取字符长度
- 避免缓冲区溢出
- 减少修改字符操作时引起的内存分配次数(注意:free内存大小最大为1M)
- 二进制安全的
- 兼容部分C字符串函数(因为字符串后面以‘\0‘结尾)
2 链表
链表在Redis应用很广泛,比如列表键底层实现之一就是链表,当一个列表键包含了数量比较多的元素,或者列表中包含元素是比较长的字符串时,redis就使用链表作为其底层实现。除了列表键之外,发布与订阅、慢查询、监视器等功能也用到了链表,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;
list结构链表提供了表头指针head、表尾指针tail及链表长度len,而dup/free/match用于实现存储类型无关链表所需的类型特性函数。dup用于复制一个链表节点、free用于释放一个链表节点、match用于匹配链表节点和输入的值是否相等。
- 链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视器等。
- 每个链表节点由一个listNode结构表示,每个节点都有一个指向前置节点和后置节点的指针,所以Redis中链表是双向链表。
- 每个链表使用一个list结构表示,这个结构有表头节点指针、表尾节点指针、以及链表长度信息。
- 链表表头节点的前置节点和表尾的后置节点都指向NULL,所以Redis链表是无环链表。
- 通过将链表设置不同类型的特定函数,使得Redis链表可存储不同类型的值。
3 字典
字典,又称为符号表、映射,是一种保存键值对的数据结构。字典在Redis中应用相当广泛,比如Redis的数据库就是在使用字典作为底层实现的,对于数据库的CURD操作就是构建在对字典的操作上的。
比如当执行以下命令时:
redis> set msg "hello world"
在数据库中创建了一个键为msg,值为hello world的键值对时,这个键值对就保存在代表数据库的字典里面的。除了用作数据库之外,字典还是哈希键的底层实现之一。
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值
unsigned long used; // 已有节点数量
} dictht;
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指向下一个哈希表节点指针,解决键值对冲突问题。
Redis的字典由dict结构定义:
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的各种功能,其中包括数据库和哈希键。
- Redis中字典使用哈希表作为底层实现,每个字典有2个哈希表,一个平时使用,另一个只在rehash时使用。
- 当字典作为数据库的底层实现,或者作为哈希键的底层实现时,使用MurmurHash2算法计算键的哈希值。
- 哈希表使用分离连接法解决键冲突问题,被分配到同一个索引上多个键值会连接成一个单向链表。
- 在对哈希表进行扩展或者缩容操作时,需要将现有哈希表中键值对rehash到新哈希表中,这个rehash过程不是一次性完成的,而是渐进的。
4 跳跃表
跳跃表是一种有序数据结构,它通过在每个节点维持多个指向其他节点的指针来达到快速访问节点的目的。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的随机值,在同一个跳跃表中,多个节点可以包含相同的分值,但是每个节点的成员对象必须是唯一的。当分值相同时,节点按照成员对象的大小排序。
5 整数结合
整数集合是集合键的底层实现之一,当一个集合只包含整数元素时,并且每个集合的元素数量不多时,Redis就会使用整数集合作为集合建的底层实现。
整数集合是Redis中用于保存整数的集合抽象数据结构,它可以保存int16_t/int32_t/int64_t的值,并且保证集合中元素不会重复。
typedef struct intset {
uint32_t encoding; // 16/32/64编码
uint32_t length; // 数组长度
int8_t contents[];
} intset;
contents数组用于存储整数,数组中的值按照值的大小从小到大有序排列,并且不会包含重复项。当encoding编码的是int型整数的话,那么contents数组中每4项用于保存一个int型整数。
因为contents数组可以保存int16/int32/int64的值,所以可能会出现升级现象,也就是本来是int16编码方式,需要升级到int32编码方式,这时数组会扩容,然后将新元素添加到数组中,这期间数组始终会保持有序性。一旦整数集合进行了升级操作,编码就会一直保持升级后的状态,也就是不会出现降级操作。
6 压缩列表
压缩列表是列表键和哈希表键的底层实现之一,当一个列表键只包含少量列表项,并且每个列表项是小整数或者短的字符串,那么会使用压缩列表作为列表键的底层实现。
压缩列表是Redis为了节约内存开发的,由一系列特殊编码的连续内存块组成的顺序性数据结构。一个压缩列表可以包含多个节点,每个节点保存一个字节数组或者一个整数值。
压缩列表按照固定格式来存储的,类似于存储多个TLV消息一样。
7 Redis中的对象
Redis中共有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指向对象的底层数据结构。
7.1 字符串对象
字符串对象可以是int、raw或者embstr。如果一个字符串时整数,并且可用long型表示,那么该字符串对象编码就是int。如果字符串长度大于39字节,那么将使用一个简单动态字符串(sds)保存,并将对象编码设置为raw。如果字符串长度小于等于39字节,则字符串以编码方式embstr来保存该字符串值。
embstr编码方式是redisObject结构和sdshdr结构在一块内存中,使用embstr对象只需要调用一次内存分配函数即可,而raw方式需要调用2次。因为在同一块内存中,所以对缓存是友好的。
注意,字符串的编码方式是可以转换的,比如set num 1后执行append num hello,则会导致编码方式由int到raw转换。
7.2 列表对象
列表对象的编码可以是ziplist或者linkedlist。ziplist使用功能压缩列表作为底层实现,每个压缩列表节点保存一个列表元素。
执行以下命令时,Redis会创建一个列表存储nums的值:
如果列表不是使用的ziplist实现,而是linkedlist实现,则底层实现如下所示:
注意,linkedlist编码的列表对象在底层双端列表中包含了多个字符串对象,这个嵌套字符串对象行为在哈希表、集合中都会出现,字符串对象是Redis五种类型中唯一一种会被其他四种类型对象嵌套的对象。
列表既然有ziplist和linkedlist两种底层实现,那么列表到底使用哪一种呢?
列表对象保存的所有字符串长度都小于64字节并且列表保存的元素数量小于512个时使用ziplist编码实现,否则使用linkedlist编码实现。注意这个512的值是可以修改的,具体参见配置项list-max-ziplist-value和list-max-ziplist-entries选项。
7.3 哈希对象
哈希对象的编码可以是ziplist和hashtable。ziplist编码的哈希对象使用压缩列表作为底层实现,当有新的键值对要加入哈希对象时,会先将保存了键的压缩列表节点推入到压缩列表表尾,再将保存了值的压缩列表节点推入到列表表尾。这样的话,一对键值对总是相邻的,并且键节点在前值节点在后。
如果man编码为ziplist方式,则其对象所使用的压缩列表如下:
如果hashtable编码的哈希对象使用字典作为底层实现,则哈希对象中的每个键值对都是字典键值对来保存,此时哈希对象如下:
哈希对象既然有ziplist和hashtable两种底层实现,那么其到底使用哪一种呢?
列哈希象保存的所有字符串长度都小于64字节并且列表保存的元素数量小于512个时使用ziplist编码实现,否则使用hashtable编码实现。注意这个512的值是可以修改的,具体参见配置项hash-max-ziplist-value和hash-max-ziplist-entries选项。
7.4 集合对象
集合对象的编码可以是intset和hashtable。intset编码的集合对象使用整数集合作为底层实现,所有元素都保存在整数集合中。另一方面,使用hashtable的集合对象使用字典作为底层实现,字典中每个键都是一个字符串对象,即一个集合元素,而字典的值都是NULL的。
既然集合有intset和hashtable两种底层实现,那么其到底使用哪一种呢?
集合对象所有的元素都是整数值并且集合对象数量不超过512个时使用intset实现,否则使用hashtable实现。注意,这里的512值是可以修改的,具体参见配置项set-max-intset-entries选项。
7.5 有序集合对象
有序集合对象的编码可以是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作为底层实现,各取所长为我所用。
8 对象的其他特性
对象空转时长
redisObject结构中有一项(unsigned lru;)是记录对象最后一次访问的时间,使用命令object idletime key可以显示对象空转时长。
当Redis打开maxmemory选项时,并且Redis用于回收内存的算法为volatile-lru或者allkey-lru时,那么当Redis占用内存超过了maxmemory选项设定的值时,空转时长较高的那部分键会优先被Redis释放,从而回收内存。
内存回收
C不具备内存回收功能,Redis在自己对象机制上实现了引用计数功能,达到内存回收目的,每个对象的引用计数值在redisObject中的(int refcount;)来记录。当创建一个对象或者该对象被重新使用时,它的引用计数++;当一个对象不再被使用时,它的引用计数--;当一个对象的引用计数为0时,释放该对象内存资源。
对象共享
对象的应用计数另外一个功能就是对象的共享,当一个对象被另外一个地方使用时,可以直接在该对象引用计数上++就行。注意:Redis只对包含整数值的字符串对象进行共享。
参考资料:
1、《Redis设计与实现》