Redis 主从复制全解析:从 PSYNC 协议到复制积压缓冲区的故障切换边界
1. 先从一个真实困惑说起:为什么主节点挂了,从节点还是旧数据
假设你负责一个电商秒杀系统,Redis 采用一主两从部署:主节点 M 处理所有写请求,从节点 S1、S2 通过复制保持数据一致。某个大促凌晨,M 所在机器宕机,Sentinel 在几秒内把 S1 提升为新主。运维在监控上看复制延迟只有 50ms,理论上最多丢 50ms 的数据。但业务核查时发现,某件商品的库存扣减记录丢失了近 3 秒的写入量——用户付款成功,库存却没扣。
问题不在于 Sentinel 切换慢,而在于主从复制从来不是「主节点写完,从节点立刻就有」的强一致模型。它是一套异步复制协议:主节点写入本地内存后立即返回客户端,再把写命令异步推给从节点。M 在宕机前那几秒产生的写命令,如果还没被 S1 收到,切换后就永久丢失了。而「那几秒」的边界,恰恰由一个容易被忽视的结构决定——复制积压缓冲区(repl_backlog)。
这篇文章不会一上来就丢出 PSYNC 的状态机,而是先把整个复制体系拆成几个角色和一条数据流,让你能用自己的话复述一遍,然后再逐个机制钻进去。目标很明确:读完你能回答三个问题——复制到底怎么同步的、backlog 到底决定了什么边界、故障切换时会丢多少数据。
2. 一句话模型与整体框架:谁在跟谁说话
先用一句普通中文记住核心模型:Redis 主从复制,就是从节点连上主节点后,先领一份全量数据快照,之后持续接收主节点推送的增量写命令,并用一个不断增长的偏移量来标记「我收到哪儿了」。
把整体拆成三部分,先看它们怎样串起来:
写命令 传播写命令 客户端 ----> [主节点 Master] -----------------> [从节点 Replica 1] | | ^ | | 传播写命令 | | +-------------------------> [从节点 Replica 2] | | 维护: runid / master_repl_offset | 维护: repl_backlog (环形缓冲) | 维护: 每个从节点的 ack 偏移量 角色: Master : 接受写、生成复制流、记录 backlog Replica : 发起 PSYNC、接收 RDB 或增量流、周期性上报 ack offset Sentinel/Cluster : 观测主从状态,在故障时执行切换三个角色分工如下:
| 角色 | 核心职责 | 关键状态 |
|---|---|---|
| 主节点 Master | 执行写命令、生成复制流、维护 backlog | runid、master_repl_offset、repl_backlog |
| 从节点 Replica | 连接主节点、接收数据、上报确认偏移 | master_link_status、slave_repl_offset |
| 哨兵/集群 Sentinel/Cluster | 监控、判定客观下线、执行故障切换 | 主观下线、客观下线、quorum、epoch |
一个请求/数据的完整流转顺序是:客户端写主节点 → 主节点执行并追加到 backlog → 主节点把命令异步推给所有从节点 → 从节点执行并更新自己的偏移量 → 从节点周期性向主节点上报 ack 偏移量。任何环节中断,复制就可能退化为全量重同步。下一节开始,我们按这个顺序逐个拆解。
3. 复制偏移量:复制世界里的「打卡进度」
3.1 偏移量到底标记了什么
复制偏移量(replication offset)是最容易理解、也最容易被忽视的概念。可以先把它理解成一条不断增长的字节流上的「打卡点」:主节点每产生一个字节的复制流,就把它自己的偏移量加一;从节点每接收并应用一个字节,就把自己的偏移量加一。两者之差,就是通常说的复制延迟(以字节计,不直接等于时间)。
一个最小数字例子:
时刻 T1: 主节点执行 SET k1 v1,复制流增加 30 字节 master_repl_offset = 1000 replica_repl_offset = 1000 (从节点已跟上,差值为 0) 时刻 T2: 主节点连续执行 5 条写命令,复制流增加 200 字节 master_repl_offset = 1200 replica_repl_offset = 1000 (从节点还没收到,差值 200)差值 200 并不等于「丢了 200 字节的数据」,它只表示从节点暂时落后。只要主节点还活着,复制流会继续推送,从节点最终会追到 1200。真正危险的是主节点在这段落后期间宕机——从节点永远停在 1000,而主节点最后那 200 字节的写命令就可能丢失。
3.2 偏移量与故障切换的关系
故障切换时,Sentinel 或 Cluster 会优先选择偏移量最大的从节点作为新主,因为它「收到的最多」。这就解释了为什么复制延迟过大的从节点不应该被优先提升:它的数据更旧。
最容易误解的一点是:偏移量相等不代表数据完全一致。如果从节点在应用命令过程中出错,或者主节点曾发生过角色转换导致 runid 变化,偏移量相同也可能指向不同的数据历史。所以偏移量只能作为「量」的参考,判断一致性还要结合复制 ID(replid / runid)一起看。
4. PSYNC 协议:握手阶段决定了走哪条同步路径
4.1 为什么需要 PSYNC,而不是每次全量
如果每次从节点重连都要主节点做一次全量快照(RDB)并传输,那么网络抖动、从节点短暂重启都会引发巨额开销。为此 Redis 在 2.8 引入了 PSYNC(Partial SYNC,部分同步)协议:从节点在连接时告诉主节点「我曾经同步到哪个 runid 和哪个偏移量」,主节点据此判断能否只补发缺失的那一小段命令。
PSYNC 的核心交互可以概括成三个分支:
从节点: PSYNC <replid> <offset> 主节点判断: 分支 A: replid 匹配 且 offset 在 backlog 覆盖范围内 -> +CONTINUE,只补发缺失命令 (部分重同步) 分支 B: replid 不匹配,或第一次连接 -> +FULLRESYNC <new_replid> <offset>,走全量 (全量重同步) 分支 C: replid 匹配但 offset 太旧,已被 backlog 覆盖掉 -> +FULLRESYNC,也只能走全量第一次连接时,从节点发的是PSYNC ? -1,问号表示「我不知道之前的 replid」,-1 表示「我不知道偏移量」。主节点一律回+FULLRESYNC。
4.2 一次 PSYNC 完整握手示例
可以用redis-cli直连主节点,手工模拟一次握手来观察响应(这是调试复制时非常有用的手段):
# 目标: 观察主节点对一次 PSYNC 请求的响应分支# 前置: 本机启动一个 Redis 实例,端口 6379redis-cli-p6379# 先看看当前主节点的复制 ID 和偏移量INFO replication# 手工发送 PSYNC,用当前 replid 和当前 offset,模拟一次正常重连PSYNC<当前replid><当前offset># 预期: +CONTINUE,然后开始接收主节点后续推送的命令流# 再试一个明显过旧的 offset(例如 1),模拟 backlog 已被覆盖PSYNC<当前replid>1# 预期: +FULLRESYNC <replid> <offset>,随后主节点开始传输 RDB关键点在于:+CONTINUE和+FULLRESYNC是两条完全不同的路径。前者只补差量,代价很小;后者要 fork 子进程做 RDB 快照、传输全量数据、从节点清空并加载——在数据量大的实例上,这可能耗时几十秒甚至几分钟。所以工程上真正要争取的,是让重连尽量走+CONTINUE。而这个「能不能补差量」的判据,就落在下一节的 backlog 上。
常见错误:手工执行 PSYNC 后如果直接退出了交互会话,主节点会保留一个未完成的从节点连接一段时间,可能污染INFO replication的从节点计数。调试完记得断开,或直接重启该实例。
5. repl_backlog:决定能不能「只补一点」的环形缓冲
5.1 backlog 是什么,为什么是环形的
复制积压缓冲区(replication backlog)是主节点上的一块内存区域,用来暂存最近推送过的复制流。当从节点断线重连并请求部分同步时,主节点就从 backlog 里找出从节点缺失的那一段补给它。可以把它理解成一个「最近 N 条命令的录音带」,只保留最近的一部分。
它被实现成环形缓冲区(ring buffer):固定大小,写满后从头覆盖旧数据。这意味着 backlog 只能覆盖「最近一段时间」的命令,更早的会被挤掉。
| 概念 | 含义 | 典型配置 |
|---|---|---|
| repl-backlog-size | backlog 的字节容量 | 默认 1MB,生产常调大到 64MB 或更多 |
| repl-backlog-ttl | 最后一个从节点断开后,backlog 保留多久 | 默认 3600 秒 |
| 覆盖范围 | backlog 能容纳的复制流秒数 | 取决于写入速率,写入越快覆盖时间越短 |
5.2 backlog 命中与未命中的边界
backlog 是否命中,决定了一次重连是全量还是增量。用数字说明:假设某实例写入速率是 2MB/s,backlog 配置为 64MB,那么 backlog 大约能覆盖 32 秒的复制流。
场景一: 从节点断线 5 秒后重连 缺失复制流约 10MB < 64MB -> backlog 命中 -> +CONTINUE -> 秒级恢复 场景二: 从节点断线 60 秒后重连 缺失复制流约 120MB > 64MB -> backlog 未命中 -> +FULLRESYNC -> 全量传输这就是 backlog 的核心价值:它把「网络抖动导致的短时断连」这种高频、低代价的场景,从全量重同步里救了出来。反过来,backlog 太小会让频繁抖动的集群不断触发全量同步;backlog 太大则白占内存。工程上的经验公式是:所需容量 ≈ 平均写入速率 × 可容忍的最长断线时间 × 安全系数。
最容易误解的是:调大 backlog 并不能减少故障切换时的数据丢失。它只影响「断线重连能否走增量」,而切换时数据丢多少,取决于从节点 ack 的偏移量和主节点实际写入偏移量的差,是另一回事。这两个概念经常被混为一谈。
6. 全量重同步:一次昂贵但必要的快照传输
6.1 全量同步的完整过程
当 PSYNC 判定无法部分同步时,主节点会触发全量重同步。整个过程按时间顺序如下:
从节点 主节点 | | |--- PSYNC ? -1 --------------> | | | fork 子进程,生成 RDB 快照 |<-- +FULLRESYNC <replid> <off> | |<-- RDB 文件数据 ------------ | 同时把 fork 之后的写命令 | | 缓存到 repl_backlog / 客户端缓冲 | 清空本地数据,加载 RDB | | | |--- 加载完成 -----------------> | |<-- 补发 fork 期间的写命令 ---- | |<-- 持续增量复制流 ---------- |注意两个细节:第一,主节点在 fork 生成 RDB 期间会继续接受写请求,这些新命令被缓存起来,等 RDB 传完后补发给从节点;第二,从节点加载 RDB 时会先清空自己的旧数据,所以全量同步期间从节点对外是不可用的(取决于配置,一般对外只读)。
6.2 fork 与 copy-on-write 的代价
全量同步的昂贵不只在于网络传输,更在于主节点的fork操作和随之而来的写时复制(copy-on-write)。fork 会短暂阻塞主线程;如果实例内存很大(比如 32GB),fork 的页表复制本身就可能造成几十到几百毫秒的卡顿。同时,fork 后主节点的写操作会触发物理页复制,内存可能膨胀到接近两倍。
下面这个最小可复现脚本用来观察全量同步时的内存与耗时变化:
#!/usr/bin/env bash# 目标: 观察一次全量重同步期间主节点的内存峰值与从节点状态# 前置: 已有一主一从,主节点端口 6379,从节点端口 6380set-euopipefail# 在主节点写入约 200MB 数据,制造一定内存规模redis-cli-p6379flushallforiin$(seq1200);doredis-cli-p6379-xset"big:$i"</dev/urandom|head-c1048576>/dev/null||truedone# 打印从节点当前状态,应看到 master_link_status:down 之前是 upredis-cli-p6380info replication|grep-E'master_link_status|slave_repl_offset'# 在从节点执行全量重同步,并计时/usr/bin/time-vredis-cli-p6380replicaof127.0.0.163792>&1|tail-5# 观察主节点内存与 fork 相关指标redis-cli-p6379info memory|grep-E'used_memory_human|mem_fragmentation_ratio'redis-cli-p6379info stats|grep-E'latest_fork_usec'redis-cli-p6379info replication|grep-E'master_repl_offset|repl_backlog'运行后重点看三项:latest_fork_usec反映 fork 耗时(持续偏大说明实例内存过重)、used_memory在同步期间是否接近翻倍(说明 CoW 页复制严重)、从节点master_link_status从 down 变 up 的耗时(即全量同步总时长)。适用场景是容量评估与故障演练;边界在于该脚本会清空数据,绝不能在生产实例执行,应放在隔离的测试环境。
7. 故障切换:偏移量、backlog 与切换策略怎么共同决定丢多少数据
7.1 切换时数据丢失的定量理解
故障切换(failover)的数据丢失边界,可以用一个不等式表达:
可能丢失的数据量 ≤ 主节点最后成功写入的偏移量 — 被提升从节点最后 ack 的偏移量这里的「被提升从节点」是 Sentinel/Cluster 选出的那个偏移量最大的从节点。也就是说,即使系统里有三个从节点,能保住的数据也只取决于最「新」的那一个。如果那个从节点本身落后主节点 2 秒,那这 2 秒就得丢。
| 影响因素 | 对丢数据的影响 | 可调手段 |
|---|---|---|
| 从节点复制延迟 | 延迟越大,丢失上限越高 | 网络优化、从节点算力、避免大 Key 阻塞 |
| 主节点写入速率 | 同样延迟下,速率越高丢得越多 | 业务层限流、写命令合并 |
| backlog 大小 | 不影响切换丢失,只影响重连路径 | 主要作用是减少全量同步 |
| min-replicas-to-write | 强制至少有 N 个从节点 ack 才允许写 | 可显著降低丢失,但会牺牲可用性 |
7.2 用 min-replicas 把「异步」变成「有条件的半同步」
Redis 提供了一组配置,让主节点在从节点数量或延迟不满足条件时拒绝写入,从而给异步复制加一道「准同步」护栏:
# redis.conf 关键片段 # 至少有 2 个从节点的复制延迟不超过 10 秒,主节点才接受写 min-replicas-to-write 2 min-replicas-max-lag 10含义是:如果当前健康从节点少于 2 个,或它们落后超过 10 秒,主节点直接对写命令返回错误。这能在一定程度上把「丢 10 秒数据」的概率压下去,代价是当从节点集体抖动时,主节点会拒绝写入,可用性下降。它不能保证零丢失,只是把丢失窗口约束住。
7.3 时序:一次完整切换里发生了什么
时间轴 Sentinel 视角 t0 Master 正常,Replica 持续 ack t1 Sentinel 主观下线 Master(ping 超时) t2 Sentinel 向其他 Sentinel 询问,达到 quorum -> 客观下线 t3 选举 Leader Sentinel(基于 epoch 和 Raft 式投票) t4 Leader 从 Replica 中挑选 offset 最大、优先级最高者 t5 向该 Replica 发送 REPLICAOF NO ONE,提升为新 Master t6 其余 Replica 转向新 Master 复制 t7 旧 Master 恢复后,被降级为新 Master 的从节点从 t1 到 t5 这段时间是数据丢失窗口:主节点若在 t0 之后、t1 之前仍接受了写入,这些写入只有那些已被从节点收到的部分能保住。所以工程上要监控的核心指标,是「从节点 ack 偏移量和主节点偏移量的差值」在故障窗口内的最大值。
8. Java 工程实践:用 Spring Boot 观察复制状态并做写保护
8.1 最小示例:读取复制状态
目标:在 Spring Boot 应用中通过RedisConnection读取主从复制信息,便于在健康检查里暴露复制延迟。前置环境:Spring Boot 3.x,spring-boot-starter-data-redis,本机一主一从。
packagecom.example.replication;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;importorg.springframework.context.ConfigurableApplicationContext;importorg.springframework.data.redis.connection.RedisConnection;importorg.springframework.data.redis.connection.RedisConnectionFactory;importjava.util.Properties;@SpringBootApplicationpublicclassReplicationProbeApplication{publicstaticvoidmain(String[]args){try(ConfigurableApplicationContextctx=SpringApplication.run(ReplicationProbeApplication.class,args)){RedisConnectionFactoryfactory=ctx.getBean(RedisConnectionFactory.class);try(RedisConnectionconn=factory.getConnection()){Propertiesinfo=conn.info("replication");Stringrole=info.getProperty("role");Stringoffset=info.getProperty("master_repl_offset");StringlinkStatus=info.getProperty("master_link_status");StringslaveOffset=info.getProperty("slave_repl_offset");System.out.println("role="+role+", master_offset="+offset+", link="+linkStatus+", slave_offset="+slaveOffset);if("slave".equals(role)){longmaster=Long.parseLong(offset);longslave=Long.parseLong(slaveOffset);longlag=master-slave;System.out.println("复制延迟字节数="+lag);if(lag>20*1024*1024){System.err.println("警告: 复制延迟超过 20MB,故障切换可能丢失较多数据");}}}}}}关键步骤:conn.info("replication")返回的 Properties 里,master_repl_offset是主节点偏移量,slave_repl_offset是从节点已应用偏移量。预期输出类似role=slave, master_offset=1200, link=up, slave_offset=1000,并打印延迟字节数。适用场景是健康检查与告警;容易改错的地方是:从节点上master_repl_offset字段在部分版本里也可能是主节点的偏移快照,务必以INFO replication实际输出为准,不要想当然按字段名解析。
8.2 结合 Redisson 的分布式锁与复制延迟
目标:演示在复制延迟存在时,分布式锁可能出现的边界问题。前置:Redisson 客户端连接一主两从。
packagecom.example.replication;importorg.redisson.Redisson;importorg.redisson.api.RLock;importorg.redisson.api.RedissonClient;importorg.redisson.config.Config;importjava.util.concurrent.TimeUnit;publicclassLockWithReplicationRisk{publicstaticvoidmain(String[]args)throwsInterruptedException{Configconfig=newConfig();config.useSingleServer().setAddress("redis://127.0.0.1:6379");RedissonClientclient=Redisson.create(config);RLocklock=client.getLock("order:lock:1001");try{booleanlocked=lock.tryLock(3,10,TimeUnit.SECONDS);if(!locked){System.out.println("未获取到锁,放弃处理");return;}System.out.println("获取锁成功,处理订单扣减");// 模拟业务处理Thread.sleep(500);}catch(InterruptedExceptione){Thread.currentThread().interrupt();System.err.println("加锁过程被中断");}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}client.shutdown();}}}这段代码本身正确,但要理解它在主从复制下的边界:Redisson 的锁依赖主节点写入。如果主节点在锁写入后、同步到从节点前宕机,而故障切换又选了一个没有这条锁记录的从节点,那么新主上锁就不存在,第二个线程可能同时拿到锁。缓解手段是:对强一致要求的场景,用 Redlock 或基于min-replicas-to-write的写保护,或改用带共识的协调服务如 etcd、ZooKeeper。这里的关键结论是:基于单主异步复制的 Redis 锁,天然存在故障切换时的互斥失效窗口,不能用于对正确性要求极高的资金类互斥。适用场景是并发度控制、幂等辅助;不适用场景是严格串行的资金操作。
9. Sentinel 与 Cluster 下的复制边界对比
很多团队把 Sentinel 和 Cluster 的复制行为混为一谈,其实两者在「一个分片内的复制」上是同一套 PSYNC + backlog 机制,差别在故障切换的触发和范围。
| 维度 | Sentinel 模式 | Cluster 模式 |
|---|---|---|
| 复制单位 | 整个主节点 | 每个分片的主节点 |
| 切换触发 | Sentinel 集群判定客观下线 | 分片内节点投票 + 集群总线 |
| 切换范围 | 全局,所有从节点跟随 | 仅该分片,其他分片不受影响 |
| 数据丢失边界 | 提升偏移最大的从节点 | 同样,但只影响该分片的数据 |
| backlog 角色 | 断线重连走增量 | 同左,且分片间独立 |
| 常见风险 | 脑裂、切换期间写丢失 | 分片倾斜、跨分片事务不可用 |
结论化的选择:当数据量不大、需要全局原子操作(如 KEYS、事务、Lua 跨 Key)时选 Sentinel;当数据量大、需要水平扩展且能接受分片语义时选 Cluster。两者都不能消除异步复制的丢失窗口,只能通过min-replicas和合理的从节点布局来收窄它。
10. 常见误区与实际表现
| 误区 | 实际表现 | 正确理解 |
|---|---|---|
| 「主从复制是强一致」 | 切换后数据回退 | 异步复制,只保证最终一致 |
| 「调大 backlog 能防丢数据」 | 切换照样丢 | backlog 只影响重连是否增量 |
| 「偏移量相同就数据一致」 | 极端情况下不一致 | 还需核对 replid 与历史 |
| 「从节点越多越安全」 | 切换只认最新的那个 | 关键是最大偏移从节点的状态 |
| 「Sentinel 切换是瞬时的」 | 秒级到十几秒 | 主观下线 + 投票需要时间 |
11. 生产实践建议与排障清单
11.1 监控指标
master_repl_offset与从节点slave_repl_offset的差值,按实例和时间趋势告警。master_link_status必须持续为up,出现down立即排查。latest_fork_usec持续超过 100ms 说明实例内存过重,考虑调整持久化与内存结构。repl_backlog_size与历史最大断线时长的乘积,用于校验 backlog 是否够大。
11.2 排障清单
- 从节点
master_link_status为down:检查网络连通、主节点是否设置了requirepass、密码配置是否一致。 - 频繁全量重同步:检查 backlog 是否过小、从节点是否频繁重启、网络是否存在周期性抖动。
- 切换后大量数据回退:核对被提升从节点的 ack 偏移量、切换窗口内主节点的写入量。
- 复制延迟持续增长:排查大 Key、慢命令、从节点磁盘或内存瓶颈。
12. 面试与复盘问题
- 为什么 Redis 需要复制积压缓冲区,它和全量重同步是什么关系?
- PSYNC 的三种响应分支分别对应什么条件?
- 故障切换时的数据丢失上限由哪两个偏移量决定?
min-replicas-to-write能否保证零丢失?它的代价是什么?- 单主异步复制下的分布式锁,在什么条件下会失去互斥性?
- Sentinel 与 Cluster 的切换范围差异,对业务一致性有什么影响?
13. 总结:用一张决策清单收束
需要判断一次复制行为会走哪条路: 1) 从节点首次连接? -> 全量重同步 2) replid 不匹配? -> 全量重同步 3) 缺失偏移在 backlog 内? -> 部分重同步 (+CONTINUE) 4) 缺失偏移超出 backlog? -> 全量重同步 需要评估故障切换会丢多少数据: 1) 被提升从节点的最后 ack 偏移 2) 主节点最后成功写入的偏移 两者之差即为丢失上限;backlog 大小不直接参与该计算。 需要降低丢失窗口: 1) 减小复制延迟(网络、从节点算力、避免大 Key) 2) 配置 min-replicas-to-write 与 min-replicas-max-lag 3) 对强一致场景,改用带共识的组件或让写等待至少一个从节点确认回到开头的秒杀事故:如果当时配置了min-replicas-to-write 1且从节点延迟小于 1 秒,那么主节点在从节点跟不上时会拒绝写入,虽然会损失部分可用性,但能把这 3 秒的库存丢失压到很小。Redis 主从复制的所有配置,本质上都是在「可用性」和「一致性」之间调参,而 PSYNC、backlog、偏移量这三个概念,就是调节这个旋钮时你必须看懂的刻度盘。
14. 参考资料
- Redis 官方文档:Replication(https://redis.io/docs/management/replication/)
- Redis 官方文档:Sentinel(https://redis.io/docs/management/sentinel/)
- Redis 官方文档:Redis Cluster(https://redis.io/docs/management/scaling/)
- Redis 官方文档:INFO command(https://redis.io/commands/info/)
- Redisson 官方文档(https://redisson.org/documentation.html)
- Spring Data Redis 参考文档(https://docs.spring.io/spring-data/redis/reference/)
- 《Redis 设计与实现》黄健宏著,机械工业出版社