概叙

官网:Redis - The Real-time Data Platform

中文网:Redis中文网

Redis 教程_redis教程

redis命令手册

实战:Redis布隆过滤器性能对比-CSDN博客

科普文:Redis一问一答-CSDN博客

科普文:从Redis1.0到Redis7.0的发展历程来理解为什么Redis钟爱单线程_redis各个版本发布时间-CSDN博客

Java web应用性能分析之【高并发之缓存-多级缓存】_javaweb并发-CSDN博客

限流:深入理解微服务高可用三板斧“限流”-CSDN博客

前面的文章中都有提到redis的应用,这里我们将从redis组织架构、数据类型、集群等方面对redis做一个小结。

什么是 Redis?

话说有一名意大利程序员,在 2004 年到 2006 年间主要做嵌入式工作,之后接触了 Web,2007 年和朋友共同创建了一个网站,并为了解决这个网站的负载问题(为了避免 MySQL 的低性能),于是亲自定做一个数据库,并于 2009 年开发完成,这个就是 Redis。这个意大利程序员就是 Salvatore Sanfilippo 江湖人称 Redis 之父,大家更习惯称呼他 Antirez。

 Redis 技术越来越火爆,其超高的性能,简洁轻量的设计,易上手,分布式架构的支持,在缓存等领域出色的表现造就了它现在的地位。

  官方的 Benchmark 数据:测试完成了 50 个并发执行 10W 个请求。设置和获取的值是一个 256 字节字符串。

  结果:写的速度是 110000次/s,读的速度是 81000次/s。值得注意的是,该测试结果还是早些年旧机器上的测试结果。如果与今天的机器设备相比,预估可能是以下结果的两倍。

Redis(REmote DIctionary Service)是一个开源的键值对数据库服务器。

Redis 更准确的描述是一个数据结构服务器。Redis 的这种特殊性质让它在开发人员中很受欢迎。

下面这张图基本涵盖了本章内容:

Redis组织架构

Redis网络架构--Redis的高性能IO模型

Redis多线程模型=主线程+后台线程

基于Reactor的事件驱动类型,整体分为接受请求处理器、响应处理器和应答处理器三个同步模块,每一个请求都是要经历这三个部分。

Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。

Redis集成了libevent/epoll/kqueue/select等多种事件管理机制,可以根据操作系统 版本自由选择合适的管理机制,其中libevent是最优选择的机制。

通常说 Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,其他功能,比如持久化、异步删除、集群数据同步等,是由额外的线程执行的,所以严格来说,Redis 并不是单线程。
 

在了解多路复用之前,要先明白网络操作的基本 IO 模型和潜在的阻塞点。如果单线程被阻塞了,就无法进行多路复用了。以 Get 请求为例如下图,bind/listen、accept、recv、parse 和 send 属于网络 IO 处理,get 属于键值数据操作。

这里的网络 IO 操作中,潜在的阻塞点分别是 accept() 和 recv()。当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv(),这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。不过,Socket 网络模型可以设置非阻塞模式,基于此 Linux 中的 IO 多路复用机制就要登场了。

redis基于IO多路复用进行封装,就有了自己的IO模型。Redis的IO模型是单线程Reactor模型,也就是事件监听(reactor)、建立建立连接(acceptor)、事件处理(handler),都由同一个线程负责。这里的reactor、acceptor、handler是IO模型中的三种角色,三个角色可以由不同线程担当,也可由同一个线程担当,在单线程Reactor这种IO模型中,显然三种角色都是由同一个线程担当。此时reactor表示事件监听的处理逻辑,acceptor表示连接建立的处理逻辑,handler表示处理读写事件的逻辑。

当注册到epoll实例中的socket有事件就绪,epoll_wait函数就会返回,当前线程就会遍历有事件就绪的socket,根据事件类型进行事件分派。如果是建立连接事件,就会调用acceptor的逻辑处理,acceptor中的逻辑就是调用socket.accept()获取已建立连接的socket,然后调用epoll_ctl把该socket注册到epoll实例;如果是读写事件,就会调用handler的逻辑处理读写事件,读事件的处理就是调用socket.read()获取数据然后进行相应处理,写事件就是调用socket.write()把数据写出。

Redis6.0开始,将主线程拆分成多线程模型,核心线程还是单线程执行命令,拆分出多线程处理网络请求和结果分发。

Redis 6.0 以前,主线程处理网络 IO;Redis 6.0 增加了多线程处理网络 IO 功能,详见下图。

  • 如果没开启多线程,那么 Redis 只会使用主线程处理网络 IO,主线程单线程处理网络 IO 是串行的。
  • 为了保证主逻辑处理方式整体不变,多线程 IO 工作方式,不允许同时并发读写操作,同一时刻只允许读或只允许写。
  • 如果开启了多线程,而且等待处理的 client 数量很少,新增的网络 IO 线程会被挂起,仍然使用主线程工作;否则启用多线程工作,将等待的 clients,平均分配给多个线程(主线程+新增线程)并行处理。
  • 任务分配完以后,主线程将处理自己的任务,并等待新增线程都处理完任务后,才会执行下一个步骤的其它操作,这样做的目的是为了保证整体逻辑串行;不因为引入多线程处理方式改变了原来的主逻辑,尽力将多线程并行逻辑的影响减少到最小。

Redis 6.0 之前,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF 重写),但是,从网络 IO 处理到实际的读写命令处理,都是由单个线程完成的,有时会成为 Redis 的性能瓶颈。Redis 6.0 之后采用多个 IO 线程来处理网络请求,提高网络请求处理的并行度,对于读写命令,仍然使用单线程来处理。

具体流程:
(1)主线程接收到客户端连接请求后创建连接,将 Socket 放入全局等待队列中,通过轮询分配给 IO 线程。
(2)分配后主线程就会进入阻塞状态,等待 IO 线程完成客户端请求读取和解析,多个 IO 线程在并行处理,嗖嗖嗖。
(3)IO 线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。
 

