自底向上谈Redis:从数据结构到集群

117 阅读17分钟

Redis是基于内存的非关系型数据库,具有极高的读写性能和丰富的数据类型,适用于多种场景,目前得到广泛的应用。

本文从Redis的基本数据类型入手,在此基础上介绍五种数据对象及其典型应用场景,最后介绍Redis支持的一些特性,例如通过过期策略优化内存使用效率,通过将数据持久化到硬盘提升稳定性,通过主从切换支持容灾,通过复制来扩展读性能,通过分片来扩展写性能。

本文结构如下:

  • Redis的数据结构
  • Redis的数据对象
  • 内存数据的过期与持久化
  • 集群的复制与分片

如有表意不清或错误的地方,欢迎讨论。

一、Redis的数据结构

Redis有多种数据结构类型,下面选择其中比较特别的五个进行介绍:

  1. 字符串
  2. 整数集合
  3. 哈希表
  4. 跳表
  5. 压缩列表

1. 简单动态字符串(SDS)

由于c语言是以字符数组的形式来实现字符串,效率比较低,因此Redis定义了SDS类型以更好地支持字符串的读写操作。 SDS定义如下:

typedef struct sdshdr {

    int len;     //当前存buf数组中字符串长度

    int free;    //当前buf数组中未使用字节的数量

    char buf[];  //字符数组,存储字符串

}

例如,存储一个字符串hello,字符数组并未被完全占满。

image.png

相比C语言,SDS类型增加了lenfree两个属性字段。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。

image.png

对应的,缩容时SDS采用惰性释放的方式。当字符串变短时,并不立即回收缩短后多出来的字节,而是添加到free里面,预备未来使用。例如,当字符串helloworld被改成hi后,SDS为:

image.png

总的来看,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位
元素123新分配空间
  • 再将1,2,3升级为int32_t并放到新数组指定空间,执行顺序由后向前,即先迁移3,再迁移2,最后迁移1。
0-31位32-63位64-95位96-127位
元素123新分配空间
  • 然后再存入123456789
0-31位32-63位64-95位96-127位
元素123123456789
  • 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; // 拉链法,指向下一个哈希节点。

}

因此,一个字典的层次结构如下图:

image.png

下面说说rehash过程:

什么是rehash?

随着哈希表中保存键值对的增多/减少,Redis需要对哈希表中的数组大小进行扩/缩容,数组大小与哈希取模过程直接相关,因此需要对现有数据重新哈希出索引,然后存到新哈希表的指定位置。该过程即rehash过程。

何时执行rehash?

Redis定义了负载因子的概念:

loadfactor=ht[0].usedht[0].sizeloadfactor = \frac {ht[0].used} {ht[0].size}

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))

image.png

如图,原始链表为最底层,可以视为第0层,从底层选取若干节点构建第一层索引,以此类推,直到最高层。例如要查找元素8:

  1. 先从第一个元素的最上层的索引开始,即第二层索引。由于目标节点8大于当前节点1,且大于下一节点7,因此跳到该层索引的下一个节点7。由于8大于当前节点7,且该层节点7后无其他节点,说明目标节点在该索引之后(如果有的话),因此从节点7处向下一层。
  2. 在第一层索引,由于8大于7且小于下一节点9,继续向下一层,到达原始链表。
  3. 在原始链表层,当前节点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的跳表实现分为两个部分:zskiplistzskiplistNode,其结构如下:

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)

压缩列表是一种占用连续内存的链表结构,可以包含任意多个列表节点,每个节点可以保存一个字符串或整数。其结构如下:

zlbyteszltailzllen节点1节点2...节点Nzlend
  • zlbytes:整个压缩列表占用的字节数

  • zltail:记录表尾节点到列表起始地址的字节距离,用于计算尾节点N的地址

    • 例如:指向压缩列表的起始地址p,则指向节点N的地址为p+zltail
  • zllen:记录压缩列表的节点数量

  • zlend:特殊值oxFF,用于标记压缩列表的末端

对压缩列表中的每个节点,其结构为:

