Redis是一个高性能的基于内存的数据存储系统,在互联网应用中是必备的核心中间件。随着我们平台的不断发展,对Redis的使用越来越频繁,存储的数据越来越多。高峰期,我们的Redis一分钟的访问量会有万+,在如此高的并发下,要想用好Redis,必须深入理解其原理,探究每一个技术细节,才能最大压榨Redis的性能。下面带大家从Redis的数据结构层面剖析一下其原理。
一、数据库
我们平时使用公共组件操作Redis的时候,都会首先设置DbNumber,这个值便是我们接下来要操作的目标数据库的索引。在Redis里面的实现方式是这样的:
structredisServer{
/*General*/
……
redisDb*db;
……
intdbnum;/*TotalnumberofconfiguredDBs*/
……
};
voidinitServer(void){
……
server.db=zmalloc(sizeof(redisDb)*server.dbnum);
……
/*CreatetheRedisdatabases,andinitializeotherinternalstate.*/
for(j=0;jserver.dbnum;j++){
……
server.db[j].id=j;
……
}
}
在redisServer结构里有一个redisDb结构的数组,数组中每个redisDb元素都代表一个数据库。dbnum的值表示要创建数据库的数量,默认值是16。在initServer时,redis服务器会进行数据库的创建操作,并且将数据库的id设置为0~15。
初始化结束后的数据结构图
每个redis客户端都有自己的目标数据库,当设置了目标数据库后,所有操作都在此数据库上进行。redis在服务器内部为每一个客户端维护了一个客户端状态client,其中的db属性指向了该客户端的目标数据库。
typedefstructclient{
……
redisDb*db;/*PointertocurrentlySELECTedDB.*/
……
}client;
设置目标数据库后的数据结构图
二、键空间
Redis的键值对保存在redisDb结构中的dict属性里,我们将这个dict称为键空间,dict是一个哈希表,它的实现结构是这样的:
typedefstructredisDb{
dict*dict;/*ThekeyspaceforthisDB*/
……
}redisDb;
typedefstructdictht{
dictEntry**table;
……
}dictht;
typedefstructdictEntry{
void*key;
union{
void*val;
……
}v;
……
}dictEntry;
dict的每个key都指向一个redisObject结构的字符串对象,dict的每个value也指向一个redisObject结构,它可以是包括字符串对象、列表对象、哈希表对象、集合对象和有序集合对象在内的任意一种。redisObject结构中的type属性决定对象类型,ptr指针指向对象的底层实现数据结构。
typedefstructredisObject{
unsignedtype:4;
……
void*ptr;
}robj;
/*Objecttypes*/
#defineOBJ_STRING0
#defineOBJ_LIST1
#defineOBJ_SET2
#defineOBJ_ZSET3
#defineOBJ_HASH4
键空间的数据结构图
三、Redis对象
上文已经提到Redis提供了五种对象类型:字符串对象、列表对象、哈希表对象、集合对象和有序集合对象,每一种对象的都对应着一个或多个底层数据结构的实现,而这些数据结构是由redisObject中的encoding属性决定的。
typedefstructredisObject{
unsignedtype:4;
unsignedencoding:4;
……
void*ptr;
}robj;
1.字符串对象
字符串对象的编码可以是int、raw或者embstr。如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面,并将字符串对象的编码设置为int。
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于39字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw。使用SDS使获取字符串长度的复杂度为O(1)。
如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于39字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisObject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisObject和sdshdr两个结构。
2.列表对象
列表对象的编码可以是ziplist或者linkedlist。默认当列表对象保存的所有字符串元素的长度都小于64字节且元素数量小于个时使用ziplist编码。
其中zlbytes记录整个压缩列表占用的内存字节数,zltail用来确定表尾,zllen记录表的节点数,使获取列表长度的复杂度为O(1)。zlend用于标记表的末端。
当使用linkedlist编码时列表对象使用双端链表作为底层实现,每个双端链表节点都保存了一个字符串对象作为列表元素。双端链表的数据结构是这样的:
typedefstructlist{
listNode*head;
listNode*tail;
……
unsignedlonglen;
}list;
typedefstructlistNode{
structlistNode*prev;
structlistNode*next;
void*value;
}listNode;
list结构中的len属性使获取列表长度的复杂度为O(1)。
双端链表的数据结构图如下。
3.哈希对象
哈希对象的编码可以是ziplist或者hashtable。ziplist编码时每当有新的键值对要加入到哈希对象,程序会先将保存了键的压缩列表节点推入到压缩列表表尾,然后再将保存了值的压缩列表节点推入到压缩列表表尾。而用hashtable编码时哈希对象是由哈希表来实现的,哈希表的数据结构是这样的:
typedefstructdictht{
dictEntry**table;
……
}dictht;
typedefstructdictEntry{
void*key;
union{
void*val;
uint64_tu64;
int64_ts64;
doubled;
}v;
structdictEntry*next;
}dictEntry;
dictEntry结构中包含key,v和指向链表下一项的next指针。其中key可以指向任何类型;当v的值是uint64_t、int64_t或double类型时,就不再需要额外的存储,这有利于减少内存碎片,当v是void指针时能存储任何类型的数据。但对于哈希对象,key和v都指向字符串对象。
4.集合对象
集合对象的编码可以是intset或者hashtable。intset编码的集合对象使用整数集合作为底层实现,集合对象包含的所有元素都被保存在整数集合里面。Intset的数据结构是这样的:
typedefstructintset{
……
uint32_tlength;
int8_tcontents[];
}intset;
数据结构示意图
若是hashtable编码则字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。
5.有序集合对象
有序集合的编码可以是ziplist或者skiplist。ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素则保存元素的分值。压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。
当使用skiplist编码时,有序集合对象使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表。同时使用两种数据结构的好处是既能满足用O(1)的时间复杂度来根据成员查找分值,又可以用O(logn)的时间复杂度来根据分值查找成员。
数据结构示意图
四、redis使用和运维最佳实践
1.key的命名
不要用无意义的字符串为key命名(例如guid),建议用模块名开头,用冒号“:”进行层级的分隔。在能表达清楚意义的前提下,Key的命名不要过长,否则会浪费内存空间。
2.value的大小
value不易过大,否则可能会造成网络传输瓶颈或客户端序列化瓶颈。
3.慢查询命令
不要用keys命令,它会遍历当前db的所有键,非常慢,会将redis阻塞住。推荐使用scan命令来作为替代。scan命令是一个基于游标的迭代器。每次调用都需要使用上一次这个调用返回的游标作为该次调用的游标参数,以此来延续之前的迭代过程。当SCAN命令的游标参数被设置为0时,服务器将开始一次新的迭代,而当服务器向用户返回值为0的游标时,表示迭代已结束。
例如:
.0.0.1:scan0
1)"17"
2)1)"key:12"
2)"key:8"
3)"key:4"
4)"key:14"
.0.0.1:scan17
1)"0"
2)1)"key:5"
2)"key:18"
3)"key:0"
4)"key:2"
4.避免使用DEL命令删除大的集合数据
避免使用DEL命令删除大的集合数据,因为redis会遍历整个集合,时间复杂度为O(N),这是个缓慢的过程,因此推荐的方式应该先将他们重命名,然后后台使用一个删除进程小批量的删。
5.减少与redis的通信次数
Redis执行一条命令是微秒级的速度,大部分时间都耗在了网络IO上,因此我们应该尽量减少与redis的通信次数。对于多次访问,若相互之间没有对查询结果的依赖,可以使用pipeline模式批量处理。
6.主从切换的时间
我们使用哨兵机制来保证redis的高可用,因此需要设定合适的down-after-milliseconds,如果设定的太短,一个单纯的网络波动就可能导致redis的主从切换,而主从切换会断开与客户端的连接并且短时间内不可访问,肯定会影响到业务。因此这个值不能设置太短,一般我们建议设置10s~20s。
7.将持久化操作放到slave节点上
一开始我们的持久化在master和slave节点上同时进行,但经过观察发现,master的持久化操作会明显影响到redis服务的响应速度,cpu会有明显的上升。因此我们将master的持久化关闭,只在slave节点上进行。但是必须要注意的是,此时绝不可以将redis服务设置为开机自动启动,否则一旦机器重启,会导致master加载空数据启动,并通过主从复制把slave的数据清除,造成整个redis服务的数据丢失。
8.持久化机制选择
由于我们平台的redis是作为缓存使用的,而且我们有完善的缓存更新机制和校验补偿机制,因此我们是可以容忍在故障时承受几分钟的数据丢失的。所以我们采取的策略是直接关闭了AOF,只开启RDB方式。这样既能保证redis对数据的快速加载,又确保了redis的性能不受AOF干扰(AOFRewrite),也方便进行异地灾备(定时将RDB文件备份到存储介质上)。
沙洲,特来电云平台技术架构师、资深后端开发工程师,在公共技术服务化、中间件等领域有丰富的实战经验。热爱新技术并致力于通过公共技术平台化提升系统的研发效率和质量。
赞赏