ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Redis高可用基石:主从复制核心机制与排障实战

Redis高可用基石:主从复制核心机制与排障实战

要说 Redis 高可用,我向来主张先把主从复制这块硬骨头啃下来,再去看哨兵和集群。原因很简单,不管是哨兵做故障转移,还是集群里的分片容灾,底层跑的都是同一套主从复制逻辑。你如果只会往配置文件里写一行 replicaof,那遇到同步延迟、断线后疯狂全量复制、主库切换后从库数据对不齐这类问题,基本就是抓瞎。这篇是 Redis 高可用进阶的第一篇,我就把主从复制的核心机制从头拆到尾:从复制ID和偏移量这两个地基概念,到全量同步、增量续传的完整链路,再到日常运维里最值得关注的参数和排障手法。

1. 复制的本质和定位——高可用进阶为什么要先啃主从复制

1.1 单机 Redis 的三个软肋,复制逐一化解

先搞清楚一件事:主从复制解决的是什么?最直观的是单点故障。我早期接手过一套线上 Redis,单节点部署,某天物理机宕机,整个业务晚高峰直接瘫痪。等运维把机器恢复、重新加载 RDB,时间根本不是自己说了算,好几个 G 的快照加载加上后面追日志,用户体验已经被打得稀烂。所以“数据必须有热副本”是主从复制存在的第一理由。

其次是读压力。一台 Redis 哪怕纯内存,在单线程模型下,复杂命令一多,CPU 还是会成为瓶颈。把一部分读流量分流到从库,是成本最低的横向扩展方式。注意我说的是读,写入依然集中在主库。主从复制不是一个分布式分片方案,它解决的是可用性和读扩展,不是容量扩展。容量要上去得靠集群,这个后面再说。

第三,也是最容易被忽略的一点,主从复制是 Redis 所有高可用方案的底座。你去看哨兵,它的核心工作就是发现主库故障后,把一个数据最新的从库提升为新主库;你去看集群,每个分片都是一个主节点带若干从节点的复制组。底层的同步机制完全相同。所以说白了,搞不懂主从复制的人,看哨兵和集群只能看到 API,看不到内核。

1.2 复制在 Redis 高可用演进路线里的位置

Redis 的高可用演进其实是一条很清晰的路线:单机 -> 主从复制 -> 哨兵 -> 集群。每一步都不是凭空出现的替代关系,而是向上叠加。主从复制提供了“一台机器倒下,还有副本能顶上”的基本能力;哨兵把“顶上”这个动作自动化,不再需要人工去改从库的 replicaof;集群则进一步把数据分片和多副本组合起来,解决超大容量下的高可用。

理解了这条线,你就会明白为什么我把主从复制放在第一篇。它不是最炫的技术,但它是后面所有方案的地基。地基不稳,后面全是空中楼阁。

2. 复制的地基:复制 ID 与复制偏移量

2.1 复制 ID:数据历史的身份证

很多第一次接触 Redis 内部机制的人都会有一个疑问:主从之间用 IP 和端口来标识关系不就行了吗?为什么还要搞一个复制 ID(replid)?IP 端口是网络层面的身份,它只能告诉你“我连的是哪台机器”,不能告诉你“这台机器上的数据属于哪段历史”。

复制 ID 是 40 字节十六进制字符串,主库每次启动都会生成一个全新的 replid。从库首次全量同步成功后,会把主库的 replid 继承下来。这相当于主从之间确认了“我们来自同一条数据时间线”。一旦主库重启,replid 变了,旧从库带着旧 replid 来请求同步,主库一看身份对不上,只能走全量复制。

为什么要设计得这么严格?因为 Redis 的复制本质是命令流的重放,命令执行顺序错乱一点,数据就分叉了。复制 ID 就是用来判断“两个节点是否在同一个数据历史上”的准绳。

这里还牵出一个关键概念:replid2。它是主库保留的“上一个 replid”。比如从库 A 被提升为新主库时,它会把自己的旧 replid 保留为 replid2。原来同一批的兄弟从库带着旧 replid 请求同步时,新主库能认出它们,继续增量走,而不是一股脑全量。这就是 PSYNC 2.0 让人印象最深的改进,后面断线续传部分我还会细讲。

2.2 复制偏移量:逐字节对齐的游标

复制偏移量(offset)是一个不断累加的计数器,单位是字节。主库每发送一段复制流,master_repl_offset 就累加;从库每执行完一段复制流,slave_repl_offset 也跟着累加。主从各自维护自己的 offset,最终它们应该收敛到同一个值。

