1. Redis的底层数据结构
1.1 RedisDB结构
typedef struct redisDb {
int id; //id是数据库序号,为0-15(默认Redis有16个数据库)
long avg_ttl; //存储的数据库对象的平均ttl(time to live),用于统计
dict *dict; //存储数据库所有的key-value
dict *expires; //存储key的过期时间
dict *blocking_keys;//blpop 存储阻塞key和客户端对象
dict *ready_keys;//阻塞后push 响应阻塞客户端 存储阻塞后push的key和客户端对象
dict *watched_keys;//存储watch监控的的key和客户端对象
} redisDb;
1.2 RedisObject结构(Value)
typedef struct redisObject {
unsigned type:4;//类型 五种对象类型
unsigned encoding:4;//编码
void *ptr;//指向底层实现数据结构的指针
//...
int refcount;//引用计数
//...
unsigned lru:LRU_BITS; //LRU_BITS为24bit 记录最后一次被命令程序访问的时间
//...
}robj;
type
4位tyoe表示对象的类型:REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有 序集合)。
当我们执行 type 命令时,便是通过读取 RedisObject 的 type 字段获得对象的类型
encoding
4 位encoding表示对象的内部编码:每个对象有不同的实现编码 Redis 可以根据不同的使用场景来为对象设置不同的编码,大大提高了 Redis 的灵活性和效率。 通过 object encoding 命令,可以查看对象采用的编码方式。
ptr
ptr 指针指向具体的数据,比如:set hello world,ptr 指向包含字符串 world 的 SDS。
lru
lru 记录的是对象最后一次被命令程序访问的时间,( 4.0 版本占 24 位,2.6 版本占 22 位)。
高16位存储一个分钟数级别的时间戳,低8位存储访问计数
lru——> 高16位: 最后被访问的时间
lfu——->低8位:最近访问次数
refcount
refcount 记录的是该对象被引用的次数,类型为整型。 refcount 的作用,主要在于对象的引用计数和内存回收。 当对象的refcount>1时,称为共享对象 Redis 为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。
1.3 底层数据结构
1.3.1 字符串SDS
Redis 使用了 SDS(Simple Dynamic String)。用于存储字符串和整型数据。对c语言字符串数组的再次封装。
C语言的字符串数组是以空字符结尾"\0"
,所以存在问题:
- 无法存储带空格的字符串
- 二进制字符串通常带空格
- 追加字符串时如果忘记分配新的内存空间,会造成缓冲区的溢出。
- 添加和删除的操作需要内存重分配,有可能造成内存泄漏。
SDS的空间分配策略:
- 空间预分配
当执行字符串增长操作并且需要扩展内存时,程序不仅仅会给SDS分配必需的空间还会分配额外的未使用空间,其长度存到free属性中。
- 如果修改后len长度将小于1M,这时分配给free的大小和len一样,例如修改过后为10字节,那么给free也是10字节,buf实际长度变成了10+10+1 = 21byte。
- 如果修改后len长度将大于等于1M,这时分配给free的长度为1M,例如修改过后为30M,那么给free是1M,buf实际长度变成了30M+1M+1byte。
- 惰性释放空间
惰性空间释放用于字符串缩短的操作。当字符串缩短时,程序并不是立即使用内存重分配来回收缩短出来的字节,而是使用free属性记录起来,并等待将来使用。
Redis通过空间预分配和惰性空间释放策略在字符串操作中一定程度上减少了内存重分配的次数。但这种策略同样会造成一定的内存浪费,因此Redis SDS API提供相应的API让我们在有需要的时候真正的释放SDS的未使用空间。
SDS的优点:
- SDS 在 C 字符串的基础上加入了 free 和 len 字段,获取字符串长度:SDS 是 O(1),C 字符串是 O(n)。 、
- SDS 由于记录了长度,在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。
buf数组的长度=free+len+1
-
1.3.2 跳跃表
跳跃表是有序集合(zset)的底层实现,效率高,实现简单。
跳跃表的基本思想: 将有序链表中的部分节点分层,每一层都是一个有序链表。
优点: 快速查询到所需要的节点。
- 跳跃表结构体记录了 头节点、尾节点、长度、高度,可以O(1)的复杂度获取。
1.3.3 字典 dict
整体结构
dict
typedef struct dict{
//类型特定函数
void *type;
//私有数据
void *privdata;
//哈希表-见2.1.2
dictht ht[2];
//rehash 索引 当rehash不在进行时 值为-1
int trehashidx;
}dict;
type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的
- type属性是一个指向dictType结构的指针,每个dictType用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
- privdata属性则保存了需要传给给那些类型特定函数的可选参数。
- ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表, 一般情况下,字典只使用ht[0] 哈希表,ht[1]哈希表只会对ht[0]哈希表进行rehash时使用。
- rehashidx记录了rehash目前的进度,如果目前没有进行rehash,值为-1
散列表dictht
typedef struct dictht{
//哈希表数组,C语言中,*号是为了表明该变量为指针,有几个* 号就相当于是几级指针,这里是二级指针,理解为指向指针的指针
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希已有节点的数量
unsigned long used;
}dictht;
- table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
- size属性记录了哈希表的大小,也是table数组的大小。
- used属性则记录哈希表目前已有节点(键值对)的数量。
- sizemask属性的值总是等于 size-1(从0开始),这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面(索引下标值)。
散列表节点
//哈希表节点定义dictEntry结构表示,每个dictEntry结构都保存着一个键值对。
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
}dictEntry;
key属性保存着键值中的键,而v属性则保存着键值对中的值,其中键值(v属性)可以是一个指针,或uint64_t整数,或int64_t整数。 next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,解决键冲突问题。
ReHash
随着操作的进行,散列表中保存的键值对会也会不断地增加或减少,为了保证负载因子维持在一个合理的范围,当散列表内的键值对过多或过少时,内需要定期进行rehash,以提升性能或节省内存。
Redis的rehash的步骤如下:
- 为字典的ht[1]散列表分配空间,这个空间的大小取决于要执行的操作以及ht[0]当前包含的键值对数量(即:ht[0].used的属性值)。
- 扩展操作:ht[1]的大小为 第一个大于等于ht[0].used*2的2的n次方幂。如:ht[0].used=3则ht[1]的大小为8,ht[0].used=4则ht[1]的大小为8。
- 收缩操作:ht[1]的大小为 第一个大于等于ht[0].used的2的n次方幂。
- 将保存在ht[0]中的键值对重新计算键的散列值和索引值,然后放到ht[1]指定的位置上。
- 将ht[0]包含的所有键值对都迁移到了ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并创建一个新的ht[1]哈希表为下一次rehash做准备。
rehash操作需要满足以下条件:
- 服务器目前没有执行BGSAVE(rdb持久化)命令或者BGREWRITEAOF(AOF文件重写)命令,并且散列表的负载因子大于等于1。
- 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且负载因子大于等于5。
- 当负载因子小于0.1时,程序自动开始执行收缩操作。
Redis这么做的目的是基于操作系统创建子进程后写时复制技术,避免不必要的写入操作。
渐进式ReHash
为了解决一次性扩容耗时过多的情况,可以将扩容操作穿插在插入操作的过程中,分批完成。当负载因子触达阈值之后,只申请新空间,但并不将老的数据搬移到新散列表中。当有新数据要插入时,将新数据插入新散列表中,并且从老的散列表中拿出一组数据放入到新散列表。每次插入一个数据到散列表,都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次一次性数据搬移,插入操作就都变得很快了。
Redis渐进式rehash的详细步骤:
- 为ht[1]分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
- 在字典中维持一个索引计数器变量 rehashidx, 并将它的值设置为 0 ,表示 rehash 工作正式开始。
- 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在dictEntry[rehashidx]上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一,表示下一个桶上的链表。
- 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。
说明:
- 因为在进行渐进式 rehash 的过程中,字典会同时使用 ht[0] 和 ht[1] 两个哈希表,所以在渐进式 rehash 进行期间,字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行。
在渐进式 rehash 执行期间,新添加到字典的键值对一律会被保存到 ht[1] 里面,而 ht[0] 则不再进行任何添加操作:这一措施保证了 ht[0] 包含的键值对数量会只减不增,并随着 rehash 操作的执行而最终变成空表。
1.3.4 压缩列表
传统数组:
传统数组每个元素大小是相同的,假设一个元素大小是10,而其他元素实际大小只有1,但是数组的每个元素空间大小都会是10。会浪费内存空间。
数组的优势占用一片连续的空间可以很好的利用CPU缓存访问数据。如果我们想要保留这种优势,又想节省存储空间我们可以对数组进行压缩。
但是这样有一个问题,我们在遍历它的时候由于不知道每个元素的大小是多少,因此也就无法计算出下一个节点的具体位置。这个时候我们可以给每个节点增加一个lenght的属性。
如此。我们在遍历节点的之后就知道每个节点的长度(占用内存的大小),就可以很容易计算出下一个节点再内存中的位置。这种结构就像一个简单的压缩列表了。
压缩列表的结构typedf struct ziplist<T>{
//压缩列表占用字符数
int32 zlbytes;
//最后一个元素距离起始位置的偏移量,用于快速定位最后一个节点
int32 zltail_offset;
//元素个数
int16 zllength;
//元素内容
T[] entries;
//结束位 0xFF
int8 zlend;
}ziplist;
- zlbytes: 存储一个无符号整数,固定四个字节长度(32bit),用于存储压缩列表所占用的字节(也包括
本身占用的4个字节),当重新分配内存的时候使用,不需要遍历整个列表来计算内存大小。 - zltail: 存储一个无符号整数,固定四个字节长度(32bit),表示ziplist表中最后一项(entry)在ziplist中的偏移字节数。
的存在,使得我们可以很方便地找到最后一项(不用遍历整个ziplist),从而可以在ziplist尾端快速地执行push或pop操作。 - zllen: 压缩列表包含的节点个数,固定两个字节长度(16bit), 表示ziplist中数据项(entry)的个数。由于zllen字段只有16bit,所以可以表达的最大值为2^16-1。
ziplist节点entry结构
// 压缩链表结构体
// 元素实体所有信息, 仅仅是描述使用, 内存中并非如此存储
typedef struct zlentry {
// prevrawlen为上一个链表节点占用的长度
// prevrawlensize为存储上一个链表节点的长度数值所需要的字节数
unsigned int prevrawlensize, prevrawlen;
// len为当前链表节点占用的长度
// lensize为存储当前链表节点长度数值所需要的字节数
unsigned int lensize, len;
// 当前链表节点的头部大小(prevrawlensize + lensize),即非数据域的大小
unsigned int headersize;
// 元素内容编码方式
unsigned char encoding;
//元素实际内容
// 压缩链表以字符串的形式保存,该指针指向当前节点起始位置
unsigned char *p;
} zlentry;
为了便于理解我们可以
- 将第一部分prev_entry_length域看做对prevrawlensize、prevrawlen字段的抽象;
- 将第二部分cur_entry_length域看做是对lensize、len字段的抽象。
- 另外,我们经常需要跳过节点的header部分(第一部分和第二部分)读取节点真正存储的数据,所以zlentry结构定义了headersize字段记录节点头部长度。
每个数据项entry由三部分构成:
struct entry{
//前一个 entry 的长度
int<var> prevlen;
//元素类型编码
int<var> encoding;
//元素内容
optional byte[] content;
}
压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。
缺点:
- 不能保存过多的元素,否则查询效率就会降低;
新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题。
1.3.5 整数集合
整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
整数集合是集合键的底层实现之一
- 整数集合的底层实现是数组,并且这个数组中的元素有序、不重复,在需要时会根据新添加的元素进行编码升级改变整数集合的类型
- 升级操作为整数集合带来操作上的灵活性,并且尽可能节约了内存
- 整数集合只支持升级操作,不支持降级操作
1.3.6 快速列表
- 双向链表的前后指针各占8个字节,空间利用率低。
- 每个节点的内存单独分配,造成内存碎片化,影响内存管理效率。
因此引入了快速列表,双向链表上挂压缩列表。
- 每一段使用 zipList 来紧凑存储。
- 多个 zipList 之间使用双向指针串接起来。
- 解决了zipList存储过多数据效率低的问题,同事减少了双向链表的指针浪费太多空间的问题。
1.3.7 流对象
1.4 五种数据类型的编码
1.4.1 string
字符串类型的内部编码有3种:
- int:8个字节的长整型
- embstr:小于等于44个字节的字符串。 (3.2 之前是39个字节)
- raw:大于44个字节的字符串。(3.2 之前是39个字节)
embstr存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc 方法一次分配。embstr的最小占用空间为19(16+3),而64-19-1(结尾的\0)=44,所以empstr只能容纳44字节。
为什么是44?
embstr = redisObject + SDS。
redisObject是16个字节
SDS = uint8t (1个字节)* 2 + char(1个字节)(3.2版本之前是两个int共占8个字节)_
c语言字符窜最后一位是’\0’占1个字节。
因此 64 - 16 - 3 - 1 = 44
Redis会根据当前值的类型和长度决定使用内部编码实现。
1.4.2 hash
哈希类型的内部编码有两种:
- ziplist:当哈希类型元素个数小于hash-max-ziplist-entries配置(默认512个),同时所有值都小于hash-max-ziplist-value配置(默认64个字节)时,Redis会使用ziplist作为哈希的内部实现。
hashtable:当哈希类型无法满足ziplist的条件时,Redis会使用hashtable作为哈希的内部实现。
1.4.3 list
3.2之前:
ziplist(压缩列表):当哈希类型元素个数小于hash-max-ziplist-entries配置(默认512个),同时所有值都小于hash-max-ziplist-value配置(默认64个字节)时,Redis会使用ziplist作为哈希的内部实现。
- linkedlist(链表):当列表类型无法满足ziplist的条件时,Redis会使用linkedlist作为列表的内部实现。
1.4.4 set
集合类型的内部编码有两种:
- intset(整数集合):当集合中的元素都是整数且元素个数小于set-max-intset-entries配置(默认512个)时,Redis会选用intset来作为集合内部实现,从而减少内存的使用。
hashtable:当集合类型无法满足intset的条件时,Redis会使用hashtable作为集合的内部实现。
1.4.5 zset
有序集合类型的内部编码有两种:
ziplist:当有序集合的元素个数小于zset-max-ziplist-entries配置(默认128个)同时每个元素的值小于zset-max-ziplist-value配置(默认64个字节)时,Redis会用ziplist来作为有序集合的内部实现,ziplist可以有效减少内存使用。
- skiplist:当ziplist条件不满足时,有序集合会使用skiplist作为内部实现,因为此时zip的读写效率会下降。
2 缓存过期和淘汰策略
2.1 设置缓存淘汰
长期使用,key会不断增加,Redis作为缓存使用,物理内存也会满 内存与硬盘交换(swap) 虚拟内存 ,频繁IO 性能急剧下降,为了解决这一问题,可以设置redis的maxmemory。
maxmemory 默认为0不限制。
maxmemory-policy 参数配置淘汰策略:
- noeviction
不进行数据淘汰,也是Redis的默认配置。这时,当缓存被写满时,再有写请求进来,Redis不再提供服务,直接返回错误。
- volatile-random
缓存满了之后,在设置了过期时间的键值对中进行随机删除。
- volatile-ttl
缓存满了之后,会针对设置了过期时间的键值对中,根据过期时间的先后顺序进行删除。
- volatile-lru
缓存满了之后,针对设置了过期时间的键值对,采用LRU算法进行淘汰。
- volatile-lfu
缓存满了之后,针对设置了过期时间的键值对,采用LFU的算法进行淘汰。
- allkeys-random
缓存满了之后,从所有键值对中随机选择并删除数据。
- allkeys-lru
缓存满之后,使用LRU算法在所有的数据中进行筛选删除。
- allkeys-lfu
2.2 expire
在1.1节中,由RedisDB结构体的定义可知,每个key的过期事件存放在*expires
中。
2.3 删除策略
Redis的数据删除有定时删除(不建议使用)、惰性删除和定期删除三种方式。
Redis目前采用惰性删除+定期删除的方式。
2.3.1 惰性删除
惰性删除是定时删除和定期删除的折中处理方案。它放任 key 过期不管,但是每次获取 key 时,都会检查取得的 key 是否过期,如果过期,则删除该 key;若没有过期,就返回该 key 的值。
优点:
对 CPU 时间最友好。只在取出 key 时,才对 key 进行过期检查,即只会在非做不可的情况下进行,并且删除的目标仅限于当前处理的 key,不会在删除其他无关的过期 key 上花费任何 CPU 时间。
缺点:
对内存最不友好。如果一个 key 已经过期,而这个 key 又仍然保留在 db 中,那么只要这个过期 key 不被删除,它所占用的内存就不会释放。如果 db 中有大量的过期 key,而这些过期 key 又恰好没有被访问到的话,那它们也许永远也不会被删除,除非用户手动执行 flushdb 命令清空,这样会导致大量的无用的脏数据占用大量的内存。
2.3.2 定期删除
LRU
Redis的LRU算法是一种近似的LRU。
- RedisObject有个字段
**unsigned lru:LRU_BITS;**
用来记录最近的一次的访问时间。 - Redis会随机找到5个key(注意:这里的 5 个 key 是可以修改的,由 maxmemory-smples 控制)。
- 把这些key放入一个候选池pool中。
- 在后续的筛选过程中只有 lru 小于候选池中最小的 lru 才能被放入到候选池,直至候选池放满。
- 当候选池满了的时候,如果有新的数据继续放入,则需要将候选池中 lru 字段最大值取出。
- 在淘汰的时候,只需要将候选池中 lru 字段值最小的淘汰掉即可。
LFU
- 将
**unsigned lru**
分为前16位和后8位。- Ldt (last decrement time):前16位用来记录上次访问的时间,精确到分钟。
- LOG_C:后8位记录访问频率最大为255。
- 新对象的 LOG_C 值为 LFU_INIT_VAL = 5,避免刚被创建即被淘汰。
- LFU的核心配置
- lfu-log-factor:counter 增长对数因子,调整概率计数器 counter 的增长速度,lfu-log-factor值越大 counter 增长越慢;lfu-log-factor 默认10。
- lfu-decay-time:衰变时间周期,调整概率计数器的减少速度,单位分钟,默认1。N 分钟未访问,counter 将衰减 N/lfu-decay-time,直至衰减到0。
- LFU 算法下 Ldt 的值不是在key被访问时更新,而是在内存达到 maxmemory 时,触发淘汰策略时更新。
LFU也是利用了池的思想,淘汰逻辑和LRU的逻辑一样,只是依据不同:
- LRU:依据最近访问时间。unsigned lru字段。
- LFU:依据unsigned lru字段的后8位。counter
3 持久化
3.1 RDB
RDB(Redis Database) 通过快照的形式将数据保存到磁盘中。Redis 通过这种方式可以在指定的时间间隔或者执行特定命令时将当前系统中的数据保存备份,以二进制的形式写入磁盘中,默认文件名为dump.rdb。3.1.1 触发RDB
- 在redis.conf中配置:save 多少秒内 数据变了多少 ```shell save “” # 不使用RDB存储 不能主从(默认)
save 900 1 # 表示15分钟(900秒钟)内至少1个键被更改则进行快照。 save 300 10 # 表示5分钟(300秒)内至少10个键被更改则进行快照。 save 60 10000 # 表示1分钟内至少10000个键被更改则进行快照。
2. 执行save或者bgsave命令
2. 执行flush all命令
2. 执行主从复制操作 (第一次)
<a name="aRDid"></a>
### 3.1.2 save和bgsave
save命令执行一个同步保存操作,将当前Redis实例的所有数据快照(snapshort)已RDB文件的方式保存到磁盘。
- save同步阻塞主进程,只有等save完后成,才能进行新操作。
bgsave执行后,会返回OK,Redis 会fork一个子进程,原来的redis主进程继续执行后续操作,新fork的子进程负责将数据保存到磁盘,然后退出。
- bgsave 是fork的子进程,非阻塞,等执行完后会通知主进程,然后关闭子进程。
- bgsave采用** 写时复制_(Copy on Write)_ **技术
<a name="Z9r8V"></a>
### 3.1.3 Copy on Write
执行 bgsava 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22245218/1655814322712-3b9ee316-8660-4447-a955-2e1e80ad9de6.png#clientId=u593e3ca4-f308-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=397&id=u0691e8a5&margin=%5Bobject%20Object%5D&name=image.png&originHeight=708&originWidth=767&originalType=binary&ratio=1&rotation=0&showTitle=false&size=82912&status=done&style=none&taskId=u959e25d5-2f27-44cf-8370-50ebe91cc64&title=&width=429.60003662109375)<br />只有在发生修改内存数据的情况时,物理内存才会被复制一份。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22245218/1655814483150-7a4880ef-12ab-4735-8400-dc4be5e95ba9.png#clientId=u593e3ca4-f308-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=359&id=u92bfb2c1&margin=%5Bobject%20Object%5D&name=image.png&originHeight=710&originWidth=963&originalType=binary&ratio=1&rotation=0&showTitle=false&size=91156&status=done&style=none&taskId=u239d2142-965d-4d7e-bb2c-146801d607e&title=&width=487)<br />这样的目的是为了减少创建子进程时的性能损耗,从而加快创建子进程的速度,毕竟创建子进程的过程中,是会阻塞主线程的。<br />所以,创建 bgsave 子进程后,由于共享父进程的所有内存数据,于是就可以直接读取主线程里的内存数据,并将数据写入到 RDB 文件。<br />当主线程对这些共享的内存数据也都是只读操作,那么,主线程和 bgsave 子进程相互不影响。<br />但是,如果主线程要**修改共享数据里的某一块数据**(比如键值对 A)时,就会发生写时复制,于是这块数据的**物理内存就会被复制一份(键值对** **A')**,然后**主线程在这个数据副本(键值对** **A')进行修改操作**。与此同时,**bgsave 子进程可以继续把原来的数据(键值对** **A)写入到 RDB 文件**。<br />就是这样,Redis 使用 bgsave 对当前内存中的所有数据做快照,这个操作是由 bgsave 子进程在后台完成的,执行时不会阻塞主线程,这就使得主线程同时可以修改数据。<br />细心的同学,肯定发现了,bgsave 快照过程中,如果主线程修改了共享数据,**发生了写时复制后,RDB 快照保存的是原本的内存数据**,而主线程刚修改的数据,是被办法在这一时间写入 RDB 文件的,只能交由下一次的 bgsave 快照。<br />**对于写时复制的总结:**
1. 创建子进程减少了损耗,而且不会阻塞主进程。
1. 当主进程发生写操作时,才会复制内存数据。
1. 由 2 可知,子进程无法获取到主进程实时修改的数据。
1. 极端情况下,当如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍。
<a name="v5Jvh"></a>
### 3.1.4 执行原理
1. Redis父进程首先判断:当前是否在执行save,或bgsave/bgrewriteaof(aof文件重写命令)的子<br />进程,如果在执行则bgsave命令直接返回。
1. 父进程执行fork(调用OS函数复制主进程)操作创建子进程,这个复制过程中父进程是阻塞的,<br />Redis不能执行来自客户端的任何命令。
1. 父进程fork后,bgsave命令返回”Background saving started”信息并不再阻塞父进程,并可以响<br />应其他命令。
1. 子进程创建RDB文件,根据父进程内存快照生成临时快照文件,完成后对原有文件进行原子替换。<br />(RDB始终完整)
1. 子进程发送信号给父进程表示完成,父进程更新统计信息。
1. 父进程fork子进程后,继续工作。
<a name="cJvOh"></a>
### 3.1.5 优缺点
**优点:**
- RDB 是一个非常紧凑(compact)的文件(保存二进制数据),它保存了 Redis 在某个时间点上的数据集。 这种文件非常适合用于进行备份: 比如说,你可以在最近的 24 小时内,每小时备份一次 RDB 文件,并且在每个月的每一天,也备份一个 RDB 文件。 这样的话,即使遇上问题,也可以随时将数据集还原到不同的版本;
- RDB 可以最大化 Redis 的性能:父进程在保存 RDB 文件时唯一要做的就是 fork 出一个子进程,然后这个子进程就会处理接下来的所有保存工作,父进程无须执行任何磁盘 I/O 操作;
- RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
**缺点:**
- 不能完全保证数据的完整性。
- fork()出子进程时,如果数据量太大,会造成主进程的阻塞。
<a name="dXqdY"></a>
## 3.2 AOF
<a name="WiLfu"></a>
### 3.2.1 AOF日志
**配置**
```properties
# 可以通过修改redis.conf配置文件中的appendonly参数开启
appendonly yes
# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的。
dir ./
# 默认的文件名是appendonly.aof,可以通过appendfilename参数修改
appendfilename appendonly.aof
AOF日志只会记录写操作。
下面是执行set name mxiaoy
时,AOF日志中的记录。
*3 # 表示这条命令有3个部分
$3 # 表示有几个字节
set # 命令符
$4
name
$5
mxiaoy
AOF是在执行完写操作后记录的,这样做有两个好处:
- 只有命令成功时,才会记录。如果先记录后写,如果执行出错,还要回滚日志。
- 不会阻塞当前的写操作,当然有可能阻塞下一次的操作。
同样这样的模式也会有风险:
- 如果redis写命令执行完后,在AOF记录时宕机了,会丢失数据。
-
3.2.2 写回策略
Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
- 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
- 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。
Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。
在 redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:
- Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
- Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
- No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
这 3 种写回策略都无法能完美解决「主进程阻塞」和「减少数据丢失」的问题,因为两个问题是对立的,偏向于一边的话,就会要牺牲另外一边,原因如下:
- Always 策略的话,可以最大程度保证数据不丢失,但是由于它每执行一条写操作命令就同步将 AOF 内容写回硬盘,所以是不可避免会影响主进程的性能;
- No 策略的话,是交由操作系统来决定何时将 AOF 日志内容写回硬盘,相比于 Always 策略性能较好,但是操作系统写回硬盘的时机是不可预知的,如果 AOF 日志内容没有写回硬盘,一旦服务器宕机,就会丢失不定数量的数据。
- Everysec 策略的话,是折中的一种方式,避免了 Always 策略的性能开销,也比 No 策略更能避免数据丢失,当然如果上一秒的写操作命令日志没有写回到硬盘,发生了宕机,这一秒内的数据自然也会丢失。 | 写回策略 | 写回时机 | 优点 | 缺点 | | —- | —- | —- | —- | | Always | 同步写回 | 可靠性高、最大程度保证 | 性能开销大 | | EverySec | 每秒写回 | 性能适中 | 最多丢失1秒的数据 | | No | 由操作系统控制 | 性能高 | 宕机时丢失不定量数据 |
其实,这三种策略只是在控制fsync()
函数的调用时机。
- Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
- Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
-
3.2.3 重写机制
Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。
# 表示当前aof文件大小超过上一次aof文件大小的百分之多少的时候会进行重写。如果之前没有重写过,以启动时aof文件大小为准
auto-aof-rewrite-percentage 100
# 限制允许重写最小aof文件大小,也就是文件大小小于64mb的时候,不需要进行优化
auto-aof-rewrite-min-size 64mb
AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。
重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这样就减少了 AOF 文件中的命令数量。最后在重写工作完成后,将新的 AOF 文件覆盖现有的 AOF 文件。
AOF 重写过程,先重写到新的 AOF 文件,重写失败的话,就直接删除这个文件就好,不会对现有的 AOF 文件造成影响。
后台重写
写入 AOF 日志的操作虽然是在主进程完成的,因为它写入的内容不多,所以一般不太影响命令的操作。
但是在触发 AOF 重写时,比如当 AOF 文件大于 64M 时,就会对 AOF 文件进行重写,这时是需要读取所有缓存的键值对数据,并为每个键值对生成一条命令,然后将其写入到新的 AOF 文件,重写完后,就把现在的 AOF 文件替换掉。
这个过程其实是很耗时的,所以重写的操作不能放在主进程里。
Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的。这里和RDB的**bgsave**
类似不再赘述。除此之外,为了保证数据一致性,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。
在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作: 执行客户端发来的命令。
- 将执行后的写命令追加到 「AOF 缓冲区」。
- 将执行后的写命令追加到 「AOF 重写缓冲区」。
当子进程完成 AOF 重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。
主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:
- 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
- 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。
信号函数执行完后,主进程就可以继续像往常一样处理命令了。
在整个 AOF 后台重写过程中,除了发生写时复制会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。
3.2.4 数据恢复
- 恢复数据的时候,redis会创建一个本地的伪客户端 ,这是因为Redis的命令只能在客 户端上下文中执行。
-
3.2.5 混合持久化
RDB和AOF各有优缺点,Redis 4.0 开始支持 rdb 和 aof 的混合持久化。如果把混合持久化打开,aof rewrite 的时候就直接把 rdb 的内容写到 aof 文件开头。 RDB的头+AOF的身体——>appendonly.aof 。
配置:aof-use-rdb-preamble yes
4 事务
4.1 概述
Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。
总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。
执行顺序:开始事务—>命令入队—>执行事务4.2 没有隔离级别
批量操作在发送 EXEC 命令前被放入队列缓存,并不会被实际执行,也就不存在事务内的查询要看到事务里的更新,事务外查询不能看到。
4.3 不保证原子性
Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。
4.4 命令操作
| multi | 标记一个事务块的开始。 | | —- | —- | | exec | 执行所有事务块内的命令。 | | discard | 取消事务,放弃执行事务块内的所有命令。 | | unwatch | 取消watch命令对所有key的监视。 | | watch | 监视一个或多个key,如果在事务执行之前这个key被其他命令所改变,那么事务将被打断。 |
在事务队列中存在命令性错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行。
- 在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常。
- 使用watch
批量执行 | 原子性 | |
---|---|---|
pipeline | √ | 无,所有命令都是独立的,无状态的 |
事务 | √ | 一部分原子性,命令式错误都无法执行,执行错误只有当前命令失败 |
lua | √ | 完全的原子性 |
6 慢查询
6.1 设置慢查询
在redis.conf中可以配置和慢查询日志相关的选项:
#执行时间超过多少微秒的命令请求会被记录到日志上 0 :全记录 <0 不记录
slowlog-log-slower-than 10000
#slowlog-max-len 存储慢查询日志条数
slowlog-max-len 128
config set的方式可以临时设置,redis重启后就无效
config set slowlog-log-slower-than 微秒
config set slowlog-max-len 条数
例子:
127.0.0.1:6379> config set slowlog-log-slower-than 0
OK
127.0.0.1:6379> config set slowlog-max-len 2
OK
127.0.0.1:6379> set name:001 zhaoyun
OK
127.0.0.1:6379> set name:002 zhangfei
OK
127.0.0.1:6379> get name:002
"zhangfei"
127.0.0.1:6379> slowlog get
1) 1) (integer) 7 #日志的唯一标识符(uid)
2) (integer) 1589774302 #命令执行时的UNIX时间戳
3) (integer) 65 #命令执行的时长(微秒)
4) 1) "get" #执行命令及参数
2) "name:002"
5) "127.0.0.1:37277"
6) ""
2) 1) (integer) 6
2) (integer) 1589774281
3) (integer) 7
4) 1) "set"
2) "name:002"
3) "zhangfei"
5) "127.0.0.1:37277"
6) ""
# set和get都记录,第一条被移除了。
6.2 慢查询的定位和处理
使用slowlog get 可以获得执行较慢的redis命令,针对该命令可以进行优化:
- 尽量使用短的key,对于value有些也可精简,能使用int就int。
- 避免使用keys *、hgetall等全量操作。
- 减少大key的存取,打散为小key
- 将rdb改为aof模式 rdb fork 子进程 主进程阻塞 redis大幅下降关闭持久化 ,(适合于数据量较小)改aof 命令式
- 想要一次添加多条数据的时候可以使用管道
- 尽可能地使用哈希存储
尽量限制下redis使用的内存大小,这样可以避免redis使用swap分区或者出现OOM错误 内存与硬盘的swap
7 高可用
7.1 主从
部署多台redis节点,其中只有一台节点是主节点(master),其他的节点都是从节点(slave),也叫备份节点(replica)。只有master节点提供数据的事务性操作(增删改),slave节点只提供读操作。
配置修改# slaveof <masterip> <masterport>
# 表示当前【从服务器】对应的【主服务器】的IP是192.168.10.135,端口是6379。
replicaof 127.0.0.1 6379
7.1.1 复制流程
保存主节点信息
当客户端向从服务器发送slaveof(replicaof) 主机地址(127.0.0.1) 端口(6379)时:从服务器将主机 ip(127.0.0.1)和端口(6379)保存到redisServer的masterhost和masterport中。
Struct redisServer{
char *masterhost;//主服务器ip
int masterport;//主服务器端口
};
- 建立socket连接
- slaver与master建立socket连接
- slaver关联文件事件处理器
- 主服务器accept从服务器Socket连接后,创建相应的客户端状态。相当于从服务器是主服务器的Client 端
- ping-pong
- 权限验证
- 发送端口信息
- 同步数据
- 2.8之前采用SYNC
- 主服务器生成RDB文件并发送给从服务器,同时发送保存所有写命令给从服务器
- 从服务器清空之前数据并执行解释RDB文件
- 缺陷:没有全量同步和增量同步的概念,从服务器在同步时,会清空所有数据。 主从服务器断线后重复制,主服务器会重新生成RDB文件和重新记录缓冲区的所有命令,并全量同步到 从服务器上。
- 2.8之后采用FSYNC
- 分为全量同步和增量同步。
- 只有从机第一次连接上主机是全量同步。
- 断线重连有可能触发全量同步也有可能是增量同步( master 判断 runid 是否一致)
- 2.8之前采用SYNC
-
7.1.2 总结
优点:
一个Master可以同步多个Slaves。
- Slave同样可以接受其它Slaves的连接和同步请求,这样可以有效的分载Master的同步压力。因此我们可以将Redis的Replication架构视为图结构。
- 主机进行写,从机只读,读写分离。
缺点:
- 所有从机全量复制,受单机容量的限制。
-
7.2 哨兵
哨兵模式是Redis的一种高可用解决方案:由一个或多个Sentinel实例组成一个系统,可以监视一个或多个主服务器以及他们的从服务器。当主服务器下线时,可以自动将他的从服务器升级成主服务器。下线的主服务器恢复后变成从服务器。
7.2.1 启动及初始化Sentienl
初始化服务器
Sentienl本质上是特殊的Redis服务器,但是有一些功能不需要,如下图:
- 将普通的redis服务器代码替换成sentienl专用代码
- 初始化sentienl状态
初始化sentinel.c
中的sentinelState
结构,这里记录了和Sentienl功能额有关的状态。
- 根据配置文件初始化sentienl的监视主服务器列表
- 第3步中
sentinel
的状态里的master
字典记录了被监视主服务器的信息。
- 第3步中
key :服务器名称
value:sentinelRedisInstence
结构
sentinel.c
中的sentinelRedisInstence
结构记录了- 状态标志
flags
- IP和PORT
- 实例的runId
- 主观下线的毫秒值
- 客观下线的投票数量
- 故障迁移的最大时限等等
- 状态标志
创建连向主服务器的连接
- 订阅连接:向主服务器发送或接收命令
- 命令链接:用于订阅主服务器的sentinel:hello频道
7.2.2 获取主服务器信息
7.2.3 获取从服务器信息
7.2.4 向服务器发送信息
7.2.5 检测主观下线
默认情况下,Sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他sentinel)发送PING命令,通过实例返回的PONG命令来判断实例是否在线。
在sentinel的配置文件中的down-after-milliseconds
选项指定了sentinel判断实例主观下线的时间。如果在这个时间段内,实例连续进行无效回复,则sentinel会修改该实例对应的sentinelRedisInstence
的flag
字段为:SRI_S_DOWN
,以此来表示该实例进入到了主观下线状态。7.2.6 检测客观下线
当Sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,它会向同样监视这一主服务器的其他Sentinel进行询问,看它们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当Sentinel从其他Sentinel那里接收到足够数量的已下线判断之后,Sentinel就会将从服务器判定为客观下线,并对主服务器执行故障转移操作。
发送命令:
SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>
- 接受命令:
1) <down_state>
2) <leader_runid>
3) <leader_epoch>
- 设置客观下线标志
Sentinel将统计其他Sentinel同意主服务器已下线的数量,当这一数量达到配置指定的判断客观下线所需的数量时,Sentinel会将主服务器实例结构flags属性的SRI_O_DOWN标识打开,表示主服务器已经进入客观下线状态。
Sentinel配置中设置的quorum参数的值,表示客观下线的判断条件。
7.2.7 选举领头Sentinel
当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个Sentinel会进行协商,选举出一个领头Sentinel,并由领头Sentinel对下线主服务器执行故障转移操作。
- 所有在线的Sentinel都有被选为领头Sentinel的资格,换句话说,监视同一个主服务器的多个在线Sentinel中的任意一个都有可能成为领头Sentinel。
- 每次进行领头Sentinel选举之后,不论选举是否成功,所有Sentinel的配置纪元(configuration epoch)的值都会自增一次。配置纪元实际上就是一个计数器。
- 在一个配置纪元里面,所有Sentinel都有一次将某个Sentinel设置为局部领头Sentinel的机会,并且局部领头一旦设置,在这个配置纪元里面就不能再更改。
- 每个发现主服务器进入客观下线的Sentinel都会要求其他Sentinel将自己设置为局部领头Sentinel。
- 当一个Sentinel(源Sentinel)向另一个Sentinel(目标Sentinel)发送
SENTINEL is-master-down-by-addr
命令,并且命令中的runid参数不是*符号而是源Sentinel的运行ID时,这表示源Sentinel要求目标Sentinel将前者设置为后者的局部领头Sentinel。 - Sentinel设置局部领头Sentinel的规则是先到先得:最先向目标Sentinel发送设置要求的源Sentinel将成为目标Sentinel的局部领头Sentinel,而之后接收到的所有设置要求都会被目标Sentinel拒绝。
- 目标Sentinel在接收到SENTINEL is-master-down-by-addr命令之后,将向源Sentinel返回一条命令回复,回复中的leader_runid参数和leader_epoch参数分别记录了目标Sentinel的局部领头Sentinel的运行ID和配置纪元。
- 源Sentinel在接收到目标Sentinel返回的命令回复之后,会检查回复中leader_epoch参数的值和自己的配置纪元是否相同,如果相同的话,那么源Sentinel继续取出回复中的leader_runid参数,如果leader_runid参数的值和源Sentinel的运行ID一致,那么表示目标Sentinel将源Sentinel设置成了局部领头Sentinel。
- 如果有某个Sentinel被半数以上的Sentinel设置成了局部领头Sentinel,那么这个Sentinel成为领头Sentinel。举个例子,在一个由10个Sentinel组成的Sentinel系统里面,只要有大于等于10/2+1=6个Sentinel将某个Sentinel设置为局部领头Sentinel,那么被设置的那个Sentinel就会成为领头Sentinel。
- 因为领头Sentinel的产生需要半数以上Sentinel的支持,并且每个Sentinel在每个配置纪元里面只能设置一次局部领头Sentinel,所以在一个配置纪元里面,只会出现一个领头Sentinel。
- 如果在给定时限内,没有一个Sentinel被选举为领头Sentinel,那么各个Sentinel将在一段时间之后再次进行选举,直到选出领头Sentinel为止。
7.2.8 故障转移
在选举产生出领头Sentinel之后,领头Sentinel将对已下线的主服务器执行故障转移操作,包含以下三个步骤:
- 在已下线主服务器属下的所有从服务器里面,挑选出一个从服务器,并将其转换为主服务器。
- 让已下线主服务器属下的所有从服务器改为复制新的主服务器。
- 将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器。
主服务器挑选规则:
领头Sentinel会将已下线主服务器的所有从服务器保存到一个列表里面
- 删除列表中所有处于下线或者断线状态的从服务器,保证是正常在线的。
- 删除列表中所有最近五秒内没有回复过领头Sentinel的INFO命令的从服务器,保证是最近通信过的。
- 删除所有与已下线主服务器连接断开超过down-after-milliseconds*10毫秒的从服务器,保证是主服务器下线前还有通讯的。
- 选出偏移量最大的,保证数据最新的。
- 前面都一样,根据ID排序。