ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redis主从复制原理详解:RunID、偏移量与Backlog如何决定全量还是增量同步

Redis主从复制原理详解:RunID、偏移量与Backlog如何决定全量还是增量同步

聊到 Redis 主从复制,很多人的第一反应就是执行SLAVEOF或者新版REPLICAOF,一条命令把从库指向主库,然后等数据同步完就收工。但真到生产环境里,问题远没有这么简单:主库一重启,所有从库集体触发全量重同步,内存和带宽瞬间被打满;从库只是断线十几秒,重连后居然也走了全量;更头疼的是主从切换后,明明数据只差一点点,却被迫重新同步整个数据集。要解释清楚这些现象,绕不开三个核心概念:RunID(更准确说是复制 ID)、复制偏移量、复制积压缓冲区 Backlog。

这篇文章是我在实际维护主从环境中总结出的核心经验,适合正在线上排查同步问题、或者准备深入学习 Redis 复制原理的开发者。读完你不仅能看懂INFO replication里那一堆字段,还能知道什么时候会全量、什么时候会增量,以及如何通过调整 Backlog 来尽量避免不必要的全量同步。

1. 先看全局:主从复制到底在解决什么问题

1.1 读写分离与基础容灾,但别指望它是高可用

主从复制的初衷很朴素:让一台 Redis 扛写入,让其他节点分担读取压力。一旦主库出现硬件故障或者进程崩溃,从库仍然握着完整数据,配合哨兵可以完成自动切换,这是它在生产环境中最大的价值。

但必须强调一个很多人忽略的前提:Redis 复制是异步的。主库执行写命令之后,并不会等所有从库都确认收到,而是直接把结果返回给客户端,再异步把命令传播给从库。这意味着从库上的数据天然存在一个时间差,严格来说不是强一致,而是“最终一致”。如果主库在命令传播前宕机,这部分数据就丢了,即使有从库也无济于事。

所以主从复制解决的是“读扩展”和“故障后还能有数据可用”的问题,但它本身不提供自动故障转移,也不保证零丢失。真正的自动高可用要交给哨兵,数据强一致要换方案。很多人把主从当成高可用,线上出问题才发现没人帮他把从库顶上,这是最常见的认知误区。

1.2 一次复制会话从开始到结束的完整链路

一次看似简单的同步,内部其实是一条完整的状态机链路。从库启动并配置好主库地址后,会经历以下阶段:

  1. 从库建立到主库的 TCP 连接,发PING确认主库存活。
  2. 如果配置了密码,完成AUTH认证。
  3. 从库发送REPLCONF listening-port <port>告知自己的监听端口,方便主库侧信息展示和后续检测。
  4. 从库发送PSYNC <replid> <offset>,尝试部分同步或者全量同步。
  5. 主库根据情况返回FULLRESYNC或CONTINUE。
  6. 全量同步时代主库需要生成 RDB 快照并传送到从库,期间产生的写命令会缓存在 backlog 中,从库加载完 RDB 后继续接收增量命令。
  7. 进入正常增量传播阶段,主库把新写命令推送给从库,从库周期性回复REPLCONF ACK <offset>上报自己的进度。

这里最关键的一步就是第 4 步:从库如何决定自己该全量还是增量?答案藏在它发送的replid和offset里,也就是标题里提到的 RunID 和 Backlog 的联动逻辑。

2. RunID:决定全量还是增量的身份凭证

2.1 RunID 到底是什么,为什么一变就要全量

Redis 每个实例在启动时会生成一个 40 位的十六进制随机字符串,用来唯一标识当前实例。在老的复制协议里,这个 ID 直接被称为 RunID;在现在的INFO replication输出里,你更多看到的是master_replid和master_replid2,可以把它看作 RunID 在复制场景下的具体形态。

为什么从库要把主库的 RunID 记住?因为复制可以理解为:从库告诉主库“我是跟着这个主库的,我已经同步到了这个偏移量”。主库收到PSYNC <replid> <offset>后,会先检查 replid 是否对得上:如果对不上,说明我们之间没有历史关系,直接全量同步;如果对得上,再检查 offset 是否还在自己的 backlog 覆盖范围内,在就增量,不在就全量。

所以 RunID 本质上是复制断点的“主机名”。只要主库实例没有变化,从库每次重连都可以拿着旧坐标回来续传。但 Redis 重启后会生成新的 RunID,从库拿着旧 ID 去找新实例,主库自然不认识它,只能回FULLRESYNC,于是所有从库瞬间全部进入全量重同步。实际生产中我见过最惨烈的一次,就是主库因为内存碎片问题被运维直接重启,结果二十多个从库同时开始全量拉数据,主库一边生成 RDB 一边喂增量,IO 直接打满,整个业务读了半天旧数据才慢慢恢复。

