Redis 复制

使用主从复制

  1. 运行 Master
    调整 Master 内存中保存的缓冲积压部分(replication backlog),以便执行部分重同步。
    1
    2
    3
    # 缓冲区越大,可断开连接再重连执行部分重同步的时间越长,缓冲区会在每次连接时分配。
    repl-backlog-size 1mb
    repl-backlog-ttl 3600
  2. 运行 Slave
    先在配置文件中设置 Master 和 logfile 路径再运行
    1
    2
    slaveof 172.16.205.141 6379
    logfile "/usr/redis/log/slave.log"
  3. 级联复制(从从复制)
    之前是所有 Slave 连到一个 Master 上,这是一种中心化的办法,对 Master 的负担较大,事实上我们完全可以不全部连到 Master 上,而是 Master->Slave1->Slave2 这样传递。
    实现级联复制也较简单,只用修改 Slave2 配置文件的slaveof属性即可。
  4. Master write,Slave read
    通过程序(客户端)实现数据的读写分离,即在程序中判断请求是读是写,让 Master 负责处理写请求,Slave 负责处理读请求;通过扩展 Slave 处理更多的并发请求,减轻 Master 端的负载。

只读 Slave

Redis2.6 之后,Redis 支持只读模式,可以使用slave-read-only配置来控制这个行为。
只读模式下的 slave 将会拒绝所有写入命令,因此实践中不可能由于某种出错而将数据写入 slave 。但这并不意味着该特性旨在将一个 slave 实例暴露到 Internet ,或者更广泛地说,将之暴露在存在不可信客户端的网络,因为像 DEBUG 或者 CONFIG 这样的管理员命令仍在启用。但是,在 redis.conf 文件中使用 rename-command 指令可以禁用上述管理员命令以提高只读实例的安全性。

同步复制和异步复制

Redis 使用默认的异步复制,其特点是低延迟和高性能,不会影响 Redis 主线程的响应效率。

  • Redis 复制在 master 侧是非阻塞的。这意味着 master 在一个或多个 slave 进行初次同步或者是部分重同步时,可以继续处理查询请求。
  • 复制在 slave 侧大部分也是非阻塞的。当 slave 进行初次同步时,它可以使用旧数据集处理查询请求,假设你在 redis.conf 中配置了让 Redis 这样做的话。否则,你可以配置如果复制流断开, Redis slave 会返回一个 error 给客户端。但是,在初次同步之后,旧数据集必须被删除,同时加载新的数据集。 slave 在这个短暂的时间窗口内(如果数据集很大,会持续较长时间),会阻塞到来的连接请求。自 Redis 4.0 开始,可以配置 Redis 使删除旧数据集的操作在另一个不同的线程中进行,但是,加载新数据集的操作依然需要在主线程中进行并且会阻塞 slave 。

Redis 虽然声称是单线程模型,但是很多功能仍然是采用多线程实现的。

什么时候触发复制

  • 当一个 Master 和一个 Slave 实例连接正常时,Master 通过向 Slave 发送命令流来增量同步自身数据集的改变情况,包括客户端的写入、key 的过期等;
  • Master 与 Slave 之间因为网络问题或宕机,之后 Slave 重新连上 Master 时会尝试进行部分重同步,即只获取在断开连接期间内丢失的命令流;
    为此,slave 会记住旧 master 的旧 replication ID复制偏移量,因此即使询问旧的 replication ID,其也可以将部分复制缓冲提供给连接的 slave 。
  • 当无法进行部分重同步时,Slave 会请求进行全量重同步。Master 需要创建所有数据的快照,将之发送给 Slave,之后在数据集发生更改时持续发送命令流到 Slave。

主从复制原理