你可以把 offset 想象成两本账本上的页码。主库写完了第 100 页,从库只记到第 98 页,说明还有两页内容没追上。通过差值,我们就能精确判断同步延迟的大小。这也是从库做心跳上报的核心数据,从库每秒都会把自己的 offset 通过 REPLCONF ACK 报告给主库,主库据此更新 lag 字段。

有一点值得说清楚,offset 不是命令条数,而是字节数。一条 SET 命令可能就几十字节,一条批量写入或者一个超大 key 的写入,可能一下就推进几千字节。所以用 offset 的差距判断延迟,比看命令数更准确。

2.3 握手协议:PSYNC 的两种裁决

从库发起同步时,核心命令是 PSYNC,格式是:

PSYNC <replid> <offset>

如果从库是第一次连接,或者不记得主库的 replid 了,它会发:

PSYNC ? -1

主库收到后,会做两个判断:从库请求的 replid 是不是我的(或者我的 replid2),以及请求的 offset 是否还在我的复制积压缓冲区有效区间内。两个条件都满足,返回 +CONTINUE,进入增量续传;任何一个不满足,就返回 +FULLRESYNC ,走进全量同步。

这一段是整个复制机制的“入口裁决”,也是后续所有流程的分岔口。理解它,下面再说全量和增量就顺理成章了。补充一点历史:Redis 2.8 之前只有 SYNC 命令,断线重连一律全量同步,代价极大;2.8 引入 PSYNC 支持部分重同步;4.0 又做了 PSYNC 2.0,专门解决主从切换后兄弟从库被迫全量的痛点。

3. 全量复制:从零到一完整链路拆解

3.1 首次同步的十个关键环节

第一次同步最直观也最复杂,我把完整链路拆成十个环节:

  1. 从库配置 replicaof 记录主库地址,主动发起 TCP 连接;
  2. 连接建立后,从库发送 PING 确认主库存活;
  3. 如果主库开启了密码认证,从库发送 AUTH 完成身份校验;
  4. 从库发送携带 replid 和 offset 的 PSYNC 命令;
  5. 主库发现无法增量续传,返回 +FULLRESYNC,携带自己的 replid 和最新 offset;
  6. 主库触发 BGSAVE,fork 出子进程生成 RDB 快照;
  7. RDB 生成期间,主库继续服务,新来的写命令不会进 RDB,而是同时写入复制积压缓冲区和当前从库的发送缓冲区;
  8. 主库把 RDB 文件发送给从库;
  9. 从库接收完 RDB 后,先清空旧数据,再加载 RDB 到内存;
  10. 主库把积压的写命令继续发给从库,从库执行完毕后进入 online 状态,转为持续增量复制。

这十个环节看起来不复杂,但每一个都藏着一个“为什么”。我挑几个关键的说。

第 6 步为什么用 fork 子进程而不是在主线程直接生成快照?因为 Redis 是单线程服务模型,如果主线程一边处理客户端请求一边写 RDB,期间的停顿会让延迟飙升。fork 子进程的好处是:子进程和主进程共享同一份内存快照,子进程专注生成 RDB,主进程继续处理命令。代价是 fork 瞬间要复制页表,而且后续主进程每写入一个内存页,都会触发写时复制(COW),内存占用会临时上涨。

第 9 步从库为什么要先清空旧数据?这一点很多新手想不通。原因在于复制是全量覆盖,为了保证从库和主库的数据历史严格一致,旧数据必须让位给主库发来的 RDB。这也是为什么从库在加载 RDB 期间,对外服务能力会下降甚至暂时不可用,它正在主动重构自己的数据集。

3.2 为什么同步期间的写入不会丢

有一个非常容易困惑的点:RDB 是某个时间点的静态快照,那在快照生成之后、从库加载完成之前,主库上的新写入怎么办?答案是这些写入不会被丢,全量同步不是“RDB 一个文件就搞定”的事,而是“RDB + 积压写命令”的合力。

RDB 生成期间,主库会同时做两个动作:一是把复制流写进全局的 repl_backlog 复制积压缓冲区,这是为了将来其他从库断线重连时能做增量续传;二是写入当前正在同步的从库自己的输出缓冲区,这是为了在这个从库加载完 RDB 之后,立刻把缺失的命令补给它。等从库把 RDB 加载完,主库就开始吐缓冲区里积压的命令,从库逐条执行,直到 offset 彻底对齐。

