Redis理论基础
什么是redis
Redis(Remote Dictionary Server) 是一个使用 C 语言编写的,开源的(BSD许可)高性能非关系型(NoSQL)的键值对数据库。
与传统数据库不同的是 Redis 的数据是存在内存中的,所以读写速度非常快,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value DB。并且Redis 支持事务 、持久化、LUA脚本、LRU驱动事件、多种集群方案。
redis的优缺点
优点:
- 读写性能优异, Redis能读的速度是110000次/s,写的速度是81000次/s。
- 支持数据持久化,支持AOF和RDB两种持久化方式。
- 数据结构丰富,除了支持string类型的value外还支持hash、set、zset、list等数据结构。
- 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离。
- 支持事务,Redis的所有单个操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。
缺点:
- 数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。
- Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。为避免这一问题,运维人员在系统上线时必须确保有足够的空间,这对资源造成了很大的浪费。
- Redis 不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP才能恢复。
- 主机宕机,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性。
redis应用场景
- 分布式缓存
- 分布式锁
- 消息队列
- 计数器和统计
- 分布式id生成
Redis数据结构
基础数据结构:
- String
- List
- Set
- Sorted Set
- Hash
高级数据结构:
- **HyperLogLog:**通常用于基数统计。使用少量固定大小的内存,来统计集合中唯一元素的数量。统计结果不是精确值,而是一个带有0.81%标准差(standard error)的近似值。所以,HyperLogLog适用于一些对于统计结果精确度要求不是特别高的场景,例如网站的UV统计。
- **Geo:**redis 3.2版本的新特性。可以将用户给定的地理位置信息储存起来,并对这些信息进行操作:获取2个位置的距离、根据给定地理位置坐标获取指定范围内的地理位置集合。
- **bitmap:**可以用来实现布隆过滤器。
- **BloomFilter:**布隆过滤器。
- **Pub/Sub:**发布订阅。
- **Stream:**主要用于消息队列,类似于kafka,可以认为是pub/sub的改进版。提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
String类型详解
常用命令
- SET key value:设置指定key的值为value
- GET key:获取指定key的值
- INCR key:将指定key的值增加1
- DECR key:将指定key的值减少1
- APPEND key value:将指定value追加到指定key的值的末尾
底层实现
Redis的字符串类型是简单动态字符串,简称为SDS(Simple Dynamic String)。是Redis自己封装的字符串结构。当字符串长度小于1M时,扩容都是加倍现有的空间,如果超过1M,扩容时一次只会多扩1M的空间。需要注意的是字符串最大长度为512M。
数据结构
Redis字符串结构如下:
struct sdshdr{
//字符串长度,即buf已用字节的数量
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串,最后一位保存'\0'
char buf[];
}
内存分配图如下:
空间预分配
为减少修改字符串带来的内存重分配次数,sds采用了“一次管够”的策略
- 若修改之后sds长度小于1MB,则多分配现有len长度的空间
- 若修改之后sds长度大于等于1MB,则扩充除了满足修改之后的长度外,额外多1MB空间
内存扩容图如下:
惰性释放
为避免缩短字符串时候的内存重分配操作,sds在数据减少时,并不立刻释放空间。而是通过渐少len的长度,增加free的长度的方式实现。当然也可以通过调用SDS提供的API直接释放内存,在需要的时候也能真正的释放掉多余的空间。
内存释放后如下图:
Redis字符串与C语言字符串的区别
Redis字符串 | C语言字符串 | |
---|---|---|
获取字符串长度复杂度 | 顺序遍历O(N) | 读取len属性O(1) |
API安全与否 | 不安全,可能会造成缓冲区溢出 | 安全,不会 |
N次修改字符串长度内存重分配次数 | 必是N次 | 最多N次 |
保存的数据类型 | 空字符会被认为是结尾(会有二进制安全问题),只能保存文本数据 | 文本或二进制数据 |
<string.h>库中的函数使用 | 可使用所有 | 可使用部分 |
List类型详解
常用命令
- LPUSH key value [value …]:将一个或多个元素插入到列表头部
- RPUSH key value [value …]:将一个或多个元素插入到列表尾部
- LPOP key:移除并返回列表的头元素
- RPOP key:移除并返回列表的尾元素
- LRANGE key start stop:获取列表中指定范围内的元素
底层实现
Redis3.2之前采用的linked list(双向链表)和ziplist(压缩列表)来存储数据。当元素长度 < 64字节,且元素数量 < 512使用ziplist存储,否则将转换为双向链表。
Redis3.2及其之后采用quicklist(快速列表)存储数据。
likedlist(双向链表)
链表节点结构如下:
/* Node, List, and Iterator are the only data structures used currently. */
typedef struct listNode {
struct listNode *prev; //上一元素
struct listNode *next; //下一元素
void *value; //元素值
} listNode;
双向链表结构如下:
typedef struct list {
//头结点
listNode *head;
//尾元素
listNode *tail;
//元素值复制函数
void *(*dup)(void *ptr);
//元素值释放函数
void (*free)(void *ptr);
//元素值对比函数
int (*match)(void *ptr, void *key);
//元素长度
unsigned long len;
} list;
Redis的链表是一个双端链表结构,它有如下特点:
- 无环的结构(尾的next没有指向头,头的prev没有指向尾)
- 在list中维护了len很方便的可以取到链表的长度,复杂度是o(1)
- 使用void* 指针保存元素的值可以保存各种类型的值
- 对链表的表头和表尾进行插入的复杂度都为 o(1)
但是链表的缺点也是比较明显的,就是链表的附加空间比较高,在数据较小的时候会造成空间的浪费,prev和next就需要占用16个字节(64bit的系统,指针8字节),每个元素的内存是独立分配的,不要求连续性,会增加内存的碎片化,影响内存管理效率。所以它不适合存储少量或者小数据。所以Redis在列表对象中小数据量的时候使用压缩列表ziplist作为底层实现。
ziplist(压缩列表)
ziplist本质上就是一个字节数组,是Redis为了解决内存而设计的一种线性数据结构。在Redis3.2 版本前压缩列表不仅是 List 的底层实现之一,同时也是 Hash、 ZSet 两种数据类型底层实现之一。
一个压缩列表可以包含任意多个元素(entry),每个元素可以保存一个字节数组或者一个整数值。元素之间紧挨着存储,没有任何冗余空隙。
压缩列表结构如下图所示:
- **zlbytes:**压缩列表的字节长度,占4个字节
- **zltail:**压缩列表尾元素相对于压缩列表起始地址的偏移量,占4字节
- **zllen:**压缩列表的元素(entry)个数,占用2个字节
- **entry:**压缩列表存储的元素,可以是字节数组或者整数
- **zlend:**压缩列表的结尾,占用1字节
压缩列表元素结构如下图所示:
- **previous_entry_length:**表示的是前一个元素的字节长度,如果前一元素的长度小于254 字节,用1字节长的空间来保存这个长度值。如果前一元素的长度大于等于254 字节,用5 字节长的空间来保存这个长度值。
- **encoding:**表示当前元素的编码,即conent字段存储的数据类型和长度,整数或者字节数组
- **content:**数据的内容存储在content中,值的类型和长度由元素的encoding属性决定。
压缩列表如何遍历
- **向前遍历:**根据该previous_entry_length的长度可以计算出前一个元素的地址,当前元素的地址减去上一个元素的长度即得到上一个元素的起始地址,所以该值可以用来实现表尾向表头遍历。这种设计非常节约内存,不需要额外的空间维护prev和next指向。
- **向后遍历:**通过当前元素的地址加上当前元素的占用字节长度可以计算出下一个元素的位置,来达到向后遍历的效果。
连锁更新
-
**新增节点:**如果压缩表中节点长度均在250到253字节之间,在第一个节点前面加入一个大于等于254字节的节点,那么此刻原本第一个节点的previous_entry_length需要新增4个字节空间,它的长度就在254到257字节之间了,后续节点的previous_entry_length使用一个字节也是没有办法保存的,所以需要对后续节点进行空间重分配,同理一直到最后一个节点,都需要空间重分配,引发连锁更新。
-
**删除节点:**一个big节点(长度大于等于254),big节点后边是一个small节点(长度小于254),small节点后续是若干长度均在250到253字节之间的节点,此刻删除small,原本small的下一个节点的previous_entry_length只用一个字节,但现在要增加4个字节才能存下big节点的长度,此时该节点重新分配空间后长度大于等于了254,后续节点都要重新分配。引发连锁更新。
连锁更新的平均复杂度是O(n),最坏复杂度为O(n^2)。
需要注意的是:虽然ziplist是一种节约内存的设计,但是只是适合存储少量数据,以及短字符串内容,因为它的ziplist空间是连续的,如果元素多伴随着增加,删除操作需要重新分配存储空间,这会给Redis的执行效率带来很大的影响,故而当元素数量和内容达到一定阈值就会采用双向链表结构。
quicklist(快速列表)
quicklist是redis 3.2之后出现的,quicklist是由list链表和ziplist结合而成的,或者可以看做是quicklist由若干个ziplist组成的双向链表结构,它将双向链表按段切分,每一段使用压缩列表进行内存的连续存储,多个压缩列表通过 prev 和 next 指针组成,它在时间和空间的性能上得到了平衡。
quicklist整体结构图:
quicklist结构如下:
/* quicklist is a 32 byte struct (on 64-bit systems) describing a quicklist.
* 'count' is the number of total entries.
* 'len' is the number of quicklist nodes.
* 'compress' is: -1 if compression disabled, otherwise it's the number
* of quicklistNodes to leave uncompressed at ends of quicklist.
* 'fill' is the user-requested (or default) fill factor. */
typedef struct quicklist {
// 指向链表的头
quicklistNode *head;
// 指向链表的尾
quicklistNode *tail;
// quicklist中的元素总数
unsigned long count; /* total count of all entries in all ziplists */
// len : quicklist的节点数
unsigned int len; /* number of quicklistNodes */
int fill : 16; /* fill factor for individual nodes */
unsigned int compress : 16; /* depth of end nodes not to compress;0=off */
} quicklist;
- head : 指向链表的头
- tail : 指向链表的尾
- count: quicklist中的元素总数
- len : quicklist的节点数
- compress: 节点压缩,16bit,通过修改参数list-compress-depth进行配置。
- fill : 16bit,用来表示ziplist大小,如果为正数表示每个ziplist最多含有的数据项,最大值为 215215,如果该值为负数则:
- **-1:**ziplist节点最大为4kb
- **-2:**ziplist节点最大为8kb
- **-3:**ziplist节点最大为16kb
- **-4:**ziplist节点最大为32kb
- **-5:**ziplist节点最大为64kb
quicklistNode结构如下:
typedef struct quicklistNode {
struct quicklistNode *prev; // 上一个node节点
struct quicklistNode *next; // 下一个node
unsigned char *zl; // 保存的数据 压缩前ziplist 压缩后压缩的数据
unsigned int sz; /* ziplist size in bytes */
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
/* quicklistLZF is a 4+N byte struct holding 'sz' followed by 'compressed'.
* 'sz' is byte length of 'compressed' field.
* 'compressed' is LZF data with total (compressed) length 'sz'
* NOTE: uncompressed length is stored in quicklistNode->sz.
* When quicklistNode->zl is compressed, node->zl points to a quicklistLZF
注意:未压缩的长度存储在quicklistNode->sz中 ,
压缩quicklistNode->zl时,node->zl指向quicklistLZF */
- prev: 指向前一个节点的指针。
- next: 指向后一个节点的指针。
- zl: 数据指针,如果当前节点没有压缩那么它指向一个ziplist结构,否则它指向一个quicklistLZF结构。
- sz: 表示整个ziplist的总大小,如果ziplist被压缩了这个sz的值保存的是压缩前的ziplist大小。
- count: 表示ziplist里面包含的元素个数,16bit。
- encoding: 表示ziplist采用的编码方式,2表示使用了LZF压缩,1表示没有压缩。
- container: 为quicklistNode节点zl指向的容器类型:1代表none,2代表使用ziplist存储数据。
- recompress: 1代表是压缩节点,在使用压缩节点时会先进行解压缩,使用后重新压缩。
- attempted_compress: 自动化测试时使用。
- extra: 预留字段,目前Redis的实现里也没用上。
quicklistLZF结构如下:
//压缩结构
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF;
- **sz:**压缩后的quicklist大小
- compressed[] : 存放压缩后的ziplist字节数组
Hash类型详解
常用命令
- HSET key field value:设置指定key中的指定field为指定value
- HGET key field:获取指定key中的指定field的值
- HGETALL key:获取指定key中所有的field和value
- HDEL key field [field …]:删除指定key中的一个或多个field
- HINCRBY key field increment:将指定key中的指定field增加指定的increment
底层实现
Hash也是Redis中非常常用的一种存储结构了,Redis的hash底层用到了两种存储结构,ziplist(压缩列表)和hashtable(哈希表)。
当元素个数<512个,并且元素长度<64字节时,使用ziplist存储,否则使用hashtable存储。
ziplist我们已经在List结构写过了,接下来直接看hashtable的结构:
typedef struct dict {
/*类型*/
dictType *type;
/*私有数据*/
void *privdata;
/*2个哈希表,一个存储值,一个位空,哈希表进行rehash的时候才会用到第2个哈希表 */
dictht ht[2];
/*rehash目前进度,rehash的时候用,其他情况下为-1*/
int rehashidx;
/*目前正在运行的安全迭代器的数量*/
int iterators;
}dict;
hash的存储结构叫做dict(字典),其中维护了两个 dictht ,也就是两个哈希表,一个存储值,一个为空,在哈希表进行rehash重新散列的时候才会用到第二个哈希表,rehash后面会说。
dictht 结构如下:
typedef struct dictht{
/ * 哈希表数组 */
dictEntry **table;
/ * 哈希表大小 */
unsigned long size;
/ * 掩码,用于计算索引值 : size-1 */
unsigned long sizemask;
/ * 哈希表已有节元素个数 */
unsigned long used;
}dictht
其中dictEntry **table是一个二维哈希表数组,是真正用来存储数据的,使用的是 dictEntry 类型。
dictEntry结构如下:
typedef struct dictEntry{
/* 键 */
void *key;
/* 值*/
union{
/*值*/
void *val;
/*无符号 64位整数*/
uint64_tu64;
/*有符号 64位整数*/
int64_ts64;
}v;
/* 指向下一个哈希表节点,形成链表*/
struct dictEntry *next;
}dictEntry
key是键,val是值,next是下一个hash节点的指针,形成一个单向链表用于解决hash冲突。val可以存储64位带符号整数,和无符号整数,占8字节。
hash整体结构图如下:
rehash重新散列
rehash重新散列指的是对hashtable进行扩容,或者缩容,当元素越来越多势必要扩展空间,当元素变少,多余的空间就是浪费,所以也要缩容。Hash中元素满了就会以原来数组的2倍扩容,缩容条件是元素个数少于数组长度的 10%。redis的rehash不是一次性把ht[0]的元素全部复制到ht[1],而是采用的是渐进式扩容,渐进式rehash流程如下:
1、(初始化)计算新表size等信息,为新表ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
2、(索引复制)将rehash索引计数器变量rehashidx的值设置为0,表示rehash正式开始。
3、(rehash)在rehash进行期间,每次对字典执行增删改查操作时,程序除了执行指定的操作以外,还会触发额外的rehash操作,在源码中的_dictRehashStep方法。该方法会从ht[0] 表的 rehashidx 索引位置上开始向后查找,找到第一个不为空的索引位置,将该索引位置的所有节点rehash到ht[1],再将ht[0]的rehashidx的节点清空,rehashidx+1。
4、(时间事件)Redis会定期触发时间事件,时间事件用于执行一些后台操作,其中包含rehash操作:当Redis发现有字典正在进行 rehash 操作时,会花费1毫秒的时间,一起帮忙进行rehash。
5、(新增数据)新增的键值对会被直接保存到ht[1],ht[0]不再进行任何添加操作。
6、(重置table)随着操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],此时rehash流程完成,会执行最后的清理工作:释放ht[0]的空间、将ht[0]指向ht[1]、重置 ht[1]、重置rehashidx的值为-1。
渐进式rehash优缺点
优点:
- 避免了集中式rehash而带来的庞大计算量。
缺点:
- 耗费额外空间,整个扩容期间,会同时存在ht[0]和ht[1],会占用额外的空间。
- 操作耗时增加,渐进式rehash进行期间,字典的删、査、该等操作会在两个哈希表上进行。例如,要在字典里面査找一个键的话,程序会先在ht[0]里面进行査找,如果没找到的话,就会继续到ht[1]里面进行査找,会增加耗时。
- 可能导致主从不一致,Redis在内存使用接近maxmemory并且有设置驱逐策略的情况下,出现rehash会使得内存占用超过maxmemory,触发驱逐淘汰操作,导致master/slave均有有大量的key被驱逐淘汰,从而出现master/slave主从不一致。
Set类型详解
常用命令
- SADD key member [member …]:向指定key中添加一个或多个元素
- SREM key member [member …]:从指定key中删除一个或多个元素
- SISMEMBER key member:检查指定元素是否存在于指定集合中
- SMEMBERS key:获取指定集合中的所有元素
- SUNION key [key …]:返回指定集合的并集
底层实现
Redis中的set和java中的set集合有相似之处,它的元素不会按照插入的向后顺序而存储,且元素是不允许重复的。set内部使用到了intset(整数集合)和hashtable(哈希表)两种方式来存储元素。
当set存储的元素是整数,且当元素个数小于512个会选择intset存储。目的是减少内存空间,遇到两种情况会发生变化,就是当存储的元素个数达到512(通过set-max-intset-entries 配置)或者添加了非整数值时如:‘b’,set会选择hashtable作为存储结构。
hashtable已经在hash结构时写过了,这里直接看intset的结构:
typedef struct intset{
//编码类型
uint32_t encoding;
//集合元素数量
uint32_t length;
//存储元素的数组
int8_t contents[];
} intset;
//下面是相关函数
intset *intsetNew(void);
intset *intsetAdd(intset *is, int64_t value, uint8_t *success);
intset *intsetRemove(intset *is, int64_t value, int *success);
uint8_t intsetFind(intset *is, int64_t value);
int64_t intsetRandom(intset *is);
uint8_t intsetGet(intset *is, uint32_t pos, int64_t *value);
uint32_t intsetLen(const intset *is);
size_t intsetBlobLen(intset *is);
int intsetValidateIntegrity(const unsigned char *is, size_t size, int deep);
- length: contents中元素的个数
- **contents:**存储元素的数组,需要根据encoding来决定多少个字节表示一个元素
- **encoding:**编码类型,决定每个元素占用几个字节,有以下三种类型
- INTSET_ENC_INT16:该方式每个元素占2个字节,存储 -32768 到 32767
- INTSET_ENC_INT32:该方式每个元素占4个字节,存储 32767到2147483647 或 -2147483647 到 -32768
- INTSET_ENC_INT64:该方式每个元素占8个字节,存储 2147483647 到 922337203684775807 或 -922337203684775808 到 -2147483648
intset会根据插入的值判断是否扩容,根据插入内容来修改encoding使用什么类型,从而决定contents每个元素的字节数,紧跟着对contents进行扩容。
intset的升级
假如我们在Set结构添加这样的数据 sadd 1 2 3 ,底层会执行intsetAdd函数,它会选择INTSET_ENC_INT16类型 去存储,且按照元素大小顺序,它在intset中的结构是这样的:
如果我们再执行 sadd 32999会发生什么?32999是超过了INTSET_ENC_INT16类型的存储大小,如果硬塞进去就会内存溢出。所以为了防止内存溢出,intset在添加新元素的时候会先判断是直接插入新元素还是先扩容过后再插入新元素,扩容流程如下:
- 判断新元素的编码类型是否大于以后元素的编码类型,如果大于就进行扩容
- 根据新元素的编码类型,为contents的存储空间扩容,同时为新元素分配空间
- 将所有元素都转换成和新元素相同的编码类型,并调整元素存储位置,且要保证底层数组的有序性质不变
- 将新元素添加到contents中
上诉案例扩容后如下图所示:
这样设计的目的就是为了节约内存,通常情况下当我们存储的元素在 -32768 到 32767之间,就使用更小的存储空间,INT16,当插入元素超过该范围就升级更大的内存空间去存储,我们可以任意的存储INT16,INT32,INT64类型的整数不用担心内存问题。
**注意:**intset是没有降级的概念,一旦存储了INT32类型的元素那么数组的元素一直都是INT32类型了。
Sorted Set类型详解
常用命令
- ZADD key score member [score member …]:向指定有序集合中添加一个或多个元素
- ZRANGE key start stop [WITHSCORES]:获取指定范围内的元素,可选择同时返回分数
- ZREM key member [member …]:从指定有序集合中删除一个或多个元素
- ZINCRBY key increment member:将指定元素的分数增加指定的increment
- ZCARD key:获取指定有序集合中元素的数量
底层实现
SortedSet(zset)有序集合可以看做是在Set集合的的基础上为集合中的每个元素维护了一个顺序值score,它允许集合中的元素可以按照score进行排序。
对于SortedSet有序集合而言它需要维护一个顺序值,而对于有序集合的底层实现可以选择:数组,链表,平衡树或者红黑树等结构,但是SortedSet没有选择这些结构。数组插入和删除元素性能很差,链表查询慢,平衡树或红黑树虽然查询效率高,但是在插入和删除元素的时候需要维持树的平衡导致性能下降,而且实现极为复杂。所以,SortedSet底层而是采用了一种新型的数据结构skiplist(跳跃表)。当然SortedSet不只是有skiplist实现,它在元素个数较少和元素内容较小时也是用的ziplist进行存储。
当元素个数少于128个 (zset-max-ziplist-entries: 128),并且每个元素长度小于64字节 (zset-max-ziplist-value: 64)时使用ziplist存储,否则使用skiplist存储。
跳表原理
跳表也是链表的一种,是在链表的基础上发展出来的,我们都知道,链表的插入和删除只需要改动指针就行了,时间复杂度是O(1),但是插入和删除必然伴随着查找,而查找需要从头/尾遍历,时间复杂度为O(N),如下图所示是一个有序链表(最左侧的灰色表示一个空的头节点)
链表中,每个节点都指向下一个节点,想要访问下下个节点,必然要经过下个节点,即无法跳过节点访问,假设,现在要查找22,我们要先后查找 3->7->11->19->22,需要五次查找。
但是如果我们能够实现跳过一些节点访问,就可以提高查找效率了,所以对链表进行一些修改,如下图:
这里我们在原链表的基础上向上再抽取一个链表,原链表为第一层,新链表为第二层,第二层是在第一层的基础上隔一个取一个。假设,现在还是要查找22,我们先从第二层查找,从7开始,7小于22,再往后,19小于22,再往后,26大于22,所以从节点19转到第一层,找到了22,先后查找 7->19->26->22,只需要四次查找。
以此类推,如果再提取一层链表,查找效率岂不是更高,如下图所示:
现在,又多了第三层链表,第三层是在第二层的基础上隔一个取一个,假设现在还是要查找22,我们先从第三层开始查找,从19开始,19小于22,再往后,发现是空的,则转到第二层,19后面的26大于22,转到第一层,19后面的就是22,先后查找 19->26>22,只需要三次查找。
由上例可见,在查找时,跳过多个节点,可以大大提高查找效率,skiplist 就是基于此原理。虽然我这里举得例子中每新增一层就仅仅少一次查找,但是如果元素越来越多时,skiplist的查询数据就会趋近于O(logn)。
上面的例子中,每一层的节点个数都是下一层的一半,这种查找的过程有点类似二分法,查找的时间复杂度是O(logN),但是例子中的多层链表有一个致命的缺陷,就是一旦有节点插入或者删除,就会破坏这种上下层链表节点个数是2:1的结构,如果想要继续维持,则需要在插入或者删除节点之后,对后面的所有节点进行一次重新调整,这样一来,插入/删除的时间复杂度就变成了O(N)。跳表为了解决插入和删除节点时造成的后续节点重新调整的问题,引入了随机层数的做法。相邻层数之间的节点个数不再是严格的2:1的结构,而是为每个新插入的节点赋予一个随机的层数。下图展示了如何通过一步步的插入操作从而形成一个跳表:
每一个节点的层数都是随机算法得出的,插入一个新的节点不会影响其他节点的层数,因此,插入操作只需要修改插入节点前后的指针即可,避免了对后续节点的重新调整。这是跳表的一个很重要的特性,也是跳表性能明显优于平衡树的原因,因为平衡树在失去平衡之后也需要进行平衡调整。
redis的skiplist实现
skiplist结构如下:
typedef struct zskiplist {
struct zskiplistNode *header, *tail;//跳表节点 ,头节点 , 尾节点
unsigned long length;//节点数量
int level;//目前表内节点的最大层数
} zskiplist;
- header: 指向跳跃表头节点,头节点是跳跃表的一个特殊节点,它的level数组元素个数为32。头节点在有序集合中不存储任何member和score值,obj值为NULL,score值为0;也不计入跳跃表的总长度。头节点在初始化时,32个元素的forward都指向NULL,span值都为0。
- **tail:**指向跳跃表尾节点。
- **length:**跳跃表长度,表示除头节点之外的节点总数。
- **level:**跳跃表的最大的节点的高度。
zskiplistNode结构如下:
//跳表节点
typedef struct zskiplistNode {
robj *obj;//指向存储数据的指针
double score;//分值
struct zskiplistNode *backward;//后向指针
struct zskiplistLevel {//节点所在的层
struct zskiplistNode *forward;//前向指针
unsigned int span;//该层向前跨越的节点数量
} level[]; //节点层结构 数组,每次创建一个跳表节点时,都会随机生成一个[1,32]之间的值作为level数组的大小。
} zskiplistNode;
- obj: 指向存储数据的指针,robj类型是 Redis 中用C语言实现一种集合数据结构,它可以表示 string、hash、list、set 和 zset 五种数据类型,这里不做详细说明,在跳表节点中,这个类型的指针表示节点的成员对象。
- **backward:**后退指针,只能指向当前节点最底层的前一个节点,头节点和第一个节点的backward指向NULL,从后向前遍历跳跃表时使用。
- **score:**用于存储排序的分值。
- **level:**为柔性数组。每个节点的数组长度不一样,在生成跳跃表节点时,根据幂次定律随机生成一个1~32的值,值越大出现的概率越低。
- **forward:**指向本层下一个节点,尾节点的forward指向NULL。
- **span:**forward指向的节点与本节点之间的元素个数。span值越大,跳过的节点个数越多
redis的跳跃表整体结构图如下图所示:
这个结构图中可以看到header指向的是一个只有32个level元素组成的头节点;
tail指向的跳表的尾节点;
跳表中有o1、o2、o3这三个元素,所以跳表的length为3;
其中o1元素的level是4层,o2是2层,o3是5层,所以整个调表的最大层数level为5;
而每个节点的BW(backword)表示后退指针,每个节点都有一个,指向当前节点的表头方向的下一个节点,用于从表尾进行遍历;
BW下面的1.0、2.0、3.0对应的每个元素的score分数,跳表的元素存储顺序按分数升序排序,如果如果分数一致则按值的字典顺序排序。
节点中的每个层级的箭头指向表示forward指向的地址,上面的数值为span对应的步长。
键与缓存
Redis结构
库结构
Redis服务器的所有数据库保存在redis.h/redisService结构的db数组中:
truct redisService{
//...
redisDB *db; // 保存所有数据库的数组
int dbnum ;// 服务器的数据库数量,默认16个
//...
}
Redis客户端数据库保存在在redisClient结构的db属性:
typedef struct redisClient{
//...
//记录客户端当前正在使用的数据库
redisDB *db;
} redisClient
整体结构如下图所示:
键结构
数据库的键定义在redis.h/redisDb结构中:
typedef struct redisDb{
//...
// 数据库键空间,保存数据库中所有的键值对
dict *dict;
// 过期字典,保存着键的过期时间
dict *expires;
} redisDb;
Redis的键由字典保存。每个键是一个字符串对象。每个值可以是字符串对象、列表对象、哈希表对象、集合对象和有序集合对象中的一种。另外还有一个expires也是字典结构,保存着过期的时间。
整体结构如下图所示:
库与键的操作
库的操作
- **SELECT index:**选择一个数据库,index为数据库索引号(0到15)。
- **FLUSHDB:**清空当前数据库中的所有数据。
- **FLUSHALL:**清空所有数据库中的所有数据。
- **DBSIZE:**返回当前数据库中的键的数量。
键的操作
- **KEYS pattern:**查找符合给定模式的键。
- **EXISTS key:**检查给定键是否存在于当前数据库中。
- **DEL key [key …]:**删除指定的键。
- **RENAME key newkey:**将键重命名为新名称。
- **MOVE key db:**将键从当前数据库移动到指定的数据库。
- **EXPIRE key seconds:**设置键的过期时间,单位为秒。
- **TTL key:**获取键的剩余过期时间。
- **PERSIST key:**移除键的过期时间,使其永久有效。
- **TYPE key:**返回键存储的值的数据类型。
- **DUMP key:**将指定键的值序列化为字符串并返回。这个命令可以用于创建键的备份。
- **RESTORE key ttl serialized-value:**将之前使用DUMP命令创建的备份字符串反序列化,并将其恢复为键的值。ttl参数表示过期时间,如果不需要过期时间,可以将其设置为0。
- **MIGRATE host port key destination-db timeout [COPY] [REPLACE]:**将键从当前Redis实例迁移到另一个Redis实例。它需要指定目标Redis实例的主机和端口,要迁移的键,目标数据库的索引,以及可选的超时时间。迁移命令还可以选择性地使用COPY和REPLACE选项,COPY选项表示在迁移完成后是否保留源键,REPLACE选项表示是否替换目标实例上已存在的同名键。
键遍历
**keys *** 命令,在大数据量时,会阻塞主线程。另外,使用一些时间复杂度为O(N)的命令时要非常谨慎。可能一不小心就会阻塞进程,导致Redis出现卡顿。
数据量大时,应该使用渐进式遍历来代替。
scan cursor [match pattern] [count number]
- **cursor:**第一次遍历从0开始,每次scan遍历完后会返回当前游标值,直到为0表示遍历结束。
- **match patten:**匹配模式。
- **count number:**每次要遍历的个数,默认10个。
举例:scan 0 match java count 1000。
解释:从0开始遍历,匹配key为java,总数是1000,1000不是结果数量,是redis单次遍历字典槽位数量(约等于)。
SCAN的实现原理
键的过期删除策略
- 定时删除:在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。对内存最友好,对 CPU 时间最不友好。
- 惰性删除:但是每次获取键时,都检査键是否过期,过期就删除。对CPU时间最优化,对内存最不友好。
- 定期删除:每隔一段时间,默认100ms,程序就对数据库进行一次检査,删除里面的过期键。至于要删除多少过期键,以及要检査多少个数据库,则由算法决定。前两种策略的折中,对 CPU 时间和内存的友好程度较平衡。
Redis使用惰性删除和定期删除。
防止键被意外删除
分为被攻击,或者被开发运维人员误删。其解决方案如下:
- 提供密码,注意密码要够复杂,以免被暴力破解。
- 可以添加如下配置rename-command COMMAND_NAME NEW_NAME。例如rename-command flushall xxxx,表示将flushall命令改为xxxx。
- 将危险命令伪装。比如keys,flushall,flushdb,save,shutdown等命令。注意,如果持久化文件包含了rename之前的命令,将导致Redis无法启动。另外Redis客户端也需要同步地修改命令。
- Redis端口不对外网开发。
- bind指定固定ip。
- 定期备份。
- 使用非root权限启动。
内存管理
内存划分
Redis内存消耗包括自身内存,对象内存,缓冲区内存,内存碎片。通过执行info memory获取内存相关指标。
其中有三个重要指标:
- **used_memory:**内部存储的所有数据占用的内存量,即自身内存+对象内存+缓冲区内存。
- **used_memory_rss:**操作系统角度显示Redis进程占用的物理内存总量。
- **mem_fragmentation_ratio:**used_memory_rss / used_memory。> 1说明有碎片(空间的扩
容和释放)。< 1说明出现内存交换到硬盘,Redis性能会变得很差,甚至僵死。解决方案如下:- 重启Redis实例。
- config set activedefrag yes来清理内存碎片。内存碎片 > active-defrag-ignore-bytes开始清
理,< active-defrag-threshold-lower停止清理。
设置内存上限
Redis默认无限使用服务器内存。需要防止所用内存超过物理内存。通过config set maxmemory 6GB动态调整。
内存淘汰策略
当达到最大内存限制时,如果还需要更多的内存,根据不同的策略进行不同的处理,如下四类算法lru(最近最少使用),random(随机),ttl(剩余时间),lfu(最不经常使用)。
-
**noeviction(默认策略):**不删除,直接返回错误。
-
**allkeys-lru:**在所有的key中,淘汰部分最近最少使用的key。
-
**allkeys-random:**在所有的key中,随机淘汰部分key。
-
**allkeys-lfu:**在所有的key中,淘汰部分使用频率最低的key。
-
**volatile-lru:**在设置了过期时间的key中,淘汰部分最近最少使用的key。
-
**volatile-random:**在设置了过期时间的key中,随机淘汰部分key。
-
**volatile-lfu:**在设置了过期时间的key中,淘汰部分使用频率最低的key。
-
**volatile-ttl:**在设置了过期时间的key中,淘汰部分剩余时间短的key。
可以通过以下命令来查看redis的内存淘汰策略:
CONFIG GET maxmemory-policy
可以通过以下命令来设置redis的内存淘汰策略,如将内存淘汰策略设置为volatile-lru:
maxmemory-policy volatile-lru
**注意:**内存淘汰只会在执行写操作时触发,读操作不会引起内存淘汰。
LRU实现原理
Redis在redisObject结构体中定义了一个长度3字节无符号类型的字段(unsignedlru:LRU_BITS),在LRU算法用来存储对象最后一次被命令程序访问的时间戳。
3.0之前
随机选N(默认5)个key,把lru最小的那个key移除。这边的N可通过maxmemory-samples配置项修改。
3.0及之后
引入了缓冲池(pool,默认16)的概念。
1、首次随机取N(默认5)个key放入pool。
2、在后续的每轮N个中只有lru小于候选池中最小的lru才能被放入到候选池,直至候选池放满。
3、如果有新的数据继续放入,则需要将候选池中lru字段最大值移除。
4、淘汰时的时候,只需要将候选池中lru字段值最小的淘汰掉即可。
5、循环上述流程,直至释放足够的内存。
**注意:**redis的LRU实现只是一种近似的LRU算法,每次选取一批数据进行LRU淘汰,而不是针对所有的数据,通过牺牲部分准确率来提高LRU算法的执行效率。
LFU实现原理
LFU算法的实现没有使用额外的数据结构,复用了redisObject数据结构的lru字段,把这24bit空间拆分成两部分去使用。
- **last decr time(16bit):**由于记录时间戳在空间被压缩到16bit,所以LFU改成以分钟为单位,大概45.5天会出现数值折返,比LRU时钟周期还短。
- **LOG_C(8bit):**低位的8bit用来记录热度值(counter),8bit空间最大值为255,无法记录数据在访问总次数。
实现步骤如下:
1、随机抽样选出N个数据放入pool。
2、在后续的每轮N个中只有counter小于候选池中最小的lru才能被放入到候选池,直至候选池放满。
3、淘汰时的时候,只需要将候选池中counter字段值最小的淘汰掉即可。
4、循环上述流程,直至释放足够的内存。
Redis内存优化
Redis对内存的使用一向很“抠门”,所以使用了很多优化手段来减少内存的使用,比如:
- 共享对象池:共享使用对象共享池存储0-9999的整数值。
- SDS字符串优化。
- 压缩表:使用ziplist压缩编码优化hash、list等结构,注重效率和空间的平衡。
- intset:优化整数集合。
处理大key
大key是指key对应的value所占的内存空间比较大,
bigkey的危害
- 内存空间不均匀:在集群中,bigkey会造成节点的内存空间使用不均匀。
- 超时阻塞:由于是单线程,操作bigkey比较耗时,也就意味着阻塞。
- 网络拥塞:占用带宽。
如何发现大key
通过scan + debug object key命令。通过debug object key命令可以得到如下输出。
Value at:0x7fc06c1b1430 refcount:1 encoding:raw serializedlength:1256350 lru:11686193
lru_seconds_idle:20
其中serializedlength表示RDB编码下的value长度(比实际长度偏小)。
通过scan循环扫描,发现serializedlength超过阈值,就进行报警。
如果有从节点,建议在从节点上执行该操作。
**注意:**这是一条调试命令,不建议在生产环境中频繁使用,以免对Redis性能产生负面影响。
缓存设计
布隆过滤器
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它由一个二进制数组和一组hash函数(算法)组成。
添加一个元素时,通过hash函数得出二进制数组的下标,并把这些位置的值全部改为1。
当查询key时,也计算数组下标,若计算出来的下标位置的值不全为1,则表示该key没有被添加过。
优点:
- 内存消耗少
- 速度快。
缺点:
- 布隆过滤器判断存在时,真实情况不一定存在,但是一定能精确判断一个数据不存在。
- 无法删除数据。
缓存穿透
访问一个缓存和数据库都不存在的key,缓存不起作用,所有请求都打到数据库,造成数据库瞬时请求量骤增。
解决方案如下:
- key未在DB查询到值,将空值写进缓存,但可以设置较短的过期时间。要注意这样占用了等多的空间,数据会有一段时间窗口不一致。
- 可能是非法攻击,通过接口先做校验,不通过则直接返回。
- 使用布隆过滤器存储所有可能访问的key,不存在的key直接被过滤,存在的key则再进一步查询缓存和数据库
缓存击穿
一个热点key,在缓存过期后,造成数据库瞬时请求量骤增。
解决方案如下:
- 加锁互斥。注意死锁。
- 热点数据不过期。但不能保证一致性。
缓存雪崩
缓存在同一时刻大量失效,或者Redis实例宕机或重启,造成数据库瞬时请求量骤增。
解决方案如下:
- 给过期时间时加个随机值时间,使过期时间分布开来,不会集中。
- 应用层引入限流降级机制。
- 热点数据设置永不过期。
- 构建主从高可用集群。
缓存无底洞
在集群中一次批量操作,会涉及多个节点,也就是会涉及多次网络操作,导致速度不升反降。
解决方案如下:
- 避免大规模的批量操作,将批量操作拆解为单次操作,依次执行。速度较慢。
- 通过smart客户端保存的slot与节点的关系,将批量操作的key按照节点归档,然后然后对对应的节点执行批量操作。节点少时快,节点多就慢。
- 与上一个方案类似,只是并行地对对应的节点执行批量操作。延迟却决于最慢的节点。
- 利用hashtag,将要批量操作的键放在同一个节点。容易发生数据倾斜。(hashtag:如果键带有{}大括号,则只会将大括号里的部分用于计算槽)
缓存污染
有些数据被访问的次数非常少。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。
解决方案如下:
- 使用LFU相关策略。
热点key
热点key是指经常被访问到的key,极端情况下,热点key可能会超过Redis本身能够承受的QPS。另外可能造成缓存击穿与缓存雪崩。
如何查找热点key:
- 客户端统计。
- Twemproxy,Codis代理端统计。
- Redis服务端的monitor命令统计。
- 通过抓包统计热点数据。
解决方案如下:
- 拆分复杂数据结构。
- 在集群环境下,迁移热点key所在的slots到新的节点或者让各个节点均摊热点key
数据一致性
先更新后淘汰
读取数据:
1、读取数据时优先读取缓存。
2、如果缓存中没有再查询数据库。
3、数据库查询到数据后,将数据写入到缓存中。
写入数据:
1、优先写入数据库。
2、写入数据库之后再将缓存删掉。
**缺陷:**多线程环境下还是有可能造成缓存不一致问题,例如:
线程A | 线程B |
---|---|
查询数据库,查询到用户id为1的用户名为tom | |
修改数据库,将用户id为1的用户名改为jerry | |
将缓存中的数据删掉 | |
将查询到的结果写tom入缓存 |
**改进方案:**使用加锁保证数据强一致性
读取数据:当读取redis缓存不存在时,对后续数据库的查询和写入缓存操作加分布式锁。
写入数据:对数据库的操作和删除缓存操作加分布式锁。
延迟双删
读取数据:
1、读取数据时优先读取缓存。
2、如果缓存中没有再查询数据库。
3、数据库查询到数据后,将数据写入到缓存中。
写入数据:
1、先删除缓存中的数据。
2、写入数据到数据库。
3、延迟一定时间(要大于一次写操作的时间,一般为3-5秒),等待MySQL与Redis主从节点同步成功之后再删除一次缓存中的数据。
**缺陷:**保证最终一致性,数据完全同步有一定的延迟,在这个时间点仍然可能会读取到旧的数据。
持久化方案
RDB
简介
RDB全称为Redis Database。通过保存数据库中所有的键值对快照来记录数据库状态。是Redis默认的持久化策略。可以修改redis的配置为save ""
来关闭RDB持久化策略。
RDB文件创建和载入
创建RDB
- **SAVE:**SAVE方式会阻塞服务器。
- **BGSAVE:**BGSAVE方式是额外fork一个子进程进行RDB文件的创建。父进程(服务器进程)继续处理命令,但是服务器不接受其他的SAVE命令和BGSAVE命令,因为会造成竞争。
载入RDB
- 由于AOF文件地更新频率通常比RDB更高,所以通常优先加载AOF文件,只有在AOF持久化功能关闭的情况下,才会使用RDB来恢复数据库状态。
- Redis服务器启动时,打印的第二条日志“DB loaded from disk:…”就是RDB载入成功时打印
的。
自动间隔保存
Redis的周期性函数ServerCron,会每隔100ms检查一次save选项保存的配置是否被满足,如果满足就执行BGSAVE。
redisServer结构中有如下属性:
struct redisService{
//...
struct saveparam *saveparams;
long dirty;
time_t lastsave;
//...
}
-
**saveparams:**是一个数组,其结构如下:
struct saveparam { time_t seconds; //秒数 int changes; //修改数 }
通过命令save seconds changes,告诉服务器,在senconds秒内执行changes修改,BGSAVE就会被执行。
在redis的默认配置RDB保存配置中有三个配置参数,只要以下任意条件被满足,BGSAVE都会被执行。三个默认配置如下:
// 在900秒内有1个改变就会执行RDB保存 save 900 1 // 在300秒内有10个改变就会执行RDB保存 save 300 10 // 在60秒内有10000个改变就会执行RDB保存 save 60 10000
-
**dirty:**距离上次成功执行SAVE或者BGSAVE后,Redis进行了多少次更新。
-
**lastsave:**UNIX时间戳,上次成功执行SAVE或者BGSAVE的时间
RDB优缺点
优点
-
rdb文件是经过压缩的二进制文件,占用空间很小。
-
bgsave不会阻塞主线程。
-
恢复大数据时快。
缺点
-
可能会有数据丢失。
-
新老版本Redis不兼容。
-
子进程在数据量大的时候可能阻塞。
-
可能会频繁fork子进程。
AOF
简介
全称Append Only File。AOF文件中所有命令都是以Redis命令请求协议的格式保存的。它通过保存了所有修改数据库的写命令来记录数据库状态。Redis默认关闭AOF持久化策略,可以通过修改redis配置为appendonly yes
来开启AOF持久化策略。
AOF持久化原理
命令追加
在redisServer中定义了一个aof缓冲区,如下
struct redisServer{
//AOF缓冲区
sds aof_buf;
}
执行完一个写命令,会在redisServer的aof_buf缓冲区的末尾添加被执行的写命令。之所以要在执行完命令后记录日志,这样可以避免记录日志前对命令进行检查带来的开销。
文件写入同步
在处理完文件事件(接受客户端命令,向客户端发送回复)与时间事件(定时运行的函数)后,会调用flushAppendOnlyFile函数,将缓冲区的内容写入及同步到AOF文件,具体同步的操作由appendfsync配置。
appendfsync可选配置如下:
- **always:**将aof_buff所有内容写入并同步到AOF文件。
- **everysec:**默认值,将aof_buf所有内容写入AOF文件,如果距离上次同步超过一秒,将再次同步,同步的操作由专门的线程执行。
- **no:**将aof_buf所有内容写入AOF文件,何时同步由操作系统决定。
AOF文件载入
创建一个没有网络连接的伪客户端。读取AOF文件中的命令交给伪客户端执行。
AOF文件重写
什么是AOF重写
随着写入命令的增加,AOF文件体积会越来越大。例如,对一个计数器调用了100次INCR , AOF文件就需要使用100条记录。然而在实际上, 只使用一条SET命令已经足以保存计数器的当前值了, 其余99条记录实际上都是多余的。
为了处理这种情况, Redis引入了AOF重写:可以在不打断服务端处理请求的情况下, 对AOF文件进行重(rebuild)。
但重写需要解决如下问题:当子进程在重写,主进程可能也在修改数据。使得现有数据与新的aof文件保存的数据不一致。
解决方案为:主进程修改数据时,会将修改命令放入aof缓冲区以及aof重写缓冲区。这样当aof重写完毕后,会将aof缓冲区里面的命令追加到aof的新文件中去。最后对新aof文件改名,覆盖旧的aof文件。
AOF重写触发机制
- 通过bgrewriteaof手动执行。
- 通过auto-aof-rewrite-min-size(重写时最小文件大小)和auto-aof-rewrite-percentage(当前aof文件空间与上次重写后文件控件的比值)配置。如果当前aof文件 > auto-aof-rewrite - min-size && (当前aof文件大小 - 上次重写后文件大小) /上次重写后文件大小 >= auto-aof-rewrite-percentage时就会重写。
AOF重写原理
1、当子进程重写AOF文件时,将执行后的命令依次追加进AOF缓冲区,AOF重写缓冲区。
2、当子进程重写完毕,向父进程发送信号,父进程调用信号处理函数(会阻塞父进程),执行如下操作:
- 将aof缓冲区里面的命令追加到aof的新文件中去。
- 原子地对新aof文件改名并覆盖旧的aof文件。
AOF优缺点
优点
- appendfsync默认为everysec的情况,最多丢一秒的数据。
- 以Redis命令请求协议的格式保存的,文件易读。
缺点
- AOF文件较大。
- 大数据量时恢复较慢。
- fork创建子进程时。内核要把主线程的PCB内容(用于管理子进程的相关数据结构)拷贝给子进程。这个创建和拷贝过程会给主线程带来阻塞风险。
- 写时复制会因为申请大空间而面临阻塞风险。
混合持久化方案
简介
在Redis4.0开始引入了混合持久化,混合持久化是指同时使用RDB和AOF。
混合持久化原理
只发生于AOF重写过程。重写后的新AOF文件前半段是RDB格式的全量数据,后半段是AOF格式的增量数据。
在Redis4刚引入时,默认是关闭混合持久化的,但是在Redis5中默认已经打开了。
fork出的子进程先将当前全量数据以RDB方式写入新的AOF文件,然后再将AOF重写缓冲区的增量命令以AOF方式写入到文件,写入完成后通知主进程将新的含有 RDB格式和AOF格式的AOF文件替换旧的的AOF文件。
混合持久化文件加载顺序
1、先载入RDB文件,通过判断文件开头是否为REDIS,来确认是否是混合持久化。
2、若开启了混合持久化,则先载入RDB部分数据,然后再载入AOF部分数据。
3、若没有开启混合持久化,则载入AOF文件的数据。
4、如果没有开启AOF,则载入RDB文件
混合持久化优缺点
优点
- 结合RDB和AOF的优点, 更快的重写和恢复。
缺点
- AOF文件里面的RDB部分不再是AOF格式,可读性差。
如何选用持久化方案
- 数据不能丢失时,RDB和AOF的混合使用是一个很好的选择。
- 如果允许分钟级别的数据丢失,可以只使用RDB。
- 如果只用AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。
架构
Redis线程模型
4.0之前是完全单线程。
4.0到6.0版本之前是核心流程单线程,额外的线程只是用于后台处理。
6.0及之后多线程用于网络I/O阶段与后台处理。
选择单线程原因
CPU不是瓶颈,内存与I/O(网络传输与磁盘读写)才是。
CPU不是瓶颈,就不需要多线程,不然引入了线程上下文切换,资源加锁,会有额外的性能消耗。
网络带宽,引入多线程处理I/O阶段。
单线程也很快的原因
基于内存操作。
避免了线程上下文切换,资源加锁。
非阻塞I/O,使用了I/O多路复用模型epoll,基于reactor模式开发了自己的网络事件处理器。
对数据结构进行了优化,简单动态字符串、压缩列表。
Redis事件
Redis服务器是一个事件驱动程序,服务器处理的时间可以分为如下两类。
- **文件事件(File Event):**Redis服务器通过套接字与客户端(或者其他Redis服务器)进行连接,而文件事件就是服务器对套接字操作的抽象。它们之间的通信会产生相应的文件事件。如连接建立、接受请求命令、发送响应等。
- **时间事件(Time Event):**Redis服务器中的一些操作(比如serverCron函数)需要在给定的时间点执行,而时间事件就是服务器对这类定时操作的抽象。如 Redis 中定期要执行的统计、key 淘汰、缓冲数据写出、rehash等。
文件事件
文件事件处理器
redis基于reactor模式开发了自己的网络事件处理器,由4个部分组成:套接字、I/O 多路复用程序、文
件事件分派器(dispatcher)、以及事件处理器。如下图所示:
-
**套接字(Socket):**监听客户端的一些操作,当一个套接字准备好执行连接(accept)、写入(write)、读取(read)、关闭(close)等操作时,就会产生一个相应的文件事件。因为一个服务器通常会连接多个套接字, 所以多个文件事件有可能会并发地出现。
-
**I/O多路复用程序:**通过包装select、epoll、evport、kqueue这些I/O多路复用函数来实现,会根据当前系统自动选择最佳的方式。负责监听多个套接字,当套接字产生事件时,会向文件事件分派器传送那些产生了事件的套接字。当多个文件事件并发出现时,I/O多路复用程序会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字:当上一个套接字产生的事件被处理完毕之后,才会继续传送下一个套接字。
-
**文件事件分派器:**接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型, 调用相应的事件处理器。
-
**事件处理器:**事件处理器就是一个个函数,定义了某个事件发生时,服务器应该执行的动作。例如:建立连接、命令查询、命令写入、连接关闭等。
文件事件分类
当每次套接字变为可应答,可读,可写时,都会产生对应的文件事件。
当套接字变得可读(客户端对套接字执行write或close操作时),或者有新的可应答的套接字出现(客户端对服务端监听的套接字执行connect操作时)。套接字会产生AE_READABLE
事件。
当套接字会变得可写(客户端对套接字执行read操作时)。套接字会产生AE_WRITABLE
事件。
完整的客户端和服务器连接示例
客户端向服务器发送连接请求,监听套接字将产生 AE_READABLE
事件,触发连接应答处理器执行。处理器会对客户端的连接请求进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的 AE_READABLE
事件与命令请求处理器进行关联,使得客户端可以向主服务器发送命令请求。
之后当客户端发送一个命令请求时,客户端套接字会产生 AE_READABLE
事件,引发命令请求处理器执行,处理器读取客户端的命令内容,然后交给程序去执行。
执行命令将产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的AE_WRITEABLE
事件与命令回复处理器进行关联,当客户端尝试读取命令回复时,客户端套接字将产生 AE_WRITEABLE
事件,触发命令回复处理器执行。
时间事件
时间事件分类
- **定时事件:**让一段程序在指定的时间之后执行一次。
- **周期事件:**让一段程序每隔指定时间就执行一次。
一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值。如果返回ae.h/AE_NOMORE,这个事件为定时事件。该事件在达到一次之后就会被删除,之后不再到达。如果返回一个非AE_NOMORE的整数值,那么这个事件为周期性时间。当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的when属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。
时间事件组成属性
- **id:**服务器为时间事件创建的全局唯一ID(标识号)。ID号按从小到大的顺序递增。
- **when:**毫秒精度的UNIX时间戳,记录了时间事件的到达(arrive)时间。
- **timeProc:**时间事件处理器,是一个函数,当时间事件到达时,服务器就会调用相应的处理器来处理事件。
时间事件实现
服务器将所有时间事件都放在一个无序链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器。
新的时间事件总是插入到链表的表头,所以三个时间事件分别按ID逆序排序。而不按when属性的大小排序。如下图所示:
serverCron函数
serverCron函数是时间事件应用实现。
serverCron负责定期对Redis服务器资源和状态进行检查和调整,主要工作包括:
- 更新服务器的各类统计信息,比如时间、内存占用、命令执行次数、数据库占用情况等。
- 清理数据库中的过期键值对。
- 关闭和清理连接失效的客户端。
- 尝试进行AOF或RDB持久化操作。
- 如果服务器是主服务器,那么对从服务器进行定期同步。
- 如果处于集群模式,对集群进行定期同步和连接测试。
- rehash操作
Redis服务器以周期性事件的方式来运行serverCron函数,直到服务器关闭为止。默认规定
serverCron每秒运行10次。
aeProcessEvents函数
Redis 中的时间事件和文件事件的调度是由 aeProcessEvents函数来实现的,它对于两种事件的处理都是同步、有序、原子地执行的,服务器不会中途中断事件处理,也不会对事件进行抢占。所以两种事件处理器都会尽可能地减少程序地阻塞时间,并且在有需要时主动让出执行权,从而降低造成事件饥饿的可能性。
流程如下图所示:
Redis集群
主从模式(Master Slave)
主从模式部署多台redis节点,其中只有一台节点是主节点(master),其他的节点都是从节点(slave),也叫备份节点(replica)。只有master节点提供数据的写操作(增删改),slave节点只提供读操作。所有slave节点的数据都是从master节点同步过来的。
该模式的架构图如下:
上图只是主从模式最简单的结构,主从模式主要有以下集中结构:
- 一主一从:一个master下面挂载一个slave。
- 一主多从:一个master下面挂载多个slave。优点是slave节点与master节点的数据延迟较小;缺点是如果slave节点数量很多,master同步一次数据的耗时就很长。
- 树状主从:master下面只挂载少量slave节点,这些slave又作为其它节点的master节点,这样做虽然降低了master节点做数据同步的压力,但也导致slave节点与master节点数据不一致的延迟更高。
全量数据同步
全量数据同步一般发生在slave节点初始化阶段,需要将master上的所有数据全部复制过来。全量同步的流程图如下:
- slave节点根据配置的master节点信息,连接上master节点,并向master节点发送SYNC命令;
- master节点收到SYNC命令后,执行BGSAVE命令异步将内存数据生成到rdb快照文件中,同时将生成rdb文件期间所有的写命令记录到一个缓冲区,保证数据同步的完整性;
- master节点的rdb快照文件生成完成后,将该rdb文件发送给slave节点;
- slave节点收到rdb快照文件后,丢弃所有内存中的旧数据,并将rdb文件中的数据载入到内存中;
- master节点将rdb快照文件发送完毕后,开始将缓冲区中的写命令发送给slave节点;
- slave节点完成rdb文件数据的载入后,开始执行接收到的写命令。
- 保持长链接,同步后续master的写命令,用作增量同步
以上就是master-slave全量同步的原理,执行完上述动作后,slave节点就可以接受来自用户的读请求,同时,master节点与slave节点进入命令传播阶段,在该阶段master节点会将自己执行的写命令发送给slave节点,slave节点接受并执行写命令,从而保证master节点与slave节点的数据一致性。
增量数据同步
Redis2.8版本之前,是不支持增量数据同步的,只支持全量同步。Redis2.8及以后版本使用PSYNC代替SYNC。提出了部分同步方案。其中包含复制偏移量,复制挤压缓冲区,主服务运行id三个概念。
复制偏移量
在master和slave双方都会各自维持一个offset。
master成功发送N个字节的命令后会将master里的offset加上N,slave在接收到N个字节命令后同样会将slave里的offset增加N。
复制挤压缓冲区
master维护的一个固定长度环形积压队列(FIFO队列,默认1MB),它的作用是缓存已经传播出去的命令。
当master进行命令传播时,不仅将命令发送给所有slave,还会将命令写入到复制积压缓冲区里面。
主服务运行id
40位16进制字符,在PSYNC中发送的这个ID是指之前连接的master的id,用这个id来鉴别当前这次复制的master是否为上次复制的master。
增量数据同步流程
- 使用slaveof命令与master建立连接。判断slave是否是第一次执行复制。
- 是,向服务器发送PSYNC ? -1命令,请求主服务器进行全量服务。
- 否,发送PSYNC runid offset命令,进行部分同步。runid是上次复制的主服务器的运行id。offset:是从服务器当前的复制偏移量。
- 根据slave发送的PSYNC命令。master经过判断后,会返回如下三种之一。
- 返回+FULLRESYNC runid offset。(表示salve请求的offset已经超出了复制积压缓冲区,或者slave请求的携带的runid不是该master的id)。那么master将与slave做全量同步操作。
- 返回+CONTINUE。(表示salve请求的offset已经超出了复制积压缓冲区,且slave请求的携带的runid不是该master的id)那么执行部分同步操作,将slave缺少的部分发给slave即可。
- 返回-ERR。表示master的版本低于2.8,不识别PSYNC命令,salve将向主服务发送SYNC命令,进行全量同步操作。
- master每次执行一个写命令都会同步发送给slave。
- slave有心跳机制,默认每秒一次的频率,向master发送REPLCONF ACK offset命令,有三个作用。
- 检测主从间的网络连接。master执行 INFO replication,可以查看slave的lag(即最后一次收到REPLCONF ACK offset命令距离当前的时间)。
- 禁止master不安全写。当slave数量 < min-slaves-to-write或者所有slave的延迟 >= min slaves-max-lag。master将禁止执行写入命令。
- 检测命令丢失。当master发现slave通过REPLCONF ACK offset命令传过来的offset少于自己的offset时,会在复制积压缓冲区李找到缺少的部分发给slave。
异步复制
master自身处理完写命令后,立即返回给客户端,并不等待slave复制完成。该方式会造成主从延迟。
另外Redis支持全同步(默认)与半同步方式。
主从复制常见问题
数据延迟
采用异步复制会造成该问题。
编写监控程序定期检查主从节点的偏移量,主节点偏移量在info replication的master_repl_offset中,从节点偏移量在slave0的offset中。他们俩的差值就是主从节点延迟子节点,如果超过一定阈值,则通知客户端,让其从其它延迟量小的slave,或者master节点读取数据。
读过期数据
在应用主从集群时,尽量使用 Redis 3.2 及以上版本。如果读取的数据已经过期了,从库虽然不会删除,但是会返回空值,这就避免了客户端读到过期数据。
从节点故障
需要客户端维护slave列表,某个slave发生故障时,立刻切换到其它slave或者master上。
全量复制
- 节点运行id不匹配
当master重启后,运行id会改变,这时候slave会进行全量复制。
应该提供故障转移方案,比如手动提升slave为master,或者采用哨兵或集群方案。
- 复制积压缓冲区不足
另外当复制加压缓冲区不足,slave请求的偏移量不在缓冲区内,也会进行全量复制。
应该在大流量场景下加大缓冲区大小。
避免复制风暴
- 单节点复制风暴
master挂载多个slave情况下,master重启后,可能多个salve都会发起全量复制。导致master网络带宽消耗严重。
可以采用树状主从结构来保护主节点,但增加了故障转移的难度。
- 单机复制风暴
一台服务器可能会部署多个Redis实例,如果把多个master部署在一台服务器上。服务器重启后会有大量的slave进行全量复制。
应该尽量把master节点分在不同的机器上。
主从模式优缺点
优点
- 数据备份。同样的数据在多个节点都存一份,起到了备份数据的作用。
- 故障恢复。当master节点发生故障后,可以选择一个slave节点作为master节点继续提供服务。
- 读写分离,提高性能。写操作交给master,读操作交给slave。
- 配置简单,容易搭建。只需要在slave节点上维护master节点的地址信息就可实现。
以上就是redis主从架构的实现原理和搭建过程。
缺点
- 无自动容错与恢复的功能。
- 存储能力受到单机限制。
哨兵模式(Sentinel)
什么是Redis Sentinel
哨兵(sentinel)在Redis主从架构中是一个非常重要的组件,是在Redis2.8版本引入的。它的主要作用就是监控所有的Redis实例,并实现master节点的故障转移。哨兵是一个特殊的redis服务,它不负责数据的读写,只用来监控Redis实例。
所以实际上哨兵模式还是主从架构,只是通过引入哨兵来监控服务节点并实现故障转移。
哨兵模式工作原理
哨兵模式架构如下图所示:
在哨兵模式架构中,client端在首次访问Redis服务时,实际上访问的是哨兵(sentinel),sentinel会将自己监控的Redis实例的master节点信息返回给client端,client后续就会直接访问Redis的master节点,并不是每次都从哨兵处获取master节点的信息。
sentinel会实时监控所有的Redis实例是否可用,当监控到Redis的master节点发生故障后,会从剩余的slave节点中选举出一个作为新的master节点提供服务,并将新master节点的地址通知给client端,其他的slave节点会通过slaveof命令重新挂载到新的master节点下。当原来的master节点恢复后,也会作为slave节点挂在新的master节点下。
一般情况下,为了保证高可用,sentinel也会进行集群部署,防止单节点sentinel挂掉。当sentinel集群部署时,各sentinel除了监控redis实例外,还会彼此进行监控。
Redis sentinel是如何进行监控的
1、启动sentinel时,会创建与redis master节点的连接并向master节点发送一个info命令,master节点在收到info命令后,会将自身节点的信息和自己下面所有的slave节点的信息返回给sentinel,sentinel收到反馈后,会与新的slave节点创建连接,接下来就会每隔10秒钟向所有的redis节点发送info命令来获取最新的redis主从结构信息。
2、每隔2秒每个Sentinel节点会向Redis数据节点的_sentinel_:hello
频道发送该Sentinel节点对于主节点的判断以及当前Sentinel节点的信息,同时每个Sentinel节点也会订阅该频道。主要用于发现新的Sentinel节点、Sentinel节点之间替换主节点的状态,作为主观下线以及领导者选举的根据。
3、每隔1秒每个Sentinel节点会向主节点、从节点、其余 Sentinel 节点发送一条 ping 命令做一次心跳检测,然后通过收到ping命令的实例的返回结果来判断实例是否正常。
下线检测
1、在默认情况下,Sentinel会以每秒一次的频率向所有与它创建了连接的实例(包括master、slave、其它Sentinel在内)发送一个PING命令。
2、当redis实例和sentinel实例收到PING命令后,会向sentinel返回一个有效的回复:+PONG 、-LOADING 或者 -MASTERDOWN,若返回其他的回复,或者在指定时间内(sentinel down-after-milliseconds 选项配置)没有回复,那么sentinel认为实例的回复无效。该实例就会被sentinel标记为主观下线(Subjectively Down,简称 SDOWN,指的是单个 sentinel 实例对服务节点做出的下线判断)。
3、当Sentinel将一个master判断为主观下线之后,为了确定这个master是否真的下线了,它会向同样监视这一master的其它Sentinel进行询问,看它们是否也认为master已经进入了下线状态(可以是主观下线或者客观下线)。
4、其它Sentinel接收到询问时,会主动检测服务器是否下线,并向源Sentinel返回检测结果。当Sentinel从其它Sentinel那里接收到足够数量(>=quorum)的已下线判断之后,Sentinel就会将master置为客观下线(Objectively Down,简称 ODOWN,指的是多个 sentinel 实例在对同一个服务器做出 SDOWN 判断),然后选举出领头Sentinel对master执行故障转移操作。
领头sentinel选举
当master被判断为客观下线后,监视这个master的Sentinel会协商选举一个领头Sentinel,由领头Sentinel对下线master进行故障转移操作。
1、所有Sentinel向其它Sentinel发出请求,并带上自己的Sentinel id。
2、接收到请求的Sentinel如果还未设置领头Sentinel的话,就将请求中的Sentinel id所指的Sentinel设为自己的领头Sentinel,并给请求者返回设置成功。如果Sentinel已经设置过领头Sentinel,之后的请求都会被拒绝。
3、发出请求的Sentinel如果收到了半数以上的设置成功的回复,那么它就是领头Sentinel。
4、如果在给定时间内,没有Sentinel成为领头Sentinel,那么各个Sentinel将在一段时间后在进行选举。
故障转移
1、领头Sentinel在已下线master的所有salve里面,挑选出:正常在线的,没有与已下线master断开连接超过down-after-milliseconds * 10,复制偏移量最大的slave作为新的master,如果复制偏移量相同那么会选择最小运行ID的salve成为新的master。通过SLAVEOF NO ONE命令实现。
2、领头Sentinel通过SLAVEOF命令,将剩余的slave改为复制新的master。
3、当这个旧的master重新上线时,Sentinel将其设置为新的master的slave。
哨兵模式常见问题
- 勿将Sentinel部署都在一台物理机上。
- 部署至少3个,且为奇数的Sentinel节点。是因为领头Sentinel至少要1半加1个的节点。
- 如果Sentinel节点集合监控的是同一个业务的多个主节点结合,那么建议一套Sentinel监控这多个主节点。否则建议一套Sentinel只监控一个主节点。
- 如果要主动地下线master,切勿直接强行shutdown,而是通过在任意Sentinel上执行Sentinel failover让其晋升为主节点。注意Sentinel会对下线节点进行监控。
- 在设计读写分离的高可用哨兵方案时,由于一般的RedisSentinel客户端只能感知master的切换,而不能感知slave的状态。这会影响应用程序对slave的读。所以应用程序可能需要订阅+switch-master(切换主节点),+convert-to-slave(主节点降级为从节点),+sdown(主观下线),+reboot(重启)来感知slave的变化。
- 脑裂:旧master假死,导致故障转移,而旧master在故障期间仍然写入了数据,但是故障转移后清空了本地数据,与新master做了全量数据同步。而导致数据丢失。
哨兵模式优缺点
优点
- 自动故障转移,系统更健壮。
缺点
- 实际还是主从,存储能力受到单机限制。
- 高可用读写分离搭建较为复杂。
Cluster模式 (Redis Cluster)
Redis Cluster也叫Redis集群模式,是一个提供在多个Redis节点之间共享数据的程序集。它并不像Redis主从复制模式那样只提供一个master节点提供写服务,而是会提供多个master节点提供写服务,每个master节点中存储的数据都不一样,这些数据通过数据分片的方式被自动分割到不同的master节点上。
为了保证集群的高可用,每个master节点下面还需要添加至少1个slave节点,这样当某个master节点发生故障后,可以从它的slave节点中选举一个作为新的master节点继续提供服务。不过当某个master节点和它下面所有的slave节点都发生故障时,整个集群就不可用了。
Redis集群模式架构如下如所示:
节点通信
操作方式
对某个Redis实例执行CLUSTER MEET ip port。就将ip port所指的另外一个Redis实例组成了集群。例如,在独立的实例A上执行CLUSTER MEET ipB portB,就将独立的实例A与独立的实例B组成了集群。再在A或者B上执行CLUSTER MEET ipC portC,则将独立实例C也加入了集群。
原理
1、节点A根据CUSTER MEET命令向节点B发送一条MEET消息。
2、节点B收到节点A的MEET消息后,为节点A创建clusterNode结构(保存节点的数据结构),并添加到custerState.nodes字典中。
3、节点B向节点A返回PONG。
4、节点A收到PONG后,知道节点B已经成功的收到了自己发送的MEET。
5、节点A向节点B发送PING。
6、节点B收到PING后,知道节点A已经收到了字节的PONG,握手完成。
7、之后节点A通过Gossip协议将节点B的信息传播给集群中的其它节点,让其它节点也与节点B握手。
Gossip协议
gossip协议有4种常用的消息类型:
- **MEET:**当需要向集群中加入新节点时,需要集群中的某个节点发送MEET消息到新节点,通知新节点加入集群。新节点收到MEET消息后,会回复PONG命令给发送者。
- **PING:**集群内每个节点每秒会向其他节点发送PING消息,用来检测其他节点是否正常工作,并交换彼此的状态信息。PING消息中会包括自身节点的状态数据和其他部分节点的状态数据。
- **PONG:**当接收到PING消息和MEET消息时,会向发送发回复PONG消息,PONG消息中包括自身节点的状态数据。节点也可以通过广播的方式发送自身的PONG消息来通知整个集群对自身状态的更新。
- **FAIL:**当一个节点判定另一个节点下线时,会向集群内广播一个FAIL消息,其他节点接收到FAIL消息之后,把对应节点更新为下线状态。
哈希槽指派
Redis集群中引入了哈希槽的概念,Redis集群有16384个哈希槽,进行set操作时,每个key会通过CRC16校验后再对16384取模来决定放置在哪个槽,搭建Redis集群时会先给集群中每个master节点分配一部分哈希槽。比如当前集群有3个master节点,master1节点包含05500号哈希槽,master2节点包含550111000号哈希槽,master3节点包含11001~16384号哈希槽,当我们执行“set key value”时,假如 CRC16(key) % 16384 = 777,那么这个key就会被分配到master1节点上。
通过在某个节点上执行如下代码来分配哈希槽:
// 为指定的节点分配0~5000的槽位
CLUSTER ADDSLOTS ip:port 0 1 2 ... 5000
其实现原理如下:
struct clusterState {
//...
clusterNode *myself;
dict *nodes;
clusterNode *slots[16384];
zskiplist *slots_to_keys;
//...
}
struct clusterNode {
//...
unsigned char slots[16384/8];
int numslots;
char ip[REDIS_IP_STR_LEN];
int port;
//...
}
每一个节点都有clusterState结构,含义如下:
- **myself:**记录当前节点的clusterNode。
- **nodes:**key = 节点名,value = clusterNode,字典记录集群中所有节点的clusterNode。
- **slots:**如果slots[i] = NULL,代表该槽还未被指派。等于一个clusterNode结构,代表该槽被指派给了该clusterNode结构所对应的节点。
- **slots_to_keys:**跳跃表,score表示槽号,member表示数据库键。删除某个键时,会从该跳跃表中删除该节点。通过该数据结构可以方便的操作指定槽的数据库键。
clusterNode含义如下:
- **slots:**二进制数组,数组长度为16384/8=2048字节,包含16384个二进制位。根据索引slots[i] = 1代表节点负责处理槽i,否则不处理。
- **numslots:**节点负责处理槽的数量。
- **ip:**当前clusterNode代表的节点的ip。
- **port:**当前clusterNode代表的节点的ip。
重新分片
clusterState结构还有如下两个重要属性:
struct clusterState {
//...
clusterNode *importing_slots_from[16384];
clusterNode *migrating_slots_to[16384];
//...
}
- **importing_slots_from:**importing_slots_from[i]不为NULL,而是指向一个clusterNode,表示当前节点正在从clusterNode代表的节点导入槽i。
- **migrating_slots_to:**migrating_slots_to[i]不为NULL,而是指向一个clusterNode,表示当前节点正在将槽i迁移到clusterNode代表的节点。
由Redis的集群管理软件(redis-trib)负责执行的。原理步骤如下:
1、 redis-trib对目标节点发送CLUSTER SETSLOT IMPORTING 命令,让目标节点对其clusterState.importing_slots_from赋值。
2、redis-trib对源节点发送CLUSTER SETSLOT MIGRATING 命令,让源节点对其clusterState.migrating_slots_to赋值。
3、redis-trib向源节点发送CLUSTER GETKEYSINSLOT 命令,获得最多count个属于槽slot键值对的键名。
4、redis-trib遍历第3步的所有键名,根据每一个键名去向源节点发送MIGRATING 0 命令,将键值对从源节点迁移到目标节点。
5、重复3-4步,直到所有键值对被迁移完为止。
6、redis-trib向集群中的任意一个节点发送CLUSTER SETSLOT NODE 命令,该节点将该消息发送到集群上。
集群中命令执行流程
1、客户端发送命令给某个节点,节点通过CRC16(key) & 16384计算一个0-16383的数字i,这个数字就是该key所在的槽。注意:如果键带有{}大括号也就是hashtag,则只会将大括号里的部分用于计算槽。
2、如果clusterState.slots[i] = clusterState.myself,那么当前节点可以执行客户端发送的命令。
3、如果clusterState.slots[i] != clusterState.myself,表示槽i不由当前节点负责,那么根据clusterNode结构中记录的ip,port,向客户端返回 MOVED key key所在节点的ip:port。
4、客户端根据新的ip,port向另外的节点发送想执行的命令。
5、另外当客户端收到服务端返回的ASK key ip:port,表示当前正在进行槽迁移,源节点没有找到该键值对,ip与port为目标槽所在的目标节点的地址与端口号。
6、收到ASK的客户端,首先向目标节点发送ASKING命令,之后再重新发送原本想执行的命令。
7、如果服务端收到客户端带有ASKING标示,且clusterState.importing_slots_from[i]显示该节点正在导入槽i,那么节点破例执行一次客户端发送的命令。
复制与故障转移
向节点发送CLUSTERRELICATE 让其成为note_id所指节点的从节点。
故障检测
1、集群中的每个节点会定期地向其它节点发送PING消息,如果某个节点在规定时间内没有返回PONG,那么将其标记为疑似下线状态。并把该消息通知出去。
2、当半数以上的主节点都将某个节点报告为疑似下线,那么该节点会被标记为已下线。将该节点标记为已下线的节点会向集群广播这条消息。
3、所有收到这条消息的节点都会立即将该节点标记为已下线。
主节点选举
1、slave发现自己的master已下线时会发起一次选举:currentEpoch+1,集群广播一条REQUEST ,要求所有收到这条消息、且具有投票权的master向这个slave投票。
2、master收到消息后,看其有没有投过票。若没有就给slave响应FAILOVER_AUTH_ACK,表示给它投票。
3、当一个slave收集到大于等于N/2+1 张支持票时,这个slave就会当选为新的master。
4、如果在一个配置纪元里面没有slave能收集到足够多的支持票,那么集群进入一个新的配置纪元,并再次进行选举,直到选出新的master为止。
自动故障转移
1、新主节点会撤销所对已下线节点的槽指派,并将这些槽指派给自己。
2、向集群广播一条PONG消息,让其它节点知道当前节点已变成了主节点。
以下情况可能会导致自动故障转移失败:
- 主从节点断开时间超过阈值,从节点没有转移资格。
- 一半以上master同时故障。
- 网络不稳定,故障检测,主节点选举不能在规定时间完成,导致从节点失去资格。
手动故障转移
向从节点发送”CLUSTER FAILOVER FORCE”,使其在master未下线的情况下,发起投票选举新master,并发起故障转移流程,而原来的master降级为slave。在发出CLUSTER FAILOVER命令之后到转移完成之前,客户端关于这个master的请求会被阻塞。
向从节点发送”CLUSTER FAILOVER TAKEOVER”,用于一半以上master故障,无法选举新master的场景。接到命令的从节点直接升级成主节点。该方式可能会造成master冲突(会采用纪元更大的master,纪元相同使用nodeId更大的master),所以在集群能够自动完成故障转移的场景向下不要使用。
集群模式常见问题
- key批量操作支持有限。如mset、mget,目前只支持具有相同slot值的key执行批量操作。
- key事务操作支持有限。只支持多key在同一节点上的事务操作。
- 不支持多数据库空间。集群模式下只能使用一个数据库空间,即db0。
- 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
集群模式优缺点
优点
- 支持横向扩展,允许通过增加节点来扩展容量和吞吐量。
- 自动分片,会自动将数据按照哈希槽进行分片,并将其分布在不同的节点上
缺点
- 在满足业务需求的情况下尽量避免大集群,原因是Gossip消息会消耗带宽,实例过多运维成本很大。
- 数据倾斜:节点与槽点分配严重不均,槽点数据量差异过大。需要指定规范,重新分配。
- 请求倾斜:存在热点key,特定的节点流量过大。将带有不同key前后缀的多副本方法,让多个副本分散在不同的节点上。
- 集群结构复杂。
高级特性
发布订阅
Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。Redis 客户端可以订阅任意数量的频道。没订阅的接收者当然是接收不到消息的,(pub/sub)是一种广播模式,会把消息发送给所有的订阅者。
发布订阅结构图如下:
订阅与发布
可以订阅频道,也可以订阅模式(通过正则匹配来进行订阅)。
执行SUBSCRIBE "news"订阅news频道。
执行PUBLISH “news” “hello” 向news频道发布消息hello。
执行PSUBSCRIBE "news[12]"订阅与news1,news2相匹配的模式news[12]。
订阅相关的状态保存在如下结构中:
struct redisServer {
//...
dict *pubsub_channels;
list *pubsub_pattern;
//...
}
频道订阅与退订
- **订阅:**所有频道的订阅关系都放在redisServer的pubsub_channels中,pubsub_channels的key是频道,value是订阅的客户端链表。如果频道还不存在任何订阅者,那么该频道不会存在于pubsub_channels字典。此时第一个订阅者就会在字典中创建对应的键,值为空链表,并把客户端添加到链表中。如果频道中存在订阅者,那么pubsub_channels中有对应的链表,此时新的订阅者的客户端就会被添加到链表尾部。
- **退订:**通过退订频道名字,在pubsub_channels字典中找到对应的链表,然后从链表中删除对应的客户端。如果删除后链表变成了空链表,则需要把对应的key都给删掉。
模式订阅与退订
pubsub_pattern结构如下:
typedef struct pubsubPattern {
// 订阅模式的客户端
redisClient *client;
// 被订阅的模式
robj *pattern;
} pubsubPattern;
新增订阅模式时,就新建一个pubsubPattern,将其放到list尾部。退订模式时,将对应的客户端从list中删除。
- **发布:**当发布一消息时,就会在pubsub_channels字典里通过频道找到对应的链表,通过遍历链表,把消息发送给链表上的所有客户端。另外遍历整个pubsub_pattern链表,找到与发布频道相同的模式,然后将消息发给这些客户端。
- **查询看订阅信息:**执行PUBSUB CHANNELS [patterns]命令返回当前被订阅的频道。其中,不给pattern参代表所有频道,给pattern参数代表返回与模式相匹配的频道。执行PUBSUB NUMSUB [ch1 ch2]用于返回给定频道的订阅者数量。执行PUBSUB NUMPAT用于返回被订阅模式的数量。即pubsubPattern链表长度。
事务
事务执行流程
Redis事务提供了将多个命令打包,然后一次性有序的执行。
1、开始事务:执行MULTI命令,将客户端切换至事务状态,代表事务开始。
2、命令入队:此时执行EXEC,DISCARD,WATCH,UNWATCH、MULTI命令,服务器会立即执行。其他命令,不立即执行,仅入事务队列(先进先出)。
3、执行事务:执行EXEC命令,遍历事务队列,执行队列中的命令。
Redis事务的ACID
- **原子性:**单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。
- **一致性:**Redis事务在执行期间会保持数据的一致性。在一个事务内,所有命令按照顺序执行,不会出现数据不一致的情况。
- **隔离性:**Redis是单线程程序,执行事务时不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的。
- **持久性:**只有AOF持久化模式下,appendfsync选项值为always时,程序总是在执行完命令后调用sync函数,将数据保存到硬盘,这种请款修改事务是具有持久性的,其余情况都没有持久性。
Lua脚本
redis 中加载了一个 lua 虚拟机,用来执行 redis lua 脚本。redis lua 脚本的执行是原子性的,当 某个脚本正在执行的时候,不会有其他命令或者脚本被执行。从别的客户端的视角来看,一个lua脚本要么不可见,要么已经执行完。
EVAL命令
EVAL用于执行lua脚本,其命令结构如下:
EVAL script numkeys key [key ...] arg [arg ...]
- script:脚本内容
- numkeys:key的个数
- key:key列表
- arg:参数列表
EVALSHA命令
EVALSHA 命令是 Redis 提供的一个与 EVAL 命令类似的命令,用于执行已经缓存的 Lua 脚本。其命令结构如下:
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
整体跟EVAL命令差不多,不过EVALSHA用于执行已缓存的lua脚本,sha1表示已经缓存的Lua脚本的SHA1哈希值。该哈希值是通过SCRIPT LOAD命令加载脚本时生成的。
运维功能
慢日志
执行CONFIG SET slowlog-slower-than N。将超过N微秒的命令记录起来。
执行CONFIG SET slowlog-max-len。将让服务器最多保存slowlog-max-len条慢日志,如果超出将删掉前面的日志,在添加新日志。默认值10,高并发场景可设置为1。
执行SLOW GET查询慢日志。
监视器
执行MONITOR命令,可以将自己变为一个监视器,实时地接受并打印出服务器当前处理的命令请求。
此时,当其他客户端向服务器发送一条命令请求时,服务器除了会处理这条命令请求,还会将这条命令的信息发送给所有监视器
_pattern结构如下:
typedef struct pubsubPattern {
// 订阅模式的客户端
redisClient *client;
// 被订阅的模式
robj *pattern;
} pubsubPattern;
新增订阅模式时,就新建一个pubsubPattern,将其放到list尾部。退订模式时,将对应的客户端从list中删除。
- **发布:**当发布一消息时,就会在pubsub_channels字典里通过频道找到对应的链表,通过遍历链表,把消息发送给链表上的所有客户端。另外遍历整个pubsub_pattern链表,找到与发布频道相同的模式,然后将消息发给这些客户端。
- **查询看订阅信息:**执行PUBSUB CHANNELS [patterns]命令返回当前被订阅的频道。其中,不给pattern参代表所有频道,给pattern参数代表返回与模式相匹配的频道。执行PUBSUB NUMSUB [ch1 ch2]用于返回给定频道的订阅者数量。执行PUBSUB NUMPAT用于返回被订阅模式的数量。即pubsubPattern链表长度。
事务
事务执行流程
Redis事务提供了将多个命令打包,然后一次性有序的执行。
1、开始事务:执行MULTI命令,将客户端切换至事务状态,代表事务开始。
2、命令入队:此时执行EXEC,DISCARD,WATCH,UNWATCH、MULTI命令,服务器会立即执行。其他命令,不立即执行,仅入事务队列(先进先出)。
3、执行事务:执行EXEC命令,遍历事务队列,执行队列中的命令。
Redis事务的ACID
- **原子性:**单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。
- **一致性:**Redis事务在执行期间会保持数据的一致性。在一个事务内,所有命令按照顺序执行,不会出现数据不一致的情况。
- **隔离性:**Redis是单线程程序,执行事务时不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的。
- **持久性:**只有AOF持久化模式下,appendfsync选项值为always时,程序总是在执行完命令后调用sync函数,将数据保存到硬盘,这种请款修改事务是具有持久性的,其余情况都没有持久性。
Lua脚本
redis 中加载了一个 lua 虚拟机,用来执行 redis lua 脚本。redis lua 脚本的执行是原子性的,当 某个脚本正在执行的时候,不会有其他命令或者脚本被执行。从别的客户端的视角来看,一个lua脚本要么不可见,要么已经执行完。
EVAL命令
EVAL用于执行lua脚本,其命令结构如下:
EVAL script numkeys key [key ...] arg [arg ...]
- script:脚本内容
- numkeys:key的个数
- key:key列表
- arg:参数列表
EVALSHA命令
EVALSHA 命令是 Redis 提供的一个与 EVAL 命令类似的命令,用于执行已经缓存的 Lua 脚本。其命令结构如下:
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
整体跟EVAL命令差不多,不过EVALSHA用于执行已缓存的lua脚本,sha1表示已经缓存的Lua脚本的SHA1哈希值。该哈希值是通过SCRIPT LOAD命令加载脚本时生成的。
运维功能
慢日志
执行CONFIG SET slowlog-slower-than N。将超过N微秒的命令记录起来。
执行CONFIG SET slowlog-max-len。将让服务器最多保存slowlog-max-len条慢日志,如果超出将删掉前面的日志,在添加新日志。默认值10,高并发场景可设置为1。
执行SLOW GET查询慢日志。
监视器
执行MONITOR命令,可以将自己变为一个监视器,实时地接受并打印出服务器当前处理的命令请求。
此时,当其他客户端向服务器发送一条命令请求时,服务器除了会处理这条命令请求,还会将这条命令的信息发送给所有监视器