【Redis技术进阶之路】「源码分析系列开篇」高可用之Master-Slave主从架书的点制问题分析(分析旧版复制功能)

855 阅读21分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


【专栏简介】

随着数据需求的迅猛增长,持久化和数据查询技术的重要性日益凸显。关系型数据库已不再是唯一选择,数据的处理方式正变得日益多样化。在众多新兴的解决方案与工具中,Redis凭借其独特的优势脱颖而出。

【技术大纲】

为何Redis备受瞩目?原因在于其学习曲线平缓,短时间内便能对Redis有初步了解。同时,Redis在处理特定问题时展现出卓越的通用性,专注于其擅长的领域。深入了解Redis后,您将能够明确哪些任务适合由Redis承担,哪些则不适宜。这一经验对开发人员来说是一笔宝贵的财富。

在这里插入图片描述

在这个专栏中,我们将专注于Redis的6.2版本进行深入分析和介绍。Redis 6.2不仅是我个人特别偏爱的一个版本,而且在实际应用中也被广泛认为是稳定性和性能表现都相当出色的版本

【专栏目标】

本专栏深入浅出地传授Redis的基础知识,旨在助力读者掌握其核心概念与技能。深入剖析了Redis的大多数功能以及全部多机功能的实现原理,详细展示了这些功能的核心数据结构和关键算法思想。读者将能够快速且有效地理解Redis的内部构造和运作机制,这些知识将助力读者更好地运用Redis,提升其使用效率。

将聚焦于Redis的五大数据结构,深入剖析各种数据建模方法,并分享关键的管理细节与调试技巧。

【目标人群】

Redis技术进阶之路专栏:目标人群与受众对象,对于希望深入了解Redis实现原理底层细节的人群

1. Redis爱好者与社区成员

Redis技术有浓厚兴趣,经常参与社区讨论,希望深入研究Redis内部机制、性能优化和扩展性的读者。

2. 后端开发和系统架构师

在日常工作中经常使用Redis作为数据存储和缓存工具,他们在项目中需要利用Redis进行数据存储、缓存、消息队列等操作时,此专栏将为他们提供有力的技术支撑。

3. 计算机专业的本科生及研究生

对于学习计算机科学、软件工程、数据分析等相关专业的在校学生,以及对Redis技术感兴趣的教育工作者,此专栏可以作为他们的学习资料和教学参考。

无论是初学者还是资深专家,无论是从业者还是学生,只要对Redis技术感兴趣并希望深入了解其原理和实践,都是此专栏的目标人群和受众对象

让我们携手踏上学习Redis的旅程,探索其无尽的可能性!


Redis主从复制

在Redis架构中,用户拥有灵活的机制来配置数据复制,这一过程通过执行SLAVEOF命令或配置slaveof选项实现,旨在让一个Redis实例(我们称之为从服务器,slave)去同步并复制另一个Redis实例(即主服务器,master)的数据。

在这里插入图片描述

具体而言,SLAVEOF命令的引入,为Redis集群管理提供了高度的动态性和灵活性。通过简单地执行这一命令并指定主服务器的地址和端口,从服务器便能立即开始复制过程,自动同步主服务器上的数据变更。

通过配置文件设置slaveof选项,用户可以在Redis实例启动时自动建立复制关系,无需手动干预,进一步简化了运维流程

案例介绍

在设想的场景中,我们配置了两个Redis服务器实例,它们虽共处同一物理或逻辑隔离的环境中,却各自独立地监听不同的端口以提供服务:一个是遵循惯例的Redis默认端口6379(对应地址127.0.0.1:6379),另一个则是自定义的非标准端口12345(对应地址127.0.0.1:12345),以命令行工具redis-cli为例,发送命令至127.0.0.1:12345的示例命令如下:

127.0.0.1:12345>SLAVE0F127,0,0.16379
OK

在此配置中,我们设定了一个主从关系,其中原本监听于非标准端口12345的Redis服务器(地址127.0.0.1:12345)被配置为从服务器,而监听于标准端口6379的Redis服务器(地址127.0.0.1:6379)则相应地扮演了主服务器的角色。

主从数据一致性问题

在主从复制架构中,主服务器与从服务器之间的数据库通过持续的数据同步过程,确保了双方存储着完全一致的数据集合。这一现象在概念层面上被精确地定义为“数据库状态一致性”,简称则为“一致性”。

当我们向位于127.0.0.1:6379的主Redis服务器发送一个SET命令来设置键msg的值为"hello world",并成功接收到OK响应时,这标志着数据写入操作在主服务器上顺利完成。

127.0.0.1:6379>SET msg "he11owor1d"
OK