这个设计让我想到一个词:接力。RDB 负责把过去的全量状态搬过去,积压命令负责把搬的过程中新发生的事补上。两者合在一起,才能保证最终一致。

3.3 主库资源消耗与无盘复制

全量同步对主库的资源消耗是实打实的。首先是 fork 子进程带来的内存暴涨,内存越大的实例越明显。我给你一个具体感觉:一台 8G 内存、写入压力不小的 Redis,全量同步期间 COW 可能让实际内存多出 1 到 2G。如果机器内存本就很紧张,这个峰值很可能触发 OOM。其次是磁盘,BGSAVE 要写 RDB 文件,磁盘慢的话同步时间会被拉长。再就是带宽,RDB 动辄十几 G,网络差一点,传输时间非常可观。

针对磁盘 IO 这种瓶颈,Redis 提供了无盘复制(diskless replication),核心参数是:

repl-diskless-sync yes repl-diskless-sync-delay 5

开启后,主库不再生成 RDB 文件落盘,而是由 fork 出的子进程直接把 RDB 数据流式写到从库的 socket。适合磁盘慢、网络带宽充足的环境。但要注意,它也有短板:没有落盘 RDB 文件,等于少了一份可用于备份的快照;而且如果从库中途断开,整个传输就得推倒重来。所以这个开关要根据实际硬件环境来定,别盲目开启。还有一个细节,如果多个从库同时发起全量同步,主库会等待 repl-diskless-sync-delay 的秒数,把多个从库的请求合并成一次快照生成,减少 fork 次数和系统开销。

4. 增量复制:断线续传的核心智慧

4.1 复制积压缓冲区:一条可以倒带的录像带

前面的全量流程里反复提到了 repl_backlog,但它真正的主场在这里。复制积压缓冲区是主库在内存里维护的一个环形缓冲区,保存最近一段时间内复制流的字节内容。我把它比作录像带:它只保留最近一小段,新内容不断写入,旧内容被覆盖。从库断线重连后,主库只要确认从库请求的 offset 还在这盘录像带里,就能直接把缺失那段数据发过去,不需要从头再来。

这里很容易和另一个概念混淆:client output buffer。backlog 是全局的,属于主库,服务于任何一个可能断线重连的从库;而 output buffer 是每个从库连接独立的,服务于正在同步的这个从库。简单说,backlog 面向过去的断线,output buffer 面向当下的传输。这两个缓冲区在全量同步期间同时工作,但职责完全不同。

4.2 断线重连:CONTINUE 还是 FULLRESYNC

从库断线后重连,心里是“有底”的:它记得自己已经执行到哪个 offset 了,也知道自己继承的是哪条数据历史的 replid。于是它重新连接,发 PSYNC,把这两样报给主库。主库的判断逻辑我前面已经提过:

  1. replid 必须匹配;
  2. 从库的 offset 必须大于等于 backlog 的有效起始位置,小于等于主库当前的最新 offset。

两个条件都满足,返回 +CONTINUE,主库从 backlog 中捞出缺失的字节流发给从库,从库继续执行。反之,只要有一个不满足,就返回 +FULLRESYNC,回到全量同步。

这个判断是性能的分水岭。全量同步要生成 RDB、传输大文件、加载内存,耗时动辄分钟级起步;而增量续传可能只是发送几十 KB 的命令,秒级完成。所以在高可用架构里,backlog 的容量设计直接决定了断线重连的代价。

这里还有一个 PSYNC 2.0 的典型场景值得展开。假设主库 M 挂着从库 A 和从库 B,A 被提升为新的主库。如果 PSYNC 还是老版本,B 发现新的主库 A 的 replid 和 M 不一样,只能全量同步。但 PSYNC 2.0 下,A 把自己从 M 继承的旧 replid 保留为 replid2,B 带着旧 replid 来请求,A 能认出“我们来自同一条历史线”,并且只要 B 的 offset 大于等于 A 被提升那一刻的 second_repl_offset,就能继续增量同步。这个机制,让主从切换后的恢复成本大幅下降。

4.3 积压缓冲区容量怎么估算才算合理

repl-backlog-size 的默认值是 1MB,这在实际业务里往往偏小。怎么估算合适的大小?我的做法是抓两个变量:断线窗口和多久时间内允许它全量。

公式很简单:

backlog_size = 平均每秒写入字节数 * 期望容忍的断线秒数 * 2

