1.概述
redis在目前生产环境都是集群部署,通过哨兵保证集群的高可用。但凡涉及到集群以及存储方面的需求,无法避免的就是数据复制问题。这篇文章主要说一下redis复制原理,以及redis复制需要注意的问题,还有就是业务方应该避免的坑。
2.核心点
建立主从命令slaveof
slaveof no one: 取消现有的主从关系,使slave变成master
slaveof host port:将当前实例变成指定节点的从节点。
主从命令slaveof实现(定时任务驱动)
通过定时任务和主节点建立连接并进行复制前逻辑
定时任务流程:
(1)任务发现存在新的主节点,建立连接
(2)发送ping
(3)鉴权 如果主节点有requirepass。那么从节点需要配置masterauth
(4)同步全量数据
(5)持续发送写命令到从节点
内部数据同步使用psync命令
psync命令响应:
- +FULLRESYNC:全量复制
- +CONTINUE 部分复制
- +ERR 版本太低
全量复制
从节点发送psync-1 进行全量复制
部分复制
通过offset 以及 运行id来实现部分复制,如果主节点的复制积压缓冲区(back_log)存在数据的话。直接返回给从节点可进行部分复制。 这里说一下部分复制依赖的是slave的offset,以及主节点的back_log。主节点的back_log是一个1M大小的环形链表。主节点每次处理命令的时候都会将命令写入back_log。
心跳探活
主节点通过心跳可以判断节点是否存活。 从节点每1s通过replconf ack offset命令上报当前复制的偏移量。
3.原理分析
Redis的slave通过server.repl_state状态来区分当前的复制状态。整个复制流程就是状态转变的过程。
#define REDIS_REPL_NONE 0 /* No active replication */ 没有进行复制
#define REDIS_REPL_CONNECT 1 /* Must connect to master */ 连接
#define REDIS_REPL_CONNECTING 2 /* Connecting to master */ 建立连接
#define REDIS_REPL_RECEIVE_PONG 3 /* Wait for PING reply */ 等待ping
#define REDIS_REPL_TRANSFER 4 /* Receiving .rdb from master */ 传输 rdb
#define REDIS_REPL_CONNECTED 5 /* Connected to master */ 复制完成,进行增量同步
Redis的master通过对应slave客户端的replstate来记录当前客户端的状态
#define REDIS_REPL_WAIT_BGSAVE_START 6 /* We need to produce a new RDB file. */ 客户等待执行rdb
#define REDIS_REPL_WAIT_BGSAVE_END 7 /* Waiting RDB file creation to finish. */ 等待rdb执行完成
#define REDIS_REPL_SEND_BULK 8 /* Sending RDB file to slave. */ 接收rdb中
#define REDIS_REPL_ONLINE 9 /* RDB file transmitted, sending just updates. */ 在线 命令传播
接下来我们对源码进行分析。
slaveofCommand方法
该方法为slaveof命令的命令处理方法。我们主要看一下slaveof host port逻辑。
long port;
if ((getLongFromObjectOrReply(c, c->argv[2], &port, NULL) != REDIS_OK))
return;
if (server.masterhost && !strcasecmp(server.masterhost,c->argv[1]->ptr)
&& server.masterport == port) {
addReplySds(c,sdsnew("+OK Already connected to specified master\r\n"));
return;
}
replicationSetMaster(c->argv[1]->ptr, port);
如果指定的host、port已经是当前节点的master,直接返回,否则调用replicationSetMaster去设置master
replicationSetMaster方法
void replicationSetMaster(char *ip, int port) {
// 清除原有的主服务器地址(如果有的话)
sdsfree(server.masterhost);
// IP
server.masterhost = sdsnew(ip);
// 端口
server.masterport = port;
if (server.master) freeClient(server.master);
disconnectSlaves(); /* Force our slaves to resync with us as well. */
// 清空可能有的 master 缓存,因为已经不会执行 PSYNC 了
replicationDiscardCachedMaster();
freeReplicationBacklog();
cancelReplicationHandshake();
// 进入连接状态
server.repl_state = REDIS_REPL_CONNECT;
server.master_repl_offset = 0;
server.repl_down_since = 0;
}
这个方法主要是指定新master之前的清理工作 1.清除原有的主服务器信息
2.断开所有从服务器连接,强制所有从服务器执行重同步。因为redis支持树状复制。
3.清空master缓冲区,以及backlog缓冲区。以为暂时不会进行psync了。
4.取消之前的复制任务
5.将复制状态设置为REDIS_REPL_CONNECT(连接状态)
replicationCron方法
这个方法为redis的复制函数,每1s执行一次。
1.和master建立连接过程超时
2.rdb文件传输超时
3.曾经成功建立连接,但是现在超时
前3点都会释放连接,并恢复状态为REDIS_REPL_CONNECT
4.尝试连接master。成功后更新状态为REDIS_REPL_CONNECTING
MASTER <-> SLAVE sync started”
5.向主节点发送ACK,并发送当前复制偏移量。
6.定时向所有从节点发送ping命令(并不是每次都执行,到达定时时间才会执行,repl_ping_slave_period控制)
7.断开超时的从服务器
syncWithMaster方法
和主服务器建立连接时,会注册syncWithMaster回调函数,用于同步主服务器的数据
if (aeCreateFileEvent(server.el,fd,AE_READABLE|AE_WRITABLE,syncWithMaster,NULL) ==AE_ERR)
1.如果状态为REDIS_REPL_CONNECTING,同步发送ping命令给主节点。因为rdb操作时非常耗时的,首先应该和主节点进行预约确认。
if (server.repl_state == REDIS_REPL_CONNECTING) {
redisLog(REDIS_NOTICE,"Non blocking connect for SYNC fired the event.");
/* Delete the writable event so that the readable event remains
* registered and we can wait for the PONG reply. */
aeDeleteFileEvent(server.el,fd,AE_WRITABLE);
// 更新状态
server.repl_state = REDIS_REPL_RECEIVE_PONG;
/* Send the PING, don't check for errors at all, we have the timeout
* that will take care about this. */
syncWrite(fd,"PING\r\n",6,100);
return;
}
2.如果状态为REDIS_REPL_RECEIVE_PONG,则同步接收pong请求。如果需要鉴权验证,master返回-NOAUTH。如果不需要,则返回+PONG
3.如果开启了鉴权,进行鉴权。
4.执行slaveTryPartialResynchronization,判断是执行全量复制还是增量。如果是增量复制直接返回,如果是全量复制则创建临时文件以及读事件回调函数接收主节点发送的rdb文件。
5.更新状态为REDIS_REPL_TRANSFER
slaveTryPartialResynchronization方法
这个方法主要是判断执行增量复制还是全量复制。
server.repl_master_initial_offset = -1;
if (server.cached_master) {
// 缓存存在,尝试部分重同步
// 命令为 "PSYNC <master_run_id> <repl_offset>"
psync_runid = server.cached_master->replrunid;
snprintf(psync_offset,sizeof(psync_offset),"%lld", server.cached_master->reploff+1);
redisLog(REDIS_NOTICE,"Trying a partial resynchronization (request %s:%s).", psync_runid, psync_offset);
} else {
// 缓存不存在
// 发送 "PSYNC ? -1" ,要求完整重同步
redisLog(REDIS_NOTICE,"Partial resynchronization not possible (no cached master)");
psync_runid = "?";
memcpy(psync_offset,"-1",3);
}
/* Issue the PSYNC command */
// 向主服务器发送 PSYNC 命令
reply = sendSynchronousCommand(fd,"PSYNC",psync_runid,psync_offset,NULL);
如果当前节点的cached_master存在,则给主节点发送PSYNC <master_run_id> <repl_offset> 命令。否则发送PSYNC ? -1 主节点会根据当前的run_id和back_lag数据判断是否可以进行增量同步。如果不可以则进行全量同步。
下面代码为简化代码,主要处理主节点针对从节点PSYNC的返回值。
// 接收到 FULLRESYNC ,进行 full-resync
if (!strncmp(reply,"+FULLRESYNC",11)) {
//校验注解点返回的 run_id和offset
//如果run_id和offset不合法,设置run_id为0,下次PSYNC命令失败
//合法更新run_id 和offset
// 返回状态
return PSYNC_FULLRESYNC;
}
if (!strncmp(reply,"+CONTINUE",9)) {
/* Partial resync was accepted, set the replication state accordingly */
redisLog(REDIS_NOTICE,
"Successful partial resynchronization with master.");
sdsfree(reply);
// 将缓存中的 master 设为当前 master
replicationResurrectCachedMaster(fd);
// 返回状态
return PSYNC_CONTINUE;
}
如果是+FULLRESYNC,则说明要增量同步。这里会更新当前节点的run_id和offset。
如果是+CONTINUE,则调用replicationResurrectCachedMaster方法,将当前server的master恢复。并更新repl_state为REDIS_REPL_CONNECTED,然后注册当前fd的读写回调。
上面流程将salveof命令流程串了起来。如果正常没有错误的时候,是ok的。在异常情况下,比如网络问题,会有一些超时。这些超时在replicationCron方法开始部分做的处理。
接收处理RDB
通过readSyncBulkPayload异步接受rdb文件。 读取流程: 1.读取数据头部
2.分片读取数据体
3.持久化到本地
4.清空本地数据库
5.load rdb文件
// 注意 createClient 会为主服务器绑定事件,为接下来接收命令做好准备
server.master = createClient(server.repl_transfer_s);
// 标记这个客户端为主服务器
server.master->flags |= REDIS_MASTER;
// 标记它为已验证身份
server.master->authenticated = 1;
// 更新复制状态
server.repl_state = REDIS_REPL_CONNECTED;
// 设置主服务器的复制偏移量
server.master->reploff = server.repl_master_initial_offset;
// 保存主服务器的 RUN ID
memcpy(server.master->replrunid, server.repl_master_runid,
sizeof(server.repl_master_runid));
加载完后会修改后续数据。如上代码。更新当前节点复制状态、复制偏移量以及为当前master创建客户端,监听读时间。
为啥要创建客户端?
因为后续要吧master当作客户端进行命令传播操作。保证后续的增量同步。创建客户端后,master的该slave变成可写状态。master会将期间的命令数据从客户端缓冲区发送给slave。
syncCommand方法
这个方法主要是psync命令处理。我们要关注master如何处理slave的psync请求。
if (!strcasecmp(c->argv[0]->ptr,"psync")) {
// 尝试进行 PSYNC
if (masterTryPartialResynchronization(c) == REDIS_OK) {
// 可执行 PSYNC
server.stat_sync_partial_ok++;
return; /* No full resync needed, return. */
} else {
// 不可执行 PSYNC
char *master_runid = c->argv[1]->ptr;
if (master_runid[0] != '?') server.stat_sync_partial_err++;
}
} else {
/* If a slave uses SYNC, we are dealing with an old implementation
* of the replication protocol (like redis-cli --slave). Flag the client
* so that we don't expect to receive REPLCONF ACK feedbacks. */
c->flags |= REDIS_PRE_PSYNC;
}
首先执行masterTryPartialResynchronization方法。判断是否可执行增量同步。如果成功则表示能进行增量同步,直接返回。否则进行全同步。
masterTryPartialResynchronization方法
这个方法流程如下:
1.校验发来的runID,如果不一致,执行全量同步。
2.没有back_log或者offset不在back_log中,执行全量同步
如果两点都满足,执行增量同步。
buflen = snprintf(buf,sizeof(buf),"+CONTINUE\r\n");
psync_len = addReplyReplicationBacklog(c,psync_offset);
返回客户端+CONTINUE。并调用addReplyReplicationBacklog将back_log中客户端需要的命令发送给客户端。
否则进行全量同步
need_full_resync:
/* We need a full resync for some reason... notify the client. */
psync_offset = server.master_repl_offset;
/* Add 1 to psync_offset if it the replication backlog does not exists
* as when it will be created later we'll increment the offset by one. */
// 刷新 psync_offset
if (server.repl_backlog == NULL) psync_offset++;
/* Again, we can't use the connection buffers (see above). */
// 发送 +FULLRESYNC ,表示需要完整重同步
buflen = snprintf(buf,sizeof(buf),"+FULLRESYNC %s %lld\r\n",
server.runid,psync_offset);
if (write(c->fd,buf,buflen) != buflen) {
freeClientAsync(c);
return REDIS_OK;
}
return REDIS_ERR;
}
先给客户端同步写+FULLRESYNC,runid ;以及当前复制偏移量。这个偏移量会后面会作为客户端复制偏移量。
全量复制流程
if (server.rdb_child_pid != -1) {
redisClient *slave;
listNode *ln;
listIter li;
// 如果有至少一个 slave 在等待这个 BGSAVE 完成
// 那么说明正在进行的 BGSAVE 所产生的 RDB 也可以为其他 slave 所用
listRewind(server.slaves,&li);
while((ln = listNext(&li))) {
slave = ln->value;
if (slave->replstate == REDIS_REPL_WAIT_BGSAVE_END) break;
}
if (ln) {
/* Perfect, the server is already registering differences for
* another slave. Set the right state, and copy the buffer. */
// 幸运的情况,可以使用目前 BGSAVE 所生成的 RDB
copyClientOutputBuffer(c,slave);
c->replstate = REDIS_REPL_WAIT_BGSAVE_END;
redisLog(REDIS_NOTICE,"Waiting for end of BGSAVE for SYNC");
} else {
/* No way, we need to wait for the next BGSAVE in order to
* register differences */
// 不好运的情况,必须等待下个 BGSAVE
c->replstate = REDIS_REPL_WAIT_BGSAVE_START;
redisLog(REDIS_NOTICE,"Waiting for next BGSAVE for SYNC");
}
} else {
/* Ok we don't have a BGSAVE in progress, let's start one */
// 没有 BGSAVE 在进行,开始一个新的 BGSAVE
redisLog(REDIS_NOTICE,"Starting BGSAVE for SYNC");
if (rdbSaveBackground(server.rdb_filename) != REDIS_OK) {
redisLog(REDIS_NOTICE,"Replication failed, can't BGSAVE");
addReplyError(c,"Unable to perform background save");
return;
}
// 设置状态
c->replstate = REDIS_REPL_WAIT_BGSAVE_END;
/* Flush the script cache for the new slave. */
// 因为新 slave 进入,刷新复制脚本缓存
replicationScriptCacheFlush();
}
if (server.repl_disable_tcp_nodelay)
anetDisableTcpNoDelay(NULL, c->fd); /* Non critical if it fails. */
c->repldbfd = -1;
c->flags |= REDIS_SLAVE;
server.slaveseldb = -1; /* Force to re-emit the SELECT command. */
listAddNodeTail(server.slaves,c);
// 如果是第一个 slave ,那么初始化 backlog
if (listLength(server.slaves) == 1 && server.repl_backlog == NULL)
createReplicationBacklog();
return;
这段代码有点长。但是有几个核心点。流程如下:
1.首先判断当前是否有bgsave在执行。如果有跳转到2,否则3
2.判断当前bgsave是否是客户端复制触发的。也就是判断是否有slave的标志为REDIS_REPL_WAIT_BGSAVE_END,如果有说明可以复用这个bgsave产生的rdb文件。那么我们直接拷贝这个客户端的缓冲区。 为什么要拷贝? 因为在bgsave期间。主服务器还是会处理写命令。并且会将命令写入当前客户端缓冲区。所以我们要将缓冲区数据拷贝到这个目标客户端。避免命令丢失。
3.如果没有bgsave,调用rdbSaveBackground触发bgsave执行。也就是fork当前父进程。生成内存快照。这个对copy_on_write熟悉的应该清楚,只会copy内存页表,只有在主服务器处理写命令的时候,才会有内存拷贝。
4.将当前slave加入到server.slaves。这个比较关键,假如后主服务器在处理写命令的时候才能将当前命令加入到客户端缓冲区。保证命令不丢失。这也完全依赖redis单进程逻辑,因此fork时长是我们需要关注的问题。
RDB执行完毕(backgroundSaveDoneHandler)
Rdb执行完毕后,当前进程会发送一个信号给父进程。
父进程在serverCron()->backgroundSaveDoneHandler()方法处理这个逻辑。
这个方法逻辑很简单,核心就是调用updateSlavesWaitingBgsave方法为等待rdb的客户端注册回调方法sendBulkToSlave。
slave->repldboff = 0;
slave->repldbsize = buf.st_size;
// 更新状态
slave->replstate = REDIS_REPL_SEND_BULK;
slave->replpreamble = sdscatprintf(sdsempty(),"$%lld\r\n",
(unsigned long long) slave->repldbsize);
// 清空之前的写事件处理器
aeDeleteFileEvent(server.el,slave->fd,AE_WRITABLE);
// 将 sendBulkToSlave 安装为 slave 的写事件处理器
// 它用于将 RDB 文件发送给 slave
if (aeCreateFileEvent(server.el, slave->fd, AE_WRITABLE, sendBulkToSlave, slave) == AE_ERR) {
freeClient(slave);
continue;
}
更新当前slave的复制数据以及状态 注册写事件处理器。 sendBulkToSlave这个方法主要就是将rdb文件发送给客户端。
sendBulkToSlave
发送流程我们不关注,核心就是发送完成后主服务器处理逻辑
// 如果写入已经完成
if (slave->repldboff == slave->repldbsize) {
// 关闭 RDB 文件描述符
close(slave->repldbfd);
slave->repldbfd = -1;
aeDeleteFileEvent(server.el,slave->fd,AE_WRITABLE);
// 将状态更新为 REDIS_REPL_ONLINE
slave->replstate = REDIS_REPL_ONLINE;
// 更新响应时间
slave->repl_ack_time = server.unixtime;
if (aeCreateFileEvent(server.el, slave->fd, AE_WRITABLE,
sendReplyToClient, slave) == AE_ERR) {
redisLog(REDIS_WARNING,"Unable to register writable event for slave bulk transfer: %s", strerror(errno));
freeClient(slave);
return;
}
// 刷新低延迟 slave 数量
refreshGoodSlavesCount();
redisLog(REDIS_NOTICE,"Synchronization with slave succeeded");
}
核心就是更新状态,删除之前的写事件回调。
然后重新注册一个写事件回调。将缓冲区的数据返回给客户端。这些数据主要是客户端执行全复制过程中积压的命令。
到这里整个复制流程就结束了。
4.需要注意的问题
全量复制
repl-timeout为传输时间,如果rdb文件过大,可能会导致传输超时,导致同步失败。 全量复制过程中,主节点的写操作会被写入复制客户端的缓冲区。如果复制时间过久,会导致复制缓冲区溢出。
部分复制
这是redis对于断线的优化,但如果积压缓冲区没有对应数据,则会触发全量复制。我们应该尽量避免全量复制。 通过配置repl_backlog_size>net_break_time*write_size_per_minute避免全量复制,write_size_per_minute可以通过info replication的master_repl_offset 每秒差值确定。
异步复制带来的问题
从节点不会主动删除过期数据。所以读取从节点可能会导致读取到过期的数据。 升级到3.2版本以上可以避免该问题。 主从尽量保证内存方面的配置一致。
5.总结
这篇文章主要介绍了redis的复制 原理,不得不说redis的复制状态机,在什么状态做什么事情。当然有些地方依赖redis单线程保证数据的一致性问题。细节很多。redis在复制过程中也进行了很多优化,比如部分复制,以及复用rdb等。
文章参考
《redis开发与运维》