文章目录
1.概述
对于现在的开发人员来说,redis作为一门必须掌握的技术,其能够加快系统的响应速度,在合适的场景用合适的方式使用redis是有可能让系统的瓶颈提升一个数量级的。但是有几个问题:
- 怎么使用redis对业务系统性能提升最高?
- 怎么使用redis能保证redis的性能最好?
- 怎么选择使用redis的数据类型,使用不同的数据类型对性能有什么影响?
想解决上面的问题就需要了解redis每种数据类型的的底层结构,及其每种数据类型的数据结构的切换。本文将要介绍的就是redis的底层数据结构、不同数据类型所使用的数据结构及数据类型使用的数据结构转换规则。
2.底层数据结构实现
2.1.简单动态字符串
简单动态字符串(Simple Dynamic String,SDS)是redis自己构建的一种数据结构,作为默认字符串表示。SDS的数据结构如下:
struct sdshdr{
int len;
int free;
char buf[];
}
len
用于记录buf数组中已经保存的字节的数量,free
记录buf数组中未使用字节的数量,buf
字节数组,用于保存字符串所对应的二进制数据。
SDS特点:
- 获取字符串长度时间复杂度为O(1)。直接读取len属性即可获取到,多次读取也不会对性能有影响。
- 避免缓冲区溢出。修改字符串之前会使用free属性查看是否有足够的空间,如果不够会先进行扩容。
- 减少内存重分配次数。通过free属性和buf[]未使用空间机制来减少内存重分配次数降低耗时。即如果需要进行字符串拼接,会通过free属性查看剩余空间是否能够存储即将拼接的字符串,如果空间足够则直接拼接,避免内存重分配。
- 惰性空间释放。当缩短字符串时,使用free属性记录下缩短的字节数量等待将来使用,来减少内存重分配次数。
- 二进制安全。buf数组中存储的是二进制数据,所以它可以保存空格等一些特殊字符,而不会出现意外的情况。
- 兼容部分C字符串函数。SDS与C字符串一样都是以空字符串结尾,SDS的buf[]分配空间时也会将这个空字符串保存进去,所以SDS可以使用一部分C字符串的函数。
2.2.链表
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链表特点:
- 双向链表。listNode节点通过prev和next指针可以直接获取到当前节点的前置和后置节点并且时间复杂度为O(1)。且链表为非环形链表,链表的表头节点的prev指向null,表尾节点的next指向null。
- 获取链表首尾节点时间复杂度为O(1)。list结构中存在head和tail的指针,可以直接获取到首尾节点。
- 获取链表长度时间复杂度为O(1)。list结构中存在len属性可以直接获取链表节点的数量。
- 多态。listNode中使用void*指针保存节点的值,list结构中的dup、free、match三个属性为节点值设置类型特定的函数,所以链表节点可以保存不同类型的值。
2.3.字典/符号表/关联数组/映射
字典也称为符号表(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。
- redis解决哈希冲突的方法就是:哈希表+链表,即当出现哈希冲突时,使用链表将哈希值相同的节点链接起来,新节点添加到表头位置。实现方式与java中的hashMap结构类似。
- rehash是为了解决哈希冲突后出现的链表太多造成性能下降的情况。rehash时,如果ht[0]存在数据,ht[1]为空,则将ht[1]设为扩容/缩容后的大小,重新计算哈希值和索引值并将新ht[0]中的数据rehash到ht[1]中,之后释放ht[0]的空间。
- 渐进式哈希。当哈希表中键值对数量太大时,为避免堆服务器性能产生影响,会将哈希表中的数据分多次进行rehash,每次rehash时使用rehashidx+1记录当前进度,当rehash全部完成时再将rehashidx设为-1,在rehash过程中,字典的删除,修改,查找都会在两个ht中进行,增加操作只会在新的ht中执行,以保证在rehash时候服务可用。
2.4.跳跃表
跳跃表在大部分情况下,可以保证和平衡树相当的效率,并且实现比平衡树更加简单,而且支持平均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从小到达排序,当分值相同时,节点按照成员对象的大小排序。
2.5.整数集合
整数集合(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[]
中存储所有元素,按照值从小到大有序排列,且不包含重复项。
整数集合特点:
- 整数集合底层实现为有序无重复数组,会根据新添加元素的类型,改变整个数组元素的类型即升级操作。
- 升级。当新加入的元素类型比现有集合中所有元素的类型都要长时,会进行升级,分为三个步骤:
1)根据新元素类型,扩展集合底层数组的大小,并未新元素分配空间。
2)将底层数组现有元素都转换为新元素相同的类型,并将其放置到新数组中且保证数组有序性不变。
3)将新元素加入到底层数组中。 - 整数集合只会升级不会降级。
- 升级操作为整数集合添加了灵活性,并且尽可能的节约了内存。
2.6.压缩列表
压缩列表(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)。
压缩列表特点:
- 压缩列表是节省内存的顺序型数据结构。
- 压缩列表可以包含多个节点,节点值可以是字节数组或者整数。
- 添加节点和删除节点都有可能造成连锁更新。
- 连锁更新出现的记录不高。当压缩列表节点数量少时,即使出现连锁更新,也不会影响系统性能。
3.数据结构类型对象
redis没有直接使用底层数据结构实现键值对数据库,而是基于底层数据结构实现了一个有多种类型的对象系统。每种类型的对象至少用了一种底层数据结构。而且对象系统还实现了基于引用计数的内存回收机制,并且基于引用计数实现了对象共享机制,能够在一定条件下让多个键共享同一个对象来节约内存。
- redis中每新建一个键值对时,至少会新增两个对象,一个用来存储键对象,一个存储值对象
- redis中对象由redisObject结构标识,结构如下:
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” |
3.1.字符串类型对象
字符串对象可以是int、raw、embstr三种编码方式。
- 如果一个字符串对象保存的是整数值,且可以用long类型来表示,那么将使用int编码保存其整数值,否则用raw或者embstr保存。
- 如果一个字符串对象保存的是字符串值,且长度大于39字节,那么将使用raw编码保存字符串值。
- 如果一个字符串对象保存的是字符串值,且长度小于等于39字节,那么将使用embstr编码保存字符串值。
raw和embstr特点:
- embstr是专门用于保存短字符串的优化编码方式
- raw和embstr都使用redisObject和sdshdr来表示字符串对象。raw编码会调用两次内存分配函数分别创建者两个字符串对象,embstr则是调用一次内存分配函数创建一段连续的空间依次保存这两个字符串对象。
- embstr编码的对象只需要调用一次内存释放函数,而raw编码的字符串对象需要调用两次内存释放函数。
- embstr编码的字符串对象数据都保存在连续的内存空间中,比起raw编码可能更好的利用缓存的优势。
int编码和embstr编码的字符串对象在一定的条件下可以进行编码转换:
- int编码保存的对象,如果通过append或者其他操作,使得不再为整数或者不能用long来表示,那么将转换为raw编码。
- embstr编码的字符串对象是只读的,如果通过任何修改命令来改变对象的值,程序会先将embstr编码转换为raw,在执行修改命令。
3.2.列表类型对象
列表对象可以是ziplist和linkedlist两种编码方式。ziplist底层使用压缩列表实现,linkedlist底层使用双端链表实现。
列表对象要使用ziplist,需要同时满足以下条件:
- 列表对象的所有字符串元素长度都小于64字节,上限值可以通过配置文件中属性list-max-ziplist-value修改。
- 列表对象的元素数量小于512个,上限值可以通过配置文件中属性list-max-ziplist-entries修改。
当不满足以上条件时, 程序会执行编码转换:将存储在压缩列表中的所有元素转移到双端链表中,并将对象编码从ziplist转换为linkedlist。
使用linkedlist编码来实现列表对象,linkedlist中的双端链表是由多个字符串对象组成。
3.3.哈希类型对象
哈希对象可以是ziplist和hashtable两种编码方式。ziplist编码的哈希对象使用压缩列表实现,每次加入新的键值对时,会先将保存的键的压缩列表节点放入压缩列表节点尾部,然后将保存的值的节点放入压缩列表尾部。
使用ziplist编码的哈希对象特点:
- 保存同一键值对的两个节点总是紧挨在一起,键的节点在前,值的节点在后。
- 先加入到哈希对象中的键值对放在压缩列表表头,后放入的键值对放在表尾。
哈希对象使用ziplist,需要同时满足以下条件:
- 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节,上限值可以通过配置文件中属性hash-max-ziplist-value修改。
- 哈希对象保存的键值对数量小于512个,上限值可以通过配置文件中属性hash-max-ziplist-entries修改。
当不满足以上条件时, 程序会执行编码转换:将存储在压缩列表中的所有键值对转移并保存到字典中,并将对象编码从ziplist转换为hashtable。
使用hashtable编码的哈希对象特点:
- 每个键值对都是用字典键值对来保存。
- 字典的每个键都是字符串对象,对象中保存了键值对的键。
- 字典的每个值都是字符串对象,对象中保存了键值对的值。
3.4.集合类型对象
集合对象可以是intset和hashtable两种编码方式。intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都保存在整数集合中。
集合对象使用intset,需要同时满足以下条件:
- 集合对象保存的所有元素都是整数值。
- 集合对象保存的元素数量不超过512个,上限值可以通过配置文件中属性set-max-inset-entries修改。
当不满足以上条件时, 程序会执行编码转换:将存储在整数集合中的所有元素转移到字典中,并将对象编码从intset转换为hashtable。
使用hashtable编码的集合对象是,使用字典作为底层实现的特点:
- 字典的键都是一个字符串对象,每个字符串对象包含一个集合元素。
- 字典的全部为NULL。
3.5.有序集合类型对象
有序集合对象可以是ziplist和skiplist两种编码方式。
使用ziplist编码的有序集合对象特点:
- 使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),第二个节点保存元素的分值(score)。
- 压缩列表内的集合元素按照分值从小到大进行排序,分值小的元素放在表头,分值大的元素放在表尾。
有序集合对象使用ziplist,需要同时满足以下条件:
- 有序集合对象保存的元素数量小于128个,上限值可以通过配置文件中属性zset-max-ziplist-entries修改。
- 有序集合保存的所有元素成员的长度都小于64字节,上限值可以通过配置文件中属性zset-max-ziplist-value修改。
当不满足以上条件时, 程序会执行编码转换:将存储在压缩列表中的所有元素转移到zset中,并将对象编码从ziplist转换为skiplist。
skiplist编码的有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表,结构如下:
typedef struct zset{
zskiplist *zsl;
dict *dict;
} zset;
跳跃表zsl
按分值从小到大保存了所有集合元素,每个跳跃表节点都保存一个集合元素:跳跃表节点的object属性保存元素的成员,score保存元素的分值。
dict
为有序集合创建了一个从成员到分值的映射,字典中每个键值对的键都保存一个集合元素,键值对的值保存元素的分值。通过这个字典,能够用O(1)复杂度查找给定成员的分值。
使用skiplist编码的有序集合特点:
- 有序集合每个元素的成员都是一个字符串对象,每个元素的分值都是一个double类型的浮点数。
- zset结构的跳跃表和字典可以通过指针共享相同元素的成员和分值,一次节省内存。
4.类型检查与内存优化
类型检查机制主要体现在:有的命令可以针对任何类型的键,如果del,object,expire等命令;有的命令则只能针对特定类型的键,如set、get等只能对字符串键执行,hset,hget等只能对哈希键执行。
内存优化则分为三部分:内存回收、对象共享、对象的空转时长。
4.1.类型检查
redis类型检查机制会在redis执行命令之前,先检查键入的类型是否正确,然后决定是否执行这个命令。
类型检查通过redisObject的type属性来实现。在执行命令之前,服务器先检查输入的值对象是否为命令所需的类型,如果是的话,则执行键入的命令,否则服务拒绝执行,并且想客户端返回类型错误。
4.2.内存回收
redis在对象系统中使用引用计数方式实现了内存回收机制,通过跟踪对象的引用计数信息,在适当的时候自动释放对象并回收内存。这个特性通过redisObject中的refcount属性来控制:
- 创建新对象时候,refcount初始化为1。
- 当对象被新程序使用时,refcount会+1。
- 当对象不再被程序引用时,refcount会-1。
- 当对象refcount=0时,对象占用的内存会被释放。
4.3.对象共享
上文在介绍有序集合对象的zset结构时,提到跳跃表和字典会共用对象的元素和分值,以此来节约内存。共享对象的做法如下:
1)将键值对的值的指针指向一个现有的值对象。
2)将被共享的值对象的引用计数+1。
共享对象机制特点:
- 数据库中保存的相同值越多,对象共享机制就能节约越多的内存。
- redis初始化服务器时,会创建一万个字符串对象,包含了从0 ~ 9999的所有整数值,当服务器用到值在0 ~ 9999之间的字符串对象时,服务器就会使用这些共享对象。
4.4.对象空转时长
上文介绍的redisObject 还包含一个lru属性,该属性记录了对象最后一次被命令程序访问的时间。object idletime
命令可以打印出指定键的空转时长,且此命令不会修改值对象的lru属性。空转时长是通过当前时间减去键对应的值对象的lru时间计算所得。
如果服务器设置了maxmemory属性,并且服务器设置的回收内存算法为volatile-lru或者allkeys-lru时,当服务器内存达到maxmemory值时,会优先释放空转时长高的键所对应的元素来回收内存。
参考资料
《redis设计与实现》