当用户往 Master 端写入数据时,通过Redis Sync机制将数据文件发送至 Slave,Slave 也会执行相同的操作确保数据一致。

  1. 同一个 Master 可以拥有多个 Slaves。Master 下的 Slave 还可以接受同一架构中其它 Slave 的链接与同步请求,实现数据的级联复制,即 Master->Slave->Slave 模式;
    repl-diskless-sync-delay参数可以延迟启动数据传输,目的可以在第一个 slave 就绪后,等待更多的 slave 就绪。
    主从复制最好配置成级联复制,因为这样更容易解决单点问题,避免Master承受过大的复制压力
  2. Master 以非阻塞的方式同步数据至 slave,这将意味着 Master 会继续处理一个或多个 slave 的读写请求;
  3. Slave 端同步数据也可以修改为非阻塞的方式,当 slave 在执行新的同步时,它仍可以用旧的数据信息来提供查询;否则,当 slave 与 master 失去联系时,slave 会返回一个错误给客户端;
  4. 主从复制可以做到读写分离,保证了可扩展性,即多个 slave 专门提供只读查询与数据的冗余,Master 端专门提供写操作;
  5. 通过配置禁用 Master 数据持久化机制,将其数据持久化操作交给 Slaves 完成,避免在 Master 中要有独立的进程来完成此操作。
  6. Redis 主从复制的性能问题,为了主从复制的速度和连接的稳定性,Slave 和 Master 最好在同一个局域网内。

标识同步进程:

  1. 每个 Master 都有一个Replication ID:这是一个较大的伪随机字符串,标记了一个给定的数据集。
  2. 每个 Master 持有一个偏移量offset,Master 将自己产生的复制流发送给 slave 时,发送多少个字节的数据,自身的偏移量就会增加多少,目的是当有新的操作修改自己的数据集时,它可以以此更新 Slave 的状态。即使没有 Slave 连接到 Master,offset 也会自增,所以基本上每一对 <Replication ID, offset> 都会标识一个 Master 数据集的确切版本。
  3. Slave 也维护了一个复制偏移量offset,代表从库同步的字节数,从库每收到主节点传来的 N 个字节数据时,从库的 offset 增加 N。
    Master 和 Slave 的offset总是不断增大,这也是判断主从数据是否同步的标志,若主从的 offset 相同则表示数据同步量,不通则表示数据不同步。

复制积压缓冲区
主节点(master)响应写命令时,不但会把命名发送给从节点,还会写入复制积压缓冲区,用于复制命令丢失的数据补救。
Slave 连接中断时主节点仍然可以响应命令,但因复制连接中断命令无法发送给 Slave。之后,当 Slave 重启并触发部分复制时,Master 可以将复制积压缓冲区的内容同步给 Slave,从而提高复制效率;

部分重同步过程:

  1. 当 Slave 连接到 Master,发送一个PSYNC命令表明自己记录的旧的 Master Replication ID和它们至今为止处理的偏移量offset
  2. Master 仅发送 Slave 所需的增量部分的命令流,即上次同步偏移量offset之后执行的写命令;
  3. 但是如果 master 的缓冲区中没有足够的命令积压缓冲记录,或者如果 slave 引用了不再知道的历史记录(replication ID),则会转而进行一个全量重同步:在这种情况下, slave 会得到一个完整的数据集副本,从头开始。

全量同步(完整重同步):

  1. Slave 向 Master 发送PSYNC命令;
  2. Master 执行BGSAVE命令,开启一个后台进程用于生成一个 RDB 文件;
  3. 同时它开始缓冲所有从客户端接收到的新的写入命令;
  4. 当后台保存完成时, master 将数据集文件传输给 slave, slave 将之保存在磁盘上,然后加载文件到内存;
  5. 再然后 master 会将所有缓冲的写命令发给 slave,这个过程以指令流的形式完成并且和 Redis 协议本身的格式相同。

    可以通过telnet连接到 Redis 服务器上然后发送SYNC命令来模拟这个过程,但是因为SYNC功能有限(比如不支持部分重同步),现在的版本用PSYNC作为代替。
    正常情况下,全量同步会先在磁盘上创建一个 RDB 文件,传输时将其加载进内存,然后 Slave 对此进行数据的同步,如果磁盘性能很低,这个过程压力会比较大,Redis 2.8.18之后支持直接传输 RDB 文件,可以使用repl-diskless-sync配置参数配置。