请注意,重启并不是 RunID 变化的唯一路径。用REPLICAOF NO ONE把一个从库提升为主库时,它会生成新的复制 ID,同时把旧 ID 保留在master_replid2上,这是 4.0 版本 PSYNC2 的重要能力,下一节展开说。

2.2 PSYNC2 如何帮我们从全量同步里省下来

Redis 4.0 引入的 PSYNC2 是一次非常重要的升级。它引入了master_replid和master_replid2两个字段,为什么需要两个?因为处理主从切换场景。

假设 A 是主库,B 和 C 是从库,B 提升为新主库。对于 C 来说,它原本同步的是 A,现在要被指到 B。如果只有单一 replid,C 拿着 A 的 ID 去找 B,B 一看不认识,只能全量。但在 PSYNC2 里,B 提升时会把老主 A 的 replid 记入自己的master_replid2,同时生成属于自己的新master_replid。C 拿着 A 的 replid 来尝试PSYNC时,B 能认出来:这是我上一任主库的 ID,我们确实有共同的历史数据。如果 C 的偏移量也在 B 的 backlog 范围内,就允许部分重同步,省掉全量拉取。

这就是为什么现在很多架构里,主从切换后从库不会全部全量重同步,除非数据已经老到超出 backlog 范围。实际运维时,建议不要随便清掉master_replid2,同时尽量让从库保持对老主的记忆,切换后立即观察sync_partial_ok计数,如果大量走sync_full,就要检查 backlog 是否太小,或者切换过程中断线时间太长。

2.3 从库侧查看 RunID 相关状态

在从库上执行INFO replication,会看到类似这样的关键字段:

master_replid:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx master_replid2:0000000000000000000000000000000000000000 master_repl_offset:12345 second_repl_offset:-1

如果主从关系正常,master_replid应该和主库的master_replid一致。如果从库还没有完成全量同步,这几个字段会显示为空或全零。判断是否发生过切换,主要看master_replid2是否非全零,以及second_repl_offset是否大于零。

3. Backlog:部分重同步的兜底缓冲区

3.1 复制积压缓冲区怎样决定“能不能续传”

Backlog 是主库上维护的一块环形内存缓冲区,专门用来存放最近传播过的写命令。从库每次上报REPLCONF ACK <offset>,主库就能知道自己该从哪里开始补发。如果从库断线后重连,并且它请求的偏移量仍然落在 backlog 的有效范围内,主库返回+CONTINUE,只把这个偏移量之后的新命令发过去,这就是部分重同步。

问题在于 backlog 是环形结构,容量有限,新命令会不断覆盖旧命令。如果从库断线太长时间,或者主库写入太快,从库断线时的偏移量已经被覆盖出缓冲区,主库就再也没有办法找回这段历史命令,只能返回+FULLRESYNC,重新全量同步。

默认配置repl-backlog-size只有 1MB,这在很多业务里连几秒钟的写命令都装不下。我见过最典型的案例:主库每秒钟写入几 MB,从库因为网络抖动断开三十秒,重连后恨不得立刻全量,这就是 backlog 太小导致的。所以默认配置更像是一个“能跑但千万别出事”的保底值,生产环境必须结合写入速率重新估算。

3.2 Backlog 大小估算:用公式而不是靠感觉

估算 backlog 大小只需要一个公式:Backlog 大小 ≥ 平均写入速率 × 预期最大断线时间。

比如你的主库每秒写入 5MB,你希望从库断线 60 秒也能增量续传,那 backlog 至少要有 5MB × 60s = 300MB。考虑到主库可能瞬时写入翻倍、从库重连还要时间,我一般会再留 1.2~1.5 倍余量,也就是 400MB 其实并不夸张。但要注意它占的是内存,机器内存不够时,这个值反而会拖累整个实例,所以还需要同时想办法降低断线频率。

配置方式有三层:

# 运行时临时调整 CONFIG SET repl-backlog-size 256mb # 固化到 redis.conf repl-backlog-size 256mb # 控制没有任何从库连接时,backlog 保留多久再释放 repl-backlog-ttl 3600

repl-backlog-ttl默认是 3600 秒,意思是如果从库都断开超过一小时,主库就释放这块缓冲区。如果业务不固定带着从库,可以缩短这个时间释放内存;如果长期有一主多从,那就让它一直活着比较稳妥。