图片

(4)主线程执行完请求操作后,把返回结果写入缓冲区,主线程阻塞等待 IO 线程把这些结果回写到 Socket 中,并返回给客户端。
 

图片

和 IO 线程读取和解析请求一样,IO 线程回写 Socket 时,也是有多个线程在并发执行,所以回写 Socket 的速度也很快。等到 IO 线程回写 Socket 完毕,主线程会清空全局队列,等待客户端的后续请求。

IO 多线程配置#

在实际应用中,如果 Redis 实例的 CPU 开销不大,吞吐量却没有提升,可以考虑使用多线程机制提升吞吐量,redis.conf 中设置:
1. 设置 io-thread-do-reads 配置项为 yes,表示启用多线程

io-threads-do-reads yes

2. 设置线程个数要小于 Redis 实例所在机器的 CPU 核个数,例如,对于一个 8 核的机器来说,Redis 官方建议配置 6 个 IO 线程

io-threads  6

Redis内部架构演变

从 SimpleKV 演进到 Redis,有以下几个重要变化:

  • Redis 主要通过网络框架进行访问,而不再是动态库了,这也使得 Redis 可以作为一个基础性的网络服务进行访问,扩大了 Redis 的应用范围;
  • Redis 数据模型中的 value 类型很丰富,因此也带来了更多的操作接口,例如面向列表的 LPUSH/LPOP,面向集合的 SADD/SREM 等;
  • Redis 的持久化模块能支持两种方式:日志(AOF)和快照(RDB),这两种持久化方式具有不同的优劣势,影响到 Redis 的访问性能和可靠性;
  • SimpleKV 是个简单的单机键值数据库,但是,Redis 支持高可靠集群和高可扩展集群,因此,Redis 中包含了相应的集群功能支撑模块;

Redis如何用

主要来说,Redis 是一个内存数据库,用作另一个“真实”数据库(如 MySQL 或 PostgreSQL)前面的缓存,以帮助提高应用程序性能。它通过利用内存的高速访问速度,从而减轻核心应用程序数据库的负载,例如:

  • 不经常更改且经常被请求的数据
  • 任务关键性较低且经常变动的数据

上述数据的示例可以包括会话或数据缓存以及仪表板的排行榜或汇总分析。

但是,对于许多用例场景,Redis 都可以提供足够的保证,可以将其用作成熟的主数据库。再加上 Redis 插件及其各种高可用性(HA)设置,Redis 作为数据库对于某些场景和工作负载变得非常有用。

另一个重要方面是 Redis 模糊了缓存和数据存储之间的界限。这里要理解的重要一点是,相比于使用 SSD 或 HDD 作为存储的传统数据库,读取和操作内存中数据的速度要快得多。

最初,Redis 最常被比作 Memcached,后者当时缺乏任何非易失性持久化。

这是当前两个缓存之间的功能细分。

虽然现在拥有多种配置方式将数据持久化到磁盘,但当时首次引入持久化时,Redis 是使用快照方式,通过异步拷贝内存中的数据方式来做持久化。不幸的是,这种机制的缺点是可能会在快照之间丢失数据。

Redis数据类型

1. String字符串

Redis的字符串类型并不是直接使用C语言中的原生字符串(以空字符\0结尾的字符数组)进行存储,而是使用了一个称为简单动态字符串(Simple Dynamic String,SDS)的数据结构。这种设计选择为Redis带来了许多优势,尤其是在性能和灵活性方面。

SDS结构

SDS的数据结构定义大致如下(可能根据Redis版本有所不同):

struct sdshdr {  
    int len;      // 记录buf数组中已使用字节的数量,等于SDS所保存字符串的长度  
    int free;     // 记录buf数组中未使用字节的数量  
    char buf[];   // 字节数组,用于保存字符串。注意这里并没有指明数组的长度,这是一个柔性数组(flexible array member)  
};

优势分析

  • 预分配:SDS会为buf分配额外的未使用空间(通过free字段记录),这意味着当你向一个SDS字符串追加内容时,如果未使用空间足够,Redis就不需要重新分配内存。这减少了内存分配次数,从而提高了性能。
  • 常数时间复杂度获取字符串长度:由于SDS结构内部维护了一个len字段来记录字符串的当前长度,获取字符串长度的操作可以在常数时间复杂度O(1)内完成,而不需要像C语言的原生字符串那样遍历整个字符串。
  • 二进制安全:SDS可以存储任意二进制数据,包括空字符\0。C语言的原生字符串以空字符作为结束标志,这限制了它们不能包含空字符。而SDS则通过len字段来明确字符串的长度,因此不受此限制。
  • 兼容C语言字符串函数:尽管SDS提供了自己的一套API来进行字符串操作,但它的buf字段实际上就是一个普通的C字符串(以\0结尾),这意味着在必要时,可以直接使用标准的C语言字符串处理函数来操作buf字段(尽管通常不推荐这样做,因为可能会破坏SDS结构的完整性)。

操作优化

SDS提供了一组API来进行字符串的创建、修改、拼接等操作。这些API在内部会处理内存分配、长度更新等细节,使得用户在使用时无需关心底层实现。

例如,当你使用sdscat函数向一个SDS字符串追加内容时,该函数会首先检查未使用空间是否足够,如果不够,则会重新分配更大的内存空间,并将原有数据复制到新位置,然后再追加新内容。所有这些操作对用户都是透明的。

通过使用SDS作为字符串的底层实现,Redis实现了字符串操作的高效性和灵活性,为上层提供了丰富的数据操作接口,同时保证了内部数据的一致性和稳定性。这种设计使得Redis在处理大量字符串数据时能够保持出色的性能。

String类型是二进制安全的,意思是redis的string可以包含任何数据,比如jpg图片或序列化的对象,且一个key最大能存储512MB;应用场景:

  • 计数器,统计多单位的数量,如粉丝数,视频播放量等,set uid:12345:follow 0;
  • 单点登录,EXPIRE key seconds;
  • 对象的缓存;