全量同步完成以后,在此后的时间里主从维护着心跳检查来确认对方是否在线,每隔一段时间(默认 10 秒,通过repl-ping-slave-period参数指定)主节点向从节点发送 PING 命令判断从节点是否在线,而从节点每秒 1 次向主节点发送 REPLCONF ACK 命令,命令格式为:REPLCONF ACK {offset},其中 offset 指的是从节点保存的复制偏移量,作用是:

  1. 向主节点报告自己复制进度,主节点会对比复制偏移量向从节点发送未同步的命令;
  2. 判断主节点是否在线。

主从复制执行过程 - Slave怎么与Master建立连接

主从复制
1、Slave Redis实例上配置slaveof xxx,表示将成为另一台Redis实例的从服务器,启动 Slave时,需要设置当前节点的Master信息,并开始主从同步过程;
代码位置:replication.c/slaveofCommand()

1
2
3
4
// 进入连接状态(重点)
server.repl_state = REDIS_REPL_CONNECT;
server.master_repl_offset = 0;
server.repl_down_since = 0;

2、上边设置复制信息成功后,Redis服务器会有一个cron任务(serverCron)定时判断需要进行同步操作,向Master建立连接,也就是一个握手的过程;
代码位置:replication.c/replicationCron()

1
2
3
4
5
if (server.repl_state == REPL_STATE_CONNECT) {
if (connectWithMaster() == C_OK) {
serverLog(LL_NOTICE,"MASTER <-> SLAVE sync started");
}
}

serverCron是Redis的主事件循环,负责超多的任务,包括过期key处理、rehash、备份RDB文件、AOF重写等等。

3、确定连接后,接下来,cron任务里还有比较关键的一项是确定复制方案,
会先向 Master 发送一个 PSYNC Command,Master会返回复制方案,也就是下面的全量、增量及不支持这3种情况:
代码位置:replication.c/syncWithMaster()
replication.c/slaveTryPartialResynchronization()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 向主服务器发送 PSYNC 命令
reply = sendSynchronousCommand(fd,"PSYNC",psync_runid,psync_offset,NULL);

// 全量复制
if (!strncmp(reply,"+FULLRESYNC",11)) {
...
}

// 增量复制
if (!strncmp(reply,"+CONTINUE",9)) {
...
}

// 错误,目前master不支持PSYNC
if (strncmp(reply,"-ERR",4)) {
...
}

注意PSYNC命令的两个参数:

  • 主库的runID:每个Redis实例启动时都会自动生成的一个随机ID,用来唯一标识这个实例。
    当从库和主库第一次复制时,因为不知道主库的runID,因此会将runID设为”?”。
  • 复制进度offset:设为-1表示第一次复制。

4、Master接收到命令后需要判断需要全量同步还是部分同步
这部分代码在replication.c/syncCommand()中,接下来我们再讨论主节点如何判断同步方式及同步的流程。

主从复制执行过程 - Master如何处理PSYNC命令