举个例子便于理解:backlog 就像是公司门口的一块电子公告牌,上面贴满了最近几小时的出入记录。你要查的是“这个人离开后的那段时间发生了哪些事”,如果公告牌容量够大,贴得足够勤快,你查到的是最后那几条记录,直接补上就行;如果离开太久了,公告牌上的记录已经被后来的通知盖满,你只能重新查档案系统,也就是全量同步。

3.3 查看 Backlog 命中情况的几个计数器

主库上执行INFO stats,重点看这几个计数器:

sync_full:0 sync_partial_ok:1 sync_partial_err:3

sync_full是执行了多少次全量同步;sync_partial_ok是部分重同步成功次数;sync_partial_err是部分重同步失败次数。如果sync_partial_err持续增长,而 backlog 已经不小,就要去看是不是从库断线太久、复制链路不稳定,或者从库的偏移量被重置过。一个健康的长期运行环境,应该做到大部分重连都走sync_partial_ok,全量同步只出现在首次建连和极端故障后。

4. 从零实操:一主两从并模拟断线重连

4.1 准备三个实例并建立主从关系

实践出真知,我建议在一台开发机或容器里用三个实例体验完整链路。Master 监听 7000,两个 Replica 分别监听 7001 和 7002。最简单的办法是在三个不同的配置目录中分别写入基本配置,从库可以不写replicaof,稍后通过命令动态建立关系,这样后续重置也方便。

主库配置示例:

port 7000 daemonize yes dir /data/redis/7000 logfile /data/redis/7000/redis.log

从库 7001 的配置差异很小,关键是建立关系:

redis-cli -p 7001 replicaof 127.0.0.1 7000 redis-cli -p 7002 replicaof 127.0.0.1 7000

如果 Redis 版本较老,用slaveof也一样,但新版本里推荐replicaof,语义上更准确。

建连后立即看主库状态:

redis-cli -p 7000 info replication

你会看到类似输出:

role:master connected_slaves:2 slave0:ip=127.0.0.1,port=7001,state=online,offset=100,lag=0 slave1:ip=127.0.0.1,port=7002,state=online,offset=100,lag=0 master_replid:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx master_repl_offset:100

两个从库 offset 都是 100,说明已经完成全量同步,并且追上主库。

4.2 验证全量同步、增量传播和部分重同步

在主库写一批数据,比如:

redis-cli -p 7000 mset k1 v1 k2 v2 k3 v3

再到从库查:

redis-cli -p 7001 mget k1 k2 k3

能查到就说明增量传播正常。然后模拟从库短时间断线:直接对 7001 执行SHUTDOWN NOSAVE,再启动它。如果此时 backlog 足够大,重连后不会全量,主库日志会出现+CONTINUE,INFO stats里的sync_partial_ok增加。

为了对比,可以把 backlog 设置成极小值,再让从库断线时主库持续写入大量数据,比如用管道写入几十万条 key,重新连上从库后会看到+FULLRESYNC,从库从零开始拉取全量。这个对照实验特别有价值,它能让你直观感受到“偏移量被覆盖”和“replid 不匹配”两者各自如何触发全量同步。

4.3 模拟主从切换,观察 replid2 的变化

把 7001 提升为主库:

redis-cli -p 7001 replicaof no one

此时 7001 会变成新主库,INFO replication里role:master,并且master_replid和之前的旧 replid 不同。再把 7002 指向新的 7001:

redis-cli -p 7002 replicaof 127.0.0.1 7001

如果数据滞后不大,PSYNC2 能识别出 7002 是从同一数据链里来的,大概率可以走部分重同步。注意观察master_replid2字段是否保存了旧 ID。这一步在面试和排查中都特别会问到:为什么 Redis 4.0 之前主从切换很容易全量?就是因为没有保留老 ID 的机制,从库只能从头再来。

我在动手做这个实验时踩过一个坑:切换前没有等 7001 完全追上主库的 offset,导致 7001 缺失了一部分最新数据。它成为新主库后,7002 切换到它,理论上走部分同步也没法把缺失那段变出来,因为数据源头已经丢了。这说明一个残酷的现实:切换节点的数据新鲜度直接决定了切换后丢数据窗口的大小,所以做切换前务必要确认候选从库的 offset 与主库几乎没有差距。

5. 实战中的坑:主从同步问题的定位与解决

5.1 高频问题速查表

下面这张表是我在处理线上问题时整理出来的,可以当成排障清单直接参考。