紧接着,我们可以通过在主服务器上执行GET msg命令来验证该键的值,预期会返回"hello world"。

127.0.0.1:6379>GET msg
"hello world"

同样地,由于配置了Redis的主从复制机制,当主服务器上的数据发生变化时,这些变化会自动同步到从服务器上。

当我们转向127.0.0.1:12345的从服务器,并尝试通过GET msg命令获取msg键的值时,也应当能够接收到相同的"hello world"结果,这证明了数据在主从服务器之间保持了一致性。

127.0.0.1:12345> GET msg
"hello world"

随后在主服务器上执行DEL msg命令来删除msg键,并确认操作成功(通过(integer)1的返回值),那么该键及其关联的数据将从主服务器的数据库中移除。

127.0.0.1:6379>DEL msg
(integer)1

为了验证这一点,我们可以使用EXISTS msg命令来检查msg键是否还存在,预期会收到(integer)0的响应,表示该键已被删除。

127.0.0.1:6379>EXISTS msg
(integer) 0

由于主从复制的特性,从服务器也会实时或近乎实时地反映主服务器上的数据变化。因此,当我们对从服务器执行相同的EXISTS msg命令时,同样会收到(integer)0的响应,确认了msg键在从服务器上也已被删除。

127.0.0.1:12345>EXISTS msg
(integer) 0

上述提及的所有指令操作均依赖于Redis精心设计的指令缓存机制来高效执行数据读取。Redis通过其核心组件中定义的readQueryFromClient方法来实现对客户端请求的读取操作,这一关键方法的具体实现细节位于源码的networking.c文件中。

void processInputBufferAndReplicate(client *c) {  
    // 检查客户端的标志位,判断其是否为主服务器(Master)。  
    // 如果客户端不是主服务器(即没有CLIENT_MASTER标志),则直接处理其输入缓冲区中的命令。  
    if (!(c->flags & CLIENT_MASTER)) {  
        processInputBuffer(c); // 处理客户端c的输入缓冲区中的命令  
    } else {  
        // 如果客户端是主服务器(即具有CLIENT_MASTER标志),则首先记录处理前的复制偏移量。  
        size_t prev_offset = c->reploff;  
        // 处理输入缓冲区中的命令,这些命令通常是由从服务器发送的,主服务器在这里进行执行。  
        processInputBuffer(c);  
        // 计算处理前后复制偏移量的差值,即本次处理中已应用的命令量。  
        size_t applied = c->reploff - prev_offset;  
        // 如果存在已应用的命令(即applied大于0),则进行主从复制操作。  
        // 将已执行的命令及其影响通过复制流(replication stream)发送给所有从服务器。  
        if (applied) {  
            // 调用replicationFeedSlavesFromMasterStream函数,将命令及其影响(通过c->pending_querybuf表示)  
            // 发送给所有从服务器(server.slaves),并告知已应用的字节数。  
            replicationFeedSlavesFromMasterStream(server.slaves, c->pending_querybuf, applied); 
            // 更新pending_querybuf,移除已发送给从服务器的部分,以便后续使用。  
            // sdsrange函数修改字符串s,保留从start到end-1之间的内容,并返回修改后的字符串。  
            // 这里start为applied(即已发送的字节数),end为-1(表示保留到字符串末尾),  
            // 实际上就是将已发送的部分截掉。  
            sdsrange(c->pending_querybuf, applied, -1);  
        }  
    }  
}

在处理输入缓冲区(processInputBuffer)的过程中,核心的执行逻辑被巧妙地封装在了processCommand函数中。这一设计使得processInputBuffer函数本身能够专注于数据的准备与传递,而将复杂的命令处理逻辑交由更为专一的processCommand函数来完成。

分析旧版复制功能

Redis的复制机制精妙地融合了同步(synchronization)与命令传播(command propagation)两大核心步骤,旨在确保主从服务器间数据库状态的高度一致性与实时性。

  • 同步操作:此阶段是建立主从关系的基石,它旨在将从服务器的数据库快照迅速更新至与主服务器当前数据库状态完全匹配的水平。同步过程负责将从服务器的数据环境“对齐”到主服务器的最新状态,为后续的数据一致性维护奠定坚实的基础。

  • 命令传播操作:紧随同步阶段之后,命令传播机制扮演了关键角色。每当主服务器的数据库状态因任何操作(如数据新增、修改或删除)而发生变化,这些变更将以命令的形式自动传播至所有已连接的从服务器。

同步

当客户端向从服务器发起SLAVEOF指令,旨在配置或更新从服务器以复制指定主服务器的状态时,从服务器会首先触发同步操作这一关键步骤。这一过程的核心在于,将从服务器的数据库当前状态全面且准确地更新至与主服务器当前数据库状态相一致的水平。