1、无论是第一次连接还是重新连接,Master 都会启动一个后台进程(fork),将数据快照保存到数据文件中,同时 Master 会记录所有修改数据的命令并缓存在数据文件中(持久化),Master会将文件内容加载到内存中,等之后回传给Slave(复制);
2、Master端与Slave端完成握手后,需要判断是需要进行全量还是增量复制(也就是上面的返回+FULLRESYNC还是+CONTINUE
处理Slave的PSYNC命令的代码位置:replication.c/syncCommand()
判断是否需要执行全量复制的代码位置:replication.c/masterTryPartialResynchronization()
判断执行全量复制的条件如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 检查 master id 是否和 runid 一致,只有一致的情况下才考虑执行psync
if (strcasecmp(master_runid, server.runid)) {
/* Run id "?" is used by slaves that want to force a full resync. */
// 从服务器提供的 run id 和服务器的 run id 不一致
if (master_runid[0] != '?') {
redisLog(REDIS_NOTICE,"Partial resynchronization not accepted: "
"Runid mismatch (Client asked for runid '%s', my runid is '%s')",
master_runid, server.runid);
// 从服务器提供的 run id 为 '?' ,表示强制 FULL RESYNC
} else {
redisLog(REDIS_NOTICE,"Full resync requested by slave.");
}
// 需要 full resync
goto need_full_resync;
}

// 判断当前Slave带来的offset在Master的backlog中是否还能找到,找不到则执行全量复制
if (getLongLongFromObjectOrReply(c,c->argv[2],&psync_offset,NULL) !=
REDIS_OK) goto need_full_resync;

// 如果没有backlog
if (!server.repl_backlog ||
// 或者 psync_offset 小于 server.repl_backlog_off
// (想要恢复的那部分数据已经被覆盖)
psync_offset < server.repl_backlog_off ||
// psync offset 大于 backlog 所保存的数据的偏移量
psync_offset > (server.repl_backlog_off + server.repl_backlog_histlen))
{
// 执行 FULL RESYNC
redisLog(REDIS_NOTICE,
"Unable to partial resync with the slave for lack of backlog (Slave request was: %lld).", psync_offset);
if (psync_offset > server.master_repl_offset) {
redisLog(REDIS_WARNING,
"Warning: slave tried to PSYNC with an offset that is greater than the master replication offset.");
}
goto need_full_resync;
}

3、如果是部分复制
Master会向Slave发送 backlog 中从 offset 到 backlog 尾部之间的数据
代码:replication.c/addReplyReplicationBacklog()
部分复制在3.0版本和之后的版本中的实现有比较大的差异。
在3.0时,部分复制发生在Slave向Master发送PSYNC命令时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
void syncCommand(redisClient *c) {
...

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;

/* Increment stats for failed PSYNCs, but only if the
* runid is not "?", as this is used by slaves to force a full
* resync on purpose when they are not albe to partially
* resync. */
if (master_runid[0] != '?') server.stat_sync_partial_err++;
}
}

...
}

int masterTryPartialResynchronization(redisClient *c) {
...

/* If we reached this point, we are able to perform a partial resync:
* 程序运行到这里,说明可以执行 partial resync
*
* 1) Set client state to make it a slave.
* 将客户端状态设为 salve
*
* 2) Inform the client we can continue with +CONTINUE
* 向 slave 发送 +CONTINUE ,表示 partial resync 的请求被接受
*
* 3) Send the backlog data (from the offset to the end) to the slave.
* 发送 backlog 中,客户端所需要的数据
*/
c->flags |= REDIS_SLAVE;
c->replstate = REDIS_REPL_ONLINE;
c->repl_ack_time = server.unixtime;
listAddNodeTail(server.slaves,c);
/* We can't use the connection buffers since they are used to accumulate
* new commands at this stage. But we are sure the socket send buffer is
* emtpy so this write will never fail actually. */
// 向从服务器发送一个同步 +CONTINUE ,表示 PSYNC 可以执行
buflen = snprintf(buf,sizeof(buf),"+CONTINUE\r\n");
if (write(c->fd,buf,buflen) != buflen) {
freeClientAsync(c);
return REDIS_OK;
}
// 发送 backlog 中的内容(也即是从服务器缺失的那些内容)到从服务器
psync_len = addReplyReplicationBacklog(c,psync_offset);
redisLog(REDIS_NOTICE,
"Partial resynchronization request accepted. Sending %lld bytes of backlog starting from offset %lld.", psync_len, psync_offset);

...
}