2. Hash

哈希的底层实现:Redis中的字典与压缩列表

Redis的哈希(Hashes)类型允许用户在单个键中存储多个字段和对应的值。为了高效地支持这种数据结构,Redis在底层使用了两种主要的数据结构来实现哈希:字典(也称为哈希表)和压缩列表。

  • 字典(哈希表

当哈希中的字段和值较多或者较大时,Redis会选择使用字典作为底层实现。字典是一种通过键(在Redis哈希中是字段)来直接访问值的数据结构,它能够在平均情况下提供O(1)时间复杂度的查找、插入和删除操作。

Redis的字典实现通常包含两个哈希表,用于处理哈希表扩容时的数据迁移。每个哈希表节点保存了字段的哈希值、字段本身和对应的值。结构大致如下:

typedef struct dictEntry {  
    void *key;                // 字段  
    union {  
        void *val;  
        uint64_t u64;  
        int64_t s64;  
        // ... 其他可能的值类型  
    } v;                      // 值  
    struct dictEntry *next;   // 指向下一个节点的指针,用于解决哈希冲突  
} dictEntry;  
  
typedef struct dict {  
    dictEntry **table;        // 哈希表数组  
    unsigned long size;       // 哈希表大小  
    unsigned long sizemask;   // 用于计算索引的掩码  
    unsigned long used;       // 已使用的节点数量  
    // ... 可能还有其他字段,如哈希函数、复制函数等  
} dict;

使用字典的优势在于:

提供了快速的字段查找、插入和删除操作。

哈希表的扩容机制可以保持较低的哈希冲突率,从而保证操作的效率。

  • 压缩列表

当哈希中的字段和值较少且较小时,Redis会使用压缩列表作为底层实现来节省内存。压缩列表是一种紧凑的、连续的内存块,它按顺序存储了哈希中的字段和值对。

压缩列表的结构大致如下:

+--------+--------+--------+--------+  
| ZLBYTE | LEN1   | FIELD1 | LEN2   | VALUE2 | ...  
+--------+--------+--------+--------+
ZLBYTE:压缩列表的头部信息。
LEN1、FIELD1:第一个字段的长度和字段本身。
LEN2、VALUE2:第一个字段对应的值的长度和值本身。

以此类推,后续的字段和值对也是按照这个格式存储的。

使用压缩列表的优势在于:

  • 内存利用率高,因为字段和值是连续存储的,没有额外的指针和元数据开销。
  • 对于小哈希,操作速度可以很快,因为所有数据都在一个连续的内存块中。

操作优化

Redis的哈希实现提供了一组API来进行哈希的创建、修改、查找等操作。这些API在内部会根据哈希的大小和字段的特性选择合适的底层数据结构,并且在必要时进行数据结构之间的转换。

例如,当向一个使用压缩列表实现的哈希中添加一个新的字段和值时,如果添加后的哈希仍然满足压缩列表的使用条件(即字段和值的数量和大小都没有超过预设的阈值),那么Redis会直接在压缩列表的末尾添加新的字段和值。否则,Redis会将压缩列表转换为字典,并在字典中插入新的字段和值。

通过使用字典和压缩列表作为底层实现,Redis的哈希数据类型能够在不同的使用场景下提供高效的操作性能。这种灵活的设计使得Redis能够处理各种大小和复杂度的哈希数据,同时保持内存的低消耗和操作的快速性。

  • hash是一个键名对集合
  • 是一个string类型的field和value的映射表,hash特别适合用于存储对象
  • 每个hash可以存储2^32-1键值对(40多亿)

3. list

列表的底层实现:双向链表与压缩列表

Redis的列表(Lists)数据类型是一个非常重要的数据结构,它允许用户在列表的两端推入或者弹出元素。为了实现这种高效的操作,Redis的列表在底层使用了两种数据结构:双向链表和压缩列表。选择哪种结构取决于列表的大小和元素的特性。

  • 双向链表

当列表的元素数量较多或者元素较大时,Redis会选择使用双向链表作为底层实现。双向链表中的每个节点都保存了前一个节点和后一个节点的指针,这使得在列表的任何位置插入或删除元素都变得相对容易。

双向链表的结构大致如下:

代码语言:javascript

复制

typedef struct listNode {  
    struct listNode *prev;  // 指向前一个节点的指针  
    struct listNode *next;  // 指向后一个节点的指针  
    void *value;            // 节点保存的数据  
} listNode;  
  
typedef struct list {  
    listNode *head;         // 指向链表头部的指针  
    listNode *tail;         // 指向链表尾部的指针  
    unsigned long len;      // 链表的长度  
    // ... 可能还有其他字段,如复制函数、比较函数等  
} list;

使用双向链表的优势在于:

可以在O(1)时间复杂度内完成在列表头部或尾部的元素插入和删除。

当需要遍历列表时,可以从头部或尾部开始,沿着节点的指针依次访问。

  • 压缩列表

当列表的元素数量较少且元素较小时,Redis会使用压缩列表(ziplist)作为底层实现来节省内存。压缩列表是一个紧凑的、连续的内存块,它按顺序存储了列表中的元素。

压缩列表的结构大致如下:

+--------+--------+--------+------+  
| ZLBYTE | LEN    | 'one'  | 'two'| ...  
+--------+--------+--------+------+
  • ZLBYTE: 压缩列表的头部信息,包含了特殊编码和压缩列表的长度信息。
  • LEN: 每个元素前的长度字段,用于记录该元素的长度或前一个元素到当前元素的偏移量。
  • ‘one’, ‘two’: 实际的列表元素,它们被连续地存储在压缩列表中。

使用压缩列表的优势在于:

内存利用率高,因为元素是连续存储的,没有额外的指针开销。

对于小列表,操作速度可以很快,因为所有数据都在一个连续的内存块中。

操作优化

Redis的列表实现提供了一组API来进行列表的创建、修改、遍历等操作。这些API在内部会根据列表的大小和元素的特性选择合适的底层数据结构,并且在必要时进行数据结构之间的转换。

例如,当向一个使用压缩列表实现的列表中添加一个新元素时,如果添加后的列表仍然满足压缩列表的使用条件(即元素数量和大小都没有超过预设的阈值),那么Redis会直接在压缩列表的末尾添加新元素。否则,Redis会将压缩列表转换为双向链表,并在链表的尾部添加新元素。

通过使用双向链表和压缩列表作为底层实现,Redis的列表数据类型能够在不同的使用场景下提供高效的操作性能。这种灵活的设计使得Redis能够处理各种大小和复杂度的列表数据,同时保持内存的低消耗和操作的快速性。

简单的字符串列表,按照插入顺序排序,可以添加一个元素到列表的头部或者尾部,应用场景:

  • 栈、队列、阻塞队列;

4. Set

集合的底层实现:整数集合和字典

Redis的集合(Sets)是一个无序的、元素不重复的集合。为了高效地支持这种数据结构及其操作,Redis在底层使用了两种主要的数据结构:整数集合(intset)和字典(hashtable)。

整数集合(int set)

当集合中的元素都是整数,并且元素数量较少时,Redis会选择使用整数集合作为底层实现。整数集合是一个紧凑的数组,数组中的每个元素都是集合中的一个整数。

整数集合的优势在于:

  • 内存利用率高:整数集合将整数紧密地存储在一个连续的内存块中,没有额外的指针或元数据开销。
  • 操作速度快:对于整数集合中的元素,Redis可以直接通过数组索引访问,这使得查找、添加和删除整数的操作非常快速。 然而,整数集合也有其局限性。由于它要求集合中的元素必须是整数,并且元素数量较少,因此在处理非整数元素或大量元素时,整数集合可能不是最优的选择。

字典(hashtable)

当集合中的元素不满足整数集合的条件(即元素不是整数或元素数量较多)时,Redis会使用字典作为底层实现。字典是一种哈希表,它通过哈希函数将元素的哈希值映射到相应的桶(bucket)中,以支持快速的查找、插入和删除操作。

字典的优势在于:

  • 灵活性高:字典可以存储任意类型的元素,而不仅仅是整数。
  • 操作效率高:通过哈希函数,字典可以在平均情况下提供O(1)时间复杂度的查找、插入和删除操作。

然而,字典也有一定的开销。每个字典元素都需要额外的空间来存储哈希值、指针等元数据。此外,当哈希表发生哈希冲突时,可能需要通过链表或其他方式解决冲突,这可能会降低操作的效率。

操作优化和转换

Redis的集合实现提供了一组API来进行集合的创建、修改、查找等操作。这些API在内部会根据集合的大小和元素的特性选择合适的底层数据结构,并且在必要时进行数据结构之间的转换。

例如,当向一个使用整数集合实现的集合中添加一个新的整数元素时,如果添加后的集合仍然满足整数集合的使用条件(即元素数量没有超过预设的阈值),那么Redis会直接在整数集合的末尾添加新的元素。否则,Redis会将整数集合转换为字典,并在字典中插入新的元素。

Redis的集合在底层使用了整数集合和字典两种数据结构来实现。整数集合适用于元素较少且都是整数的场景,而字典适用于元素数量较多或元素类型不限的场景。通过这种灵活的设计,Redis能够在不同的使用场景下提供高效的操作性能,同时保持内存的低消耗和操作的快速性。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1),应用场景:

共同好友 SINTER set1 set2;
推荐好友 SDIFF set1 set2,将set2的好友推荐给set1

5. Zset

有序集合的底层实现:Redis中的数据结构

Redis的有序集合(Sorted Sets)是一个有序的、元素不重复的集合,其中每个元素都关联了一个分数(score)。为了实现这种数据结构及其相关操作的高效性,Redis在底层主要使用了两种数据结构:压缩列表(ziplist)和跳表(skiplist)。

跳表(skiplist)

当有序集合的元素数量较多或元素的大小较大时,Redis会使用跳表作为底层实现。跳表是一种多层的有序链表,它通过维护多个层次的指针来加快查找、插入和删除操作的速度。

跳表的优势在于:

  • 查找效率高:通过维护多个层次的指针,跳表可以在平均情况下提供O(log N)时间复杂度的查找操作,其中N是元素的数量。
  • 插入和删除操作快速:跳表的插入和删除操作只需要局部地调整指针,而不需要移动大量的数据。
  • 支持范围查询:跳表可以方便地支持按照分数范围查询元素的操作

然而,跳表也有一定的开销。每个元素在跳表中都有多个指向前驱和后继的指针,这些指针会占用额外的内存空间。

操作优化和转换

Redis的有序集合实现提供了一组API来进行集合的创建、修改、查找等操作。这些API在内部会根据集合的大小和元素的特性选择合适的底层数据结构,并且在必要时进行数据结构之间的转换。

例如,当向一个使用压缩列表实现的有序集合中添加一个新的元素时,如果添加后的集合仍然满足压缩列表的使用条件(即元素数量没有超过预设的阈值),那么Redis会直接在压缩列表的末尾添加新的元素。否则,Redis会将压缩列表转换为跳表,并在跳表中插入新的元素。

Redis的有序集合在底层使用了压缩列表和跳表两种数据结构来实现。压缩列表适用于元素较少且大小较小的场景,而跳表适用于元素数量较多或元素大小较大的场景。通过这种灵活的设计,Redis能够在不同的使用场景下提供高效的操作性能,同时保持内存的低消耗和操作的快速性。有序集合的实现使得Redis能够支持按照分数排序、范围查询等复杂操作,满足了业务上的多样化需求。

