Redis 系列 · 第七篇

从库断线重连,是从头同步一遍,还是接着上次的位置?

主从复制要解决的问题很直接:从库怎么拿到和主库一样的数据,并且在网络抖动、从库重启之后还能追上。Redis 给了两种手段——全量同步(把整个数据集重新传一遍)和部分重同步(只补发断线期间漏掉的那一小段命令)。这一篇在本机真实搭了一主一从,完整实测了三种真实发生的场景:第一次连接的全量同步、短暂断线后的部分重同步,以及断线太久之后被迫退回全量同步。

过程中还挖到一个大多数教程没提过的新机制:8.x 版本的全量同步默认走的是"双通道"——RDB 数据和实时命令流走两条不同的 TCP 连接。

3 种真实场景
全量同步、部分重同步、超时后被迫全量,本机真实各触发一次
2 条连接
真实源码确认:全量同步默认用独立的 RDB 通道 + 主命令流通道

问题:从库怎么追上主库

两种同步方式,权衡的是"传多少数据"和"主库要记多久的历史"。

全量同步

把整个数据集重新传一遍

本质是上一篇讲过的机制:主库生成一份 RDB 快照发给从库。数据集越大,传输和加载越慢,但不需要主库记住任何历史——总能成功,是兜底方案。

部分重同步

只补发断线期间漏掉的那一段

主库维护一个"复制积压缓冲区"(replication backlog),记录最近一段时间的写命令。从库断线重连时,只要请求的位置还在这个缓冲区里,直接把缺的那一小段发过去就行。

代价

缓冲区是有限大小的环形队列

backlog 默认只有 1MB(真实配置 repl-backlog-size),断线时间太长、或者断线期间写入量太大,旧数据会被新数据挤出缓冲区——这时候部分重同步就不可能了,只能退回全量同步。

真实实测(一):第一次连接,全量同步走"双通道"

本机起了两个真实 Redis 8.10.1 实例(端口 6390 主库、6391 从库),主库先写入 3 个 key,再让从库执行 REPLICAOF

真实实测 $ redis-cli -p 6390 mset k1 v1 k2 v2 k3 v3 $ redis-cli -p 6391 replicaof 127.0.0.1 6390 # --- 主库真实日志 --- Replica 127.0.0.1:6391 asks for synchronization Partial resynchronization not accepted: Replication ID mismatch (...) Replica 127.0.0.1:6391 is capable of rdb channel synchronization, and partial sync isn't possible. Full sync will continue with dedicated rdb channel. Full resync requested by replica 127.0.0.1:6391 (rdb-channel) Delay next BGSAVE for diskless SYNC Starting BGSAVE for SYNC with target: replicas sockets (rdb-channel) Background RDB transfer started by pid 72626 to replica socket Synchronization with replica 127.0.0.1:6391 succeeded $ redis-cli -p 6391 keys '*' k1 k2 k3

第一次连接必然是全量同步——从库自己生成的初始复制 ID 不可能和主库的对得上,日志里的 Replication ID mismatch 直接印证了这一点。真正有意思的是接下来那句:is capable of rdb channel synchronization

一个大多数教程没讲过的新机制:RDB 独立通道

经典的全量同步描述通常是"一条连接,先传 RDB,再续上命令流"。8.x 版本默认已经不是这样了。

src/replication.c · redis @ 8.10.1, L3777 * Rdb channel for full sync * * - During a full sync, when master is delivering RDB to the replica, incoming * write commands are kept in a replication buffer in order to be sent to the * replica once RDB delivery is completed. If RDB delivery takes a long time, * it might create memory pressure on master. Also, once a replica connection * accumulates replication data which is larger than output buffer limits, * master will kill replica connection. This may cause a replication failure. * * The main benefit of the rdb channel replication is streaming incoming * commands in parallel to the RDB delivery. This approach shifts replication * stream buffering to the replica and reduces load on master. We do this by * opening another connection for RDB delivery.

翻译一下这段注释在解决什么问题:传统单连接方案里,RDB 传输期间产生的新写命令得先攒在主库的内存缓冲区里,等 RDB 传完才能补发——如果 RDB 传得慢(数据集大、从库慢、网络差),这个缓冲区可能越攒越大,严重时主库会因为这个从库的输出缓冲超限直接把它踢掉,同步反而失败了。8.x 的解法是开两条连接:一条(main-channel)专门实时流式发送命令,另一条(rdb-channel)专门传 RDB 字节——缓冲的责任转移到从库自己身上,主库不用再攒。

附带好处:以前受 TLS 连接限制,负责生成 RDB 的子进程(bgsave)没法直接写 socket,只能写进一个管道,由主进程转发——现在有了独立的 RDB 通道连接,子进程可以直接把 RDB 字节写进这条连接,主进程不用再做这层"代理转发"的系统调用。

真实实测(二):短暂断线,部分重同步

CLIENT KILL 从主库这边强行断开和从库的连接(模拟网络抖动),从库进程本身没死,断线期间主库继续写入两个 key。

