常规模式下的 redis 可以使用 replicaof ip port 来进行主从复制。但是基于研究分布式的目的,本文只解析了cluster。

还有一个叫做sentinel的模式,官网上说它在非cluster中提供高可用性。

Redis Sentinel provides high availability for Redis when not using Redis Cluster.

顺便提供一个对比图,来自于 https://www.baeldung.com/redis-sentinel-vs-clustering

image

cluster 模式的主从复制

cluster 模式的主从复制,在官网上给了一个简单的命令来一次性完成。因为有时候要debug跟踪主从复制,所以repl-timeout设置为2400万秒。

port 端口
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 24000000
repl-timeout 24000000
appendonly yes
redis-cli --cluster create 127.0.0.1:700{0..5} --cluster-replicas 1

这个命令的意思是,创建 3 个主节点,每个主节点都有一个自己的从节点。以下操作均在 cli 执行:

  1. 传INFO,检查是否处于了cluster模式。

  2. 传cluster nodes。获取到的数据可能是:

ce5318f8d38b3a0fc85948289c888cb99603bfab 127.0.0.1:7002@17002 master - 0 1725158507000 3 connected 10923-16383
d97793c6b1b627f39c74909e42d1b5249886c72b 127.0.0.1:7005@17005 slave da16b2f5d3b42da0acf7f4ff64f0683c87aa1229 0 1725158508119 2 connected
bac797c6b03bc354ac9a055b64f1277eb40e5253 127.0.0.1:7003@17003 slave ce5318f8d38b3a0fc85948289c888cb99603bfab 0 1725158506000 3 connected
657e6c974d4c77701fbb18ccfa05ed6a41a6f40a 127.0.0.1:7000@17000 myself,master - 0 0 1 connected 0-5460
da16b2f5d3b42da0acf7f4ff64f0683c87aa1229 127.0.0.1:7001@17001 master - 0 1725158507111 2 connected 5461-10922
6c5ee24779efb06c9d88453891afd2e851a82e4a 127.0.0.1:7004@17004 master - 0 1725158507000 5 connected

每一列的意思分别是:name ip:port:cport,hostname 状态 主节点name PING发送时间 PONG收到时间 epoch 连接状态 slot信息1 slot信息2 ... [cli连接到的节点的slot迁移信息]

  1. 再发送一次INFO,确保db0中没有键值对(这是因为cluster模式只有db0是可用的)