3.0后,在每次命令执行完之后,还会触发命令传播:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void processInputBufferAndReplicate(client *c) {
// 处理命令然后广播命令
// if this is a slave, we just process the commands
if (!(c->flags & CLIENT_MASTER)) {
processInputBuffer(c);
} else {
/* If the client is a master we need to compute the difference
* between the applied offset before and after processing the buffer,
* to understand how much of the replication stream was actually
* applied to the master state: this quantity, and its corresponding
* part of the replication stream, will be propagated to the
* sub-replicas and to the replication backlog. */
size_t prev_offset = c->reploff;
processInputBuffer(c);
// applied is how much of the replication stream was actually applied to the master state
size_t applied = c->reploff - prev_offset;
if (applied) {

replicationFeedSlavesFromMasterStream(server.slaves,
c->pending_querybuf, applied);
sdsrange(c->pending_querybuf,applied,-1);
}
}
}

所谓命令传播,就是当Master节点每处理完一个命令都会把命令广播给所有的子节点,而每个子节点接收到Master的广播过来的命令后,会在处理完之后继续广播给自己的子节点。
命令传播也是异步的操作,即Master节点处理完客户端的命令之后会立马向客户端返回结果,而不会一直等待所有的子节点都确认完成操作后再返回以保证Redis高效的性能。
4、什么时候会改为采用全量复制
上面的增量复制中,我们看到Redis实际上是将repl_backlog中的内容复制给了Slave,backlog是一块内存缓冲区(默认大小为1M),每次处理完命令之后,先写入缓冲区repl_backlog, 然后再发送给Slave。
如果一个Slave断连了一段时间,重启后Master可以将这块缓冲区内的内容复制给Slave,但是如果断连的时间比较长,也有可能会触发全量复制,因为缓冲区能保存的命令有限,只能至多保存的命令长度为repl_backlog_length,如果某个子节点落后当前最新命令的长度大于了repl_backlog_length,那么就会触发全量复制。
5、如果是全量复制
这种情况下,Master并不会直接将RDB文件传给Slave,而是先发给Slave+FULLRESYNC,;
代码:replication.c/masterTryPartialResynchronization()的末尾
什么时候Master会将RDB文件传给Slave呢?如果当前已经有可用的RDB文件,则直接将RDB文件传输给Slave;如果当前RDB正在备份过程中,Master会在每次RDB文件备份完毕后执行一次传输任务。
replication.c/syncCommand()末尾Master判断RDB当前的备份状态,设置标识表示当前RDB文件是否可用于复制,如果可以复制则会在之后的主事件循环中触发文件的发送:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
void syncCommand(redisClient *c) {
...

/* Here we need to check if there is a background saving operation
* in progress, or if it is required to start one */
// 检查是否有 BGSAVE 在执行
if (server.rdb_child_pid != -1) {
/* Ok a background save is in progress. Let's check if it is a good
* one for replication, i.e. if there is another slave that is
* registering differences since the server forked to save */
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();
}

...

}

主事件循环中发RDB文件的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {

...

/* Check if a background saving or AOF rewrite in progress terminated. */
// 检查 BGSAVE 或者 BGREWRITEAOF 是否已经执行完毕
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1) {
int statloc;
pid_t pid;

// 接收子进程发来的信号,非阻塞
if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
int exitcode = WEXITSTATUS(statloc);
int bysignal = 0;

if (WIFSIGNALED(statloc)) bysignal = WTERMSIG(statloc);

// BGSAVE 执行完毕
if (pid == server.rdb_child_pid) {
backgroundSaveDoneHandler(exitcode,bysignal);

// BGREWRITEAOF 执行完毕
} else if (pid == server.aof_child_pid) {
backgroundRewriteDoneHandler(exitcode,bysignal);

} else {
redisLog(REDIS_WARNING,
"Warning, detected child with unmatched pid: %ld",
(long)pid);
}
updateDictResizePolicy();
}
}

...
}