5.1 什么是zset

  • 与set一样也是string类型 元素的集合,且不允许成员重复;
  • 不同的是每个成员都会关联一个double类型的分数,redis正是通过分数来为集合中的成员进行从小到大的排序;
  • zset的成员是唯一的,但分数(score)可以重复;

5.2 应用场景

  • 工资表、成绩表;
  • 带权重的信息,如普通消息分数为1,重要消息分数为2;
  • 排行榜;

5.3 sorted set的底层实现原理
哈希表+跳表
我们一般用sorted set时,会经常根据集合元素的分数进行范围查询,例如ZRANGEBYSCORE或者ZREVRANGEBYSCORE,这些操作基于跳表就可以实现O(logN)的复杂度。此时,跳表的每个节点同时保存了元素值和它的score。而对于ZSCORE这样的操作,而且它的操作复杂度为O(1)。如果只有跳表,这个是做不到O(1)的,之所以可以做到O(1),就是因为还用了dict,里面存储的key是sorted set的member,value就是这个member的score。

6. Geospatial

应用场景:

朋友的定位;
附近的人;
打车距离计算

7. Hyperloglog

HyperLogLog是一种概率数据结构,用于计算唯一的内容(估计集合的基数)。通常,计算唯一项需要使用与项数成比例的内存。然而,有一组算法可以用内存来换取精度:你最终会得到一个标准误差的估计,在Redis实现的情况下,这个误差小于1%,并且可以使用固定大小的内存,在最坏的情况下是12k字节。
如统计2^64不同元素的基数,相比传统用set保存元素的方式,大大节省内存,但存在0.81%的容错率。

8. Bitmaps

位图不是实际的数据类型,而是在String类型上定义的一组面向位的操作。由于String是二进制安全的blobs,其最大长度为512 MB,因此可以将其设置为2^32个不同的位。适用于非0即1的场景,如:

  • 活跃用户和非活跃用户;
  • 登陆与未登录;
  • 打卡与未打卡

Redis持久化

redis的持久模块分为两种方式:日志(AOF)和快照(RDB)

processon上的整理:ProcessOn Mindmap

如果我们要使用 Redis 存储任何类型的数据同时要求安全保存,了解 Redis 是如何做到这一点很重要。在许多用例中,如果你丢失了 Redis 存储的数据,这并不是世界末日。将其用作缓存或在其支持实时分析的情况下,如果发生数据丢失,则并非世界末日。

在其他场景中,我们希望围绕数据持久性和恢复有一些保证。

Redis是内存数据库,数据都是存储在内存中,为了避免进程退出导致数据的永久丢失,需要定期将Redis中的数据以某种形式(数据或命令)从内存保存到硬盘;当下次Redis重启时,利用持久化文件实现数据恢复。除此之外,为了进行灾难备份,可以将持久化文件拷贝到一个远程位置

持久化过程:

  1. 客户端向服务端发送写操作(数据在客户端的内存中)
  2. 数据库服务接收到请求的数据(数据在服务端的内存中)
  3. 服务器调用write这个系统调用,将数据往磁盘上写(数据在系统内存的缓冲区中)
  4. 操作系统将缓冲区中的数据转移到磁盘控制器上(数据在磁盘缓存中)
  5. 磁盘控制器将数据写到磁盘的物理介质中(数据真正落到磁盘上)
     

无持久化

无持久化:如果你愿意,可以完全禁用持久化。这是运行 Redis 的最快方式,并且没有持久性保证。

RDB文件

RDB(Redis 数据库):RDB 持久化以指定的时间间隔执行数据集的时间点快照。

这种机制的主要缺点是快照之间的数据会丢失。此外,这种存储机制还依赖于主进程的 fork,在更大的数据集中,这可能会导致服务请求的瞬间延迟。话虽如此,RDB 文件在内存中的加载速度要比 AOF 快得多。

RDB是将当前进程中的数据生成快照保存到硬盘,当Redis重新启动时,可以读取快照文件恢复数据。

触发机制
save(手动触发)、bgsave(手动触发)、自动化

save(手动触发)
执行save命令期间,Redis不能处理其他命令,直到RDB过程完成为止。

执行完成后,如果存在老的RDB文件,就会用新的替换旧的,对于用户多的客户端显然不可取

bgsave(手动触发)
自动化也是采取的这种方式,手动执行该命令:

Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间非常短。具体流程如下:

  1. 执行besave命令,父进程检查是否有需要运行的子进程,有的话就直接返回(类似于函数返回值)
  2. 父进程执行fork创建子进程,fork过程中父进程可能会发生堵塞,通过info status命令可以查看latest_fork_usec选项,可以获取最近一个fork操作的耗时,单位为微秒
  3. fork完成后,bgsave命令返回“Background saving started”信息并不再阻塞父进程,可以继续响应其他命令
  4. 子进程创建RDB文件,根据父进程内存生成临时快照文件,完成后 对原有文件进行原子替换。执行lastsave命令可以获取最后一次生成RDB的 时间,对应info统计的rdb_last_save_time选项
  5. 进程发送信号给父进程表示完成,父进程更新统计信息

自动化

  1. 使用save相关配置,如“save m n”。表示m秒内数据集存在n次修改 时,自动触发bgsave(原理图见上图)。
  2. 如果从节点执行全量复制操作,主节点自动执行bgsave生成RDB文件并发送给从节点
  3. 执行debug reload命令重新加载Redis时,也会自动触发save操作。
  4. 默认情况下执行shutdown命令时,如果没有开启AOF持久化功能则 自动执行bgsave。
     

AOF

AOF(Append Only File):AOF 持久化记录服务器接收到的每个写入操作,这些操作将在服务器启动时再次被执行,重建原始数据集。

这种持久性的方法能够确保比 RDB 快照更持久,因为它是一个仅附加文件。随着操作的发生,我们将它们缓冲到日志中,但它们还没有被持久化。该日志与我们运行的实际命令一致,以便在需要时进行重放。

