【数据库篇】Redis知识点

文章目录

  • 一、redis 特性
    • redis为什么这么快
        • 1.基于内存
        • 2.合理线程模型
          • 单线程上下文切换
          • IO多路复用技术
        • 3.高效数据结构
        • 4.合理使用数据编码
  • Redis实现原理
    • 字典表
      • redis如何添加键值对
      • 渐进式rehash
        • 为什么需要渐进式rehash
      • 共存的策略
    • redis RedisObject对象
    • redis基本类型底层实现
      • string
        • 字符串的实现
        • RedisObject
        • SDS
      • hash 字典 (ziplist+hash)
        • 什么是rehash
      • rehash的步骤
      • list (linked list+quicklist)
      • Set(intset、hash)
      • zset(ziplist+skiplist)
        • ziplist、skipList 为什么需要转换?
      • 基本数据结构 底层实现 总结
  • Redis高可用、高性能
    • Redis Sentinel 监控
      • Sentinel 本质上是主从模式
      • 故障转移过程
    • Redis Cluster
      • 迁移(重分片)
    • Redis主从同步
      • 全量同步
        • 全量同步的缺点
      • 增量同步
    • Redis IO模型
      • Redis的客户端与服务端的交互
    • Redis底层持久化实现
      • 2.1 持久化 rdb
        • 2.1.1 Redis BG Save
      • 2.2 持久化 aof
        • 2.2.1 AOF 刷盘对比
        • 2.2.2 重写 AOF
      • 2.3 混合持久化
    • Redis 内存淘汰策略
      • Redis是如何淘汰key的?
        • serverCron 定期执行淘汰
        • Redis 在执行命令请求时,检查淘汰 key
      • 淘汰方式
    • 六、redis多线程
      • 单线程性能瓶颈
      • 命令处理流程
      • 多线程方案优劣
    • redis运行模式
      • 主从复制模式:
      • 集群模式:
      • 哨兵模式:
    • redis cluster的数据迁移方案
      • 整体流程
      • 可用性
    • 一致性 Hash
      • 数据倾斜问题
      • 虚拟节点
    • redis管理
      • redis异步线程
      • Redis 集群管理
    • redis高效数据结构
      • SDS简单动态字符串
      • ziplist实现原理
      • quicklist实现原理
      • skiplist 跳跃列表
  • 面试题
      • 1.redis单线程为什么需要加锁
      • 2.如何保证redis所有的数据都是热点数据
      • 3.redis 到底是单线程还是多线程
      • 4.Redis主从同步策略
        • 全量同步
        • 增量同步
      • 6.redis使用场景
      • 7.redis过期策略
      • 8.通常淘汰策略的算法有哪些?
      • 9.redis hash冲突解决方案(链地址法)
      • 10.redis集群模式,增加一个节点。服务重新部署会导致KEY无法命中吗?(ask重定向)
        • MOVED转向
        • ASK转向
      • 11.redis expire实现原理

一、redis 特性

redis为什么这么快

【数据库篇】Redis知识点_第1张图片

1.基于内存

Redis是纯内存数据库,一般都是简单的存取操作,线程占用的时间很多,时间的花费主要集中在IO上,所以读取速度快。

2.合理线程模型

单线程上下文切换

Redis 的网络 IO 和命令处理,都在核心进程中由单线程处理

https://www.jianshu.com/p/c4aa888b3538
线程只需要保存线程的上下文(相关寄存器状态和栈的信息)

Redis采用了单线程的模型,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU。

IO多路复用技术

redis 采用网络IO多路复用技术来保证在多连接的时候, 系统的高吞吐量。

多路-指的是多个socket连接,复用-指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll。epoll是最新的也是目前最好的多路复用技术。

Epoll 事件模型开发,可以进行非阻塞网络 IO,同时由于单线程命令处理,整个处理过程不存在竞争,不需要加锁,没有上下文切换开销

这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。

https://zhuanlan.zhihu.com/p/58038188

3.高效数据结构

【数据库篇】Redis知识点_第2张图片

4.合理使用数据编码

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实现原理

字典表

redis单机服务端有16个数据库,每个数据库都有一个字典结构,这个字典里存着两个hash表(为了之后的扩缩容),而这个hash表里有一个dictEntry 组成的数组,里面存放的就是所有的键值对。这个dictEntry还有指向下一个节点的指针,就是为了在hash冲突的情况,采用拉链法扩展出一个链表。

【数据库篇】Redis知识点_第3张图片

/*
 * 字典
 */
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;

redis如何添加键值对

向字典表再添加一个元素 set name abin
我们会先对key做散列运算,将得到的值再对哈希表的大小4做一个取余,假设得到的值是3,那么这个key就会落在3的位置,比如:

【数据库篇】Redis知识点_第4张图片

渐进式rehash