接下来的调用包括:
replication.c/backgroundSaveDoneHandler()
replication.c/updateSlavesWaitingBgsave()
replication.c/sendBulkToSlave()

全量同步的大致流程如此,主要分为以下几步:

  1. Master节点开启子进程进行RDB文件生成
  2. Master节点将RDB文件发送给Slave节点
  3. Slave节点清空内存中的所有数据并删除之前的RDB文件
  4. Slave节点使用从Master接收的RDB文件恢复数据到内存中

需要注意的是,这个过程中的每一步都是耗时的IO操作,所以大部分时候Redis都是尽可能采用增量复制,而不是全量复制。
下面再来讨论Master如何发送及Slave如何接收这份数据。

主从复制执行过程 - Master如何发送及Slave如何接收复制数据

1、如果是全量复制
Slave和Master刚开始握手完毕后,会注册一个readSyncBulkPayload处理器,用于读取从Master发送过来的RDB文件。
2、Slave 将数据文件保存到磁盘上,然后再加载到内存中;
从库接收到RDB文件后,会先清空当前数据库,然后加载RDB文件,这是因为从库在开始和主库同步前可能保存了其他数据,为了避免之前数据的影响,从库需要先把当前数据库清空。
3、同步过程中主库产生的新数据也要同步给从库
主库同步数据给从库的过程中,主库不会被阻塞,仍然可以正常接收请求(否则Redis服务不就中断了?),但是这些请求中的写操作并没有记录到刚刚生成的RDB文件中,为了保证主从库的数据一致性,主库会在内存中用专门的replication buffer(代码中对应repl_backlog_buffer记录RDB文件生成后收到的所有写操作。
repl_backlog_buffer是一个环形缓冲区,主库会记录自己写到的位置,而从库则会记录自己已经读到的位置,可以使用repl_backlog_size来配置这个缓冲区的大小,如果配得过小,可能会导致增量复制阶段从库复制进度赶不上主库,进而导致从库重新进行全量复制。
在Master端定义的offset是master_repl_offset,在Slave端定义的offset是slave_repl_offset,正常情况下这两个偏移量是基本相等的。
增量同步期间,从库在发送psync的同时,会把自己当前的slave_repl_offset发给主库,主库判断自己的master_repl_offset和slave_repl_offset之间的差距,如果断连了,master_repl_offset可能会超过slave_repl_offset,那么将这超过的部分发给slave就可以恢复同步了。

主从复制存在的问题

主从库间网络断了怎么办?

Redis2.8之前,如果主从同步过程中出现了网络闪断,那么主从是会重新进行一次全量复制的,开销非常大。
Redis2.8之后,网络闪断后,主从会采取增量复制,将闪断期间的命令发给从库。

宕机恢复

因为 slave 顶多只负责处理读请求,slave 挂掉不会造成数据丢失的问题。
slave 宕机的情况下,应该要求客户端具有一定的熔断恢复能力,并且能在重启后快速恢复:

  1. 恢复正常后重新连接;
  2. Master 收到 Slave 的连接后,第一次同步时,主节点做一次 bgsave,并同时将后续修改操作记录到内存 buffer;
  3. Master 将其完整的 rdb 数据文件全量发送给 Slave;
  4. Slave 接收完成后将 rdb 镜像文件加载到内存,加载完成后,再通知 Master 将期间修改的操作记录同步到 Slave 节点进行重放就完成了同步过程;
  5. 如果 Master 同时收到多个 Slave 发来的同步请求,Master 只会在后台启动一个进程保存数据文件,然后将其发送给所有的 Slave,确保 Slave 正常。

主从复制无法应对 Master 挂掉的情况,实际上这种方案只能尽量保证数据不会丢失,不能保证服务的高可用性,为此,需要引入 Redis 的 Sentinel 机制。

客户端可以使用 WAIT 命令来请求同步复制某些特定的数据。但是,WAIT 命令只能确保在其他 Redis 实例中有指定数量的已确认的副本:在故障转移期间,由于不同原因的故障转移或是由于 Redis 持久性的实际配置,故障转移期间确认的写入操作可能仍然会丢失。

是否可以关闭持久化

作为复制方案中的一环,可以考虑关闭 Master 或 Slave 的持久化功能,但是并不建议关掉它们,因为:

  • 如果关闭 Master 的持久化:重启(重启功能可以由一些只能运维工具来保证,比如 K8S)的 Master 将从一个空数据集开始,如果一个 Slave 试图与它同步,那么这个 Slave 也会被清空。
  • 如果关闭 Slave 的持久化:重启的 Slave 需要从 Master 全量同步数据。

正如前所述,关闭了持久化并配置了自动重启的 Master 是危险的——会导致整个集群的数据全部被清空。
如果 Sentinel 集群用于需要高可用的场景、且 Master 被关闭掉了持久化功能,也是非常危险的:

  • 如果重启比较慢,Sentinel 的故障迁移机制重新选主,一个 Slave 会上升为 Master;
  • 如果重启得足够快,Sentinel 没有探测到故障,此时 Master 数据被清空了,而 Slave 仍从 Master 同步数据,这将引起上边提到的故障模式——数据将丢失。

因此,如果考虑磁盘性能过慢会导致延迟、关掉了持久化,那么自动重启进程这项应该被禁用。

如何保证主从数据的一致性 - 数据丢失窗口的存在

由于 Redis 使用异步复制,无法保证Slave和Master的实时一致性,因此总会有一个数据丢失窗口
那在什么情况下,从库会滞后执行同步命令呢?

  1. 一方面,主从库间的网络可能会有传输延迟,所以从库不能及时地收到主库发送的命令,从库上执行同步命令的时间就会被延后。
  2. 另一方面,即使从库及时收到了主库的命令,但是,也可能会因为正在处理其它复杂度高的命令(例如集合操作命令)而阻塞。此时,从库需要处理完当前的命令,才能执行主库发送的命令操作,这就会造成主从数据不一致。而在主库命令被滞后处理的这段时间内,主库本身可能又执行了新的写操作。这样一来,主从库间的数据不一致程度就会进一步加剧。

因为异步复制的本质,Redis主从复制无法完全避免数据的丢失,除了尽量保证网络连接状况良好外,还可以写一些监控程序来监控主从库间的复制进度,原理是实时给Redis实例发info replication命令得到master_repl_offsetslave_repl_offset这两个进度信息,计算这二者的差值即可得到主从复制进度的实时程度,如果某个从库进度差值大于我们预设的一个阈值,我们可以让客户端不再和这个从库连接进行数据读取,从而减少读到不一致数据的情况。

这个阈值当然不能设置得过低,否则可能导致所有从库都连不上了。

既然无法避免,那么只能退一步、控制影响范围了,Redis 可以保证:

  1. Redis slave 每秒钟都会 ping master,确认已处理的复制流的数量。
  2. Redis master 会记得上一次从每个 slave 都收到 ping 的时间。
  3. 用户可以配置一个最小的 slave 数量,使得它滞后 <= 最大秒数。
  4. 如果至少有 N 个 slave ,并且滞后小于 M 秒,则写入将被接受。如果条件不满足,master 将会回复一个 error 并且写入将不被接受。

这些条件是通过min-slaves-to-writemin-slaves-max-lag这两个配置来实现的:

  • min-slaves-to-write:最少有n个slave的连接还是健康的情况下才能提供服务,至于怎么判断连接是否健康,需要看下面一个配置;
  • min-slaves-max-lag:判断连接健康的最大延迟时间,slave每次PING Master时Master都会记录该Slave 最后一次PING的时间,如果最后一次PING成功的时间距今比较长了,就说明该Slave的连接状态很有可能已经出问题了。

对于给定的写入来说,虽然不能保证绝对实时的一致性,但至少数据丢失的时间窗限制在给定的秒数内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# It is possible for a master to stop accepting writes if there are less than
# N slaves connected, having a lag less or equal than M seconds.
#
# The N slaves need to be in "online" state.
#
# The lag in seconds, that must be <= the specified value, is calculated from
# the last ping received from the slave, that is usually sent every second.
#
# This option does not GUARANTEES that N replicas will accept the write, but
# will limit the window of exposure for lost writes in case not enough slaves
# are available, to the specified number of seconds.
#
# For example to require at least 3 slaves with a lag <= 10 seconds use:
#
# min-slaves-to-write 3
# min-slaves-max-lag 10
#
# Setting one or the other to 0 disables the feature.
#
# By default min-slaves-to-write is set to 0 (feature disabled) and
# min-slaves-max-lag is set to 10.

min-slaves-to-write <slave 数量>
min-slaves-max-lag <秒数>

过期的 key 问题

由于复制的异步特性,对 key 设置过期时间和写入操作很容易导致 race condition 及导致数据集不一致,比如:

1
2
3
(1) sadd x 1
(2) expire x 100
(3) sadd x 2

在 Master 上,命令(3)是在过期前执行的,而 Slave 上可能因为延后导致命令(3)执行前 x 就已经过期了,此时 x 是没有过期时间的(ttl x 得到-1 表示不过期),这就导致了数据的不一致。

set 命令不会出现这个问题,因为 set 会将过期时间给覆盖成-1。当然情况比较复杂,也有可能是我没有想到。

为了保证针对过期的 key 的复制能够正确工作,Redis 提供如下保证:

  1. slave 不会让 key 过期,而是等待 master 让 key 过期。当一个 master 让一个 key 到期(或由于 LRU 算法将之驱逐)时,它会合成一个 DEL 命令并传输到所有的 slave。一旦一个 slave 被提升为一个 master ,它将开始独立地过期 key,而不需要任何旧 master 的帮助。
  2. 但是,由于这是 master 驱动的 key 过期行为,master 无法及时提供 DEL 命令,所以有时候 slave 的内存中仍然可能存在在逻辑上已经过期的 key 。为了处理这个问题,slave 使用它的逻辑时钟以报告只有在不违反数据集的一致性的读取操作(从主机的新命令到达)中才存在 key。用这种方法,slave 避免报告逻辑过期的 key 仍然存在。在实际应用中,使用 slave 程序进行缩放的 HTML 碎片缓存,将避免返回已经比期望的时间更早的数据项。
  3. 在 Lua 脚本执行期间,不执行任何 key 过期操作。当一个 Lua 脚本运行时,从概念上讲,master 中的时间是被冻结的,这样脚本运行的时候,一个给定的键要么存在要么不存在。这可以防止 key 在脚本中间过期,保证将相同的脚本发送到 slave ,从而在二者的数据集中产生相同的效果。

QA

AOF日志更全,为什么主从同步不使用AOF而是RDB呢?

网络传输效率:RDB直接存储数据,而不是命令,数据量更小,传输更快。
恢复效率:因为使用AOF恢复数据库的话是需要将AOF中记录的命令再执行一次的,这个效率远不如直接将RDB中的数据直接加载到内存里。

主从切换过程中,客户端能正常进行请求吗?

主库故障后从库仍能正常接收读请求,但主库挂掉了所以无法处理写请求。

如果实现应用程序不感知服务器的中断?

  1. 客户端可以缓存写请求,因为使用Redis的场景同步写请求比较少,且一般都不会在应用程序的关键路径上,所以在不能立刻执行写请求的情况下,客户端完全可以先把请求缓存起来,给应用程序返回一个确认即可。
  2. 另外,主从切换后,客户端要能及时和新主库重新建立连接。

主从数据发生不一致怎么办?

参考

  1. Redis复制实现原理
  2. Redis集群——主从复制数据同步