然后,如果可能,我们使用 fsync 将其刷新到磁盘(当此运行可配置时),它将被持久化。缺点是格式不紧凑,并且比 RDB 文件使用更多的磁盘。

AOF(append only file)将Redis执行的每次写命令(读请求不记录到文件中)记录到单独的日志文件中(有点像MySQL的binlog);当Redis重启时再次执行AOF文件中的命令来恢复数据

说白了就是一个存储之前操作的地方,方便重启后可以通过之前的操作复原

Redis服务器默认开启RDB,关闭AOF;要开启AOF,需要在配置文件中配置:appendonly yes

工作流程
命令写入 (append)、文件同步(sync)、文件重写(rewrite)、重启加载 (load),流程图如下:

  1. 所有的写入命令会追加到aof_buf(缓冲区)中。
  2. AOF缓冲区根据对应的策略向硬盘做同步操作。
  3. 随着AOF文件越来越大,需要定期对AOF文件进行重写,达到压缩的目的。
     

AOF命令追加(append)
Redis先将写命令追加到缓冲区,而不是直接写入文件,主要是为了避免每次有写命令都直接写入硬盘,导致硬盘IO成为Redis负载的瓶颈。(写命令都攒一起,最后一起写)

AOF文件写入(write)和文件同步(sync)
为了提高文件写入效率,在现代操作系统中,当用户调用write函数将数据写入文件时,操作系统通常会将数据暂存到一个内存缓冲区里,当缓冲区被填满或超过了指定时限后,才真正将缓冲区的数据写入到硬盘里。这样的操作虽然提高了效率,但也带来了安全问题:如果计算机停机,内存缓冲区中的数据会丢失;因此系统同时提供了fsync、fdatasync等同步函数,可以强制操作系统立刻将缓冲区中的数据写入到硬盘里,从而确保数据的安全性。

两种持久化方式的对比


RDB的优缺点
RDB优点

  1. RDB是一个紧凑压缩的二进制文件,代表Redis在某个时间点上的数据 快照。非常适用于备份,全量复制等场景。比如每6小时执行bgsave备份, 并把RDB文件拷贝到远程机器或者文件系统中(如hdfs),用于灾难恢复
  2. Redis加载RDB恢复数据远远快于AOF的方式
  3. 如果对于完整性要求不高,可以考虑RDB
  4. 生产环境可以定期将RDB文件备份,用于恢复数据

RDB缺点

  1. RDB方式数据没办法做到实时持久化/秒级持久化。生成快照数据属于重量级操作,频繁执行成本过高
  2. RDB文件使用特定二进制格式保存,Redis版本演进过程中有多个格式 的RDB版本,存在老版本Redis服务无法兼容新版RDB格式的问题
  3. 需要一定的时间间隔持久化时间,如果redis意外宕机了,最后一段时间的修改数据就没有了
  4. 针对RDB不适合实时持久化的问题,Redis提供了AOF持久化方式来解决

AOF的优缺点
AOF的优点

  1. AOF可以更好的保护数据不丢失,一般AOF会每隔1秒,通过一个后台线程执行一次fsync操作,最多丢失1秒钟的数据
  2. AOF日志文件没有任何磁盘寻址的开销,写入性能非常高,文件不容易破损
  3. AOF日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写
  4. AOF日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如某人不小心用flushall命令清空了所有数据,只要这个时候后台rewrite还没有发生,那么就可以立即拷贝AOF文件,将最后一条flushall命令给删了,然后再将该AOF文件放回去,就可以通过恢复机制,自动恢复所有数据

AOF的缺点

  1. 对于同一份数据来说,AOF日志文件通常比RDB数据快照文件更大
  2. AOF开启后,支持的写QPS会比RDB支持的写QPS低,因为AOF一般会配置成每秒fsync一次日志文件,当然,每秒一次fsync,性能也还是很高的

故障恢复
在redis宕机后,如果有持久化文件,可以通过加载持久化文件恢复数据

加载RDB恢复数据
RDB文件的载入工作是在服务器启动时自动执行的,并没有专门的命令。但是由于AOF的优先级更高,因此当AOF开启时,Redis会优先载入AOF文件来恢复数据;只有当AOF关闭时,才会在Redis服务器启动时检测RDB文件,并自动载入。服务器载入RDB文件期间处于阻塞状态,直到载入完成为止。

Redis载入RDB文件时,会对RDB文件进行校验,如果文件损坏,则日志中会打印错误,Redis启动失败。

加载AOF恢复数据
如果aof文件有错位(被改了内容),redis会启动不起来,需要修复aof文件,可以用redis自带的redis-check-aof来修复aof文件

为什么不兼得?

RDB + AOF:可以将 AOF 和 RDB 组合在同一个 Redis 实例中。如果你愿意的话,可以以速度换取持久化是一种折衷方法。我认为这是设置 Redis 的一种可接受的方式。在重启的情况下,请记住如果两者都启用,Redis 将使用 AOF 来重建数据,因为它是最完整的。

Forking

现在我们了解了持久化的类型,让我们讨论一下我们如何在像 Redis 这样的单线程应用程序中实际执行它。

在我看来,Redis 最酷的部分是它如何利用 forking 和写时复制来高效地促进数据持久化。

Forking 是操作系统通过创建自身副本来创建新进程的一种方式。这样,你将获得一个新的进程 ID 和一些其他信息和句柄,因此新 forking 的进程(子进程)可以与原始进程父进程通信。

现在事情变得有趣了。Redis 是一个分配了大量内存的进程,那么它如何在不耗尽内存的情况下进行复制呢?

当你 fork 一个进程时,父进程和子进程共享内存,并且在该子进程中 Redis 开始快照(Redis)进程。这是通过一种称为写时复制的内存共享技术实现的——该技术在创建分叉时传递对内存的引用。如果在子进程持久化到磁盘时没有发生任何更改,则不会进行新的分配。