为什么乘 2?因为 backlog 要保留的是从断线点到重连完成这段窗口的数据,重连过程本身也需要时间,留出余量更稳妥。举个例子,业务高峰每秒写入约 1MB,希望容忍 15 分钟内断线重连,那就是 1MB * 900 秒 * 2 = 1800MB。设成 2GB 左右比较稳妥。如果内存紧张,至少也要按峰值写入来算,别拿平均值糊弄。

怎么拿到平均每秒写入字节数?可以直接在 INFO stats 里看瞬时写请求量,或者用 redis-cli --stat 观察一段时间,再结合实际命令的平均大小估算。还有一个更直观的方法:看 INFO replication 里 repl_backlog_histlen,它表示 backlog 里当前保存的有效数据字节数。如果这个值长期顶着 repl_backlog_size 的上限,说明缓冲区一直在被写满,你要么调大容量,要么就得接受断线后走全量的现实。

5. 复制链路上的心跳、状态与参数

5.1 心跳机制与超时判定

主从复制不是建好连接就万事大吉的,它需要一套心跳机制来探测对方是否还活着。主库每隔 repl-ping-replica-period 秒(默认 10 秒)向从库发送一次 PING;从库则每秒向主库发送一次 REPLCONF ACK,带上自己的复制偏移量。双方哪一方在 repl-timeout(默认 60 秒)内没有收到对方的有效数据,就判定连接超时。

这个超时时间很有讲究。设小了,网络一抖动就容易误判,从库来回重连甚至触发全量同步;设大了,主库真的宕机时,从库要过很久才感知到,拖慢哨兵切换的进度。我一般建议 repl-timeout 和哨兵的 down-after-milliseconds 配套起来看,整体超时节奏要协调,别让一个环节成为另一个环节的瓶颈。

5.2 INFO replication 状态解读

排障时最常用的命令就是 INFO replication,主库视角的输出长这样:

# Replication role:master connected_slaves:2 slave0:ip=10.0.2.21,port=6379,state=online,offset=94217762,lag=0 slave1:ip=10.0.2.22,port=6379,state=online,offset=94217760,lag=1 master_replid:7b3f7c5e0d8a4f22aa9e4c7f6d3e2b1a9c8d7e6f master_replid2:0000000000000000000000000000000000000000 master_repl_offset:94217762 repl_backlog_active:1 repl_backlog_size:67108864 repl_backlog_first_byte_offset:88217762 repl_backlog_histlen:5999999

几个字段重点看:connected_slaves 表示当前有几个从库连上来;每个从库的 lag 表示距离上一次收到它 ACK 的秒数,稳定在 0 或 1 是健康的,持续变大说明有问题;master_repl_offset 是主库当前复制的字节位置;repl_backlog_first_byte_offset 和 repl_backlog_histlen 则告诉你 backlog 里有效数据的区间,断线的从库如果请求的 offset 比 first_byte_offset 还小,那就只能全量。

从库视角的 INFO replication 我一般看 master_link_status 是不是 up,还有 slave_repl_offset 和 master_repl_offset 的差值。这两个值差得越大,说明同步堆积越严重。

5.3 值得过一遍的配置参数清单

我把日常运维中真正用得上的参数整理成了一张表:

参数默认值作用与建议
replicaof无指定主库地址端口,有配置文件法和运行期 REPLICAOF 命令两种
replica-read-onlyyes从库只读,避免误写入导致主从数据分叉;没有特殊理由别改动
replica-serve-stale-datayes同步中断时是否继续用旧数据服务请求;一致性要求高可改 no
repl-backlog-size1mb复制积压缓冲大小,建议按带宽与断线时间估算后调大
repl-timeout60s主从连接超时阈值,网络不稳定可适当调大
repl-ping-replica-period10s主库向从库发送心跳的间隔
min-replicas-to-write0至少几个从库在线且延迟在限制内,主库才接受写命令
min-replicas-max-lag10s与上面的参数配合,从库最大允许延迟
masterauth无从库连接主库时使用的密码,主库开启 requirepass 后必须配置
repl-diskless-syncno是否开启无盘复制,磁盘慢网络快的场景可开启

这堆参数里容易被忽视的是 min-replicas-to-write 和 min-replicas-max-lag 这对组合。它们的意思是:当主库发现存活且延迟达标的从库数量不足时,直接拒绝写命令。听起来反直觉,但它的价值在于“用可用性换一致性”。一旦所有从库都断了,主库继续写入只会积累大量没法被同步的数据,未来恢复的代价巨大。对于一些对一致性要求很高的内部系统,这个开关能兜底。

6. 常见问题与排障实操实录

6.1 一觉醒来从库全在疯狂全量同步