previous_entry_lengthencodingcontent
  • previous_entry_length:记录前一个节点的字节长度。如果前一个节点长度<254,则该属性值只需要1个字节即可保存,如果前一个节点长度大于等于254字节,则该属性的值占用5个字节,其中第一个字节会被设置为oxFE,后四个字节的内容才是实际的前一个节点长度。

根据zltail定位到表尾节点,借助该参数,压缩链表便可以实现从表尾到表头的遍历操作。

  • encoding:记录content所保存数据的类型和长度
  • content:保存节点的值,可以是字符数组/整数

压缩列表在插入/删除节点时,由于每个节点的previous_entry_length占用空间会随着前驱节点的长度变化而变化,因此可能会发生连锁更新的问题。

例如,有多个连续的,长度介于250字节-253字节的节点e1,e2,...,eN,这些节点的previous_entry_length属性都只占用1个字节。

image.png

此时在表头插入一个e0,其长度为254字节。此时,e1的previous_entry_length需要由1字节变成5字节来存254这个值,导致e1的整体长度大于254字节,引起e2也要更新,这就是连锁更新。

image.png

同理,在删除节点时,也可能引起连锁更新。

压缩列表插入/删除的平均时间复杂度是O(N),连锁更新会导致复杂度在最坏条件下由O(N)变成O(N^2)

本质上,压缩列表里运用的一些奇特技巧,是为了优化内存空间的占用,用整块的内存空间,又快又省地完成一些k-v数量不多,v的长度不大的需求。而这些需求,在所有需求中占了绝大多数,因此带来非常可观的收益。

小结

image.png

二、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_STRINGOBJ_LISTOBJ_HASHOBJ_SETOBJ_ZSET,分别指代字符串、列表、哈希、集合、有序集合这五种对象类型。
  • encoding:对象使用哪一种底层数据结构来实现。如下表所示,每种类型的对象至少有两个及以上的编码方式:

image.png

通过encoding属性来指定对象所用的编码,而不是为每个类型的对象关联一个特定的编码方式,极大地提升Redis的灵活性和效率。Redis可以根据不同的场景,为一个对象设置不同的编码,从而优化指定对象在不同场景下的效率。

例如,当列表中元素较少时,Redis采用压缩列表作来实现列表,因为压缩列表比双端列表更节约内存,且压缩列表是连续内存,更便于载入内存。当列表中元素越来越多时,列表对象改为使用双端列表来实现,因为双端列表更便于存储大量元素。

  • ptr:指向实际的数据结构存储地址

  • refcount:引用计数。

    • Redis中有对象共享机制,例如Redis在初始化时,会创建整数0-9999总共1万个字符串对象。后续使用到这其中的任何数字时,将不再创建新的字符串,而直接建立指向其的指针。
    • refcount 为 0 的时候, 表示该对象已经不被任何对象引用, 则可以进行垃圾回收。
  • lru:对象最后一次被命令程序访问的时间,当前时间减去lru即为对象的空转时间,当内存占用达到上限时,会优先选择空转时间长的对象进行释放。

按照value的类型,有不同的读写指令。

1. 字符串

单键单值。值按字符串类型存储,如果value是数字,支持直接加减操作。

字符串对象的编码有三种,分别是intembstrraw

如果保存的是整数数值,则采用int作为底层编码。如果保存的是字符串,其存储结构如下:

image.png

当字符串长度不超过44字节时,采用embstr编码,否则采用raw编码。二者都是用sds来存储字符串,不同点在于,raw编码会调用两次内存分配来分别创建RedisObject结构和sdshdr结构,而embstr则是一次分配和创建,且二者处于相邻的内存空间。因此,在创建/销毁时embstr能节省运行开销,同时连续内存也能更好地利用缓存。但是当字符串较大时,申请一块连续内存的难度会增大。因此,一般短string用embstr编码,长string用raw编码。

对于浮点数值,是以字符串形式来存储的。对浮点数进行运算时,会先将字符串转换为浮点数,运行完成后再将结果转换成字符串存储。

int或者embstr编码的值发生变化时,会转变为raw编码格式。如对数字123追加aaa变成123aaa,则格式由原本的int变成了rawembstr编码的值是只读的,因此一旦发生修改,Redis会先将其转换为raw编码,再进行追加。

