Redis是基于内存的非关系型数据库,具有极高的读写性能和丰富的数据类型,适用于多种场景,目前得到广泛的应用。
本文从Redis的基本数据类型入手,在此基础上介绍五种数据对象及其典型应用场景,最后介绍Redis支持的一些特性,例如通过过期策略优化内存使用效率,通过将数据持久化到硬盘提升稳定性,通过主从切换支持容灾,通过复制来扩展读性能,通过分片来扩展写性能。
本文结构如下:
- Redis的数据结构
- Redis的数据对象
- 内存数据的过期与持久化
- 集群的复制与分片
如有表意不清或错误的地方,欢迎讨论。
一、Redis的数据结构
Redis有多种数据结构类型,下面选择其中比较特别的五个进行介绍:
- 字符串
- 整数集合
- 哈希表
- 跳表
- 压缩列表
1. 简单动态字符串(SDS)
由于c语言是以字符数组的形式来实现字符串,效率比较低,因此Redis定义了SDS类型以更好地支持字符串的读写操作。 SDS定义如下:
typedef struct sdshdr {
int len; //当前存buf数组中字符串长度
int free; //当前buf数组中未使用字节的数量
char buf[]; //字符数组,存储字符串
}
例如,存储一个字符串hello,字符数组并未被完全占满。
相比C语言,SDS类型增加了len和free两个属性字段。len用于优化读取字符串长度的操作,free用于优化内存分配策略。这两个字段带来的好处有:
1. O(1)时间获取字符串长度
C语言中通过遍历数组查找\0来获取数组长度,时间复杂度O(n)。对Redis来说,读负载很大,这个复杂度难以接受,因此用len字段来记录当前字符串长度,空间换时间。
2. 避免缓冲区溢出和频繁的内存再分配
在C语言中,字符数组在定义时就要指定长度。当使用strcat等命令追加内容后,可能导致新字符串长度超过字符数组长度,即缓冲区溢出。为了防止缓冲区溢出,在修改字符串时,需要先进行内存重新分配,给数组扩容。对于Redis这种高写负载的组件,不安全性和扩容的耗时是无法忍受的。
因此,Redis在修改字符串之前,都会先检查SDS的空间是否能放下修改后的结果,如果不够,会先进行扩容,然后才执行写入。SDS的扩容采用空间预分配的模式,不仅满足当前的存储需要,还会分配额外的未使用空间。假设待写入字符串长度为k,其策略为:
- 如果k <1MB,则len=k,free=k
- 如果k>1MB,则len=k,free=1MB
例如,在上图中的字符串hello后追加字符串world,显然空间不够,于是需要重新分配,分配后,len=10,因为不超过1MB,因此free也分配为10。
对应的,缩容时SDS采用惰性释放的方式。当字符串变短时,并不立即回收缩短后多出来的字节,而是添加到free里面,预备未来使用。例如,当字符串helloworld被改成hi后,SDS为:
总的来看,Redis用牺牲内存空间的方式,尽量减少字符串写入的耗时。
3. 二进制安全
C语言通过空字符符串\0来判读一个字符串的结束,因此,C字符串只能保存文本,无法保存图片、音频、视频等二进制文件(内容中存在\0)。而SDS基于len属性而不是空字符串来判断字符的结束,因此SDS则支持二进制文件的存储。
4. 兼容部分C字符串函数
SDS由字符数组类型派生而来,因此可以继承使用C语言字符串的库函数。
2. 整数集合(intset)
整数集合是set的底层实现之一,适用于只包含整数元素且元素数量不多的场景。intset的结构如下:
typedef struct intset {
uint32_t encoding; //编码方式
uint32_t length; //集合元素总数
int8_t contents[]; //存储集合元素的数组
}
因此,集合的存储本质上还是数组。contents是一个去重且排好序的数组,虽然结构体中定义的类型是int8_t,但实际上contents的类型取决于encoding属性:
- encoding = INTSET_ENC_INT16,则contents是一个int16_t类型的数组
- encoding = INTSET_ENC_INT32,则contents是一个int32_t类型的数组
- encoding = INTSET_ENC_INT64,则contents是一个int64_t类型的数组
不同类型占用的空间不一样。Redis的策略是:先给定一个最小的空间,当新加入的元素比当前类型长时,则再升级空间。例如,当前存了1,2,3这三个int16_t类型的整数,现在存入一个int32_t的整数123456789:
- 首先将数组
contents变成int32_t类型,由于当前四个元素,因此占用空间由16*3=48位变成32*4=128位
| 位 | 0-15位 | 16-31位 | 32-47位 | 48-127位 |
|---|---|---|---|---|
| 元素 | 1 | 2 | 3 | 新分配空间 |
- 再将1,2,3升级为
int32_t并放到新数组指定空间,执行顺序由后向前,即先迁移3,再迁移2,最后迁移1。
| 位 | 0-31位 | 32-63位 | 64-95位 | 96-127位 |
|---|---|---|---|---|
| 元素 | 1 | 2 | 3 | 新分配空间 |
- 然后再存入123456789
| 位 | 0-31位 | 32-63位 | 64-95位 | 96-127位 |
|---|---|---|---|---|
| 元素 | 1 | 2 | 3 | 123456789 |
- 将
encoding值从INTSET_ENC_INT16变为INTSET_ENC_INT32,并将length从3改为4。
底层数组自动升级的策略,对外屏蔽了不同位数整数类型的差异,同时集合能够按照需要申请内存空间,节省了资源。需要注意的是,一旦升级了整数类型,intset将不会降级,即encoding的值只增不减(延续惰性释放的思想)。
由于集合是基于有序去重数组实现,因此插入/删除的时间复杂度是O(N),查询的复杂度O(logN)。
3. 字典(dict)
字典结构如下。可以看到一个字典中有两个哈希表,ht[0]用于保存当前数据,ht[1]用于扩容时的rehash过程。
typedef struct dict {
dictType *type; //类型特定函数
void *privdata; //私有数据
dictht ht[2]; //哈希表
rehashindex; //记录目前rehash的进度,当前无rehash过程时,值为-1
}
其中dictht类型即哈希表,其结构如下:
typedef struct dictht {
dictEntry **table; //哈希表数组
unsigned long size; //哈希表大小
unsigned long sizemark; // sizemark = size-1,用于取模计算数组索引值
unsigned long used; //哈希表中已有的节点数量
}
dictht中的table是一个数组,每个数组元素存储指向dictEntry类型的指针。dictEntry即哈希表节点,其结构如下:
typedef struct dictEntry {
void *key; //键
union{
void *val;
uint64_t u64;
int64_t s64;
}v; //值,共用体类型
struct dictEntry *next; // 拉链法,指向下一个哈希节点。
}
因此,一个字典的层次结构如下图:
下面说说rehash过程:
什么是rehash?
随着哈希表中保存键值对的增多/减少,Redis需要对哈希表中的数组大小进行扩/缩容,数组大小与哈希取模过程直接相关,因此需要对现有数据重新哈希出索引,然后存到新哈希表的指定位置。该过程即rehash过程。
何时执行rehash?
Redis定义了负载因子的概念:
当load_factor <= 0.1: 执行缩容操作
当load_factor >= 1: 执行扩容操作。如果当前正在执行BGSAVE或者BGREWRITEAOF指令(持久化),load_factor >= 5时才执行扩容。在持久化时Redis会fork一个子进程来进行后台的写进程,而子进程一般采用写时复制。如果此时同时进行rehash过程,则子进程也需要执行相同的rehash过程,带来较大的写负担。因此,当存在子进程时,会提高load_factor的阈值,尽量避免子进程存在期间的哈希表扩容操作。
rehash是如何进行?
分为如下三个步骤:
1. 根据ht[0]中当前的节点数(ht[0].used),为ht[1]分配空间
- 如果是扩容:ht[1]分配的大小为第一个大于等于
2*ht[0].used的2的n次幂。例如ht[0].used=7,则2*ht[0].used=14,第一个大于14的2的n次幂为16,即ht[1]分配的空间为16 - 如果是缩容:ht[1]分配的大小为第一个大于等于
ht[0].used的2的n次幂
2. 将ht[0]中的所有键值对渐进地迁移到ht[1]上
该过程的关键是字典中的rehashidx变量。
Redis每次将ht[0]中索引为rehashidx的键值对迁移到ht[1]上。如果当前节点rehash到ht[1]成功,则ht[0].used减1,rehashidx加1,继续对下一个节点进行rehash。因此,rehash其实就是通过rehashidx遍历ht[0]中的table数组。
除了Redis自身的rehash进程,rehash执行期间外界每次对字典的读写请求,Redis在执行完指定操作后,也会顺带进行一次rehash。
简单来说,老哈希表与新哈希表是通过rehashidx这个字段来记录同步过程。
由于rehash期间有两张哈希表,因此读操作需要同时读ht[0],ht[1],但新写入的键值对只会写入ht[1],即ht[0]中的键值对只会减少不会增加,当ht[0]中的ht[0].used减少到0时,rehash过程完成,rehashidx将重新设置为-1。
3. 当ht[0]中所有键值对都迁移到ht[1]后
释放ht[0],将ht[1]设置为ht[0],并新创建一个空白哈希表ht[1],以便下一次rehash。
4. 跳表(skiplist)
跳表是对有序链表的优化,通过多层索引,实现查询从O(N)时间复杂度优化到O(log(N))。
如图,原始链表为最底层,可以视为第0层,从底层选取若干节点构建第一层索引,以此类推,直到最高层。例如要查找元素8:
- 先从第一个元素的最上层的索引开始,即第二层索引。由于目标节点8大于当前节点1,且大于下一节点7,因此跳到该层索引的下一个节点7。由于8大于当前节点7,且该层节点7后无其他节点,说明目标节点在该索引之后(如果有的话),因此从节点7处向下一层。
- 在第一层索引,由于8大于7且小于下一节点9,继续向下一层,到达原始链表。
- 在原始链表层,当前节点7的下一个节点为8,即目标节点。
该过程经过4次比较(1,7,9,8共四个节点),优于遍历链表(8次比较)。链表越长,收益越明显。
因此,对每个跳表节点,不仅像普通双链表一样记录前驱和后继节点,还有多层的索引指针。
那么如何确定某个节点上建立几层索引?上图中有的节点上建立了两层索引,有的节点只有一层索引,有的节点上甚至没有索引。索引是牺牲一定的写性能来换取读性能的提升,因此,索引既不能太多也不能太少。
Redis在实现时采用了概率分布的思想,基于经验来得到一个读写较为平衡的期望。每个节点都有一定概率建立索引,假设节点上建立每层索引的概率为p,则在节点上建立一层索引的概率为p,建立两层索引的概率为p^2,建立三层索引的概率为p^3,以此类推,节点上索引层数越高,出现的概率越低。具体实现逻辑为:
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^32 elements */
#define ZSKIPLIST_P 0.25 /* Skiplist P = 1/4 */
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
// 即每次随机生成一个uint32随机数,&0xFFFF是模运算,即去掉高16位,只保留低16位
// 0.25 * 0xFFFF = 0x3FFF,当随机数低16位小于0x3FFF时,则层数+1
// 因此n层节点出现的概率为ZSKIPLIST_P的n次方,ZSKIPLIST_P取0,25是基于经验
// 同时对最大层数做了限制,最多不能超过32层
跳表与二叉搜索树的功能类似,查询的时间复杂度一样。但跳表查找区间元素的效率更高(本身就切分为多个嵌套区间),且实现更简单一些(没有红黑树的再平衡过程)。当然,索引的引入,增加了空间消耗,同时插入删除时需要维护索引,时间复杂度也由O(1)变成O(log(N))。
Redis的跳表实现分为两个部分:zskiplist和zskiplistNode,其结构如下:
typedef struct zskiplistNode {
struct zskiplistNode *backward; //后退指针
double score; //分值,zset根据score升序排列
zobj *obj; //成员对象
struct zskiplistLevel{
struct zskiplistNode *forward; //前进指针,指向当前level的下一个节点
unsigned int span; //跨度,用于计算rank
}level[]; //各层索引,第0层level[0],第1层level[1]...
}
其中,分值score是在插入时就进行指定,zset根据元素的score来对元素进行排序。
前进指针用于跳跃向前,后退指针则用于反向逐个遍历。
成员对象obj指向一个字符串对象,其中保存着一个SDS值。
跨度span是指两个节点的距离,例如level[0]层,每个节点的跨度为1。在查找某个元素时,只需将沿路途径的span求和,便可得到所查元素在链表内的rank。
有了跳表节点,Redis用专门的跳表结构体来记录表头,表尾,长度等汇总信息:
typedef struct zskiplist {
struct zskiplistNode* head, tail; //首尾指针
unsigned long length; //当前表中的节点数
int level; //当前表中的最大层数
}
5. 压缩列表(ziplist)
压缩列表是一种占用连续内存的链表结构,可以包含任意多个列表节点,每个节点可以保存一个字符串或整数。其结构如下:
| zlbytes | zltail | zllen | 节点1 | 节点2 | ... | 节点N | zlend |
|---|
-
zlbytes:整个压缩列表占用的字节数 -
zltail:记录表尾节点到列表起始地址的字节距离,用于计算尾节点N的地址- 例如:指向压缩列表的起始地址p,则指向节点N的地址为
p+zltail
- 例如:指向压缩列表的起始地址p,则指向节点N的地址为
-
zllen:记录压缩列表的节点数量 -
zlend:特殊值oxFF,用于标记压缩列表的末端
对压缩列表中的每个节点,其结构为:
| previous_entry_length | encoding | content |
|---|
previous_entry_length:记录前一个节点的字节长度。如果前一个节点长度<254,则该属性值只需要1个字节即可保存,如果前一个节点长度大于等于254字节,则该属性的值占用5个字节,其中第一个字节会被设置为oxFE,后四个字节的内容才是实际的前一个节点长度。
根据zltail定位到表尾节点,借助该参数,压缩链表便可以实现从表尾到表头的遍历操作。
encoding:记录content所保存数据的类型和长度content:保存节点的值,可以是字符数组/整数
压缩列表在插入/删除节点时,由于每个节点的previous_entry_length占用空间会随着前驱节点的长度变化而变化,因此可能会发生连锁更新的问题。
例如,有多个连续的,长度介于250字节-253字节的节点e1,e2,...,eN,这些节点的previous_entry_length属性都只占用1个字节。
此时在表头插入一个e0,其长度为254字节。此时,e1的previous_entry_length需要由1字节变成5字节来存254这个值,导致e1的整体长度大于254字节,引起e2也要更新,这就是连锁更新。
同理,在删除节点时,也可能引起连锁更新。
压缩列表插入/删除的平均时间复杂度是O(N),连锁更新会导致复杂度在最坏条件下由O(N)变成O(N^2)。
本质上,压缩列表里运用的一些奇特技巧,是为了优化内存空间的占用,用整块的内存空间,又快又省地完成一些k-v数量不多,v的长度不大的需求。而这些需求,在所有需求中占了绝大多数,因此带来非常可观的收益。
小结
二、Redis的数据对象
Redis为key-value数据库,key的类型只能为字符串,value支持五种数据类型:字符串(string)、链表(list)、集合(set)、哈希表(hash)、有序集合(zset)。
下面是Redis对象的结构:
typedef struct RedisObject {
unsigned type
unsigned encoding
unsigned lru
int refcount
void *ptr; /* 指向对象实际的数据结构 */
} robj;
其中各个字段的作用如下:
type:对象的类型,枚举值为OBJ_STRING、OBJ_LIST、OBJ_HASH、OBJ_SET、OBJ_ZSET,分别指代字符串、列表、哈希、集合、有序集合这五种对象类型。encoding:对象使用哪一种底层数据结构来实现。如下表所示,每种类型的对象至少有两个及以上的编码方式:
通过encoding属性来指定对象所用的编码,而不是为每个类型的对象关联一个特定的编码方式,极大地提升Redis的灵活性和效率。Redis可以根据不同的场景,为一个对象设置不同的编码,从而优化指定对象在不同场景下的效率。
例如,当列表中元素较少时,Redis采用压缩列表作来实现列表,因为压缩列表比双端列表更节约内存,且压缩列表是连续内存,更便于载入内存。当列表中元素越来越多时,列表对象改为使用双端列表来实现,因为双端列表更便于存储大量元素。
-
ptr:指向实际的数据结构存储地址 -
refcount:引用计数。- Redis中有对象共享机制,例如Redis在初始化时,会创建整数0-9999总共1万个字符串对象。后续使用到这其中的任何数字时,将不再创建新的字符串,而直接建立指向其的指针。
- 当
refcount为 0 的时候, 表示该对象已经不被任何对象引用, 则可以进行垃圾回收。
-
lru:对象最后一次被命令程序访问的时间,当前时间减去lru即为对象的空转时间,当内存占用达到上限时,会优先选择空转时间长的对象进行释放。
按照value的类型,有不同的读写指令。
1. 字符串
单键单值。值按字符串类型存储,如果value是数字,支持直接加减操作。
字符串对象的编码有三种,分别是int,embstr,raw。
如果保存的是整数数值,则采用int作为底层编码。如果保存的是字符串,其存储结构如下:
当字符串长度不超过44字节时,采用embstr编码,否则采用raw编码。二者都是用sds来存储字符串,不同点在于,raw编码会调用两次内存分配来分别创建RedisObject结构和sdshdr结构,而embstr则是一次分配和创建,且二者处于相邻的内存空间。因此,在创建/销毁时embstr能节省运行开销,同时连续内存也能更好地利用缓存。但是当字符串较大时,申请一块连续内存的难度会增大。因此,一般短string用embstr编码,长string用raw编码。
对于浮点数值,是以字符串形式来存储的。对浮点数进行运算时,会先将字符串转换为浮点数,运行完成后再将结果转换成字符串存储。
当int或者embstr编码的值发生变化时,会转变为raw编码格式。如对数字123追加aaa变成123aaa,则格式由原本的int变成了raw。embstr编码的值是只读的,因此一旦发生修改,Redis会先将其转换为raw编码,再进行追加。
应用场景:
- 热点数据缓存。
- 计数:文章阅读量、点赞数会更新地很快,可以先缓存在Redis中,定时再同步到数据库中;也可以对访问者的ip进行计数,从而进行限流。
- 分布式场景下共享数据和锁:Redis为独立服务,可以保存共用的数据;setnx方法只有在key不存在时才能添加成功,可以用于实现分布式锁。
2. 列表
单键多值,值可能重复。
老版本中,列表对象的底层结构可以是压缩列表(ziplist)或者双端链表(linkedlist)。当满足下面两个条件时,采用ziplist,否则使用linkedlist:
- 所有节点保存的字符串长度都小于64字节
- 节点数量小于512个
3.2 版本之后,统一用 quicklist 来存储。quicklist是一个记录首尾指针的双向链表,链表中每个节点都是ziplist。
列表指令以l开头,代表是list操作。
适用场景:
- 消息时间线:例如朋友圈,微博等时序排列的场景,根据产生时间依次插入列表。
- 可以用来实现队列、栈、消息队列。
3. 哈希
一个键对应一张表,表里是一系列的字段-值对,即层次关系为key-field-value。哈希对象中的每个键都是唯一的,可以根据键得到对应的值,或者通过键来更新值,或者根据键来删除整个键值对。
哈希对象的底层结构可以是ziplist或字典。当满足下面两个条件时,使用ziplist:
- 所有保存的键值对,键和值的长度都小于64字节
- 键值对数量小于512个
可以看到,这两个条件与列表对象中的要求一致。
ziplist是怎么实现哈希对象的保存呢?
ziplist会将字段和值分别作为一个节点相邻地存储在列表中,形式如下图:
| zlbytes | zltail | zllen | field1 | value1 | field2 | value2 | zlend |
|---|
其实就是将哈希对象的field和value分别作为列表节点。受节点数量的限制,当数量过多、field过长、value过长时,编码格式会从ziplist会转换成字典hashtable。
哈希对象的指令以h开头。
应用场景:具有关联关系的多个key进行整存整取。
4. 集合
单键多值,值不重复。支持两个集合的交/并/差。
其底层实现包括intset和hashtable,分别针对小set和大set。
当满足下面两个条件时,采用intset:
- 所有集合元素都是整数值
- 集合元素不超过512个
采用字典来实现set时,字典的field来存储集合元素,而value则是null。
指令以s开头,代表是set的操作。
应用场景:
- 去重:例如点赞,签到,打卡等,要防止同一用户重复请求造成重复计数,redis可以很好地帮助使用者进行去重。
- 关系:用户关注关系,例如共同关注,我关注的人也关注了他,可能认识的人等
5. 有序集合
一个键对应多个值,值不重复,根据每个元素score属性值从升序排序。指令以z开头,表示是zset的操作。
zset的底层实现有ziplist,skiplist,hashtable三种。
ziplist的实现结构如下:
| zlbytes | zltail | zllen | member1 | score1 | member2 | score2 | zlend |
|---|
当有新元素插入时,会根据score来决定插入位置。ziplist中的元素根据score升序排列。
从这里可以看到,ziplist是一个非常通用的结构,既可以用于list,也可以用于hash,还可以用于zset。适用于多种对象的小数据量场景。
当然现实中涉及到排序的场景数据量都不会很小,实际中更多的是使用skiplist + hashtable的方式。
skiplist的结构中,object存储元素本身,score存储元素的分值或者说权重。由于skiplist是基于score的顺序结构,且上层有多个范围索引,因此能很好地支持区间操作。
字典结构中,field存储元素,而value存储分值,可以在O(1)内查询某个元素的分值。
由于Zset既有区间查询,又有单点查询,因此Redis的做法是对一份数据,建立两套结构来指向它。区间操作时使用skiplist,单点查询时使用hashtable。
应用场景:
zset很适合做排行榜类的应用。例如某网站根据点击数来排序,可以将文章id作为value,点击数作为score维护在zset中。每次点击,score+1,这样可以对前10的文章不断刷新。而对于某篇文章,可以用zrank查看其当前的排名。
小结
三、过期与持久化
Redis中的数据是动态的。一方面,Redis对过期的数据需要进行清除,提升内存的使用效率。另一方面,随着业务对redis依赖的加深,redis数据需要定时持久化到磁盘中进行备份,防止因为重启/断电等原因导致内存中的数据直接丢失。
1. 过期键的处理
在创建key的时候可以指定键的过期时间,可以是在某一段时间后过期,也可以是在某个时刻过期。当键过期后,需要对相应的键值对进行删除以回收内存空间。
过期键的删除有三种方式:
- 定时删除:在给键设置过期时,创建一个定时器。在指定时刻完成删除动作。该方式要为每个键配一个进程来执行定时任务,比较重。
- 惰性删除:每次读取键的时候,都检查键是否过期。如果已经过期,则删除,并返回空。该方式比较轻,但对于实时性较强的数据,历史数据很少访问,会形成堆积。
- 定期批量删除:Redis自身每隔一段时间,会遍历数据库,将过期的k-v删除。该方式需要占用一定的CPU资源。
实际中采用的是被动惰性删除+主动定期批量删除相结合的方式,以平衡CPU资源占用和内存回收效率。
在持久化时,也对过期键进行了处理:
- 在执行
SAVE、BGSAVE命令生成RDB文件时,已经过期的键不会保存到RDB文件中。而RDB文件在载入时,会忽略掉过期键。 - 基于AOF文件持久化模式下,过期键被删除后,系统会在AOF文件中显式地追加一条DEL指令。因此,当AOF文件重写时,过期键的操作都会被抹掉。
2. 持久化
过期键的删除提升了内存的使用效率,而在键的存活期内,保证键的不丢失也同样重要。Redis的持久化策略有两种:基于快照的RDB持久化,基于追加操作日志的AOF持久化。
RDB(Redis database)
按一定周期对Redis数据快照进行存储(生成dump.rdb文件),当内存数据丢失时,将快照重放到内存中。生成RDB文件的指令有同步阻塞式的SAVE指令,和异步的BGSAVE指令。服务器在载入RDB文件时,则处于阻塞状态,直到文件全部载入成功。
优点:
- 恢复速度快
- 节省磁盘空间(内存数据会进行压缩成快照文件存储)
缺点:
- 虽然RDB通过
fork子线程进行快照存储时,并且采用了写时拷贝。但是当数据量比较大时,依然会比较耗时 - Redis宕机时,最后一次快照后的修改会丢失
AOF(append of file)
将每次写操作追加到日志文件中,重启时Redis按照日志顺序执行一次以完成恢复工作。(生成appendonly.aof文件)
RDB文件和AOF文件同时存在时,系统优先取AOF文件进行恢复。aof文件恢复时,会逐步进行回放操作。
aof记录每次写操作,文件会越来越大。当aof文件的大小超过某个阈值时,会执行aof文件的重写,只保留可以恢复文件的最小指令集。例如,某处执行了flushdb命令,则其之前的写操作记录都可以删除。具体操作是,fork出一个子进程,先写一个临时文件,然后重命名为appendonly.aof并将原来的aof文件覆盖。
aof文件的重写是有开销的,因此只有满足条件才会执行重写:当前aof文件大小大于某个值,并且当前aof文件大小相比上次重写增加了一倍。
优点:
- 备份粒度更细,丢失数据更少(至多丢失最后一次写操作)
- aof文件对用户可读,通过操作aof文件,可以处理误操作(例如执行了
flushdb,可以在aof文件中删除该次操作,重写后便可恢复数据)
缺点:
- 相比RDB占用更多磁盘空间
- 恢复速度慢
- 如果设置为每次写操作都马上同步,会有性能问题
实际上,RDB与AOF组合持久化的方式更佳,定期备份的RDB文件提升了恢复速度,减少AOF文件的大小,而AOF又能尽量减少修改丢失。
四、复制与分片
1. 主从复制
Redis集群为了提高读写性能,增强灾备能力,分为主从模式,即一个master和多个slave。master用于接收写请求,slave用于接收读请求。每次写请求到master后,master会将写指令同步广播给到连接到自己的salve机器,从而构成了一个树状结构。
主从关系可以通过info replication指令获得。
复制过程根据起始状态,可以分为两种情况:新上的从库追赶主库,断联的从库重新上线后追赶主库。
新从库的复制
salve机器通过salveof ip port指令向指定的master注册。
注册成功后,master会向该salve机器发送rdb文件,rdb文件传输过程中,master会将自己在发送期间执行的写命令在缓冲区中记录,等rdb文件发送完毕后,会接着向从服务器发送存储在缓冲区中的写命令。
salve则丢弃所有此前的旧数据,载入master发来的rdb文件,载入完成后接收master发来的写命令。
当salve追赶上master后,master每执行一次写命令,就向slave发送相同的写命令。
断联后重新上线的老从库复制
slave可能因为网络的问题暂时与master断联,断联期间master执行的操作指令无法传播到slave。当slave重新上线时,需要追赶上这部分的差异。
追赶的方式是,master从上次复制的地方开始,将其后的变更发送给slave,即部分复制。相比全量复制,部分复制的psync更为精细,因而更节省资源,但是逻辑要更为复杂。为了实现部分复制,需要解决下面两个问题:
- slave重联成功后,怎么确定当前连的master就是之前的master?
- 怎么确定slave相对master落后了哪些内容?
第一个身份校验的问题通过run_id来解决。run_id是每个Redis服务器的唯一id,在启动时自动生成,由40个随机的十六进制字符组成。当slave向master注册时,master会将自己的run_id下发给slave,假设为run_id_m。slave断线重联后,会将此前保存的run_id_m发送给当前的master进行核验,如果一致,说明此前存在主从关系,是断线后重联,具备部分复制的前提。如果slave没有run_id_m或者提供的run_id_m与当前master不一致,说明此前不存在主从关系,只能采用全量复制。
第二个复制进度的问题通过复制偏移量来解决。master将接收到的写命令按序编号并保存,然后依次传播给salve。简化举例,假设总共3条指令,master和slave的初始偏移量分别为offset_m,offset_s:
- master向slave发送N条指令:
offset_m+ N - slave接收到这N条指令后:
offset_s+ N - 每次成功的复制后,
offset_m=offset_s
一旦slave断联,offset_m与offset_s将不一致。当slave重联成功后,master可以根据主从offset的差异,来判断应该从哪里开始进行复制。
master会存储每条历史写指令吗?No,那是AOF干的事情!
对于主从复制这个场景,master将命令(包含对应的偏移量)塞入一个固定长度的有序队列,只保存最新的一批指令。如果salve的offset_s对应的指令在队列中,则采取部分复制,将其后的所有命令都发送给salve。如果不在,说明slave断联太久,只能采取全量复制。
2. 主从切换
如果slave宕机,则其与master的从属关系会解除,重新上线时需要重新与master建立关系。
如果master宕机,salve会先进行等待,如果master又重新上线,则仍然保持master身份。当master长时间宕机时,可以执行slaveof no one指令,将某台slave升级为master,完成灾备切换。
手动切换难以做到高效,因此Redis引入哨兵模式。哨兵机器(可能不止一个)心跳监测master状态。当哨兵中的多数发现master宕机时,则会投票将某台slave升级为master,自动完成主从切换。哨兵选择新master的依据包括:各个slave预先配置的优先级(越小越好,配置为0表示永不选择为master)、各个slave的数据版本(越新越好)、salve的runid(前两项一样时选择runid小的)。当原宕机master重新上线时,哨兵会自动将其变为新master的slave。
但是当slave数量越来越多时,树的宽度太大,每次master都需要向所有slave同步写操作,会有性能问题。因此,slave也可以接受salve,通过增加树的高度来缩小宽度,降低master的同步压力。
3. 分片
当写入负载持续增加时,单台master的性能有限,需要进行分区。集群将key哈希到16384个槽(slot),并划分成多个区间到每个master节点。
例如集群中有3个master节点,则分别管理0-5460、5461-10920、10921-16383对应slot的写入。假设设置某个key-value,key哈希后的值为5465,则该key由第二个master负责。集群中每个master不仅保存着自己所负责的slot信息,也保存着其他master负责的slot信息。每个master都可以接收写请求,当某个master发现写入的key不在自己的管理范围内,会根据本地的集群配置文件,自动重定向到对应的mster完成写入。
连接Redis集群使用
Redis-cli-c命令
master节点通过一个长度为2048字节的二进制数组来记录slot信息。2048字节总共16384位,每一位对应一个槽,1表示该master负责这个槽位,0表示不负责。节点可以在O(1)时间内判定某个槽是否由自己负责。同时,每个master会维护一个长度为16384的数组,来记录某个slot分配给了哪个master节点。
当某个master节点接受到写请求时,首先通过二进制数组O(1)来判断是否由自己负责,如果不是自己负责,可以通过第二个数组O(1)判断该slot由哪个节点负责并返回给client,client重定向到该节点。
cluster addslot slot1...指令可以将指定的槽(slot1)指派给接收指令的节点,如在某节点上执行cluster addslot 1,2,则将槽1和槽2指派给当前节点。
当分区进行扩容时,原有的槽分布要重新划分,假设现在集群中只有一个master节点。现在增加一个master节点,则扩容后分别负责槽0-8191、8192-16383。扩容过程即将原本在老节点上槽8192-16383内保存的key-value,迁移到新加入的节点上,并刷新各个主节点记录的槽指派信息。迁移过程由Redis专门的集群管理软件redis-trib来控制,其过程如下:
迁移过程并不原子,例如slot(k)中有两对k-v:(k1,v1),(k2,v2)。(k1,v1)已经迁移到目标master,(k2,v2)仍处于源master。当客户端来查询k2时,能直接拿到结果。当客户端来查询k1时,源master返回一个ASK错误,并带上k1所在节点的ip+port,客户端会重新到目标master上查询。该过程对用户屏蔽,由客户端自动完成。
分区后的Redis集群实现了水平扩容,分担写压力。同时多主无中心的结构,配置相对简单。例如:集群的服务发现(如Zookeeper)都需要一个中心的服务,而Redis集群则是在各个节点维护一个集群配置文件,请求到任何节点,都可以自动路由到相应节点。这种无中心结构是raft算法的一个典型应用。
五、总结
本文选取了Redis中比较核心的几个点进行了探讨,主要集中在Redis的数据结构和对象,引申介绍了集群相关的策略和应用。限于篇幅,未对订阅、事务等内容进行介绍,更多的Redis介绍请移步官方网站:www.redis.cn/