在从服务器对主服务器进行同步操作时,一个关键步骤是向主服务器发送一个正确的命令来启动这一过程,但值得注意的是,标准Redis中使用的命令是SYNC

注意,在较新版本中可能已被REPLCONF slaveof或类似的自动重连机制所取代,且SYNC命令的具体行为也可能有所调整,特别是引入了PSYNC以支持部分同步。

在这里插入图片描述

发起同步请求

从服务器首先向主服务器发送SYNC命令,正式请求启动数据同步过程。

源码分析

call操作成功执行proc(即某个过程或函数)之后,它会进一步触发propagate函数的调用,这一步骤旨在将执行过的命令有效地传播至AOF(Append Only File,仅追加文件)日志系统以及所有连接的从服务器(Slaves)。

int processCommand(client *c) {
    void call(client *c, int flags) {
        propagate(c->cmd,c->db->id,c->argv,c->argc,propagate_flags);
    }
}

此机制确保了数据的一致性和复制的准确性,通过记录所有修改操作到AOF中,并同步这些变更到所有从节点,从而维护了Redis数据库的高可用性和数据冗余。

主服务器准备快照与缓冲区

接收到SYNC命令后,主服务器会立即执行BGSAVE命令,在后台异步生成一个RDB(Redis Database)快照文件。同时,主服务器会启动一个缓冲区,用于记录从SYNC命令接收到BGSAVE命令执行完毕期间内,所有对数据库进行修改的写命令。

源码分析

在进入主循环的“休眠”阶段之前,Redis的这一准备工作不仅是必要的,而且是高效的,因为它允许Redis在大多数时间内保持等待状态,仅在必要时被唤醒以处理实际事件。

/**  
 * 主事件循环函数,用于不断监听和处理事件,直到被明确停止。  
 * @param eventLoop 指向事件循环结构的指针,该结构包含了事件处理所需的所有信息。  
 */  
void aeMain(aeEventLoop *eventLoop) {  
    // 初始化停止标志,0表示未停止  
    eventLoop->stop = 0;  
      
    // 主循环,持续运行直到stop标志被设置为非0值  
    while (!eventLoop->stop) {  
        // 如果设置了beforesleep回调函数,则在进入休眠前调用它  
        // 这里的注释“将AOF buffer写入到AOF文件”是一个假设,实际行为取决于beforesleep的实现  
        if (eventLoop->beforesleep != NULL)  
            eventLoop->beforesleep(eventLoop); // 调用beforesleep回调函数,可能用于执行清理、持久化等操作  
          
        // 处理事件循环中的所有事件,AE_ALL_EVENTS表示处理所有类型的事件  
        // AE_CALL_AFTER_SLEEP表示在事件处理之后调用aftersleep回调函数(如果存在)  
        // 此处注释“epoll”指的是aeProcessEvents内部可能使用了epoll等I/O多路复用机制  
        aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);   
    }  
}

每当Redis的事件驱动库即将进入其核心的主循环阶段,即在准备进入“休眠”状态之前,它会执行一项关键任务:高效地检索并收集所有已就绪的文件描述符。

调用beforeSleep

Redis高性能事件处理机制的核心环节,确保了Redis能够即时响应来自客户端的请求、网络IO事件、定时器事件等多种异步事件。

/**  
 * 在事件循环进入休眠前执行的回调函数。  
 * 用于处理一些在休眠前需要完成的任务,如客户端的阻塞解除、AOF文件的刷新等。  
 *  
 * @param eventLoop 指向事件循环结构的指针。  
 */  
void beforeSleep(struct aeEventLoop *eventLoop) {  
    /*   
     * 解除所有因等待同步复制确认而被阻塞的客户端。  
     * 这些客户端在之前的操作后可能还在等待来自从节点的确认,现在尝试处理它们以解除阻塞状态。  
     */  
    if (listLength(server.clients_waiting_acks))  
        processClientsWaitingReplicas();  
  
    /*  
     * 尝试处理那些刚刚被解除阻塞的客户端的待处理命令。  
     * 某些客户端可能因为等待某些条件(如锁、同步确认等)而被阻塞,现在这些条件满足了,需要处理它们的命令。  
     */  
    if (listLength(server.unblocked_clients))  
        processUnblockedClients();  
  
    /*  
     * 将AOF(Append Only File)缓冲区的内容写入磁盘。  
     * AOF是Redis的一种持久化方式,通过记录所有的写操作来保持数据的持久性。  
     * 此处调用flushAppendOnlyFile函数,尝试将AOF缓冲区中的数据写入磁盘,参数0可能表示采用某种默认的刷新策略。  
     */  
    flushAppendOnlyFile(0);  
  
    /*  
     * 处理那些有待写入输出缓冲区的客户端。  
     * 有些客户端可能已经有了需要发送给它们的响应数据,但是这些数据还在Redis的输出缓冲区中等待发送。  
     * 此函数负责将这些数据发送到客户端。  
     */  
    handleClientsWithPendingWrites();  
}