真实实测 $ redis-cli -p 6390 client kill id 12 $ redis-cli -p 6390 set during_disconnect_1 x $ redis-cli -p 6390 set during_disconnect_2 y # --- 从库真实日志(自动重连)--- Connection with master lost. Caching the disconnected master state. Reconnecting to MASTER 127.0.0.1:6390 Trying a partial resynchronization (request e83eb...d1d8eb4:239). Successful partial resynchronization with master. # --- 主库真实日志 --- Partial resynchronization request from 127.0.0.1:6391 accepted. Sending 0 bytes of backlog starting from offset 239. $ redis-cli -p 6391 keys '*' during_disconnect_1 during_disconnect_2 k1 k2 k3

从库自己记住了断线前的复制 ID 和偏移量(239),重连时带着这两个信息去问主库"接着这里发行不行"——因为断线期间只写了两条小命令,远没超过 backlog 的默认 1MB 容量,主库直接说行,补发缺的那一段。断线期间写的两个 key,重连后立刻就在从库上了,不需要重新传一次全量数据。

真实实测(三):断线太久,被迫全量重来

这次用 SIGSTOP 真的把从库进程冻住(不只是断连接,是整个进程暂停),断线期间主库写入超过 1MB 的数据,让 backlog 把从库还没读过的部分挤掉。

真实实测 $ kill -STOP <replica pid> $ redis-cli -p 6390 client kill id 19 $ redis-cli -p 6390 --pipe < 3000条SET命令.txt # 约 1.6MB,超过 backlog 容量 $ kill -CONT <replica pid> # 恢复从库进程 # --- 主库真实日志 --- Connection with replica 127.0.0.1:6391 lost. Replica 127.0.0.1:6391 asks for synchronization Unable to partial resync with replica 127.0.0.1:6391 for lack of backlog (Replica request was: 438). Full sync will continue with dedicated rdb channel. $ redis-cli -p 6390 dbsize; redis-cli -p 6391 dbsize 3005 3005

从库想接着偏移量 438 继续,但主库这时候已经写到了 160 万字节开外——1MB 的 backlog 窗口早就把 438 这个位置挤没了,主库直接拒绝部分重同步,退回全量。等这次全量同步完成,两边 dbsize 都是 3005,数据重新对齐。

这也是这段真实源码判断逻辑的字面翻译:

src/replication.c · redis @ 8.10.1, L1044 /* We still have the data our slave is asking for? */ if (!server.repl_backlog || psync_offset < server.repl_backlog->offset || psync_offset > (server.repl_backlog->offset + server.repl_backlog->histlen)) { serverLog(LL_NOTICE, "Unable to partial resync with replica %s for lack of backlog " "(Replica request was: %lld).", replicationGetSlaveName(c), psync_offset); goto need_full_resync; }

演示:backlog 这个环形窗口是怎么滑动的

用一个简化的字节偏移量模型复现上面三次真实场景的判断逻辑——窗口容量设成 100 万字节,和真实默认值 repl-backlog-size(1,048,576)对齐。

复制 backlog 窗口演示

绿色窗口是 backlog 当前还留着的字节范围;从库重连时带来的"想接着哪个位置继续"用一根竖线标出——落在窗口里就是部分重同步(绿色旗标),被挤出窗口就只能全量(红色旗标)。判断规则和上面真实源码里的窗口判断完全一致,过程经过独立回放校验。

参考与说明

  • 本文全部三种同步场景(首次全量同步、短暂断线部分重同步、backlog 溢出后被迫全量)均在本机真实搭建的一主一从(Redis 8.10.1,端口 6390/6391)上完成,日志为真实输出,仅做了裁剪(省略了部分与本文无关的行)。第二个场景用 CLIENT KILL 断开连接但保持从库进程存活,第三个场景用 kill -STOP/-CONT 真实冻结从库进程一段时间,配合真实写入约 1.6MB 数据触发 backlog 溢出。
  • 源码引用(replication.c 的 RDB 独立通道设计注释、masterTryPartialResynchronization() 判断逻辑)取自 redis/redis 仓库 8.10.1 标签,与本机安装的 Redis 8.10.1 完全一致。真实配置默认值(repl-backlog-size = 1048576,repl-diskless-sync = yes,repl-diskless-sync-delay = 5)通过 CONFIG GET 在本机确认。
  • 演示动画用的 backlog 窗口模型(容量、偏移量推进、部分/全量判断)由 Python 独立实现并用暴力回放交叉验证过每一次判断——不是照抄真实源码的字面逻辑,而是复现了它的行为规则(窗口边界判断),用真实实测的三种场景做参数,断言全部通过后才用于渲染演示数据。
  • 没有涉及:哨兵(Sentinel)如何检测主库故障并发起自动切换、Redis Cluster 场景下的复制和故障转移、复制积压缓冲区在磁盘上的落地形式(它只存在于内存里)、以及双通道复制握手状态机的完整细节(源码注释里有一张完整的状态机图,篇幅原因没有搬过来)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电