在发生更改的情况下,内核会跟踪对每个页面的引用,如果某个页面有多个更改,则将更改写入新页面。子进程完全不知道更改以及具有一致的内存快照的事情。因此,在只使用了一小部分内存的情况下,我们能够非常快速有效地获得潜在千兆字节内存的时间点快照!

Redis 架构

在开始讨论 Redis 内部结构之前,让我们先讨论一下各种 Redis 部署及其权衡取舍。

我们将主要关注以下这些设置:

  • 单个 Redis 实例
  • Redis 高可用性
  • Redis 哨兵
  • Redis 集群

根据你的用例和规模,决定使用哪一种设置。

单个 Redis 实例

单个 Redis 实例是最直接的 Redis 部署方式。它允许用户设置和运行小型实例,从而帮助他们快速发展和加速服务。但是,这种部署并非没有缺点。例如,如果此实例失败或不可用,则所有客户端对 Redis 的调用都将失败,从而降低系统的整体性能和速度。

如果有足够的内存和服务器资源,这个实例可以很强大。主要用于缓存的场景可能会以最少的设置获得显著的性能提升。给定足够的系统资源,你可以在应用程序运行的同一机器上部署此 Redis 服务。

在管理系统内的数据方面,了解一些 Redis 概念是必不可少的。发送到 Redis 的命令首先在内存中处理。然后,如果在这些实例上设置了持久性,则在某个时间间隔上会有一个fork进程,来生成数据持久化 RDB(Redis 数据的非常紧凑的时间点表示)快照或 AOF(仅附加文件)。

这两个流程可以让 Redis 拥有长期存储,支持各种复制策略,并启用更复杂的拓扑。如果 Redis 未设置为持久化数据,则在重新启动或故障转移时数据会丢失。如果在重启时启用了持久化,它会将 RDB 快照或 AOF 中的所有数据加载回内存,然后实例可以支持新的客户端请求。

话虽如此,让我们看看你可能会用到的更多分布式 Redis 设置。

Redis 高可用性

Redis 的另一个流行设置是主从部署方式,从部署保持与主部署之间数据同步当数据写入主实例时,它会将这些命令的副本发送到从部署客户端输出缓冲区,从而达到数据同步的效果。从部署可以有一个或多个实例。这些实例可以帮助扩展 Redis 的读取操作或提供故障转移,以防 main 丢失。

我们现在已经进入了一个分布式系统,因此需要在此拓扑中考虑许多新事物。以前简单的事情现在变得复杂了。

Redis 主从复制

Redis 的每个主实例都有一个复制 ID 和一个偏移量。这两条数据对于确定副本可以继续其复制过程的时间点或确定它是否需要进行完整同步至关重要。对于主 Redis 部署上发生的每个操作,此偏移量都会增加。

更明确地说,当 Redis 副本实例仅落后于主实例几个偏移量时,它会从主实例接收剩余的命令,然后在其数据集上重放,直到同步完成。如果两个实例无法就复制 ID 达成一致,或者主实例不知道偏移量,则副本将请求全量同步。这时主实例会创建一个新的 RDB 快照并将其发送到副本。

在此传输之间,主实例会缓冲快照截止和当前偏移之间的所有中间更新指令,这样在快照同步完后,再将这些指令发送到副本实例。这样完成后,复制就可以正常继续。

如果一个实例具有相同的复制 ID 和偏移量,则它们具有完全相同的数据。现在你可能想知道为什么需要复制 ID。当 Redis 实例被提升为主实例或作为主实例从头开始重新启动时,它会被赋予一个新的复制 ID。

这用于推断此新提升的副本实例是从先前哪个主实例复制出来的。这允许它能够执行部分同步(与其他副本节点),因为新的主实例会记住其旧的复制 ID。

例如,两个实例(主实例和从实例)具有相同的复制 ID,但偏移量相差几百个命令,这意味着如果在实例上重放这些偏移量后面的命令,它们将具有相同的数据集。现在,如果复制 ID 完全不同,并且我们不知道新降级(或重新加入)从节点的先前复制 ID(没有共同祖先)。我们将需要执行昂贵的全量同步。

相反,如果我们知道以前的复制 ID,我们就可以推断如何使数据同步,因为我们能够推断出它们共享的共同祖先,并且偏移量对于部分同步再次有意义。

Redis 哨兵(Sentinel)

Sentinel 是一个分布式系统。与所有分布式系统一样,Sentinel 有几个优点和缺点。Sentinel 的设计方式是,一组哨兵进程协同工作以协调状态,从而为 Redis 提供高可用性。毕竟,你不希望保护你免受故障影响的系统有自己的单点故障。

Sentinel 负责一些事情。首先,它确保当前的主实例和从实例正常运行并做出响应。这是必要的,因为哨兵(与其他哨兵进程)可以在主节点和/或从节点丢失的情况下发出警报并采取行动。其次,它在服务发现中发挥作用,就像其他系统中的 Zookeeper 和 Consul 一样。所以当一个新的客户端尝试向 Redis 写东西时,Sentinel 会告诉客户端当前的主实例是什么。

因此,哨兵不断监控可用性并将该信息发送给客户端,以便他们能够在他们确实进行故障转移时对其做出反应。

以下是它的职责:

  • 监控——确保主从实例按预期工作。
  • 通知——通知系统管理员 Redis 实例中的事件。
  • 故障转移管理——如果主实例不可用并且足够多的(法定数量)节点同意这是真的,Sentinel 节点可以启动故障转移。
  • 配置管理——Sentinel 节点还充当当前主 Redis 实例的发现服务。

以这种方式使用 Redis Sentinel 可以进行故障检测。此检测涉及多个哨兵进程同意当前主实例不再可用。这个协议过程称为 Quorum。这可以提高鲁棒性并防止一台机器行为异常导致无法访问主 Redis 节点。

此设置并非没有缺点,因此我们将在使用 Redis Sentinel 时介绍一些建议和最佳实践。