当我们哈希表中存的数据越来越多,哈希冲突的概率就会越来越大。这样所有的键值对冲突后会形成一个链表,查询的效率就由原先的O(1)变成了O(n),所以我们要有一个评估的标准,用来判断是否需要扩缩容。

  • 扩容

    程序没有执行BGSAVE命令或者BGREWRITEAOF(AOF重写)命令,并且哈希表的负载因子大于等于1
    如果程序正在执行BGSAVE或者BGREWRITEAOF(AOF重写)命令并且哈希表的负载因子大于等于5。在执行RDB或者AOF重写操作时,redis会创建当前服务器的子进程执行相应操作,为了避免在子进程存在期间对哈希表进行扩展操作,将扩展因子提高。可以避RDB或者AOF重写时不必要的内存写入操作,最大限度的节约内存。

  • 缩容:当负载因子小于0.1

为什么需要渐进式rehash

然而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进行期间:

  • 所有增删改查都会先访问ht[0],再访问ht[1].比如查询会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类
  • rehash期间所有新增的键值对都会添加到h[1]里,保证ht [0]的键值对数量会只减不增,最终会变成空表

redis RedisObject对象

redis数据库中的每个键值对的键和值都是一个对象

每个对象都有相应的类型,这些类型决定了你能对他们操作的指令,比如string类型的对象只能用set命令设置。

每种类型的对象又有两种以上的编码,不同编码可以在不同场景上优化使用效率

这里的每个字段都很重要,比如类型和编码,有一个基于6.0的关系图

【数据库篇】Redis知识点_第5张图片