image

  1. 发送cluster info,确保每个节点都只知道自己(哪怕7000和7001直接cluster meet了,这种情况也不能够创建cluster,因为代码是这么写的,所以还是有自己手动创建cluster的能力的

  2. 确保至少能够有3个主节点

  3. cluster meet,等待各个节点成功加入集群

  4. 进行slot、主从节点分配,发送cluster replicate node_idcluster addslots slot1 slot2...,当然还要设置epoch

但是,以上命令创建出来的从节点,还是不 可 以 读 取

也就是说你在主节点set hello world,从节点get hello,会得到类似于这样的结果。

image

这是因为默认情况下,你的redis-cli不区分读写命令,也就是说从节点默认你输入的命令可能是写入命令,而免费版的redis是不支持从节点写入的。所以你必须表明当前redis-cli只执行读取命令。所以先输入READONLY在get就可以正常获取了。

但是!如果我在READONLY模式下在从节点执行set,它又会给出错误。只能说它是这么设计的吧……

image

cluster模式的主从复制之RDB阶段

终端输入cluster replicate,发送给server后,server只记录几个状态就返回了,故而省略client通讯的部分。此时7000、7001端口的节点都已经知道对方的信息了,然后如果7001当前还是master状态,那么必须是没有任何slot,如果7001节点当前状态是slave,那么可以直接切换。

这里需要介绍几个名词。shard_id,分片ID,即slot分组后每个组的ID;name,每个节点的名字,是一串唯一的标志符。

主从复制中从节点的状态存储为server.repl_state,还有failover状态。图中省略了REPL_STATE前缀,以及权限认证部分。
默认情况下redis使用无盘主从同步。这种方式尤其利于磁盘慢,而网络快的情况。更多信息可以见这个链接,然后查找repl-diskless-sync。图中给的是使用EOF的例子。实际上还可能是$count形式的。

image

  1. 检查是否该复用之前的rdb,不能则等待创建

因为fork()创建rdb的过程会比较久(毕竟repl-timeout单位是秒),来一个PSYNC就fork()一个进程创建rdb的代价有点太大了,所以需要考虑复用rdb。复用的方式是复制缓冲区。

什么时候可以复用之前的rdb?对于某个slave B,master为它创建的RDB可以给slave A复用,需要满足这几个条件:

a. B必须使用保存到磁盘的rdb(因为确实有直接传网络的那种)
b. B正在等待rdb创建完成(处于SLAVE_STATE_WAIT_BGSAVE_END状态)
c. replication buffer复制,也就是命令历史记录复制。这部分内容看文章末尾。
d. B的能力是A的父集(所以也可以完全相同)
e. 过滤掉的数据相同,比如RDB可能只需要函数function(好像是和lua脚本相关的,所以不想深究,更多信息可以读这个issue

  1. replicationCron,一般是一秒钟调用一次(failover中会是0.1秒)

它的作用是:处理主从复制中的超时重连(rep..c:3827 diskbased没有处理,为什么?)、向从需要rdb节点发送PING用以保持链接有效、向从节点发送同步信息、fork子进程来创建rdb。

这个PING很简单,就是'\n'而已。

  1. rdb创建的细节

创建rdb需要fork子进程,这样就需要确定最小能力——因为每个从节点发送的replconf capa ... 可能不尽相同,所以要找最通用的那个。所以说每个redis实例,都要尽量保证版本和配置相同。另一个要确定的是之前提到的function,所以也不深究了。

下一步是创建两个管道,然后fork,之后子进程调用rdbSaveRio,将rdb通过管道传给父进程。以下是子进程做的事情,可以看出,子进程的生命周期就这么点长。

        /* Child */
        int retval, dummy;
        rio rdb;

        rioInitWithFd(&rdb,rdb_pipe_write);

        /* Close the reading part, so that if the parent crashes, the child will
         * get a write error and exit. */
        close(server.rdb_pipe_read);

        redisSetProcTitle("redis-rdb-to-slaves");
        redisSetCpuAffinity(server.bgsave_cpulist);

        retval = rdbSaveRioWithEOFMark(req,&rdb,NULL,rsi);
        if (retval == C_OK && rioFlush(&rdb) == 0)
            retval = C_ERR;

        if (retval == C_OK) {
            sendChildCowInfo(CHILD_INFO_TYPE_RDB_COW_SIZE, "RDB");
        }

        rioFreeFd(&rdb);
        /* wake up the reader, tell it we're done. */
        close(rdb_pipe_write);
        close(server.rdb_child_exit_pipe); /* close write end so that we can detect the close on the parent. */
        /* hold exit until the parent tells us it's safe. we're not expecting
         * to read anything, just get the error when the pipe is closed. */
        dummy = read(safe_to_exit_pipe, pipefds, 1);
        UNUSED(dummy);
        exitFromChild((retval == C_OK) ? 0 : 1);

值得一提的是,同时使用多线程和多进程是危险的。所以redis是这么用的:保持子进程只有一个线程,并且子进程尽量只操作刚刚说的管道。redis实践证明这么保守是可行的,我也觉得是可行的,但我还是觉得危险……毕竟fork之后,所有线程中只复制了创建该进程的那个线程(即只有一个进程),而且redis用了其他的线程。关于fork(),可以看看这篇文章,里面还提到了一个chrome广泛使用多进程和多线程导致的bug

  1. 使用管道的进程间通讯

其中一个管道用于通知子进程:“父进程已经准备好了,你可以正常退出了”。另一个管道则是通知父进程“出问题了”

子safe_to_exit_pipe 父server.rdb_child_exit_pipe 父通知子
父server.rdb_pipe_read 子rdb_pipe_write 子通知父

父进程对server.rdb_pipe_read绑定的读事件回调是rdbPipeReadHandler,通过这个fd,父进程可以读取rdb(一次读16K),然后读一次,广播到所有接受rdb的从节点上。(所以最终是父节点发的rdb)

子进程发完rdb文件后,父进程关闭server.rdb_child_exit_pipe,子进程的阻塞read收到返回值,子进程exit

  1. buffer无法完整发送的特殊情况处理

在发送给从节点的时候可能会出现它无法将管道缓冲区读到的内容一次性提交给系统的tcp发送缓冲区中的情况,这种情况redis也会处理。

具体做法是设置这个连接的可写入事件的回调为rdbPipeWriteHandler,然后等待可写入事件后写入到网络中,之后才尝试发送将该缓冲区的内容发送到下一个从节点。

这种做法是可行的,虽然说如果要rdb的从节点比较多的话,某些从节点可能要等待很久,但是需要rdb的从节点不会很多。不过这么考虑的话,复用rdb真的有必要吗?似乎只有整个分布式大规模崩溃时,或者分布式刚启动的时候才可能真的有用。

  1. rdb传输的格式(EOF和count)

EOF的格式是:$EOF:<40字节的16进制数字>真正的rdb二进制数据<前面那个40字节的16进制数字>

40字节的16进制数字是随机生成的。每次读到数据后都保留最后40字节,如果这40字节刚好是和这个16进制相同,则认为rdb读取完成了。

而count,就是标志了这个rdb有多少字节。

  1. 有盘加载模式的性能优化

有盘加载,就需要保存rdb到磁盘中。有一种方式,直接调用fsync来强制文件保存到磁盘上(而不是一部分还存在内存中)。但是fsync会比较慢,而另一个系统调用sync_file_range提供了只刷一小段数据的能力(但是据man sync_file_range所说,这是个危险的系统调用,留着以后研究吧),相关的可以见这个issue

  1. SLAVE_STATE_SEND_BULK,SLAVE_STATE_ONLINE,SLAVE_STATE_RDB_TRANSMITTED状态转换

SEND_BULK是正在发送的状态,如果rdb很快就创建、发送完了,那么这个状态甚至不会出现。

ONLINE与RDB_TRANSMITTED都是rdb传输完成之后的状态,它们也在serverCron中被设置,区别是后者要replconf设置后才会进入这个状态,它表明不需要后续的更新(可能这是用来创建只读的从节点的)

cluster主从复制之写命令传播

感觉如何确定其流程是挺有趣的。所以也记录一下这个寻找的过程。

首先redis官网说repl_backlog用于实现命令传播,然后readQueryFromClient中调用processInputBuffer会在命令执行之后写入到backlog中,此时tcpdump并没有输出这个命令。

查找repl_backlog,它里面包含了一个链表,推测这个链表是真正存储信息的。因为肯定要读取链表,所以我们可以找到以repl_backlog以及其成员为参数的函数调用。到此可以定位到replication.c的前面700行,这里有很多带有ReplicationBacklog的函数,我们可能找到了。其中有个写了Propagate的注释的函数,说不定就是它在向所有从节点传播命令。

/* Propagate write commands to replication stream.
 *
 * This function is used if the instance is a master: we use the commands
 * received by our clients in order to create the replication stream.
 * Instead if the instance is a replica and has sub-replicas attached, we use
 * replicationFeedStreamFromMasterStream() */
void replicationFeedSlaves(list *slaves, int dictid, robj **argv, int argc) {

上debug看了一下,这个函数的作用是把命令写入backlog,所有读取、写入命令都保存到backlog。那么真正发送命令的是谁?对connWriteconnWritev打断点后,发现实际上是beforeSleep调用的handleClientsWithPendingWrites在发送命令。后面又发现它实际上是处理client的数据发送。目前可以确定的有两种,一种是回复,比如set hello 1后,要回复+OK;另一种是向从节点发命令,如让从节点set hello 1

在beforeSleep调用的好处是,它是批量将命令写进内核态的,而且可以比较好的提升一致性。因为一个命令可能很短,这样进内核态可能比较浪费时间,但是redis确实可能同时处理了多个写入命令。

所以redis如何确定一个命令是否是写入命令的?

在执行写入命令的时候,它会首先把命令切开成字符串,保存为robj,增加其引用技术,然后执行。每个命令都会在真的写入的时候标志一个dirty,表示发生了变化,然后保存到server.also_propagate数组中,之后从数组通过replicationFeedSlaves转移到输出缓存中,并且还会通过feedAppendOnlyFile写入本地的AOF。

虽然说是标志dirty,但是实际上是利用server.dirty计数器,在执行命令前记录server.dirty,如果真的是写入,则递增server.dirty,然后前后对比一下就知道了这是否是写入了。使用dirty,可能是为了实现redis的不完备的“事务”

那么主节点怎么知道从节点需要哪一段AOF的?比如中间某个时候从节点崩溃了,主节点更新了好几次,之后从节点才成功和主节点连接,这个时候是怎么知道要哪一段AOF?

用tcpdump观察主从cluster之间的信息交互,可以发现它们除了PING、广播的写命令之外,还有一个replconf ack ? fack ?命令,简单查阅资料后发现它是从节点告诉主节点自己同步到哪里的命令。

主节点收到replconf ack ? fack ?执行:

            if ((getLongLongFromObject(c->argv[j+1], &offset) != C_OK))
                return;
            if (offset > c->repl_ack_off)
                c->repl_ack_off = offset;
            if (c->argc > j+3 && !strcasecmp(c->argv[j+2]->ptr,"fack")) {
                if ((getLongLongFromObject(c->argv[j+3], &offset) != C_OK))
                    return;
                if (offset > c->repl_aof_off)
                    c->repl_aof_off = offset;
            }

从节点发送replconf ack ? fack ?的代码段:

void replicationSendAck(void) {
    client *c = server.master;

    if (c != NULL) {
        int send_fack = server.fsynced_reploff != -1;
        c->flags |= CLIENT_MASTER_FORCE_REPLY;
        addReplyArrayLen(c,send_fack ? 5 : 3);
        addReplyBulkCString(c,"REPLCONF");
        addReplyBulkCString(c,"ACK");
        addReplyBulkLongLong(c,c->reploff);
        if (send_fack) {
            addReplyBulkCString(c,"FACK");
            addReplyBulkLongLong(c,server.fsynced_reploff);
        }
        c->flags &= ~CLIENT_MASTER_FORCE_REPLY;
    }
}

看得出从节点发送ack要获取的就是全局状态。一个是server.master中记录的reploff,一个是全局变量server.fsynced_reploff。

更改reploff的地方是:

    if (c->flags & CLIENT_MASTER && !(c->flags & CLIENT_MULTI)) {
        /* Update the applied replication offset of our master. */
        c->reploff = c->read_reploff - sdslen(c->querybuf) + c->qb_pos;
    }

可以从if的第一个条件看出实际上操作的还是server.master,并且它是按读取的字节数来改变的。

而fsynced_reploff是:

atomicGet(server.fsynced_reploff_pending, fsynced_reploff_pending);
server.fsynced_reploff = fsynced_reploff_pending;

而server.fsynced_reploff_pending只和aof有关,即每次fsync这个aof后,刷新已经保存的数据的字节数。之所以这里有原子操作,是因为redis有bio线程在后台将数据保存到磁盘,而主线程也说不定会进行相同的操作,这是为了防止以外情况。

但是问题来了,为什么实际上发送的时候是o->buf + c->ref_block_pos而不是o->buf + c->repl_ack_off?回想一下TCP就明白了——在使用发送窗口的时候,虽然接受方会记录发送方记录的是ack的值,但是实际上发送的时候是不管它的。当然这里的ack相比TCP的而言,还是太简单了。

int _writeToClient(client *c, ssize_t *nwritten) {
...
        if (o->used > c->ref_block_pos) {
            *nwritten = connWrite(c->conn, o->buf+c->ref_block_pos,
                                  o->used-c->ref_block_pos);
            if (*nwritten <= 0) return C_ERR;
            c->ref_block_pos += *nwritten;
        }
...

redis的部分重同步

部分重同步是解决短时间内连接断开之后的数据同步问题。早期使用的是rdb,但是它的代价很大,所以会有这么一个机制来解决这个问题。

重现一个部分重同步,需要终止从节点到主节点的链接后写入主节点。(我试过重启了,它依然还是完全重同步,可以参考这个issue,至少到现在一直没有什么新动静了。)

重现的方式很简单,connClose即可。

而部分重同步的过程很简单。收到psync id offset之后,读取offset,调用masterTryPartialResynchronization,然后它又调用addReplyReplicationBacklog将命令历史记录中对应的发送缓存信息(包括发送node,node中的offset)写到client中,后续由handleClientsWithPendingWrites发送。

还有另一个问题,主从节点发送完了rdb,那么接下来又是如何同步的?都会经过masterTryPartialResynchronization,所以过程也是一样的。

replication buffer 命令历史记录

命令历史记录是server.repl_backlog,它本质上是一个链表,元素是replBufBlock。replBufBlock是一个带引用计数的结构体,所以它是在多个地方共享的结构体。

typedef struct replBufBlock {
    int refcount;           /* Number of replicas or repl backlog using. */
    long long id;           /* The unique incremental number. */
    long long repl_offset;  /* Start replication offset of the block. */
    size_t size, used;
    char buf[];
} replBufBlock;

repl_backlog是:

typedef struct replBacklog {
    listNode *ref_repl_buf_node; /* Referenced node of replication buffer blocks,
                                  * see the definition of replBufBlock. */
    size_t unindexed_count;      /* The count from last creating index block. */
    rax *blocks_index;           /* The index of recorded blocks of replication
                                  * buffer for quickly searching replication
                                  * offset on partial resynchronization. */
    long long histlen;           /* Backlog actual data length */
    long long offset;            /* Replication "master offset" of first
                                  * byte in the replication backlog buffer.*/
} replBacklog;

其中的blocks_index是基数树,用于加速查找的。histlen是当前存放的历史记录有多少,offset是目前还有效的历史记录。

ref_repl_buf_node是链表,新的历史记录会保存到链表中,当链表中的节点中的历史记录达到了阈值,那么会创建下一个链表节点。

而主从复制中,都会有一个主节点到从节点的client结构体,这个结构体就有一个指向replBufBlock的指针,以及对应的offset。

这个历史记录可以设置其最大大小——repl-backlog-size,默认值是当前存储数据的1%,你也可以手动指定是多大。