Redis是纯内存数据库,一般都是简单的存取操作,线程占用的时间很多,时间的花费主要集中在IO上,所以读取速度快。
Redis 的网络 IO 和命令处理,都在核心进程中由单线程处理
https://www.jianshu.com/p/c4aa888b3538
线程只需要保存线程的上下文(相关寄存器状态和栈的信息)
Redis采用了单线程的模型,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU。
redis 采用网络IO多路复用技术来保证在多连接的时候, 系统的高吞吐量。
多路-指的是多个socket连接,复用-指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll。epoll是最新的也是目前最好的多路复用技术。
Epoll 事件模型开发,可以进行非阻塞网络 IO,同时由于单线程命令处理,整个处理过程不存在竞争,不需要加锁,没有上下文切换开销
这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。
https://zhuanlan.zhihu.com/p/58038188
Redis 支持多种数据类型,每种基本类型,可能对多种数据结构。什么时候,使用什么样数据结构,使用什么样编码,是redis设计者总结优化的结果。
String:如果存储数字的话,是用int类型的编码;如果存储非数字,小于等于39字节的字符串,是embstr;大于39个字节,则是raw编码。
List:如果列表的元素个数小于512个,列表每个元素的值都小于64字节(默认),使用ziplist编码,否则使用linkedlist编码
Hash:哈希类型元素个数小于512个,所有值小于64字节的话,使用ziplist编码,否则使用hashtable编码。
Set:如果集合中的元素都是整数且元素个数小于512个,使用intset编码,否则使用hashtable编码。
Zset:当有序集合的元素个数小于128个,每个元素的值小于64字节时,使用ziplist编码,否则使用skiplist(跳跃表)编码
redis单机服务端有16个数据库,每个数据库都有一个字典结构,这个字典里存着两个hash表(为了之后的扩缩容),而这个hash表里有一个dictEntry 组成的数组,里面存放的就是所有的键值对。这个dictEntry还有指向下一个节点的指针,就是为了在hash冲突的情况,采用拉链法扩展出一个链表。
/*
* 字典
*/
typedefstruct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
/*
* 哈希表
* 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
*/
typedefstruct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsignedlong size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsignedlong sizemask;
// 该哈希表已有节点的数量
unsignedlong used;
} dictht;
/*
* 哈希表节点
*/
typedefstruct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
向字典表再添加一个元素 set name abin
我们会先对key做散列运算,将得到的值再对哈希表的大小4做一个取余,假设得到的值是3,那么这个key就会落在3的位置,比如:
当我们哈希表中存的数据越来越多,哈希冲突的概率就会越来越大。这样所有的键值对冲突后会形成一个链表,查询的效率就由原先的O(1)变成了O(n),所以我们要有一个评估的标准,用来判断是否需要扩缩容。
扩容
程序没有执行BGSAVE命令或者BGREWRITEAOF(AOF重写)命令,并且哈希表的负载因子大于等于1
如果程序正在执行BGSAVE或者BGREWRITEAOF(AOF重写)命令并且哈希表的负载因子大于等于5。在执行RDB或者AOF重写操作时,redis会创建当前服务器的子进程执行相应操作,为了避免在子进程存在期间对哈希表进行扩展操作,将扩展因子提高。可以避RDB或者AOF重写时不必要的内存写入操作,最大限度的节约内存。
缩容:当负载因子小于0.1
然而redis并不像我画的那样,只有一两个key。一个生产使用的redis可以达到几百上千万个。而redis的核心计算是单线程的,一次性重新散列这么多的key会造成长时间的服务不可用,因此需要采用渐进式的rehash。
具体步骤
为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
在字典中维持-一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作正式开始。
在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。
随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。
最后将h[1]的地址设置给h[0],并将h[1]设置为null,也就是将新哈希表替换旧hash表。
渐进式rehash的好处在于它采取分而治之,的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。
因为在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间:
redis数据库中的每个键值对的键和值都是一个对象
每个对象都有相应的类型,这些类型决定了你能对他们操作的指令,比如string类型的对象只能用set命令设置。
每种类型的对象又有两种以上的编码,不同编码可以在不同场景上优化使用效率
这里的每个字段都很重要,比如类型和编码,有一个基于6.0的关系图
/*
* Redis 对象
*/
typedefstruct redisObject {
// 类型
unsigned type:4;
// 编码方式
unsigned encoding:4;
// LRU - 24位, 记录最末一次访问时间(相对于lru_clock); 或者 LFU(最少使用的数据:8位频率,16位访问时间)
unsigned lru:LRU_BITS; // LRU_BITS: 24
// 引用计数
int refcount;
// 指向底层数据结构实例
void *ptr;
} robj;
比如我们执行一个命令 hset user age 25
在字典上的数据结构大概是这样,为了方便,string类型的对象就简画成了stringobject。
核心就是搞明白,无论是key还是value,都是一个redisObject即可。
比如我们执行一个命令 hset user age 25
在字典上的数据结构大概是这样,为了方便,string类型的对象就简画成了stringobject。
https://www.modb.pro/db/552315
Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。
字符串 内部结构
Redis 中的字符串是可以修改的字符串,在内存中它是以字节数组的形式存在的。
C 语言里面的字符串标准形式是以 NULL 作为结束符,但是在 Redis 里面字符串不
是这么表示的。因为要获取 NULL 结尾的字符串的长度使用的是 strlen 标准库函数,这个函数的算法复杂度是 O(n),它需要对字节数组进行遍历扫描,作为单线程的 Redis 表示承受不起。
Redis 的字符串叫着「SDS」,也就是 Simple Dynamic String。它的结构是一个带长度信息的字节数组。
上面的 SDS 结构使用了范型 T,为什么不直接用 int 呢 ?
这是因为当字符串比较短时,len 和 capacity 可以使用 byte 和 short 来表示,Redis 为了对内存做极致的优化,不同长度的字符串使用不同的结构体来表示。
Redis 规定字符串的长度不得超过 512M 字节。创建字符串时 len 和 capacity 一样长,不会多分配冗余空间,这是因为绝大多数场景下我们不会使用 append 操作来修改字符串。
我们首先来了解一下 Redis 对象头结构体,所有的 Redis 对象都有这样一个 RedisObject 对象头需要占据 16 字节( 4bit + 4bit + 24bit + 4bytes + 8bytes )的存储空间。
struct RedisObject {
int4 type; // 4bits
int4 encoding; // 4bits
int24 lru; // 24bits
int32 refcount; // 4bytes
void *ptr; // 8bytes,64-bit system
} robj;
不同的对象具有不同的类型 type(4bit),
同一个类型的 type 会有不同的存储形式encoding(4bit),
为了记录对象的 LRU 信息,使用了 24 个 bit 来记录 LRU 信息。
每个对象都有个引用计数,当引用计数为零时,对象就会被销毁,内存被回收。
ptr 指针将指向对象内容 (body) 的具体存储位置
这样一个 RedisObject 对象头需要占据 16 字节( 4bit + 4bit + 24bit + 4bytes + 8bytes )的存储空间。
接着我们再看 SDS 结构体的大小,在字符串比较小时,SDS 对象头的大小是capacity+3,至少是 3。意味着分配一个字符串的最小空间占用为 19 字节 (16+3)。
struct SDS {
int8 capacity; // 1byte
int8 len; // 1byte
int8 flags; // 1byte
byte[] content; // 内联数组,长度为 capacity
}
如图所示,embstr 存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc 方法一次分配。
而 raw 存储形式不一样,它需要两次malloc,两个对象头在内存地址上一般是不连续的。
而内存分配器 jemalloc/tcmalloc 等分配内存大小的单位都是 2、4、8、16、32、64 等等,为了能容纳一个完整的 embstr 对象,jemalloc 最少会分配 32 字节的空间,如果字符串再稍微长一点,那就是 64 字节的空间。
如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。
当内存分配器分配了 64 空间时,那这个字符串的长度最大可以是多少呢?这个长度就是 44。那为什么是 44 呢?
SDS 结构体中的 content 中的字符串是以字节\0 结尾的字符串,之所以多出这样一个字节,是为了便于直接使用 glibc 的字符串处理函数,以及为了便于字符串的调试打印输出。
看上面这张图可以算出,留给 content 的长度最多只有 45(64-19) 字节了。字符串又是以\0 结尾,所以 embstr 最大能容纳的字符串长度就是 44。
redisObject我们可以看下源码,在server.h中有对于redisObject的定义,关于encoding,string类型有三个编码格式分别为int,embstr,raw这个区别在本文的最后做解释,因为需要有SDS的铺垫才可以。
关于string的三个编码的区别
1,int,存储8个字节的长整型,最大数字为2^63-1。
2,关于embstr和raw,embstr存储小于等于44个字节的字符串, raw 存储大于44个字节的字符串
bao test:0>set test111 1
OK
bao test:0>object encoding test111
int
embstr
假设SDS中没有任何数据的情况下,emstr需要消耗的字节数就有 4位+4位+24位 = 4字节 系统如果是64位,则地址需要8字节存储,4+4+8+3 = 19字节, 64-19=45字节,在减去最后的buf的结束符’\0’所占用一字节,所以最终embstr能存储的字符串最大为44字节。
可以比较明显的体现,embstr所分配的内存是连续的,而raw所分配的内存是非连续的,所以这就导致了,embstr只需要分配一次内存,而raw需要分配两次内存(第一次为redisObject,第二次为SDS),相对地,释放内存的次数也由一次变为两次。
当Hash的数据项较少时,Hash底层才会用压缩列表zipList进行存储数据, 数据增加,底层的zipList会转成dict,
ziplist
上图中可以看到,当数据量比较小的时候,我们会将所有的key及value都当成一个元素,顺序的存入到ziplist中,构成有序。
ziplist与hash转化
项目中使用到了redis的哈希结构 , 哈希结构的内部编码类型是 ziplist 和 hashtable
当元素个数小于512 , 并且值的大小小于64个字节时 , 采用ziplist , 大于的时候采用hashtable
ziplist最大的优势就是存储的时候是连续的内存 , 可以极大的提升cpu的缓存命中率, 但是,如果数据很大,则不会获得太多的增益,同时会花费大量的CPU时间。
哈希表中桶的数量是有限的,当Key的数量较大时自然避免不了哈希冲突(多个Key落在了同一个哈希桶中)。当哈希桶中存在哈希冲突时那么多个Entry就形成了链表,每个链表中有一个Next指针指向了下一个元素。当哈希桶中的链表过长时,那么查询性能会显著降低(链表的查找时间复杂度为O(N)),Redis为了避免类似的问题从而会进行Rehash操作
为了能够减少哈希冲突,其实最直接的做法是增加哈希桶数量从而让元素能够更加均匀的分布在哈希表中。而Redis中的Rehash操作的原理其实也是如此,只不过他的设计更加巧妙。
Redis中其实有两个「全局哈希表」,一开始时默认使用的Hash Table1来存储数据,而Hash Table2并没有分配内存空间。随着Hash Table1中的元素越来越多时,Redis会进行Rehash操作。
首先会给Hash Table2分配一定的内存空间(肯定比哈希表一大),然后将Hash Table1中的元素重新映射至Hash Table2中,最后会释放Hash Table1。这样来看的话,Redis的Rehash操作的确能减少哈希冲突,但是你有没有想过如果Hash Table1中的元素特别多时,如果这么粗暴的将数据往Hash Table2中搬,那势必会阻塞Redis的主线程进而影响Redis的性能。其实Redis也考虑到了这个问题,那么接下来我们看看Redis是如何解决这种问题的
1.为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
2.在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
3.在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
4.随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。
因为在进行渐进式 rehash 的过程中, 字典会同时使用 ht[0] 和 ht[1] 两个哈希表, 所以在渐进式 rehash 进行期间, 字典的删除(delete)、查找(find)、更新(update)等操作会在两个哈希表上进行: 比如说, 要在字典里面查找一个键的话, 程序会先在 ht[0] 里面进行查找, 如果没找到的话, 就会继续到 ht[1] 里面进行查找, 诸如此类。
另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表
3.2 版本前采用ziplist和linkedlist结构
List 是一个有序(按加入的时序排序)的数据结构,一般有序我们会采用数组或者是双向链表,其中双向链表由于有前后指针实际上会很浪费内存。
3.2版本之前采用两种数据结构作为底层实现:
压缩列表ziplist
双向链表linkedlist
压缩列表相对于双向链表更节省内存,所以再创建列表时,会先考虑压缩列表,并在一定条件下才转化为双向链表。
压缩列表转化成双向链表的条件:
3.2版本之后升级为 quicklist(双向链表)
quicklist是3.2版本之后引入的。
根据上文谈到,ziplist会引入频繁的内存申请和释放,而linkedlist由于指针也会造成内存的浪费,而且每个节点是单独存在的,会造成很多内存碎片,所以结合两个结构的特点,设计了quickList。
quickList 是一个 ziplist 组成的双向链表。每个节点使用 ziplist 来保存数据。本质上来说,quicklist 里面保存着一个一个小的 ziplist。
其底层主要有整数数组(INTSET)和哈希表两种实现方式。当我们创建set的时候如果遇上成员是整形字符串时,会直接使用intset编码存储。intset的数据结构:
其中:inset为可以理解为整数数组的一个有序集合,其内部是采用二分查找来定位元素位置。实际查询复杂度也就在log(n)
使用inset数据结构需要满足下述两个条件:
在redis中是通过两种底层数据结构实现的。
ziplist压缩列表 或者 skipList跳跃表与字典hash_table实现
zipList:
满足以下两个条件:
zset 长度越来越大的时候难以申请一块足够大的连续空间,所以转而使用了skiplist 跳跃表实现
skipList:
不满足以上两个条件时使用跳表(组合了hash和skipList)
虽然同时使用两种结构,但它们会通过指针来共享相同元素的 member 和 score,因此不会浪费额外的内存。
1.zset 的数据结构,为什么数量小的时候使用 ziplist
当刚开始选择了ziplist,会在下面两种情况下转为skipList。
ziplist所保存的元素超过服务器属性server.zset_max_ziplist_entries 的值(默认值为 128)
新添加元素的 member 的长度大于服务器属性 server.zset_max_ziplist_value 的值(默认值为 64)
那我们是否思考一下为什么需要转换呢?
ziplist 是一个紧挨着的存储空间,并且是没有预留空间的,随意对于ziplist优势在于节省空间,是因为小的连续空间容易申请,但是在容量大到一定成度扩容就是影响他的性能的主要原因之一。
大多数情况下,Redis使用简单字符串SDS作为字符串的表示,相对于C语言字符串,SDS具有常数复杂度获取字符串长度,杜绝了缓存区的溢出,减少了修改字符串长度时所需的内存重分配次数,以及二进制安全能存储各种类型的文件,并且还兼容部分C函数。
通过为链表设置不同类型的特定函数,Redis链表可以保存各种不同类型的值,除了用作列表键,还在发布与订阅、慢查询、监视器等方面发挥作用(后面会介绍)。
string: int、embstr、raw
hash: Redis的字典底层使用哈希表实现,每个字典通常有两个哈希表,一个平时使用,另一个用于rehash时使用,使用链地址法解决哈希冲突。
set: 整数集合是集合键的底层实现之一,底层由数组构成,升级特性能尽可能的节省内存。
zset:跳跃表通常是有序集合的底层实现之一,表中的节点按照分值大小进行排序。
zset:压缩列表是Redis为节省内存而开发的顺序型数据结构,通常作为列表键和哈希键的底层实现之一。
**Sentinel 本质上是主从模式,**与一般的主从模式不同的是,主节点的选举,不是从节点完成的,而是通过 Sentinel 来监控整个集群模式,发起主从选举。因此本质上 Redis Sentinel 有两个集群,一个是 Redis 数据集群,一个是哨兵集群。
三个步骤:主观下线 -> 客观下线 -> 主节点故障
转移:
Redis Sentinel 模式要注意有两个集群,一个是存放了 Redis 数据的集群,一个是监控这个数据集群的哨兵集群。于是就需要理解哨兵集群之间是如何监控的,如何就某件事达成协议,以及哨兵自身的容错。
Redis Cluster 集成了对等模式和主从模式。Redis Cluster 由多个节点组成,每个节点都可以是一个主从集群。Redis 将 key 映射为 16384 个槽(slot),均匀分配在所有节点上。
两种模式下的主从同步都有全量同步和增量同步两种(引导面试官询问两种同步模式细节),一般情况下,我们应该尽量避免全量同步(钓鱼,面试官接着就会问为什么,或者全量同步有啥缺点,或者如何避免)
一般而言,如果数据量和复杂并不大的时候,想要保证高可用,就采用 Redis Sentinel;如果负载很大,或者说触及了 Redis 单机瓶颈,那么应该采用 Redis Cluster 模式。
重分片的时候,会触发槽迁移,也就是把一部分数据挪到另外一个部分。
这个步骤是渐进式的
在迁移过程中,一个槽的部分key能在源节点,一部分在目标节点。因此如果请求过来,打到源节点,源节点发现已经迁移了,就会返回
一个ask重定向的错误,这个错误会引导客户端直接去访问目标节点。
主从同步有全量同步和增量同步两种;
从服务器发起同步,主服务器开启 BG SAVE,生成 BG SAVE 过程中的写命令 也会被放入一个缓冲队列;
• 主节点生成 RDB 文件之后,将 RDB 发 给从服务器;
• 从服务器接收文件,清空本地数据,再入 RDB 文件;(这个过程会忽略已经过期 的 key,参考过期部分的讨论)
• 主节点将缓冲队列命令发送给从节点,从 节点执行这些命令;
• 从节点重写 AOF;
• 主节点源源不断发送新的命令;
如何避免
全量同步非常重,资源消耗很大 • 大多数情况下,从服务器上是存在大部分数 据的,只是短暂失去了连接 • 如果这个时候又发起全量同步,那么很容易 陷入到无休止的全量同步之中。
增量同步的依赖于三个东西:
1.服务器ID:用于标识 Redis 服务器ID;
2. 复制偏移量:主服务器用于标记它已经发 出去多少;从服务用于标记它已经接收多 少(从服务器的比较关键);
3. 复制缓冲区:主服务器维护的一个 1M 的 FIFO队列,近期执行的写命令保存在这里;
Redis基于Reactor模式开发了自己的网络事件处理器,称之为文件事件处理器(File Event Hanlder)。
文件事件处理器由Socket、IO多路复用程序、文件事件分派器(dispather),事件处理器(handler)四部分组成,文件事件处理器的模型如下所示:
IO多路复用程序会同时监听多个socket,当被监听的socket准备好执行accept、read、write、close等操作时,与这些操作相对应的文件事件就会产生。IO多路复用程序会把所有产生事件的socket压入一个队列中,然后有序地每次仅一个socket的方式传送给文件事件分派器,文件事件分派器接收到socket之后会根据socket产生的事件类型调用对应的事件处理器进行处理。
文件事件处理器分为几种:
多路复用程序会监听不同套接字的事件
当某个事件,比如发来了一个请求,那么多路复用程序就把这个套接字丢过去套接字队列,事件分派器从 队列里边找到套接字,丢给对应的事件处理器处理。
首先,Redis本身是一个基于Key-Value结构的内存数据库,为了避免Redis故障导致数据丢失的问题,所以提供了RDB和AOF两种持久化机制。
Redis 的持久化机制分成两种,RDB 和 AOF。
RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。
RDB快照的触发方式有很多,比如
RDB 也是主从全量同步里的 RDB。 RDB 可以理解为是一个快照,直接把 Redis 内存中的数据以快照的形式保存下 来。因为这个过程很消耗资源,所以分成 SAVE 和 BG SAVE 两种。BG SAVE的核 心是利用 fork 和 COW 机制。
所以他是一个全量的方式来进行持久化的
利用fork
系统调用,复制出来一个子进程,子进程尝试将数据写入文件。这个时候, 子进程和主进程是共享内存的,当主进程发生写操作,那么就会复制一份内存, 这就是所谓的 COW (copy on write)。
COW 的核心是利用缺页异常,操作系统在捕捉到缺页异常之后,发现他们共享内存了,就会复制出来一份。
AOF持久化,它是一种近乎实时的方式,,AOF 是将 Redis 的命令逐条保留下来,而 后通过重放这些命令来复原。我们可以通过重写 AOF 来减少资源消耗。
就是客户端执行一个数据变更的操作,Redis Server就会把 Redis 的命令逐条保留下来,追加到aof缓冲区的末尾,
然后再把缓冲区的数据写入到磁盘的AOF文件里面,至于最终什么时候真正持久化到磁盘,是根据刷盘的策略来决定的。
• 可以通过重写来合并 AOF 文件
AOF 刷新磁盘的时机
MySQL redo log 刷盘:
MySQL bin log 刷盘:
写入语义
重写 AOF 整体类似于 RDB。
另外,因为AOF这种指令追加的方式,会造成AOF文件过大,带来明显的IO性能问题,所以Redis针对这种情况提供了
AOF重写机制,也就是说当AOF文件的大小达到某个阈值的时候,就会把这个文件里面相同的指令进行压缩。
在这个过程中,Redis 还在源源不断执行命令, 这部分命令将会被写入一个 AOF 的缓存队列 里面。当子进程写完 AOF 之后,发一个信号给主进程,主进程负责把缓冲队列里面的数 据写入到新 AOF。而后用新的 AOF 替换掉老 的 AOF。
Redis4.0 后大部分的使用场景都不会单独使用 RDB 或者 AOF 来做持久化机制,而是兼顾二者的优势混合使用。其原因是 RDB 虽然快,但是会丢失比较多的数据,不能保证数据完整性;AOF 虽然能尽可能保证数据完整性,但是性能确实是一个诟病,比如重放恢复数据。
Redis从4.0版本开始引入 RDB-AOF 混合持久化模式,这种模式是基于 AOF 持久化模式构建而来的,混合持久化通过 aof-use-rdb-preamble yes 开启。
那么 Redis 服务器在执行 AOF 重写操作时,就会像执行 BGSAVE 命令那样,根据数据库当前的状态生成出相应的 RDB 数据,并将这些数据写入新建的 AOF 文件中,至于那些在 AOF 重写开始之后执行的 Redis 命令,则会继续以协议文本的方式追加到新 AOF 文件的末尾,即已有的 RDB 数据的后面。
换句话说,在开启了 RDB-AOF 混合持久化功能之后,服务器生成的 AOF 文件将由两个部分组成,其中位于 AOF 文件开头的是 RDB 格式的数据,而跟在 RDB 数据后面的则是 AOF 格式的数据。
当一个支持 RDB-AOF 混合持久化模式的 Redis 服务器启动并载入 AOF 文件时,它会检查 AOF 文件的开头是否包含了 RDB 格式的内容
最后来总结这两者,到底用哪个更好呢?
推荐是两者均开启。
因此,基于对RDB和AOF的工作原理的理解,我认为RDB和AOF的优缺点有两个。
定期删除和懒惰删除:
定期删除是指 Redis 会定期遍历数据库,检查过期的 key 并且执行删除。 它的特点是随机检查,点到即止。它并不会一次遍历全部过期 key,然 后删除,而是在规定时间内,能删除多少就删除多少。这是为了平衡 CPU 开销和内存消耗。
懒惰删除是指如果在访问某个 key 的时候,会检查其过期时间,如果已经过期,则会删除该键值对
如果 Redis 开启了持久化和主从同步,那么 Redis 的过期处理要复杂 一些。
当 key 过期后,或者 Redis 实际占用的内存超过阀值后,Redis 就会对 key 进行淘汰,删除过期的或者不活跃的 key,回收其内存,供新的 key 使用。Redis 的内存阀值是通过 maxmemory 设置的,而超过内存阀值后的淘汰策略,是通过 maxmemory-policy 设置的,具体的淘汰策略后面会进行详细介绍。
Redis 会在 2 种场景下对 key 进行淘汰,
第一种场景,Redis 定期执行 serverCron 时,会对 DB 进行检测,清理过期 key。清理流程如下。
首先轮询每个 DB,检查其 expire dict,即带过期时间的过期 key 字典,从所有带过期时间的 key 中,随机选取 20 个样本 key,检查这些 key 是否过期,如果过期则清理删除。如果 20 个样本中,超过 5 个 key 都过期,即过期比例大于 25%,就继续从该 DB 的 expire dict 过期字典中,再随机取样 20 个 key 进行过期清理,持续循环,直到选择的 20 个样本 key 中,过期的 key 数小于等于 5,当前这个 DB 则清理完毕,然后继续轮询下一个 DB。
在执行 serverCron 时,如果在某个 DB 中,过期 dict 的填充率低于 1%,则放弃对该 DB 的取样检查,因为效率太低。
如果 DB 的过期 dict 中,过期 key 太多,一直持续循环回收,会占用大量主线程时间,所以 Redis 还设置了一个过期时间。这个过期时间根据 serverCron 的执行频率来计算,5.0 版本及之前采用慢循环过期策略,默认是 25ms,如果回收超过 25ms 则停止,6.0 非稳定版本采用快循环策略,过期时间为 1ms。
第二种场景,Redis 在执行命令请求时。会检查当前内存占用是否超过 maxmemory 的数值,如果超过,则按照设置的淘汰策略,进行删除淘汰 key 操作。
Redis 中 key 的淘汰方式有两种,分别是同步删除淘汰和异步删除淘汰。
在 serverCron 定期清理过期 key 时,如果设置了延迟过期配置 lazyfree-lazy-expire,会检查 key 对应的 value 是否为多元素的复合类型,即是否是 list 列表、set 集合、zset 有序集合和 hash 中的一种,并且 value 的元素数大于 64,则在将 key 从 DB 中 expire dict 过期字典和主 dict 中删除后,value 存放到 BIO 任务队列,由 BIO 延迟删除线程异步回收;
否则,直接从 DB 的 expire dict 和主 dict 中删除,并回收 key、value 所占用的空间。在执行命令时,如果设置了 lazyfree-lazy-eviction,在淘汰 key 时,也采用前面类似的检测方法,对于元素数大于 64 的 4 种复合类型,使用 BIO 线程异步删除,否则采用同步直接删除。
Redis 慢的主要原因是单进程单线程模型。虽然一些重量级操作也进行了分拆,如 RDB 的构建在子进程中进行,文件关闭、文件缓冲同步,以及大 key 清理都放在 BIO 线程异步处理
,但还远远不够。线上 Redis 处理用户请求时,十万级的 client 挂在一个 Redis 实例上,所有的事件处理、读请求、命令解析、命令执行,以及最后的响应回复,都由主线程完成,纵然是 Redis 各种极端优化,巧妇难为无米之炊,一个线程的处理能力始终是有上限的。
当前服务器 CPU 大多是 16 核到 32 核以上,Redis 日常运行主要只使用 1 个核心,其他 CPU 核就没有被很好的利用起来,Redis 的处理性能也就无法有效地提升。而 Memcached 则可以按照服务器的 CPU 核心数,配置数十个线程,这些线程并发进行 IO 读写、任务处理,处理性能可以提高一个数量级以上。
当请求命令进入时,在主线程触发读事件,主线程此时并不进行网络 IO 的读取,而将该连接所在的 client 加入待读取队列中。Redis 的 Ae 事件模型在循环中,发现待读取队列不为空,则将所有待读取请求的 client 依次分派给 IO 线程,并自旋检查等待,等待 IO 线程读取所有的网络数据。所谓自旋检查等待,也就是指主线程持续死循环,并在循环中检查 IO 线程是否读完,不做其他任何任务。只有发现 IO 线程读完所有网络数据,才停止循环,继续后续的任务处理。
面对性能提升困境,虽然 Redis 作者不以为然,认为可以通过多部署几个 Redis 实例来达到类似多线程的效果。但多实例部署则带来了运维复杂的问题,而且单机多实例部署,会相互影响,进一步增大运维的复杂度。为此,社区一直有种声音,希望 Redis 能开发多线程版本。
因此,Redis 即将在 6.0 版本引入多线程模型。Redis 的多线程模型,分为主线程和 IO 线程。
因为处理命令请求的几个耗时点,分别是请求读取、协议解析、协议执行,以及响应回复等。所以 Redis 引入 IO 多线程,并发地进行请求命令的读取、解析,以及响应的回复。
而其他的所有任务,如事件触发、命令执行、IO 任务分发,以及其他各种核心操作,仍然在主线程中进行,也就说这些任务仍然由单线程处理。这样可以在最大程度不改变原处理流程的情况下,引入多线程。
Redis 6.0 版本中新引入的多线程模型,主要是指可配置多个 IO 线程,这些线程专门负责请求读取、解析,以及响应的回复。通过 IO 多线程,Redis 的性能可以提升 1 倍以上。
虽然多线程方案能提升1倍以上的性能,但整个方案仍然比较粗糙。
首先所有命令的执行仍然在主线程中进行,存在性能瓶颈。然后所有的事件触发也是在主线程中进行,也依然无法有效使用多核心。
而且,IO 读写为批处理读写,即所有 IO 线程先一起读完所有请求,待主线程解析处理完毕后,所有 IO 线程再一起回复所有响应,不同请求需要相互等待,效率不高。最后在 IO 批处理读写时,主线程自旋检测等待,效率更是低下,即便任务很少,也很容易把 CPU 打满。
整个多线程方案比较粗糙,所以性能提升也很有限,也就 1~2 倍多一点而已。要想更大幅提升处理性能,命令的执行、事件的触发等都需要分拆到不同线程中进行,而且多线程处理模型也需要优化,各个线程自行进行 IO 读写和执行,互不干扰、等待与竞争,才能真正高效地利用服务器多核心,达到性能数量级的提升。
优点:
缺点:
优点:
缺点:
优点:
缺点:
redis官方文档中提供的数据迁移办法是借助redis-trib脚本,其实严格来说,这个redis-trib并不是redis本体的一部分,它只是官方按照redis设计规范实现的一套脚本集合,帮助用户更方便的使用redis-cluster。 实际上,我们完全可以脱离这个脚本来使用cluster, 或者用其他方式实现这套逻辑,比如搜狐tv的redis运维工具cachecloud里,就用java实现了整套逻辑。
我们可以参考redis-trip或者cachecloud的代码来了解cluster数据迁移的流程,主要分为如下几步:
CLUSTER SETSLOT IMPORTING
CLUSTER SETSLOT MIGRATING
在整个迁移中,会出现对于单个key的阻塞情况,原因是MIGRATE命令是原子性的,在单个key的迁移过程中,对这个key的访问会被阻塞。但是,一般来说,一个key的数据不会特别大,所以绝大多数情况下瞬间都能完成,所以一般不会真正影响使用。而其他任何情况都不会造成集群的不可用,如果出现了,比如出现slot级的不可用,说明client端的处理存在某些问题。接下来,本文也会介绍一些client端使用的注意事项。
一致性 hash 是将数据按照特征值映射到一个首尾相接的 hash 环上,同时也将节点(按照 IP 地址或者机器名 hash)映射到这个环上。
对于数据,从数据在环上的位置开始,顺时针找到的第一个节点即为数据的存储节点。
余数分布式算法由于保存键的服务器会发生巨大变化而影响缓存的命中率,但Consistent Hashing 中,只有在园(continuum)上增加服务器的地点逆时针方向的第一台服务器上的键会受到影响。
一致性哈希算法在服务节点太少时,容易因为节点分部不均匀而造成数据倾斜问题。
此时必然造成大量数据集中到 Node A 上,而只有极少量会定位到 Node B 上。为了解决这种数据倾斜问题,一致性哈希算法引入了虚拟节点机制,即对每一个服务节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。
具体做法可以在服务器 IP 或主机名的后面增加编号来实现。
例如上面的情况,可以为每台服务器计算三个虚拟节点,于是可以分别计算
“Node A#1”、“Node A#2”、“Node A#3”、“Node B#1”、“Node B#2”、“Node B#3”的哈希值,于是形成六个虚拟节点。
同时数据定位算法不变,只是多了一步虚拟节点到实际节点的映射,例如定位到
“Node A#1”、“Node A#2”、“Node A#3”三个虚拟节点的数据均定位到 Node A 上。这样就解决了服务节点少时数据倾斜的问题。
除了主进程,Redis 还会 fork 一个子进程,来进行重负荷任务的处理。Redis fork 子进程主要有 3 种场景。
除了主进程,Redis 还会 fork 一个子进程,来进行重负荷任务的处理。Redis fork 子进程主要有 3 种场景。
Redis 的集群管理有 3 种方式。
字符串长度处理:Redis获取字符串长度,时间复杂度为O(1),而C语言中,需要从头开始遍历,复杂度为O(n);
空间预分配:字符串修改越频繁的话,内存分配越频繁,就会消耗性能,而SDS修改和空间扩充,会额外分配未使用的空间,减少性能损耗。
惰性空间释放:SDS 缩短时,不是回收多余的内存空间,而是free记录下多余的空间,后续有变更,直接使用free中记录的空间,减少分配。
ziplist是一种连续,无序的数据结构。压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。
Redis中的hash,List,Zset这几种类型的数据在某些情况下会使用ziplist来存储。
ziplist是一个经过特殊编码的双向链表(占用一大块内存),设计的目标是为了提高存储效率,ziplist可以用于存储字符串或者整数,其中整数是按照二进制表示进行编码的,而不是编码成字符串序列。
ziplist不是普通的双向链表, 普通的双向链表每一项都占用独立的一块内存,各项之间用地址指针连接起来,这会造成大量的内存碎片,而且地址指针也占用额外的内存。 ziplist是将表中每一项放在前后连续的地址空间中,一个ziplist整体占用一大块内存,它是一个表(list), 但其实不是一个链表
另外,ziplist为了在细节上节省内存,对于值的存储采用了变长的编码方式,即对于大的整数,就多用一些字节来存储,对于小的整数就少用一些字节存
值得注意的是,这个压缩列表的内存空间是连续的。这也是压缩列表的主要特点,空间连续,避免内存碎片,节省内存。
ziplist的特点
Redis对外暴露的List数据类型,底层实现用的就是quicklist
quicklist的实现是一个双向链表,链表的每一个节点都是一个ziplist, 为什么quicklist要这样设计呢? 其实也是一个空间和时间的折中
.双向链表便于在表的两端进行push和pop操作,但是它的内存开销比较大。 首先它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片
ziplist由于是一整块连续内存,所以存储效率很高,但是它不利于修改操作,每次数据变动都会引发一次内存的realloc。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能
这样设计的问题, 到底一个quicklist节点包含多长的ziplist合适?这是一个找平衡的问题:
.每个quicklist节点上的ziplist越短,则内存碎片越多,极端情况是一个ziplist只包含一个数据项,这就退化成了普通的双向链表
.每个quicklist节点上的ziplist越长,则为一个ziplist分配大块连续内存的难度就越大,有可能出现内存里有很多小块的内存空间,但却找不到一块足够大的空闲空间分给ziplist。极端情况是整个quicklist只有一个节点,这就退化成了一个ziplist了
跳跃表是Redis特有的数据结构,就是在链表的基础上,增加多级索引提升查找效率。
跳跃表支持平均 O(logN),最坏 O(N)复杂度的节点查找,还可以通过顺序性操作批量处理节点。
跳表(skip
List)是一种随机化的数据结构,基于并联的链表,实现简单,插入、删除、查找的复杂度均为O(logN)。简单说来跳表也是链表的一种,只不过它在链表的基础上增加了跳跃功能,正是这个跳跃的功能,使得在查找元素时,跳表能够提供O(logN)的时间复杂度。
跳跃列表缺点:跳表的效率比链表高,但是跳表需要额外存储多级索引,所以需要的更多的内存空间。
跳表全称为跳跃列表,它允许快速查询,插入和删除一个有序连续元素的数据链表。跳跃列表的平均查找和插入时间复杂度都是O(logn)。快速查询是通过维护一个多层次的链表,且每一层链表中的元素是前一层链表元素的子集(见右边的示意图)。一开始时,算法在最稀疏的层次进行搜索,直至需要查找的元素在该层两个相邻的元素中间。这时,算法将跳转到下一个层次,重复刚才的搜索,直到找到需要查找的元素为止。
skiplist插入
skiplist正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到O(log n)。但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。
skiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。
虽然redis是单线程,但是可以同时有多个客户端访问,每个客户端会有一个线程。客户端访问之间存在竞争。
因为存在多客户端并发,所以必须保证操作的原子性。比如银行卡扣款问题,获取余额,判断,扣款,写回就必须构成事务,否则就可能出错
redis 内存数据集大小上升到一定大小的时候,就会施行数据淘汰策略。redis 提供 6种数据淘汰策略:
volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
no-enviction(驱逐):禁止驱逐数据
LRU算法,淘汰策略默认volatile-lru
redis test:0>config get maxmemory-policy
1) maxmemory-policy
2) volatile-lru
这里的单线程和多线程指的是工作线程。
redis 5.0 之前工作线程都是单线程,但在4.0 的时候,redis 已经有后台线程去处理一些不影响正常流程的工作,比如批量删除过期 key、清理脏数据、无用连接的释放、大 Key 的删除。
redis 5.0 之后的工作线程就是多线程,因为 redis 的性能瓶颈在于网络IO,所以 redis 将对于网络 iO 的读写启了一些线程去进行,核心的执行命令线程还是单线程,另外因为是一个网络请求一个线程去读写,所以也没有竞态问题。
主从刚刚连接的时候,进行全量同步;全同步结束后,进行增量同步。
当然,如果有需要,slave 在任何时候都可以发起全量同步。redis 策略是,无论如何,首先会尝试进行增量同步,如不成功,要求从机进行全量同步。
Redis全量复制一般发生在Slave初始化阶段,这时Slave需要将Master上的所有数据都复制一份。具体步骤如下:
Redis增量复制是指Slave初始化后开始正常工作时主服务器发生的写操作同步到从服务器的过程。
增量复制的过程主要是主服务器每执行一个写命令就会向从服务器发送相同的写命令,从服务器接收并执行收到的写命令。
1.缓存
2.redis 分布式锁 分布式锁做的事情就是 将并行的事件改为串行来执行
3.计数器
4.zset定时任务
5.消息队列
6.bitmap签到 布隆过滤器
7.地理数据信息
8.基于ttl(滑动窗口)做限流器
过期策略
1.定时删除
把redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定时遍历这个 字典来删除到期的 key。
2.惰性删除
除了定时遍历之外,它还会使用惰性策略来删除过期的 key,所谓 惰性策略就是在客户端访问这个 key 的时候,redis 对 key 的过期时间进行检查,如果过期 了就立即删除。定时删除是集中处理,惰性删除是零散处理。
FIFO、LRU、LFU
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,称为键冲突。Redis 的哈希表使用“链地址”来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到哈希表数组同一个索引上,用单向链表把多个节点连接一起,解决了键冲突问题。
例2: 程序要将新"键值对"k2和v2 添加到哈希表中,计算的k2 索引值为2, 但该哈希表数组索引值2上已有"键值对"k1和v1。 解决键索引冲突的办法就是使用next指针将键k2和键k1的节点连接起来,如下图所示:
一个 Redis 客户端可以向集群中的任意节点(包括从节点)发送命令请求。如果所查找的槽不是由该节点处理的话, 节点将查看自身内部所保存的哈希槽到节点 ID 的映射记录, 并向客户端回复一个 MOVED 错误。
redis cluster的数据迁移基本不会影响集群使用,但是,在数据从节点A迁移到B的过程中,数据可能在A上,也可能在B上,redis是怎么知道要到哪个节点上去找的呢?这里就要先介绍一下ask和moved这两个转向信号了。顾名思义,出现这个信息就说明需要的数据并不在当前节点上,需要做一次转向处理,其中,MOVED是永久转向信号,ASK则表示只需要这一次操作做转向。
比如,在节点A向节点B的数据迁移过程中,各个key分散在节点A和节点B中,所以当客户端在A中没找到某个key时,就会得到一个ASK,然后再去B中查找,实际上就是多查一次。
需要注意的是,客户端查询B时,需要先发一条ASKING命令,否则这个针对带有IMPORTING状态的槽的命令请求将被节点B拒绝执行。
对于客户端,简单来说就是,收到MOVED时,需要更新slot映射信息,收到ASK时,则需要向新节点发ASKING命令并重新执行操作。
redis cluster除了有一个moved重定向,还存在ask重定向。
ask重定向代表的状态比较特别,它是当slot处于迁移状态时才会发生。
例如:一个slot存在三个key,分别为hello1、hello2、hello3,假设此时slot正在处于迁移状态,hello1已经迁移到了目标节点,此时如果在源节点获取hello1,则会报出ask重定向错误。
如图所示,source部分数据已经迁移到target,客户端向source发送命令,source发现slot数据已经迁移到target,就会返回给客户端ask重定向,客户端向target发送asking命令,target返回结果。
Redis的expire命令用于设置一个键的过期时间,即在指定的时间后自动将键删除。expire的实现原理如下:
Redis使用一个hashtable的dictEntry结构体来保存所有的键,链表的节点包含了指向键的指针,以及其他的元信息。
每个键都有一个redisObject结构体,其中包含了键的类型、值、过期时间等信息。如果键的过期时间为0,则表示该键永不过期。
当执行expire命令时,Redis会根据键名查找对应的redisObject结构体,并将其过期时间设置为当前时间加上expire命令传入的秒数。
Redis维护了一个全局的过期键列表,其中保存了所有已设置过期时间的键。过期键列表也是一个双向链表,每个节点表示一个过期键,包含了键的指针和过期时间等信息。
Redis通过定期执行activeExpireCycle函数来处理过期键列表中的键。该函数会遍历整个过期键列表,对于已过期的键进行删除操作,并且将没有过期的键重新插入到过期键列表的末尾。
为了减少遍历过期键列表的时间,Redis使用了一种称为“惰性删除”的策略。即在访问一个键时,先检查该键是否过期,如果过期则删除该键,并返回空值。
总之,expire命令的实现原理是将键的过期时间设置为当前时间加上指定的秒数,并将该键插入到过期键列表中。当键过期时,Redis通过定期执行activeExpireCycle函数来处理过期键列表中的键。
/*
* Redis 对象
*/
typedefstruct redisObject {
// 类型
unsigned type:4;
// 编码方式
unsigned encoding:4;
// LRU - 24位, 记录最末一次访问时间(相对于lru_clock); 或者 LFU(最少使用的数据:8位频率,16位访问时间)
unsigned lru:LRU_BITS; // LRU_BITS: 24
// 引用计数
int refcount;
// 指向底层数据结构实例
void *ptr;
} robj;
面试题集合