对于现在的开发人员来说,redis作为一门必须掌握的技术,其能够加快系统的响应速度,在合适的场景用合适的方式使用redis是有可能让系统的瓶颈提升一个数量级的。但是有几个问题:
想解决上面的问题就需要了解redis每种数据类型的的底层结构,及其每种数据类型的数据结构的切换。本文将要介绍的就是redis的底层数据结构、不同数据类型所使用的数据结构及数据类型使用的数据结构转换规则。
简单动态字符串(Simple Dynamic String,SDS)是redis自己构建的一种数据结构,作为默认字符串表示。SDS的数据结构如下:
struct sdshdr{
int len;
int free;
char buf[];
}
len
用于记录buf数组中已经保存的字节的数量,free
记录buf数组中未使用字节的数量,buf
字节数组,用于保存字符串所对应的二进制数据。
SDS特点:
C中没有链表,所以redis的链表也是自己实现的,redis的链表结构如下:
//链表节点
typedef struct listNode{
struct listNode *prev;
struct listNode *next;
void *value;
}
//链表对象
typedef struct list{
listNode *head;
listNode *tail;
unsigned long len;
void *(*dup)(void *ptr);
void *(*free)(void *ptr);
void *(*match)(void *ptr,void *key);
} list;
listNode
是组成链表结构的基础节点,listNode
节点中prev
指向前一个节点,next
节点指向后一个节点,value
为节点的值。
list
为redis中链表的结构,其中head
指向表头节点,tail
指向表尾节点,len
记录链表的长度,dup
函数用于复制链表节点保存的值,free
函数用于释放链表节点保存的值,match
函数用于对比链表节点保存的值和另一个输入的值是否相等。
redis链表特点:
字典也称为符号表(symbol table)、关联数组(associative array)、映射(map),用来保存键值对的抽象数据结构。redis中字典由哈希表实现,一个哈希表有多个哈希节点,每个哈希节点保存字典中一个键值对。哈希表接哈希节点结构如下:
//哈希节点
typedef struct dictEntry{
void *keyl
union{
void *val;
uint64_t u64;
int64_t s64;
} v;
struct dictEntry *next;
}
//哈希表
typedef struct dictht{
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long userd;
} dictht;
dictEntry
是哈希表的基础节点,key
保存键值对中的键,v
保存对应的值,值可以是一个指针,或者一个uint64_t的整数,或者int64_t的整数。next
指向下一个哈希节点,形成链表。
dictht
是哈希表对象,table
为哈希节点数组,size
为为哈希表的大小,sizemask
为哈希表大小掩码用于计算哈希值,used
为哈希表已有节点数量。
字典的结构如下:
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, const void *key);
//销毁值的函数
void *(*valDestructor)(void *privdata, const void *obj);
} dictType;
//字典对象
typedef struct dict{
dictType *type;
void *privdata;
dictht ht[2];
int rehashidx;
} dict;
dict
为字典对象,type
属性保存类型特定的函数,privdata
保存私有数据,ht
保存了两个哈希表,正常情况只使用ht[0],在rehash时候才会使用ht[1],rehashidx记录了rehash的进度,如果没有rehash则值为-1。
字典使用了哈希表,所以在存在哈希表的问题:哈希冲突、rehash。
跳跃表在大部分情况下,可以保证和平衡树相当的效率,并且实现比平衡树更加简单,而且支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。跳跃表原理本文不做介绍,看到有篇文章讲的清晰明了可以参考下:请戳。
redis中的跳跃表结构如下:
typedef struct zskiplistNode {
robj *obj;
//分值
double score;
//后退指针
struct zskiplistNode *backward;
//层级
struct zskiplistLevel {
//前进指针
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
} zskiplistNode;
level[]
包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层级节点来加快放问其他节点的速度。每次创建跳跃表节点时候,会根据每次定律(power law,越大的数出现的概率越小)随机生成一个1-32之间的数作为level[]的大小,即层级。
forward
每个层级都有个指向表尾方向的前进指针,用来向表尾遍历。
span
层级的跨度,记录两个节点之间的距离。用来计算rank,在查找节点过程中,经过的所有的层级的跨度累计就是目标节点在跳跃表中的排位(rank)。
backward
后退指针。用于从表尾向表头遍历,因为每个节点只有一个后退节点,所以每次只能后退一个指针。
score
是double类型的浮点数。跳跃表中的所有节点都按照score从小到达排序,当分值相同时,节点按照成员对象的大小排序。
整数集合(intset)是用来保存整型数值集合的数据结构。可以保存 int16_t 、 int32_t 或者 int64_t 的整数值, 并且保证集合中不会出现重复元素。结构如下:
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
encoding
为属性值的类型,encoding
的值决定contents[]
中元素的类型,encoding
可选值为INTSET_ENC_INT16(数组元素取值范围为-216 ~ 216-1)、INTSET_ENC_INT32(数组元素取值范围为-232 ~ 232-1)、INTSET_ENC_INT64(数组元素取值范围为-264 ~ 264-1),length
记录元素的数量,即contents[]
的长度。contents[]
中存储所有元素,按照值从小到大有序排列,且不包含重复项。
整数集合特点:
压缩列表(ziplist)是为了节约内存而出现的,是由一系列特殊编码的连续内存块组成的顺序性数据结构。压缩列表可以包含任意多个节点,每个节点可以是字节数组或者整数。压缩列表存储的是小整数值或者短字符串。结构如下:
zlbytes
记录整个压缩列表的内存字节数。zltail
记录压缩列表表尾节点到起始地址的字节数,以此确定表尾节点的地址。zllen
记录压缩列表的节点数量。entryN
压缩列表的节点。zlend
特殊值0xFF(十进制255),标记压缩列表的末端。
压缩列表节点entryN
的结构如下:
previous_entry_length
属性以字节为单位,记录了压缩列表中前一个节点的长度,此属性的长度可以是1字节或5字节。如果前一个节点的长度小于254字节,那么previous_entry_length
的长度为1字节,如果前一个节点的长度大于等于254字节,那么previous_entry_length
的长度为5字节。
content
保存节点值,可以是字节数组或整数,值的类型和长度由encoding
决定。
encoding
记录了节点保存值的数据类型即长度。
encoding
为1字节、2字节或5字节长,并且最高位00、01、10时,content
属性保存的值为字节数组,且长度由encoding
去除最高两位后其他位记录。encoding | encoding长度 | content保存的值 |
---|---|---|
00aaaaaa | 1字节 | 长度小于等于26-1的字节数组 |
01aaaaaa bbbbbbbb | 2字节 | 长度小于等于214-1的字节数组 |
10_ _ _ _ _ _ bbbbbbbb cccccccc dddddddd eeeeeeee | 5字节 | 长度小于等于232-1的字节数组 |
encoding
为1字节,并且最高位11时,content
属性保存的值为整数值,整数值的类型和长度由编码除去最高两位后其他位记录。encoding | encoding长度 | content保存的值 |
---|---|---|
11000000 | 1字节 | int16_t的整数 |
11010000 | 1字节 | int32_t的整数 |
11100000 | 1字节 | int64_t的整数 |
11110000 | 1字节 | 24位有符号整数 |
11111110 | 1字节 | 8位有符号整数 |
1111xxxx | 1字节 | content无值,因为encoding的xxxx思维保存了一个0~12之间的值,无需使用content |
压缩列表存在的问题:连锁更新。因为previous_entry_length
属性记录了前一个节点的长度。如果刚好有多个节点长度都是250~253字节之间,此时如果有新的节点插入其中的节点e
之前,并且新节点的长度大于等于254字节,那么此时节点e
的previous_entry_length
则需要5字节来保存。由于之前的连续节点长度都小于254字节,即previous_entry_length
属性只有1字节,没法保存新节点的长度,此时会对压缩列表进行空间重分配,将previous_entry_length
属性扩展为5字节,那么此时节点e
的长度将会大于254字节,造成后续节点的previous_entry_length
也需要5个字节才能存储,此时又造成的后续的节点的扩展,这种现象就是连锁更新。连锁更新最坏情况下会对压缩列表进行N次空间重分配,每次内存重分配最坏时间复杂度为O(N),所以连锁更新最坏复杂度为O(N2)。
压缩列表特点:
redis没有直接使用底层数据结构实现键值对数据库,而是基于底层数据结构实现了一个有多种类型的对象系统。每种类型的对象至少用了一种底层数据结构。而且对象系统还实现了基于引用计数的内存回收机制,并且基于引用计数实现了对象共享机制,能够在一定条件下让多个键共享同一个对象来节约内存。
typedef struct redisObject {
//类型
unsigned type:4;
//编码
unsigned encoding:4;
//指向底层数据结构的指针
void *ptr;
// lru 时间戳
unsigned lru:REDI;S_LRU_BITS;
// 使用引用计数管理对象生命周期
int refcount;
} robj;
type
记录对象的类型,可选值有REDIS_STRING(字符串对象)、REDIS_LIST(列表对象)、REDIS_HASH(哈希对象)、REDIS_SET(集合对象)、REDIS_ZSET(有序集合对象)。
ptr
指针指向对象的底层实现数据结构,而数据结构由encoding
属性决定。
encoding
记录对象使用的编码,即底层数据结构的实现方式,可选值如下:
encoding | 对应的底层数据结构 | OBJECT ENCODING命令输出 |
---|---|---|
REDIS_ENCODING_INT | long类型整数 | “int” |
REDIS_ENCODING_EMBSTR | embstr编码的简单SDS | “embstr” |
REDIS_ENCODING_RAW | SDS | “raw” |
REDIS_ENCODING_HT | 字典 | “hashtable” |
REDIS_ENCODING_LINKEDLIST | 双端链表 | “linkedlist” |
REDIS_ENCODING_ZIPLIST | 压缩列表 | “ziplist” |
REDIS_ENCODING_INTSET | 整数集合 | “intset” |
REDIS_ENCODING_SKIPLIST | 跳跃表和字典 | “skiplist” |
字符串对象可以是int、raw、embstr三种编码方式。
raw和embstr特点:
int编码和embstr编码的字符串对象在一定的条件下可以进行编码转换:
列表对象可以是ziplist和linkedlist两种编码方式。ziplist底层使用压缩列表实现,linkedlist底层使用双端链表实现。
列表对象要使用ziplist,需要同时满足以下条件:
当不满足以上条件时, 程序会执行编码转换:将存储在压缩列表中的所有元素转移到双端链表中,并将对象编码从ziplist转换为linkedlist。
使用linkedlist编码来实现列表对象,linkedlist中的双端链表是由多个字符串对象组成。
哈希对象可以是ziplist和hashtable两种编码方式。ziplist编码的哈希对象使用压缩列表实现,每次加入新的键值对时,会先将保存的键的压缩列表节点放入压缩列表节点尾部,然后将保存的值的节点放入压缩列表尾部。
使用ziplist编码的哈希对象特点:
哈希对象使用ziplist,需要同时满足以下条件:
当不满足以上条件时, 程序会执行编码转换:将存储在压缩列表中的所有键值对转移并保存到字典中,并将对象编码从ziplist转换为hashtable。
使用hashtable编码的哈希对象特点:
集合对象可以是intset和hashtable两种编码方式。intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都保存在整数集合中。
集合对象使用intset,需要同时满足以下条件:
当不满足以上条件时, 程序会执行编码转换:将存储在整数集合中的所有元素转移到字典中,并将对象编码从intset转换为hashtable。
使用hashtable编码的集合对象是,使用字典作为底层实现的特点:
有序集合对象可以是ziplist和skiplist两种编码方式。
使用ziplist编码的有序集合对象特点:
有序集合对象使用ziplist,需要同时满足以下条件:
当不满足以上条件时, 程序会执行编码转换:将存储在压缩列表中的所有元素转移到zset中,并将对象编码从ziplist转换为skiplist。
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表,结构如下:
typedef struct zset{
zskiplist *zsl;
dict *dict;
} zset;
跳跃表zsl
按分值从小到大保存了所有集合元素,每个跳跃表节点都保存一个集合元素:跳跃表节点的object属性保存元素的成员,score保存元素的分值。
dict
为有序集合创建了一个从成员到分值的映射,字典中每个键值对的键都保存一个集合元素,键值对的值保存元素的分值。通过这个字典,能够用O(1)复杂度查找给定成员的分值。
使用skiplist编码的有序集合特点:
类型检查机制主要体现在:有的命令可以针对任何类型的键,如果del,object,expire等命令;有的命令则只能针对特定类型的键,如set、get等只能对字符串键执行,hset,hget等只能对哈希键执行。
内存优化则分为三部分:内存回收、对象共享、对象的空转时长。
redis类型检查机制会在redis执行命令之前,先检查键入的类型是否正确,然后决定是否执行这个命令。
类型检查通过redisObject的type属性来实现。在执行命令之前,服务器先检查输入的值对象是否为命令所需的类型,如果是的话,则执行键入的命令,否则服务拒绝执行,并且想客户端返回类型错误。
redis在对象系统中使用引用计数方式实现了内存回收机制,通过跟踪对象的引用计数信息,在适当的时候自动释放对象并回收内存。这个特性通过redisObject中的refcount属性来控制:
上文在介绍有序集合对象的zset结构时,提到跳跃表和字典会共用对象的元素和分值,以此来节约内存。共享对象的做法如下:
1)将键值对的值的指针指向一个现有的值对象。
2)将被共享的值对象的引用计数+1。
共享对象机制特点:
上文介绍的redisObject 还包含一个lru属性,该属性记录了对象最后一次被命令程序访问的时间。object idletime
命令可以打印出指定键的空转时长,且此命令不会修改值对象的lru属性。空转时长是通过当前时间减去键对应的值对象的lru时间计算所得。
如果服务器设置了maxmemory属性,并且服务器设置的回收内存算法为volatile-lru或者allkeys-lru时,当服务器内存达到maxmemory值时,会优先释放空转时长高的键所对应的元素来回收内存。
参考资料
《redis设计与实现》