你可以通过多种方式部署 Redis Sentinel。老实说,要提出任何明智的建议,我需要有关你的系统的更多背景信息。作为一般指导,我建议在每个应用程序服务器旁边运行一个哨兵节点(如果可能的话),这样你也不需要考虑哨兵节点和实际使用 Redis 的客户端之间的网络可达性差异。

你可以将 Sentinel 与 Redis 实例一起运行,甚至可以在独立节点上运行,只不过它会按照别的方式处理,从而会让事情变得更复杂。我建议至少运行三个节点,并且至少具有两个法定人数(quorum)。这是一个简单的图表,分解了集群中的服务器数量以及相关的法定人数和可容忍的可持续故障。

这会因系统而异,但总体思路是不变的。

让我们花点时间思考一下这样的设置会出现什么问题。如果你运行这个系统足够长的时间,你会遇到所有这些。

1、如果哨兵节点超出法定人数怎么办?

2、如果网络分裂将旧的主实例置于少数群体中怎么办?这些写入会发生什么?(剧透:当系统完全恢复时它们会丢失)

3、如果哨兵节点和客户端节点(应用程序节点)的网络拓扑错位会发生什么?

没有持久性保证,特别是持久化到磁盘的操作(见下文)是异步的。还有一个麻烦的问题,当客户发现新的 primary 时,我们失去了多少写给一个不知道的 primary?Redis 建议在建立新连接时查询新的主节点。根据系统配置,这可能意味着大量数据丢失。

如果你强制主实例将写入复制到至少一个副本实例,有几种方法可以减轻损失程度。请记住,所有 Redis 复制都是异步的,这是有其权衡的考虑。因此,它需要独立跟踪确认,如果至少有一个副本实例没有确认它们,主实例将停止接受写入。

Redis 集群

我相信很多人都想过当你无法将所有数据存储在一台机器上的内存中时会发生什么。目前,单个服务器中可用的最大 RAM 为 24TIB,这是目前 AWS 线上列出来的。当然,这很多,但对于某些系统来说,这还不够,即使对于缓存层也是如此。

Redis Cluster 允许 Redis 的水平扩展。

首先,让我们摆脱一些术语约束;一旦我们决定使用 Redis 集群,我们就决定将我们存储的数据分散到多台机器上,这称为分片。所以集群中的每个 Redis 实例都被认为是整个数据的一个分片。

这带来了一个新的问题。如果我们向集群推送一个key,我们如何知道哪个 Redis 实例(分片)保存了该数据?有几种方法可以做到这一点,但 Redis Cluster 使用算法分片。

为了找到给定 key 的分片,我们对 key 进行哈希处理,并通过对总分片数量取模。然后,使用确定性哈希函数,这意味着给定的 key 将始终映射到同一个分片,我们可以推断将来读取特定 key 的位置。

当我们之后想在系统中添加一个新的分片时会发生什么?这个过程称为重新分片。

假设键 'foo' 之前映射到分片 0, 在引入新分片后它可能会映射到分片 5。但是,如果我们需要快速扩展系统,移动数据来达到新的分片映射,这将是缓慢且不切实际的。它还对 Redis 集群的可用性产生不利影响。

Redis Cluster 为这个问题设计了一种解决方案,称为 Hashslot,所有数据都映射到它。有 16K 哈希槽。这为我们提供了一种在集群中传播数据的合理方式,当我们添加新的分片时,我们只需在系统之间移动哈希槽。通过这样做,我们只需要将 hashlot 从一个分片移动到另一个分片,并简化将新的主实例添加到集群中的过程。

这可以在没有任何停机时间和最小的性能影响的情况下实现。让我们通过一个例子来谈谈。

  • M1 包含从 0 到 8191 的哈希槽。
  • M2 包含从 8192 到 16383 的哈希槽。

因此,为了映射 “foo”,我们采用一个确定性的键(foo)散列,并通过散列槽的数量(16K)对其进行修改,从而得到 M2 的映射。现在假设我们添加了一个新实例 M3。新的映射将是:

  • M1 包含从 0 到 5460 的哈希槽。
  • M2 包含从 5461 到 10922 的哈希槽。
  • M3 包含从 10923 到 16383 的哈希槽。

现在映射到 M2 的 M1 中映射哈希槽的所有键都需要移动。但是散列槽的各个键的散列不需要移动,因为它们已经被划分到散列槽中。因此,这一级别的误导(misdirection)解决了算法分片的重新分片问题。

Gossiping 协议

Redis Cluster 使用 gossiping 来确定整个集群的健康状况。在上图中,我们有 3 个 M 个节点和 3 个 S 节点。所有这些节点不断地进行通信以了解哪些分片可用并准备好为请求提供服务。

如果足够多的分片同意 M1 没有响应,他们可以决定将 M1 的副本 S1 提升为主节点以保持集群健康。触发此操作所需的节点数量是可配置的,并且必须正确执行此操作。如果操作不当并且在分区的两边相等时无法打破平局,则可能会导致集群被拆分。这种现象称为裂脑。作为一般规则,必须拥有奇数个主节点和两个副本,以实现最稳健的设置。

影响Redis性能的潜在因素

Redis内存回收

惰性删除:当一个数据的过期时间到了以后,并不会立即删除数据,而是等到再有请求来读写这个数据时,对数据进行检查,如果发现数据已经过期了,再删除这个数据;

在主从同步时,如果从库是3.2以前的版本,那么在服务读请求的过程中,并不会判断数据是否过期,而是直接返回过期数据;在3.2版本以后,如果读取的数据已经过期了,从库不会删除,会直接返回空值,避免客户端读到过期数据。

定期删除:Redis 每隔一段时间(默认 100ms),就会随机选出一定数量的数据,检查它们是否过期,并把其中过期的数据删除,这样就可以及时释放一些内存。

Redis并发问题和分布式锁、事务

Redis异常处理

Redis使用规范

参考:redis完整架构图 思维导图模板_ProcessOn思维导图、流程图

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