应用场景:

  1. 热点数据缓存。
  2. 计数:文章阅读量、点赞数会更新地很快,可以先缓存在Redis中,定时再同步到数据库中;也可以对访问者的ip进行计数,从而进行限流。
  3. 分布式场景下共享数据和锁:Redis为独立服务,可以保存共用的数据;setnx方法只有在key不存在时才能添加成功,可以用于实现分布式锁。

2. 列表

单键多值,值可能重复。

老版本中,列表对象的底层结构可以是压缩列表(ziplist)或者双端链表(linkedlist)。当满足下面两个条件时,采用ziplist,否则使用linkedlist

  1. 所有节点保存的字符串长度都小于64字节
  2. 节点数量小于512个

3.2 版本之后,统一用 quicklist 来存储。quicklist是一个记录首尾指针的双向链表,链表中每个节点都是ziplist。

列表指令以l开头,代表是list操作。

适用场景:

  1. 消息时间线:例如朋友圈,微博等时序排列的场景,根据产生时间依次插入列表。
  2. 可以用来实现队列、栈、消息队列。

3. 哈希

一个键对应一张表,表里是一系列的字段-值对,即层次关系为key-field-value。哈希对象中的每个键都是唯一的,可以根据键得到对应的值,或者通过键来更新值,或者根据键来删除整个键值对。

哈希对象的底层结构可以是ziplist或字典。当满足下面两个条件时,使用ziplist

  1. 所有保存的键值对,键和值的长度都小于64字节
  2. 键值对数量小于512个

可以看到,这两个条件与列表对象中的要求一致。

ziplist是怎么实现哈希对象的保存呢?

ziplist会将字段和值分别作为一个节点相邻地存储在列表中,形式如下图:

zlbyteszltailzllenfield1value1field2value2zlend

其实就是将哈希对象的fieldvalue分别作为列表节点。受节点数量的限制,当数量过多、field过长、value过长时,编码格式会从ziplist会转换成字典hashtable

哈希对象的指令以h开头。

应用场景:具有关联关系的多个key进行整存整取。

4. 集合

单键多值,值不重复。支持两个集合的交/并/差。

其底层实现包括intsethashtable,分别针对小set和大set。

当满足下面两个条件时,采用intset

  • 所有集合元素都是整数值
  • 集合元素不超过512个

采用字典来实现set时,字典的field来存储集合元素,而value则是null

指令以s开头,代表是set的操作。

应用场景:

  1. 去重:例如点赞,签到,打卡等,要防止同一用户重复请求造成重复计数,redis可以很好地帮助使用者进行去重。
  2. 关系:用户关注关系,例如共同关注,我关注的人也关注了他,可能认识的人等

5. 有序集合

一个键对应多个值,值不重复,根据每个元素score属性值从升序排序。指令以z开头,表示是zset的操作。

zset的底层实现有ziplistskiplisthashtable三种。

ziplist的实现结构如下:

zlbyteszltailzllenmember1score1member2score2zlend

当有新元素插入时,会根据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查看其当前的排名。

小结

image.png

三、过期与持久化

Redis中的数据是动态的。一方面,Redis对过期的数据需要进行清除,提升内存的使用效率。另一方面,随着业务对redis依赖的加深,redis数据需要定时持久化到磁盘中进行备份,防止因为重启/断电等原因导致内存中的数据直接丢失。

1. 过期键的处理

在创建key的时候可以指定键的过期时间,可以是在某一段时间后过期,也可以是在某个时刻过期。当键过期后,需要对相应的键值对进行删除以回收内存空间。

过期键的删除有三种方式:

  1. 定时删除:在给键设置过期时,创建一个定时器。在指定时刻完成删除动作。该方式要为每个键配一个进程来执行定时任务,比较重。
  2. 惰性删除:每次读取键的时候,都检查键是否过期。如果已经过期,则删除,并返回空。该方式比较轻,但对于实时性较强的数据,历史数据很少访问,会形成堆积。
  3. 定期批量删除:Redis自身每隔一段时间,会遍历数据库,将过期的k-v删除。该方式需要占用一定的CPU资源。