Redis会利用其先进的事件循环机制,智能地检查并筛选出那些已准备好进行读写操作或其他I/O操作的文件描述符,这些文件描述符可能代表了等待处理的网络连接、已到达的数据包或即将超时的定时任务等。通过精确管理这些资源,Redis能够以最少的CPU消耗和延迟,处理大量并发事件,从而维持其卓越的响应速度和吞吐量。

发送RDB快照并更新从服务器状态

一旦BGSAVE完成,主服务器会将新生成的RDB文件发送给从服务器。从服务器接收并加载这个RDB文件,从而将其数据库状态快速更新至与主服务器执行BGSAVE时相同的数据库状态。

  1. 命令传播与状态同步:在RDB文件传输并加载完成后,主服务器将开始将之前记录在缓冲区中的所有写命令发送给从服务器。从服务器逐一执行这些写命令,逐步将其数据库状态更新至与主服务器当前数据库状态完全一致,从而完成整个同步过程。

    在这里插入图片描述

    在同步操作圆满结束后,主服务器与从服务器的数据库会实现一个瞬间的完全一致状态,这一同步的达成标志着数据复制过程的阶段性胜利。

    在这里插入图片描述

    然而,值得注意的是,这种一致性状态是动态且相对的,它并非一种永恒的、静态的保持。每当主服务器接收到来自客户端的写操作指令并执行时,其数据库内容便可能经历变动,这一变动随即打破了先前主从服务器间的一致状态,引发了两端数据库内容的不一致现象。

命令传播

设想主服务器与从服务器刚刚成功完成了数据同步流程,它们的数据库内容宛如镜像般完全一致,存储着相同的数据快照。但随后,随着业务活动的进行,主服务器接收到了一条来自客户端的写请求,比如更新某个数据项的值或添加新的记录。主服务器响应这一请求并相应地修改了其数据库中的信息。

在这里插入图片描述

由于这一修改是实时且直接作用于主服务器的,而从服务器在默认情况下可能并未立即得知这一变更(除非有特定的同步机制被触发),因此,在这一时刻起,主服务器与从服务器之间的数据一致性便遭到了破坏,它们的数据库内容不再保持完全一致的状态。

源码分析

重新执行指定的命令(在指定的数据库ID的上下文中)到AOF文件和从服务器。

propagate函数

propagate函数负责根据给定的标志(flags)将命令传播到AOF文件或/和连接到Redis服务器的从服务器。通过flags参数,可以控制命令的传播范围,包括是否写入AOF文件(通过PROPAGATE_AOF标志)和是否传播到从服务器(通过PROPAGATE_REPL标志)。

void propagate(struct redisCommand *cmd, int dbid, robj **argv, int argc, int flags) {  
    // If AOF is enabled and PROPAGATE_AOF flag is set, write the command to the AOF file  
    if (server.aof_state != AOF_OFF && flags & PROPAGATE_AOF)  
        feedAppendOnlyFile(cmd, dbid, argv, argc); // 调用函数将命令写入AOF文件  
  
    // If the PROPAGATE_REPL flag is set, propagate the command to all connected slaves  
    if (flags & PROPAGATE_REPL)  
        replicationFeedSlaves(server.slaves, dbid, argv, argc); 
        // 调用函数将命令推送给所有连接的从服务器  
}

feedAppendOnlyFile函数的核心职责并非直接触及磁盘操作,而是高效地将待记录的命令或数据组织并暂存于AOF(Append-Only File)缓冲区中。

写AOF文件feedAppendOnlyFile

通过调用flushAppendOnlyFile这一关键步骤,负责将AOF缓冲区中累积的数据一次性、批量地写入到实际的AOF文件中

/*  
 * 将命令及其参数追加到AOF缓冲区中。  
 * 这个缓冲区的内容将在重新进入事件循环之前被刷新到磁盘上,  
 * 因此在客户端接收到操作执行成功的正面回复之前,数据已经准备好被持久化。  
 */  