这个场景我碰到过不止一次。明明业务没有上线新功能,从库却莫名其妙地反复全量同步,主库 CPU 和带宽被打满,从库卡顿。第一件事就是用 REPLICAOF NO ONE 加 REPLICAOF 把从库的同步链路重置,先止血。然后再排查根因。

常见的元凶有三个。第一,主库重启过,replid 变了,所有从库都认不出旧身份,只能全量。第二,repl_backlog_size 设置太小,从库断线时间稍长,请求的 offset 已经被 backlog 覆盖,条件不满足走了全量。第三,网络不稳定导致从库频繁断线重连,每次重连都因为 backlog 不够而全量,形成恶性循环。对应解法也很直接:别随意重启主库;按前面说的公式调大 backlog;修复网络稳定性。

另外提一句:通过 INFO replication 里的 sync_in_progress 和 master_sync_in_progress,能快速确认此刻是不是有从库在全量同步。

6.2 主从延迟居高不下

最典型的症状是:读完从库拿到的数据和主库相差很远,监控里 master_repl_offset 和 slave_repl_offset 的差值持续走高。排查时我会按优先级一层层来。

第一层是网络,ping 一下主从之间的延迟和丢包,重点看带宽是不是被 RDB 传输或者大 key 写满。第二层是主库本身,是不是有慢查询、大 key、大批量写入,导致复制流生成的速度超过从库消费的速度。第三层是从库自身,CPU 负载高、内存 swap、慢查询都会拖慢命令应用的速度。从库的 info commandstats 可以看哪些命令占了大部分执行时间。

这里有个特别容易踩的坑:超大 key 的复制放大效应。主库写一个 100MB 的 list 对象,复制流里这个对象也会以协议形式被完整传输。从库要接收、解析、再写入内存,这个放大效应会直接把延迟拉高。所以 Redis 性能调优里反复强调避免 bigkey,在复制场景下也同样成立,它不只是查询慢的问题,还直接拖累复制链路。

6.3 从库“孤儿化”:认证与配置的坑

有一次排查一个从库一直处于断线重连状态,日志里刷着认证失败的报错。查了一圈,是主库开启了 requirepass,但从库配置里的 masterauth 写错了一个字符。这类认证类的问题在真实环境里出现率极高,尤其是主库密码滚动更新时,经常有人换了主库密码却忘记同步改所有从库的 masterauth,结果从库一个个掉线。

另一个常见的坑是运行期动态调整主从关系后没有持久化。REPLICAOF 命令是即时生效的,但不会写进 redis.conf,一旦进程重启,从库就回到原来的配置,甚至变成独立的 master。所以我维护环境时有一个习惯:任何主从关系的变动,都要同步更新配置文件,并做好交接文档,否则下次上线打包或重启时就会踩坑。另外,切换主从时,REPLICAOF NO ONE 和新的 REPLICAOF 命令要按顺序执行,中间确认一下角色变化,别在集群或哨兵环境下自己乱切,那是另一个话题了。

6.4 主动断网验证复制收敛能力

最后分享一个我自己常用的故障演练手法。在业务低峰期,用防火墙规则把从库和主库之间的流量掐断几分钟,然后放开,观察从库重连之后的表现。执行 INFO replication,看它是返回了增量续传还是重新全量同步。

如果走的是增量,说明 backlog 容量够,断线时间在容忍范围内。如果走了全量,就要回看一下 backlog 是否设置过小,或者断线期间的写入量是不是太大。这个演练成本极低,但对复制链路的健康度检验非常有效。我还喜欢顺带验证一下哨兵的切换流程:手动把主库停掉,看哨兵能否按预期提升新主,兄弟从库能否快速跟上。高可用方案平时看着再美,不演练永远不知道哪里会露馅。

最后说点我的个人心得。带 Redis 这么多年,我最大的体会是:主从复制这套东西,网上讲“怎么配”的文章很多,讲“为什么这么设计”的文章很少。而真正让你在故障面前不慌的,恰恰是那些“为什么”。replid 为什么存在、backlog 为什么这么算、复制 ID 为什么在主库重启后会变化,把这些串成一条线之后,你再去看哨兵的选主、集群的迁移,会发现很多设计思路都是共通的。

这个系列既然起了个头,后面应该还会继续写哨兵和集群。如果这篇文章里的某一段能帮你少踩一个坑,那就值了。题外话,建议你下次维护 Redis 环境时,把 INFO replication 打开,对照着本文的字段解释多看几遍,比死记配置文件有用得多。

RELATED READING

延伸阅读

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