现象可能原因排查入口解决办法
从库一直全量重同步主库重启 / replid 变化INFO replication中 replid 是否一致开启持久化,避免空主库启动后强行全量
断线重连还是全量backlog 太小,偏移量被覆盖INFO stats中 sync_partial_err调大repl-backlog-size,估算写入速率
主从切换后大量全量候选从库滞后太远 / replid 未继承观察master_replid2和second_repl_offset切换前确保候选从库尽量追上主库
从库数据追不上主库大 key、慢命令、网络带宽瓶颈慢日志、INFO replication的 lag拆分大 key,增加带宽,必要时优化写模式
从库写入报 READONLY从库默认只读检查replica-read-only默认保持只读,不要强行关闭
主库内存暴涨大量从库同时全量,RDB fork看主库内存趋势、子进程状态错峰重连,避免同时全量
部分命令没同步到从库网络分区、长阻塞命令比较 master_repl_offset 与 slave_repl_offset修复网络,降低主库阻塞

5.2 排查步骤:从 INFO 到日志,别一上来就重启

遇到主从不同步,我建议按顺序做:

  1. 先执行INFO replication,区分角色、offset、lag。看到lag越来越大,通常是网络滞后或者从库处理能力不足。
  2. 在主库执行INFO stats,看sync_full、sync_partial_ok、sync_partial_err的累计值和增长率,判断全量同步是否频繁发生。
  3. 看主库日志。日志里如果频繁出现Partial resynchronization request rejected,几乎可以断定是 offset 已经不在 backlog 范围,优先查 backlog 和断线时长。
  4. 从库日志里出现MASTER <-> REPLICA sync started和Full resync from master,说明刚刚做了一次全量同步,查看时间是刚重启过主库还是 backlog 不够。
  5. 确认是否有大 key。一个包含几百 MB 的字符串 value,在主库传播到从库时可能阻塞整个复制线程,让所有从库滞后。

有一次生产事故,我排查半天发现主从 offset 差距一直在涨,但网络和 CPU 都正常。最后用SLOWLOG GET一看,主库有一条针对超大 hash 的HGETALL慢查询,虽然不会改数据,但一条命令占了复制线程太久,导致后续写命令全部排队。这种问题不调整数据结构根本解决不了。

5.3 防止全量同步风暴的几个有效手段

全量同步风暴的典型场景是:所有从库在同一时刻检测到主库不可用,或者主库重启后重连,于是同一时间发起全量同步。主库为了满足所有从库,连续 fork 子进程生成 RDB,CPU、内存、磁盘 IO 全部飙高,严重时可能把主库打挂。

对策我是这么做的:

  • 错峰重连。给不同从库配置不同的repl-backlog-ttl和脚本触发延迟,避免所有实例同时启动。
  • 让从库继续缓存数据。不要一检测到主库下线就立刻把所有从库重启,先观察哨兵或人工决策。
  • 主库开启持久化。主库重启后如果 AOF 或 RDB 能恢复旧数据,从库带着旧 replid 去找它,可能还能走部分同步;如果主库是干净空库启动,从库只能全量重建,危险系数最大。

6. 一点个人体会,关于主从复制的几个常被忽略的细节

我自己踩过几次坑之后,对主从复制的态度彻底变了:配置一条命令不是结束,而是运维工作的开始。你要不停观察 backlog 是否匹配写入速率,观察复制偏移量是否健康,观察全量同步计数器是不是在异常增长。很多人问我为什么从库总是全量,我第一句永远反问:主库最近有没有重启过?持久化开了没有?backlog 多大?这三个问题基本能定位八成问题。

还有一个经验是,主从复制不是越复杂越好。很多新手喜欢搞三级甚至四级链式复制,以为能分担主库压力,但每多一层就多一次延迟和故障点,排查链路也更困难。除非单主实例连接数确实撑不住,否则一主多从就够了,最多再用哨兵做高可用。

关于 RunID 和 replid 的关系,我建议你亲手做一遍上面的切换实验,亲眼看到master_replid变成新值、master_replid2保留了旧值,才会真正理解为什么 PSYNC2 是一个革命性升级。纸上谈兵再多,不如一次断线重连观察日志来得深刻。

最后再分享一个小技巧:每次调整repl-backlog-size时,用CONFIG SET改完虽然能立即生效,但它不会持久化到配置文件,Redis 一旦重启就回到旧值。线上一定要把调整同步写进 redis.conf,并且启动后再次核对INFO replication里的repl_backlog_size是否等于预期值。我见过不止一次,运行时改了参数、看着没问题,第二天重启后全量风暴又来,就是吃了这个亏。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进