分布式缓存利器-Redis
在本地缓存中,应用和缓存是一体的;而分布式缓存,最大的特点就是缓存本身就是一个独立的应用,多个应用可以共享缓存应用数据。但面临的问题也更具挑战性!
常用分布式缓存工具有:Redis、Memcache、Tail、EVCache等,本文重点介绍Redis。
Redis(Remote Dictionary Service)是一个key-value存储系统,由Salvatore Sanfilippo开发,使用ANSI C语言编写,遵守BSD协议。
Redis运行于独立的进程,通过网络协议和应用交互,将数据保存在内存中,并提供多种手段持久化内存数据。Redis具备跨服务器的水平拆分、复制的分布式特性。Redis不同于Memcached将value视作黑盒,Redis的value本身具有结构化的特点,对于value提供了丰富的操作。基于内存存储的特点使得Redis与传统的关系型数据库相比,拥有极高的吞吐量和响应性能。
2.1 Redis 数据结构
Redis 提供了五种常用数据结构:Strings、Lists、Hashes、Sets、Sorted Sets
2.1.1 Strings
Strings的内部实现是一种名为简单动态字符串(Simple Dynamic String)的抽象类型。
sds的实现
typedef struct sdshdr {
// 记录buf数组中已使用字节的数量
// 等于SDS所保存字符串的长度
unsigned int len;
// 记录 buf 数组中未使用字节的数量
unsigned int free;
// 字节数组,用于保存字符串
char buf[];
};
buf数组存储了字节串的内容,但它本身的长度通常大于所存储的内容的长度,后者通过len字段直接在O(1)的复杂度内得出。buf中free区域的引入提升了sds对字节串处理的性能,减少了处理过程中可能遇到的内存申请和释放的次数。
2.1.2 Lists
Lists类型的内部实现是linkedlist或ziplist。当Lists的元素个数和单个元素较小时,Redis会采用ziplist实现以减少内存占用,否则采用linkedlist结构。
linkedlist的实现
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr,void *key);
} list;
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点值
void *value;
} listNode;
list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free、match成员则是用于实现多态链表所需的类型特定函数:
- dup函数用于复制链表节点所保存的值
- free函数用于释放链表节点所保存的值
- match函数用于对比链表节点所保存值和另一个输入值是否相等 ziplist的实现
| 属性 | 类型 | 长度 | 用途 |
|---|---|---|---|
| zlbytes | uint32_t | 4字节 | 记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配,或者计算zlend的位置时使用 |
| zltail | uint32_t | 4字节 | 记录压缩列表表尾节点距离压缩列表起始地址有多少字节;通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。 |
| zllen | uint16_t | 2字节 | 记录了压缩列表包含的节点数量:当这个属性的值小于65535时,这个属性的值就是压缩列表包含节点的数量;当这个值等于65535时,节点的真实数量需要遍历整个压缩列表才能计算得出 |
| entryX | 列表节点 | 不定 | 节点的长度由节点保存的内容决定 |
| zlend | uint8_t | 1字节 | 特殊值0xFF(255),用于标识压缩列表的末端 |
entry的各个组成部分
每个节点可以保存一个字节数组或者一个整数值。
previous_entry_length属性记录了该节点前一个节点的长度。previous_entry_length的长度可以是1字节或者5字节,如果前一节点的长度小于254字节,则previous_entry_length的长度为1字节,前一节点的长度就保存在这一个字节里面。如果前一节点的长度大于等于254字节,则previous_entry_length的长度为5字节,其中第一个字节会被设置为0xFE(十进制值254),而之后的四个字节则保存前一节点的长度。
encoding属性记录了节点的content属性所保存数据的类型以及长度。当最高位为00、01、10时,表示字节数组编码,content数组的长度由encoding编码除去最高两位之后的其他位记录;当最高位为11时,表示整数值编码,整数值的类型和长度由encoding编码除去最高两位之后的其他位记录。
2.1.3 Hashes
Hashes的语义和大多数程序语言语义一致:包含若干个key-value,其中key不重复。Hashed内部的key和value不能再嵌套map了,只能是Strings所能表达的内容:整形、浮点数、字节串。其内部实现是hashtable和ziplist。
hashtable的实现
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引,当rehash不在进行时,值为-1
int trehashidx;
} dict;
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,void *key);
// 销毁值的函数
void (*valDestructor)(void *privdata,void *obj);
}
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值,总是等于size-1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
//指向下个哈希表节点,形成链表
struct dictEntry *next;
}
dictEntry的key就是键值对中的键,v属性保存着键值对中的值(值可以是指针、uint64_t整数或int64_t整数),next属性是指向另一个dictEntry的指针,以解决键哈希冲突的问题。
dictht的table是一个数组,数组中每个元素都是指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小。used属性记录了哈希表已有节点的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个建应该被放到table数组的哪个索引上面。
dict的ht属性是一个包含两个项的数组,数组的每一个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
ziplist的实现
这里的ziplist和Lists中的ziplist实现类似,但有不同点,hashes的ziplist的entry个数总是2的整数倍,第奇数个entry存放key,key对应的entry的下一个相邻entry存放该key对应的value
2.1.4 Sets
Sets类似于Lists,但它是一个无序集合。Sets内部实现是intset或hashtable(当只包含整数值元素,并且数量不多时,使用intset,否则使用hashtable)
intset的实现
typedef struct intset {
// 编码方式
uint32_t encoding;
// 包含的元素数量
uint32_t length;
// 保存元素的数组
int8_t contents[];
} intset;
2.1.5 Sorted Sets
Sorted Sets类似于Hashed是一个键值对,但它是一个有序的键值对。
key:key-value对中的键,在一个Sorted Sets中不重复
value:是一个浮点数,称为score
有序:Sorted Sets内部按照score从小到大排序。
Sorted Sets内部实现是ziplist或skiplist+hashtable来实现。(元素少时使用ziplist,否则使用skiplist+hashtable)
skiplist的实现
typedef struct skiplist {
// 表头、表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
跳跃表由zskiplist和zskiplistNode两个结构定义,zskiplistNode表示跳跃表节点,zskiplist表示跳跃表节点的相关信息,如节点数量,指向表头节点和表尾节点的指针等
zskiplist的header指向跳跃表的表头节点;tail指向表尾节点;level记录当前跳跃表内,层数最大的那个节点的层数(不包括表头节点层数);length记录跳跃表的长度,也就是目前包含的节点的数量(不包括表头节点)。
zskiplistNode的level是一个数组,数组中每一项表示该节点的一层,每层带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,跨度记录了前进指针所指向节点和当前节点的距离;backward指针指向当前节点的前一个节点。用于表尾向表头方向遍历时使用。score时各节点保存的分值,节点是按各自保存的分值从小到大排列。obj即各节点保存的成员对象。
2.2 Redis 持久化
Redis 的持久化有两种方式:全量模式(RDB)和增量模式(AOF)。
2.2.1 全量模式
Redis每处理一个写命令,就修改了db的key-value数据。基于全量模式的持久化就是在持久化触发的时刻,将当时的key-value值完全保存下来,形成一个snapshot(RDB文件)。当Redis重启时,通过加载最近一个snapshot数据,可将Redis恢复至最近一次持久化状态上。
Redis的全量写入包含两种方式:SAVE(可以显式触发,也可以在redis shutdown时触发)和BGSAVE(可以显式触发,可以通过配置定时触发,也可以在master-slave的结构下由slave节点触发)。SAVE命令会阻塞Redis进程,期间不能处理任何命令请求。BGSAVE会fork出一个子线程,期间Redis进程可以接着处理后续命令。BGSAVE相比于SAVE的优势是持久化期间可以持续提供读写服务,但这也会增加服务器内存开销。还有就是在fork之后执行的写命令产生的数据变更无法在快照中体现。
2.2.2 增量模式
全量模式的持久化保存的是数据,而增量模式的持久化保存的是数据的变更记录。增量持久化称为AOF(append-only file),并在此基础上以rewrite机制优化性能。
2.3 分布式Redis
Redis作为数据存储系统,无论数据是存储在内存中还是持久化到本地,作为单实例节点,在实际应用中总会面临如下挑战:
- 数据量伸缩:单实例Redis存储的key-value对的数量受限于单机的内存和磁盘容量。随着数据的不断增长,存储容量会达到瓶颈。
- 访问量激增:单实例Redis单线程地运行,吞吐量受限于单次请求处理的平均耗时。当业务数据面临超过单实例处理能力的高吞吐量需求时,如何提升处理能力成为难点。
- 单点故障:Redis持久化机制一定程度上缓解了宕机带来的数据丢失问题,但当单实例所在的物理节点发生不可恢复故障时,如何保证业务数据不丢以及如何在故障期间快速恢复成为了大的挑战。
对于这些问题,常用的解决方案有:
- 水平拆分:水平拆分就是将对数据的存储和访问分散到不同节点上分别处理。拆分后,各节点存储和处理的数据原则上没有交集。
- 主备复制:同一份业务数据存在多个副本,对数据的每次访问根据一定规则分发到某一个或多个副本上执行。
- 故障转移:当业务数据所在节点故障时,这部分业务数据转移到其他节点上进行,使得故障节点在恢复期间,对应的业务数据仍然可用。
2.3.1 水平拆分
要实现水平拆分,需要通过数据分布和请求路由配合。
常见的数据分步方案有:hash映射,范围映射或两者结合(典型的如一致性哈希)。
确定了业务数据如何分布到Redis的不同实例之后,实际数据访问时,根据请求中涉及的key,用对应的数据分布算法得出数据位于哪个实例,再将请求路由至该实例,这个过程叫做请求路由。但需要关注数据跨实例问题:
- 只读的跨实例请求:需要将请求中的多个key分别分发到对应实例上执行,再合并结果。
- 跨实例的原子读写请求:事务、集合型数据的转存操作(如ZUNIONSTORE),向实例B的写入操作依赖于对实例A的读取。单实例情况下,Redis的单线程特性保证此类读写依赖的并发安全,然而在跨实例情况下,这个前提被打破,因此存在跨节点读写依赖的原子请求是不支持的。
2.3.2 主备复制
主备复制是指将相同数据存放在多个不同节点上。如何保证各节点的数据一致性是关键问题。在不同存储系统架构下方案不同,有的采用客户端双写,有的采用存储层复制。Redis采用主备复制的方式保证一致性,即所有节点中,有一个节点为主节点,对外提供写入服务,所有的数据变更由外界对master的写入触发,之后Redis内部异步地将数据从主节点复制到其他节点(slave)上。
主备复制流程
master视角:
- slave向master发起SYNC命令
- master收到SYNC命令后,开启BGSAVE操作。
- BGSAVE完成后,master将快照信息发送给slave
- 发送期间,master收到新的写命令,除了正常响应外,再存入一份到backlog队列
- 快照信息发送完成后,master继续发送backlog队列信息
- backlog发送完成后,后续的写操作同时发给slave,保持实时地异步复制。
slave视角:
- 发送完SYNC后,继续对外提供服务
- 开始接受master的快照信息,此时,将slave现有数据清空,并将master快照写入自身内存
- 回放backlog内容,期间对外提供读请求
- 继续接受后续来自master的命令副本并继续回放,以保持数据和master一致。
2.3.3 故障转移
当两台以上Redis实例形成主备关系后,它们组成的集群就具备了一定的高可用性:当master故障时,slave可以成为新的master,对外提供读写服务。这种机制成为failover。问题是谁去发现master的故障并做failover的决策?
保持一个daemon进程,监控所有master-slave节点
daemon作为单点,本身的可用性无法保证。很直观的想到,可以引入多daemon,此时就变成了
引入多个daemon,解决了可用性问题,但多个daemon间,如何就某个master是否可用达成一致?
Redis的sentinel提供了一套多daemon间的交互机制,解决故障发现、failover决策协商机制等问题
sentinel节点的相互感知
sentinel和sentinel之间是通过订阅相同的channel:sentinel:hello相互感知的,一个新加入的sentinel节点向这个channel发布一条信息,包含了自己信息,该channel的订阅者们就发现了新的sentinel。随后新sentinel和已有的其他sentinel节点建立长连接。sentinel集群中所有节点两两连接。
master的故障发现
sentinel节点通过定期master发送心跳包判断其存活状态,一旦发现master没有正确响应,sentinel则将此master置为主观不可用,并发送给其它所有的sentinel节点进行确认,当确认的sentinel节点数>=quorum(可配置)时,则判定master为不可用,随后进入failover流程。
failover决策
当master真正宕机后,可能存在多个sentinel同时发现该问题并通过交互确认了相互的主观不可用态。此时需要开始一个leader选举过程,选择谁来发起failover。Redis采用类似Raft协议实现这个选举算法。当leader确定之后,从master所有的slave中依据一定规则选取一个作为新的master,告知其他slave连接这个新的master。