实际中采用的是被动惰性删除+主动定期批量删除相结合的方式,以平衡CPU资源占用和内存回收效率。

在持久化时,也对过期键进行了处理:

  1. 在执行SAVEBGSAVE命令生成RDB文件时,已经过期的键不会保存到RDB文件中。而RDB文件在载入时,会忽略掉过期键。
  2. 基于AOF文件持久化模式下,过期键被删除后,系统会在AOF文件中显式地追加一条DEL指令。因此,当AOF文件重写时,过期键的操作都会被抹掉。

2. 持久化

过期键的删除提升了内存的使用效率,而在键的存活期内,保证键的不丢失也同样重要。Redis的持久化策略有两种:基于快照的RDB持久化,基于追加操作日志的AOF持久化。

RDB(Redis database)

按一定周期对Redis数据快照进行存储(生成dump.rdb文件),当内存数据丢失时,将快照重放到内存中。生成RDB文件的指令有同步阻塞式的SAVE指令,和异步的BGSAVE指令。服务器在载入RDB文件时,则处于阻塞状态,直到文件全部载入成功。

优点:

  1. 恢复速度快
  2. 节省磁盘空间(内存数据会进行压缩成快照文件存储)

缺点:

  1. 虽然RDB通过fork子线程进行快照存储时,并且采用了写时拷贝。但是当数据量比较大时,依然会比较耗时
  2. Redis宕机时,最后一次快照后的修改会丢失

AOF(append of file)

将每次操作追加到日志文件中,重启时Redis按照日志顺序执行一次以完成恢复工作。(生成appendonly.aof文件)

RDB文件和AOF文件同时存在时,系统优先取AOF文件进行恢复。aof文件恢复时,会逐步进行回放操作。

aof记录每次写操作,文件会越来越大。当aof文件的大小超过某个阈值时,会执行aof文件的重写,只保留可以恢复文件的最小指令集。例如,某处执行了flushdb命令,则其之前的写操作记录都可以删除。具体操作是,fork出一个子进程,先写一个临时文件,然后重命名为appendonly.aof并将原来的aof文件覆盖。

aof文件的重写是有开销的,因此只有满足条件才会执行重写:当前aof文件大小大于某个值,并且当前aof文件大小相比上次重写增加了一倍。

优点:

  1. 备份粒度更细,丢失数据更少(至多丢失最后一次写操作)
  2. aof文件对用户可读,通过操作aof文件,可以处理误操作(例如执行了flushdb,可以在aof文件中删除该次操作,重写后便可恢复数据)

缺点:

  1. 相比RDB占用更多磁盘空间
  2. 恢复速度慢
  3. 如果设置为每次写操作都马上同步,会有性能问题

实际上,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更为精细,因而更节省资源,但是逻辑要更为复杂。为了实现部分复制,需要解决下面两个问题:

  1. slave重联成功后,怎么确定当前连的master就是之前的master?
  2. 怎么确定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_moffset_s

  1. master向slave发送N条指令:offset_m + N
  2. slave接收到这N条指令后:offset_s + N
  3. 每次成功的复制后,offset_m = offset_s

一旦slave断联,offset_moffset_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的同步压力。

image.png

3. 分片

当写入负载持续增加时,单台master的性能有限,需要进行分区。集群将key哈希到16384个槽(slot),并划分成多个区间到每个master节点。

例如集群中有3个master节点,则分别管理0-54605461-1092010921-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重定向到该节点。

image.png

cluster addslot slot1...指令可以将指定的槽(slot1)指派给接收指令的节点,如在某节点上执行cluster addslot 1,2,则将槽1和槽2指派给当前节点。

当分区进行扩容时,原有的槽分布要重新划分,假设现在集群中只有一个master节点。现在增加一个master节点,则扩容后分别负责槽0-81918192-16383。扩容过程即将原本在老节点上槽8192-16383内保存的key-value,迁移到新加入的节点上,并刷新各个主节点记录的槽指派信息。迁移过程由Redis专门的集群管理软件redis-trib来控制,其过程如下:

image.png

迁移过程并不原子,例如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/