void feedAppendOnlyFile(struct redisCommand *cmd, int dictid, robj **argv, int argc) {    
    /*  
     * 如果AOF功能已开启(由redis.conf配置文件中的appendonly指令控制),  
     * 则将格式化后的命令内容(存储在buf中)追加到server.aof_buf缓冲区中。  
     * server.aof_buf是AOF文件内容的内存表示,sdscatlen函数用于字符串的追加。  
     */  
    if (server.aof_state == AOF_ON) { 
        // aof_state的值由redis.conf配置文件中的appendonly指令控制 
        server.aof_buf = sdscatlen(server.aof_buf, buf, sdslen(buf));  
    }  
}

。这一做法不仅减少了磁盘I/O的频率,提高了整体性能,还通过批量处理的方式优化了磁盘操作的效率,进一步保障了数据的持久化过程既快速又可靠。

主服务器向从服务器发送命令

为了恢复主从服务器之间的一致状态,主服务器会采取命令传播机制:即将导致数据不一致的写命令(即那些仅在主服务器上执行过的写操作)转发给从服务器执行。这一过程确保了从服务器能够“追赶”上主服务器的状态变化,通过执行相同的写命令来更新其数据库内容。

在这里插入图片描述

以先前所述示例为鉴,当主服务器执行了DEL k3命令并因此导致主从服务器间出现数据不一致时,主服务器会主动将这一相同的DEL k3命令发送给从服务器。从服务器接收到命令后,会立即执行,从而删除其数据库中相应的键k3。随着这一操作的完成,主从服务器间的数据状态得以重新对齐,两者再次达到一致,此时双方的数据库都不再包含键k3的记录。

数据复制replicationFeedSlaves

在处理Redis的复制机制时,当实例作为主服务器(master)角色运行时,会采用特定的函数来确保写命令能够被准确地复制并传播至所有连接的从设备(slave)。这一过程不仅涉及命令的复制,还包括对复制积压缓冲区(replication backlog)的适当填充,以确保数据的一致性和可靠性。

这里也不会真正将数据发给Slaves,而只是将数据放入到replication buffer中


void replicationFeedSlaves(list *slaves, int dictid, robj **argv, int argc) {  
    listNode *ln; // 用于遍历列表的节点指针  
    listIter li; // 列表迭代器  
  
    // 初始化迭代器,准备遍历所有从服务器  
    listRewind(slaves, &li);  
  
    // 遍历所有的从服务器  
    while ((ln = listNext(&li))) {  
        client *slave = ln->value; // 获取当前遍历到的从服务器client对象  
  
        // 如果从服务器还在等待BGSAVE开始,则跳过此轮发送  
        if (slave->replstate == SLAVE_STATE_WAIT_BGSAVE_START) continue;  
  
        // 如果从服务器在等待初始SYNC,或者已经与主服务器同步,则向其发送命令  
        // 这里不直接判断SYNC状态,但基于注释逻辑,可以理解为包含这两种状态的从服务器都会接收命令  
  
        // 向从服务器发送命令的多个体(multi bulk)长度(即参数个数)  
        addReplyMultiBulkLen(slave, argc);  
  
        // 遍历所有命令参数,并向从服务器发送每个参数  
        for (int j = 0; j < argc; j++) {  
            addReplyBulk(slave, argv[j]); // 发送命令参数  
        }  
    }  
}

(额外补充)SYNC命令是一个非常耗费资源的操作

在每次触发SYNC命令时,主从服务器间的数据同步过程涉及一系列关键步骤,这些步骤显著影响系统资源的使用与性能表现:

  1. 主服务器资源密集型操作:首先,主服务器需执行BGSAVE命令,该命令异步地在后台生成RDB快照文件。此过程对主服务器构成了显著负担,因为它会大量占用CPU计算资源、内存空间以及磁盘I/O能力,进而影响服务器的整体响应速度和并发处理能力。

  2. 网络资源消耗:随后,主服务器需将庞大的RDB文件通过网络发送给从服务器。这一过程不仅消耗了主从服务器间的大量网络带宽资源,还可能因为数据传输的延迟和流量高峰,对主服务器的命令响应速度产生不利影响,延长了客户端请求的等待时间。

  3. 从服务器阻塞处理:在从服务器端,接收并加载RDB文件同样是一个资源密集且耗时的过程。在加载期间,从服务器将处于阻塞状态,无法处理任何来自客户端的命令请求,这可能导致从服务器的服务暂时中断,影响数据的可用性和系统的整体可靠性。

鉴于SYNC命令对系统资源的巨大消耗及其潜在的性能影响,Redis设计了一套高效的同步机制,旨在确保仅在确实需要时才执行SYNC命令。这通常发生在从服务器首次与主服务器建立连接、或从服务器与主服务器间的数据一致性遭到严重破坏需要重新同步时。