/*
 * 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即可。
【数据库篇】Redis知识点_第6张图片

redis基本类型底层实现

【数据库篇】Redis知识点_第7张图片

比如我们执行一个命令 hset user age 25
在字典上的数据结构大概是这样,为了方便,string类型的对象就简画成了stringobject。

【数据库篇】Redis知识点_第8张图片
https://www.modb.pro/db/552315

string

字符串的实现

Redis 的字符串是动态字符串,是可以修改的字符串,内部结构实现上类似于 Java 的ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。

【数据库篇】Redis知识点_第9张图片

字符串 内部结构
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 操作来修改字符串。

RedisObject

我们首先来了解一下 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 结构体的大小,在字符串比较小时,SDS 对象头的大小是capacity+3,至少是 3。意味着分配一个字符串的最小空间占用为 19 字节 (16+3)

struct SDS {
	int8 capacity; // 1byte
	int8 len; // 1byte
	int8 flags; // 1byte
	byte[] content;  // 内联数组,长度为 capacity
}
  • content 里面存储了真正的字符串内容
  • capacity 表示所分配数组的长度
  • len 表示字符串的实际长度

【数据库篇】Redis知识点_第10张图片

如图所示,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字节。

  • raw
    【数据库篇】Redis知识点_第11张图片

    可以比较明显的体现,embstr所分配的内存是连续的,而raw所分配的内存是非连续的,所以这就导致了,embstr只需要分配一次内存,而raw需要分配两次内存(第一次为redisObject,第二次为SDS),相对地,释放内存的次数也由一次变为两次。

hash 字典 (ziplist+hash)

当Hash的数据项较少时,Hash底层才会用压缩列表zipList进行存储数据, 数据增加,底层的zipList会转成dict,

ziplist
【数据库篇】Redis知识点_第12张图片
上图中可以看到,当数据量比较小的时候,我们会将所有的key及value都当成一个元素,顺序的存入到ziplist中,构成有序。

  • ziplist。当存储的数据超过配置的阀值时就是转用hashtable的结构。这种转换比较消耗性能,所以应该尽量避免这种转换操作。同时满足以下两个条件时才会使用这种结构:
    • 当键的个数小于hash-max-ziplist-entries(默认512)
    • 当所有值都小于hash-max-ziplist-value(默认64)
  • 一种就是hashtable。这种结构的时间复杂度为O(1),但是会消耗比较多的内存空间。

ziplist与hash转化

项目中使用到了redis的哈希结构 , 哈希结构的内部编码类型是 ziplist 和 hashtable

当元素个数小于512 , 并且值的大小小于64个字节时 , 采用ziplist , 大于的时候采用hashtable

ziplist最大的优势就是存储的时候是连续的内存 , 可以极大的提升cpu的缓存命中率, 但是,如果数据很大,则不会获得太多的增益,同时会花费大量的CPU时间。

什么是rehash

哈希冲突
【数据库篇】Redis知识点_第13张图片

哈希表中桶的数量是有限的,当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是如何解决这种问题的

rehash的步骤

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 操作的执行而最终变成空表

list (linked list+quicklist)

3.2 版本前采用ziplist和linkedlist结构
List 是一个有序(按加入的时序排序)的数据结构,一般有序我们会采用数组或者是双向链表,其中双向链表由于有前后指针实际上会很浪费内存。

3.2版本之前采用两种数据结构作为底层实现:

压缩列表ziplist
双向链表linkedlist

压缩列表相对于双向链表更节省内存,所以再创建列表时,会先考虑压缩列表,并在一定条件下才转化为双向链表。

压缩列表转化成双向链表的条件:

  • 如果添加的字符串元素长度超过默认值64
  • zip包含的节点数超过默认值512 这两个条件是可以修改的,在redis.conf中

3.2版本之后升级为 quicklist(双向链表)
quicklist是3.2版本之后引入的。

根据上文谈到,ziplist会引入频繁的内存申请和释放,而linkedlist由于指针也会造成内存的浪费,而且每个节点是单独存在的,会造成很多内存碎片,所以结合两个结构的特点,设计了quickList。

quickList 是一个 ziplist 组成的双向链表。每个节点使用 ziplist 来保存数据。本质上来说,quicklist 里面保存着一个一个小的 ziplist。

Set(intset、hash)

其底层主要有整数数组(INTSET)和哈希表两种实现方式。当我们创建set的时候如果遇上成员是整形字符串时,会直接使用intset编码存储。intset的数据结构:

【数据库篇】Redis知识点_第14张图片

  • encoding表示编码格式:表示intset中每个元素采用以下哪种方式存储,有三种类型INT16(2字节),INT32(4个字节),INT64(8个字节)
  • length:元素个数,表示intset保存在contents中的元素个数
  • contents:实际存储的数据

其中:inset为可以理解为整数数组的一个有序集合,其内部是采用二分查找来定位元素位置。实际查询复杂度也就在log(n)

使用inset数据结构需要满足下述两个条件:

  • 元素个数少于默认值512 : set-max-inset-entries 512
  • 元素可以用整型表示

zset(ziplist+skiplist)

【数据库篇】Redis知识点_第15张图片

在redis中是通过两种底层数据结构实现的。

ziplist压缩列表 或者 skipList跳跃表与字典hash_table实现

  • ziplist:和 hashtable 和 list 一样,可以配置长度小于一定阈值时,降级成 ziplist 压缩列表实现,ziplist 实现是用一块连续的内存空间,节省了链表实现的前后节点,并且能够顺序查找
    数量小的时候使用 ziplist 实现,是因为小的连续空间容易申请。

zipList:

满足以下两个条件:

  • [score,value]键值对数量少于128个;
  • 每个元素的长度小于64字节;

zset 长度越来越大的时候难以申请一块足够大的连续空间,所以转而使用了skiplist 跳跃表实现

skipList:
不满足以上两个条件时使用跳表(组合了hash和skipList)

  • hash用来存储value到score的映射,这样就可以在O(1)时间内找到value对应的分数;
  • skipList按照从小到大的顺序存储分数;
  • skipList每个元素的值都是[score,value]对

虽然同时使用两种结构,但它们会通过指针来共享相同元素的 member 和 score,因此不会浪费额外的内存。

ziplist、skipList 为什么需要转换?

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为节省内存而开发的顺序型数据结构,通常作为列表键和哈希键的底层实现之一。

Redis高可用、高性能

Redis Sentinel 监控

Sentinel 本质上是主从模式

**Sentinel 本质上是主从模式,**与一般的主从模式不同的是,主节点的选举,不是从节点完成的,而是通过 Sentinel 来监控整个集群模式,发起主从选举。因此本质上 Redis Sentinel 有两个集群,一个是 Redis 数据集群,一个是哨兵集群。

故障转移过程

三个步骤:主观下线 -> 客观下线 -> 主节点故障

转移:

  • 首先 Sentinel 获取了主从结构的信息,而后向 所有的节点发送心跳检测,如果这个时候发现 某个节点没有回复,就把它标记为主观下线
  • 如果这个节点是主节点,那么 Sentinel 就询问 别的 Sentinel 节点主节点信息。如果大多数都 Sentinel 都认为主节点已经下线了,就认为主 节点已经客观下线
  • 当主节点已经客观下线,就要步入故障转移阶 段。故障转移分成两个步骤,一个是 Sentinel 要选举一个 leader,另外一个步骤是 Sentinel leader 挑一个主节点

【数据库篇】Redis知识点_第16张图片

Redis Sentinel 模式要注意有两个集群,一个是存放了 Redis 数据的集群,一个是监控这个数据集群的哨兵集群。于是就需要理解哨兵集群之间是如何监控的,如何就某件事达成协议,以及哨兵自身的容错。

Redis Cluster

【数据库篇】Redis知识点_第17张图片

Redis Cluster 集成了对等模式和主从模式。Redis Cluster 由多个节点组成,每个节点都可以是一个主从集群。Redis 将 key 映射为 16384 个槽(slot),均匀分配在所有节点上。

两种模式下的主从同步都有全量同步和增量同步两种(引导面试官询问两种同步模式细节),一般情况下,我们应该尽量避免全量同步(钓鱼,面试官接着就会问为什么,或者全量同步有啥缺点,或者如何避免)

一般而言,如果数据量和复杂并不大的时候,想要保证高可用,就采用 Redis Sentinel;如果负载很大,或者说触及了 Redis 单机瓶颈,那么应该采用 Redis Cluster 模式。

迁移(重分片)

重分片的时候,会触发槽迁移,也就是把一部分数据挪到另外一个部分。

这个步骤是渐进式的

在迁移过程中,一个槽的部分key能在源节点,一部分在目标节点。因此如果请求过来,打到源节点,源节点发现已经迁移了,就会返回
一个ask重定向的错误,这个错误会引导客户端直接去访问目标节点。

Redis主从同步

主从同步有全量同步和增量同步两种;

全量同步

从服务器发起同步,主服务器开启 BG SAVE,生成 BG SAVE 过程中的写命令 也会被放入一个缓冲队列;
• 主节点生成 RDB 文件之后,将 RDB 发 给从服务器;
• 从服务器接收文件,清空本地数据,再入 RDB 文件;(这个过程会忽略已经过期 的 key,参考过期部分的讨论)
• 主节点将缓冲队列命令发送给从节点,从 节点执行这些命令;
• 从节点重写 AOF;
• 主节点源源不断发送新的命令;

全量同步的缺点

  • CUP 和 内存,缺页异常
  • IO负载
  • 网络负载
  • 失败会重新引发全量,循环往复

如何避免

  • 安全重启
  • 增大缓冲区

增量同步

全量同步非常重,资源消耗很大 • 大多数情况下,从服务器上是存在大部分数 据的,只是短暂失去了连接 • 如果这个时候又发起全量同步,那么很容易 陷入到无休止的全量同步之中。

增量同步的依赖于三个东西:

1.服务器ID:用于标识 Redis 服务器ID;
2. 复制偏移量:主服务器用于标记它已经发 出去多少;从服务用于标记它已经接收多 少(从服务器的比较关键);
3. 复制缓冲区:主服务器维护的一个 1M 的 FIFO队列,近期执行的写命令保存在这里;

Redis IO模型

Redis基于Reactor模式开发了自己的网络事件处理器,称之为文件事件处理器(File Event Hanlder)。

文件事件处理器由Socket、IO多路复用程序、文件事件分派器(dispather),事件处理器(handler)四部分组成,文件事件处理器的模型如下所示:

【数据库篇】Redis知识点_第18张图片
IO多路复用程序会同时监听多个socket,当被监听的socket准备好执行accept、read、write、close等操作时,与这些操作相对应的文件事件就会产生。IO多路复用程序会把所有产生事件的socket压入一个队列中,然后有序地每次仅一个socket的方式传送给文件事件分派器,文件事件分派器接收到socket之后会根据socket产生的事件类型调用对应的事件处理器进行处理。

文件事件处理器分为几种:

  • 连接应答处理器:用于处理客户端的连接请求;
  • 命令请求处理器:用于执行客户端传递过来的命令,比如常见的set、lpush等;
  • 命令回复处理器:用于返回客户端命令的执行结果,比如set、get等命令的结果;

Redis的客户端与服务端的交互

【数据库篇】Redis知识点_第19张图片

多路复用程序会监听不同套接字的事件

当某个事件,比如发来了一个请求,那么多路复用程序就把这个套接字丢过去套接字队列,事件分派器从 队列里边找到套接字,丢给对应的事件处理器处理。

【数据库篇】Redis知识点_第20张图片

【数据库篇】Redis知识点_第21张图片

Redis底层持久化实现

首先,Redis本身是一个基于Key-Value结构的内存数据库,为了避免Redis故障导致数据丢失的问题,所以提供了RDB和AOF两种持久化机制。

Redis 的持久化机制分成两种,RDB 和 AOF。

2.1 持久化 rdb

RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。

RDB快照的触发方式有很多,比如

  • 执行bgsave命令触发异步快照,执行save命令触发同步快照,同步快照会阻塞客户端的执行指令。
  • 根据redis.conf文件里面的配置,自动触发bgsave
  • 主从复制的时候触发

【数据库篇】Redis知识点_第22张图片

RDB 也是主从全量同步里的 RDB。 RDB 可以理解为是一个快照,直接把 Redis 内存中的数据以快照的形式保存下 来。因为这个过程很消耗资源,所以分成 SAVE 和 BG SAVE 两种。BG SAVE的核 心是利用 fork 和 COW 机制。

所以他是一个全量的方式来进行持久化的

2.1.1 Redis BG Save

利用fork系统调用,复制出来一个子进程,子进程尝试将数据写入文件。这个时候, 子进程和主进程是共享内存的,当主进程发生写操作,那么就会复制一份内存, 这就是所谓的 COW (copy on write)。

COW 的核心是利用缺页异常,操作系统在捕捉到缺页异常之后,发现他们共享内存了,就会复制出来一份。

【数据库篇】Redis知识点_第23张图片

2.2 持久化 aof

AOF持久化,它是一种近乎实时的方式,,AOF 是将 Redis 的命令逐条保留下来,而 后通过重放这些命令来复原。我们可以通过重写 AOF 来减少资源消耗。

就是客户端执行一个数据变更的操作,Redis Server就会把 Redis 的命令逐条保留下来,追加到aof缓冲区的末尾,

然后再把缓冲区的数据写入到磁盘的AOF文件里面,至于最终什么时候真正持久化到磁盘,是根据刷盘的策略来决定的。

  • 逐条记录命令
  • AOF 刷新磁盘的时机
    • always: 每次都刷盘
    • everysec: 每秒,这意味着一般情况下会 丢失一秒钟的数据。而实际上,考虑到硬 盘阻塞(见后面**使用 everysec 输盘策略 有什么缺点),那么可能丢失两秒的数据。
    • no: 由操作系统决定

• 可以通过重写来合并 AOF 文件

【数据库篇】Redis知识点_第24张图片

2.2.1 AOF 刷盘对比

AOF 刷新磁盘的时机

  • always: 每次都刷盘
  • everysec: 每秒,这意味着一般情况下会丢失一 秒钟的数据。而实际上,考虑到硬盘阻塞(见 后面**使用 everysec 输盘策略有什么缺点), 那么可能丢失两秒的数据。
  • no: 由操作系统决定

MySQL redo log 刷盘:

  • 写到 log buffer , 每秒刷新;
  • 实时刷新;
  • 写到 OS cache, 每秒刷新

MySQL bin log 刷盘:

  • 系统自由判断
  • commit刷盘
  • 每N个事务刷盘

写入语义

  • 中间件写到日志缓存(程序内缓存)就认为写入了;
  • 中间件写入到系统缓存(page cache)就认为写入了;
  • 中间件强制刷新到磁盘(发起了 fsync)就认为写入了;

2.2.2 重写 AOF

重写 AOF 整体类似于 RDB。

另外,因为AOF这种指令追加的方式,会造成AOF文件过大,带来明显的IO性能问题,所以Redis针对这种情况提供了

AOF重写机制,也就是说当AOF文件的大小达到某个阈值的时候,就会把这个文件里面相同的指令进行压缩。

在这个过程中,Redis 还在源源不断执行命令, 这部分命令将会被写入一个 AOF 的缓存队列 里面。当子进程写完 AOF 之后,发一个信号给主进程,主进程负责把缓冲队列里面的数 据写入到新 AOF。而后用新的 AOF 替换掉老 的 AOF。

【数据库篇】Redis知识点_第25张图片

2.3 混合持久化

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 数据。
  • 如果 AOF 文件只包含 AOF 数据,那么服务器将直接载入 AOF 数据。

【数据库篇】Redis知识点_第26张图片

最后来总结这两者,到底用哪个更好呢?

推荐是两者均开启。

  • 如果对数据不敏感,可以选单独用 RDB。
  • 如果只是做纯内存缓存,可以都不用。

因此,基于对RDB和AOF的工作原理的理解,我认为RDB和AOF的优缺点有两个。

  • RDB是每隔一段时间触发持久化,因此数据安全性低,AOF可以做到实时持久化,数据安全性较高
  • RDB文件默认采用压缩的方式持久化,AOF存储的是执行指令,所以RDB在数据恢复的时候性能比AOF要好

Redis 内存淘汰策略

定期删除和懒惰删除:

  • 定期删除是指 Redis 会定期遍历数据库,检查过期的 key 并且执行删除。 它的特点是随机检查,点到即止。它并不会一次遍历全部过期 key,然 后删除,而是在规定时间内,能删除多少就删除多少。这是为了平衡 CPU 开销和内存消耗。

  • 懒惰删除是指如果在访问某个 key 的时候,会检查其过期时间,如果已经过期,则会删除该键值对

如果 Redis 开启了持久化和主从同步,那么 Redis 的过期处理要复杂 一些。

  • 在 RDB 之下,加载 RDB 会忽略已经过期的 key;(RDB 不读)
  • 在 AOF 之下,重写 AOF 会忽略已经过期的 key;(AOF 不写)
  • 主从同步之下,从服务器等待主服务器的删除命令;(从服务器啥也不 干)

Redis是如何淘汰key的?

当 key 过期后,或者 Redis 实际占用的内存超过阀值后,Redis 就会对 key 进行淘汰,删除过期的或者不活跃的 key,回收其内存,供新的 key 使用。Redis 的内存阀值是通过 maxmemory 设置的,而超过内存阀值后的淘汰策略,是通过 maxmemory-policy 设置的,具体的淘汰策略后面会进行详细介绍。

Redis 会在 2 种场景下对 key 进行淘汰,

  • 第一种是在定期执行 serverCron 时,检查淘汰 key;
  • 第二种是在执行命令时,检查淘汰 key。

serverCron 定期执行淘汰

第一种场景,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 在执行命令请求时,检查淘汰 key

第二种场景,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多线程

单线程性能瓶颈

Redis 慢的主要原因是单进程单线程模型。虽然一些重量级操作也进行了分拆,如 RDB 的构建在子进程中进行,文件关闭、文件缓冲同步,以及大 key 清理都放在 BIO 线程异步处理,但还远远不够。线上 Redis 处理用户请求时,十万级的 client 挂在一个 Redis 实例上,所有的事件处理、读请求、命令解析、命令执行,以及最后的响应回复,都由主线程完成,纵然是 Redis 各种极端优化,巧妇难为无米之炊,一个线程的处理能力始终是有上限的。

当前服务器 CPU 大多是 16 核到 32 核以上,Redis 日常运行主要只使用 1 个核心,其他 CPU 核就没有被很好的利用起来,Redis 的处理性能也就无法有效地提升。而 Memcached 则可以按照服务器的 CPU 核心数,配置数十个线程,这些线程并发进行 IO 读写、任务处理,处理性能可以提高一个数量级以上。

命令处理流程

【数据库篇】Redis知识点_第27张图片

当请求命令进入时,在主线程触发读事件,主线程此时并不进行网络 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 cluster的数据迁移方案

整体流程

redis官方文档中提供的数据迁移办法是借助redis-trib脚本,其实严格来说,这个redis-trib并不是redis本体的一部分,它只是官方按照redis设计规范实现的一套脚本集合,帮助用户更方便的使用redis-cluster。 实际上,我们完全可以脱离这个脚本来使用cluster, 或者用其他方式实现这套逻辑,比如搜狐tv的redis运维工具cachecloud里,就用java实现了整套逻辑。

我们可以参考redis-trip或者cachecloud的代码来了解cluster数据迁移的流程,主要分为如下几步:

  • 设定迁移中的节点状态,比如要把slot x的数据从节点A迁移到节点B的话,需要把A设置成MIGRATING状态,B设置成IMPORTING状态。
    CLUSTER SETSLOT IMPORTING
    CLUSTER SETSLOT MIGRATING
  • 迁移数据,这一步首先使用CLUSTER GETKEYSINSLOT 命令获取该slot中所有的key, 然后每个key依次用MIGRATE命令转移数据。
  • 数据转移完毕之后,正式将slot指派给新的节点B1

可用性

在整个迁移中,会出现对于单个key的阻塞情况,原因是MIGRATE命令是原子性的,在单个key的迁移过程中,对这个key的访问会被阻塞。但是,一般来说,一个key的数据不会特别大,所以绝大多数情况下瞬间都能完成,所以一般不会真正影响使用。而其他任何情况都不会造成集群的不可用,如果出现了,比如出现slot级的不可用,说明client端的处理存在某些问题。接下来,本文也会介绍一些client端使用的注意事项。

一致性 Hash

一致性 hash 是将数据按照特征值映射到一个首尾相接的 hash 环上,同时也将节点(按照 IP 地址或者机器名 hash)映射到这个环上。

对于数据,从数据在环上的位置开始,顺时针找到的第一个节点即为数据的存储节点。

余数分布式算法由于保存键的服务器会发生巨大变化而影响缓存的命中率,但Consistent Hashing 中,只有在园(continuum)上增加服务器的地点逆时针方向的第一台服务器上的键会受到影响。

【数据库篇】Redis知识点_第28张图片

数据倾斜问题

一致性哈希算法在服务节点太少时,容易因为节点分部不均匀而造成数据倾斜问题。

【数据库篇】Redis知识点_第29张图片

此时必然造成大量数据集中到 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知识点_第30张图片

redis管理

redis异步线程

除了主进程,Redis 还会 fork 一个子进程,来进行重负荷任务的处理。Redis fork 子进程主要有 3 种场景。

除了主进程,Redis 还会 fork 一个子进程,来进行重负荷任务的处理。Redis fork 子进程主要有 3 种场景。

  • 收到 bgrewriteaof 命令时,Redis 调用 fork,构建一个子进程,子进程往临时 AOF文件中,写入重建数据库状态的所有命令,当写入完毕,子进程则通知父进程,父进程把新增的写操作也追加到临时 AOF 文件,然后将临时文件替换老的 AOF 文件,并重命名。
  • 收到 bgsave 命令时,Redis 构建子进程,子进程将内存中的所有数据通过快照做一次持久化落地,写入到 RDB 中。
  • 当需要进行全量复制时,master 也会启动一个子进程,子进程将数据库快照保存到 RDB 文件,在写完 RDB 快照文件后,master 就会把 RDB 发给 slave,同时将后续新的写指令都同步给 slave。

Redis 集群管理

Redis 的集群管理有 3 种方式。

  • client 分片访问,client 对 key 做 hash,然后按取模或一致性 hash,把 key 的读写分散到不同的 Redis 实例上。
  • 在 Redis 前加一个 proxy,把路由策略、后端 Redis 状态维护的工作都放到 proxy 中进行,client 直接访问 proxy,后端 Redis 变更,只需修改 proxy 配置即可。
  • 直接使用 Redis cluster。Redis 创建之初,使用方直接给 Redis 的节点分配 slot,后续访问时,对 key 做 hash 找到对应的 slot,然后访问 slot 所在的 Redis 实例。在需要扩容缩容时,可以在线通过 cluster setslot 指令,以及 migrate 指令,将 slot 下所有 key 迁移到目标节点,即可实现扩缩容的目的。

redis高效数据结构

SDS简单动态字符串

【数据库篇】Redis知识点_第31张图片

字符串长度处理:Redis获取字符串长度,时间复杂度为O(1),而C语言中,需要从头开始遍历,复杂度为O(n);

空间预分配:字符串修改越频繁的话,内存分配越频繁,就会消耗性能,而SDS修改和空间扩充,会额外分配未使用的空间,减少性能损耗。

惰性空间释放:SDS 缩短时,不是回收多余的内存空间,而是free记录下多余的空间,后续有变更,直接使用free中记录的空间,减少分配。

ziplist实现原理

ziplist是一种连续,无序的数据结构。压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。

Redis中的hash,List,Zset这几种类型的数据在某些情况下会使用ziplist来存储。

ziplist是一个经过特殊编码的双向链表(占用一大块内存),设计的目标是为了提高存储效率,ziplist可以用于存储字符串或者整数,其中整数是按照二进制表示进行编码的,而不是编码成字符串序列。

ziplist不是普通的双向链表, 普通的双向链表每一项都占用独立的一块内存,各项之间用地址指针连接起来,这会造成大量的内存碎片,而且地址指针也占用额外的内存。 ziplist是将表中每一项放在前后连续的地址空间中,一个ziplist整体占用一大块内存,它是一个表(list), 但其实不是一个链表

另外,ziplist为了在细节上节省内存,对于值的存储采用了变长的编码方式,即对于大的整数,就多用一些字节来存储,对于小的整数就少用一些字节存

【数据库篇】Redis知识点_第32张图片

值得注意的是,这个压缩列表的内存空间是连续的。这也是压缩列表的主要特点,空间连续,避免内存碎片,节省内存。

ziplist的特点

  • 压缩列表ziplist结构本身就是一个连续的内存块,由表头、若干个entry节点和压缩列表尾部标识符zlend组成,通过一系列编码规则,提高内存的利用率,使用于存储整数和短字符串。
  • 压缩列表ziplist结构的缺点是:每次插入或删除一个元素时,都需要进行频繁的进行内存的扩展或减小,然后进行数据”搬移”,甚至可能引发连锁更新,造成严重效率的损失。

quicklist实现原理

Redis对外暴露的List数据类型,底层实现用的就是quicklist

quicklist的实现是一个双向链表,链表的每一个节点都是一个ziplist, 为什么quicklist要这样设计呢? 其实也是一个空间和时间的折中

.双向链表便于在表的两端进行push和pop操作,但是它的内存开销比较大。 首先它在每个节点上除了要保存数据之外,还要额外保存两个指针;其次双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片

ziplist由于是一整块连续内存,所以存储效率很高,但是它不利于修改操作,每次数据变动都会引发一次内存的realloc。特别是当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能

这样设计的问题, 到底一个quicklist节点包含多长的ziplist合适?这是一个找平衡的问题:

.每个quicklist节点上的ziplist越短,则内存碎片越多,极端情况是一个ziplist只包含一个数据项,这就退化成了普通的双向链表

.每个quicklist节点上的ziplist越长,则为一个ziplist分配大块连续内存的难度就越大,有可能出现内存里有很多小块的内存空间,但却找不到一块足够大的空闲空间分给ziplist。极端情况是整个quicklist只有一个节点,这就退化成了一个ziplist了

skiplist 跳跃列表

跳跃表是Redis特有的数据结构,就是在链表的基础上,增加多级索引提升查找效率。

跳跃表支持平均 O(logN),最坏 O(N)复杂度的节点查找,还可以通过顺序性操作批量处理节点。

跳表(skip
List)是一种随机化的数据结构,基于并联的链表,实现简单,插入、删除、查找的复杂度均为O(logN)。简单说来跳表也是链表的一种,只不过它在链表的基础上增加了跳跃功能,正是这个跳跃的功能,使得在查找元素时,跳表能够提供O(logN)的时间复杂度。

跳跃列表缺点:跳表的效率比链表高,但是跳表需要额外存储多级索引,所以需要的更多的内存空间。

跳表全称为跳跃列表,它允许快速查询,插入和删除一个有序连续元素的数据链表。跳跃列表的平均查找和插入时间复杂度都是O(logn)。快速查询是通过维护一个多层次的链表,且每一层链表中的元素是前一层链表元素的子集(见右边的示意图)。一开始时,算法在最稀疏的层次进行搜索,直至需要查找的元素在该层两个相邻的元素中间。这时,算法将跳转到下一个层次,重复刚才的搜索,直到找到需要查找的元素为止。

【数据库篇】Redis知识点_第33张图片

skiplist插入

skiplist正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到O(log n)。但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。

skiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。

面试题

1.redis单线程为什么需要加锁

虽然redis是单线程,但是可以同时有多个客户端访问,每个客户端会有一个线程。客户端访问之间存在竞争。
因为存在多客户端并发,所以必须保证操作的原子性。比如银行卡扣款问题,获取余额,判断,扣款,写回就必须构成事务,否则就可能出错

2.如何保证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

3.redis 到底是单线程还是多线程

这里的单线程和多线程指的是工作线程。

redis 5.0 之前工作线程都是单线程,但在4.0 的时候,redis 已经有后台线程去处理一些不影响正常流程的工作,比如批量删除过期 key、清理脏数据、无用连接的释放、大 Key 的删除。

redis 5.0 之后的工作线程就是多线程,因为 redis 的性能瓶颈在于网络IO,所以 redis 将对于网络 iO 的读写启了一些线程去进行,核心的执行命令线程还是单线程,另外因为是一个网络请求一个线程去读写,所以也没有竞态问题。

4.Redis主从同步策略

主从刚刚连接的时候,进行全量同步;全同步结束后,进行增量同步。

当然,如果有需要,slave 在任何时候都可以发起全量同步。redis 策略是,无论如何,首先会尝试进行增量同步,如不成功,要求从机进行全量同步。

全量同步

Redis全量复制一般发生在Slave初始化阶段,这时Slave需要将Master上的所有数据都复制一份。具体步骤如下:

  • 从服务器连接主服务器,发送SYNC命令;
  • 主服务器接收到SYNC命名后,开始执行BGSAVE命令生成RDB文件并使用缓冲区记录此后执行的所有写命令;
  • 主服务器BGSAVE执行完后,向所有从服务器发送快照文件,并在发送期间继续记录被执行的写命令;
  • 从服务器收到快照文件后丢弃所有旧数据,载入收到的快照;
  • 主服务器快照发送完毕后开始向从服务器发送缓冲区中的写命令;
  • 从服务器完成对快照的载入,开始接收命令请求,并执行来自主服务器缓冲区的写命令;

增量同步

Redis增量复制是指Slave初始化后开始正常工作时主服务器发生的写操作同步到从服务器的过程。
增量复制的过程主要是主服务器每执行一个写命令就会向从服务器发送相同的写命令,从服务器接收并执行收到的写命令。

6.redis使用场景

1.缓存
2.redis 分布式锁 分布式锁做的事情就是 将并行的事件改为串行来执行
3.计数器
4.zset定时任务
5.消息队列
6.bitmap签到 布隆过滤器
7.地理数据信息
8.基于ttl(滑动窗口)做限流器

7.redis过期策略

过期策略
1.定时删除

把redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定时遍历这个 字典来删除到期的 key。

2.惰性删除

除了定时遍历之外,它还会使用惰性策略来删除过期的 key,所谓 惰性策略就是在客户端访问这个 key 的时候,redis 对 key 的过期时间进行检查,如果过期 了就立即删除。定时删除是集中处理,惰性删除是零散处理。

8.通常淘汰策略的算法有哪些?

FIFO、LRU、LFU

【数据库篇】Redis知识点_第34张图片

【数据库篇】Redis知识点_第35张图片

9.redis hash冲突解决方案(链地址法)

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,称为键冲突。Redis 的哈希表使用“链地址”来解决键冲突,每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到哈希表数组同一个索引上,用单向链表把多个节点连接一起,解决了键冲突问题

例2: 程序要将新"键值对"k2和v2 添加到哈希表中,计算的k2 索引值为2, 但该哈希表数组索引值2上已有"键值对"k1和v1。 解决键索引冲突的办法就是使用next指针将键k2和键k1的节点连接起来,如下图所示:

【数据库篇】Redis知识点_第36张图片

10.redis集群模式,增加一个节点。服务重新部署会导致KEY无法命中吗?(ask重定向)

MOVED转向

一个 Redis 客户端可以向集群中的任意节点(包括从节点)发送命令请求。如果所查找的槽不是由该节点处理的话, 节点将查看自身内部所保存的哈希槽到节点 ID 的映射记录, 并向客户端回复一个 MOVED 错误。

【数据库篇】Redis知识点_第37张图片

ASK转向

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重定向错误。

【数据库篇】Redis知识点_第38张图片
如图所示,source部分数据已经迁移到target,客户端向source发送命令,source发现slot数据已经迁移到target,就会返回给客户端ask重定向,客户端向target发送asking命令,target返回结果。

11.redis expire实现原理

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;

面试题集合

你可能感兴趣的:(工作面试总结,中间件,#,Redis,redis,big,data,数据库)