返回文章列表

主从复制与数据一致性

系统梳理 Redis 主从复制、异步复制、replication ID、offset、backlog、全量同步、部分重同步、读写分离、旧读、WAIT、WAITAOF 与一致性边界。

第 15 章:主从复制与数据一致性

1. 本章定位

上一章解决的是“Redis 进程重启后如何恢复数据”,本章解决的是“一个 Redis 节点故障时,其他节点如何拥有数据副本,以及这些副本究竟有多新”。

主从复制主要提供三个能力:

  1. 为故障转移准备副本。
  2. 将部分只读流量分散到副本。
  3. 为离线分析、备份等任务提供隔离节点。

Redis 默认使用异步复制。主节点执行写命令后通常不会等待副本确认,因此复制延迟、网络分区和故障切换都可能造成旧读或数据丢失。WAITWAITAOF 可以缩小风险窗口,但不会把 Redis 主从集群变成强一致的共识系统。(Redis)

必须区分以下概念:

  • 复制不等于持久化:副本可能复制主节点的错误操作或空数据集。
  • 复制不等于备份:误删除也会被同步到副本。
  • 复制不等于自动故障转移:单纯主从复制不会自动选举新主节点。
  • 高可用不等于强一致:切换期间仍可能丢失已经返回成功的写入。

2. 学习目标

完成本章后,应能够:

  1. 解释 Redis 异步复制模型及其数据丢失窗口。
  2. 说明 replication ID、replication offset 和 replication backlog 的关系。
  3. 根据副本状态判断会触发部分重同步还是全量同步。
  4. 完整描述全量同步过程中 RDB、增量写入和副本加载的顺序。
  5. 根据写入带宽和允许断线时间估算 replication backlog 容量。
  6. 识别读写分离中的旧读、读己之写失效和单调读失效。
  7. 正确区分 WAITWAITAOFmin-replicas-to-write
  8. 使用 go-redis/v9 实现只读副本路由和连接级复制确认。

2.1 本章边界与跳转

本章是主从复制和数据一致性的主章节,重点是异步复制、复制偏移、全量/部分重同步、读写分离和写入确认边界。Sentinel 如何基于复制副本完成自动故障转移见[第 16 章](/blog/tech/Redis/16.Sentinel 高可用/);Cluster 如何把复制与分片结合见[第 17 章](/blog/tech/Redis/17.Redis Cluster 分片与高可用/);持久化文件恢复见[第 14 章](/blog/tech/Redis/14.RDB、AOF 与数据恢复/);复制延迟和频繁故障转移的观测排查见第 20 章

3. 核心概念

3.1 异步复制模型

Redis 采用主节点到副本的复制模型:

客户端写入


主节点执行命令

    ├── 返回客户端

    └── 将命令效果写入复制流

              ├── 副本 A
              ├── 副本 B
              └── 副本 C

正常情况下,主节点把写命令、过期删除、淘汰等导致数据变化的操作编码为复制流,副本按相同顺序执行。

默认流程是:

  1. 主节点在本地执行写命令。
  2. 主节点向客户端返回结果。
  3. 复制数据通过网络发送到副本。
  4. 副本接收并应用复制流。
  5. 副本周期性向主节点汇报已处理的复制位置。

因此,当客户端收到 OK 时,数据可能:

  • 只存在于主节点内存;
  • 已发送但尚未到达副本;
  • 已到达副本但尚未持久化;
  • 已经持久化到部分节点。

Redis 选择异步复制,是为了避免让每次写入都承担副本网络延迟和磁盘延迟。代价是系统默认提供的是最终一致性,而不是线性一致性。(Redis)


3.2 replication ID、offset 与 backlog

replication ID

每段复制历史都有一个 replication ID,可以把它理解为:

“这条复制时间线的身份标识。”

两个节点拥有相同 replication ID,说明它们来自同一条复制历史,但不代表数据进度完全相同。

节点通常还维护第二个 replication ID。副本被提升为主节点时会产生新的 ID,同时暂时保留旧 ID 和对应的 offset 边界,使原有副本在故障切换后仍有机会执行部分重同步。

replication offset

主节点每生成一个字节的复制流,offset 都会向前推进。即使当前没有副本连接,主节点的复制 offset 也会继续增长。

因此:

(replication ID, replication offset)

共同标识一条复制历史上的具体位置。offset 统计的是复制协议字节位置,不是命令数量,也不是业务版本号。(Redis)

replication backlog

replication backlog 是主节点保留的一段近期复制流历史。

假设:

主节点当前 offset:10,000,000
backlog 可保留范围:9,000,001 ~ 10,000,000
副本已处理 offset:9,600,000

副本重连后只需补发:

9,600,001 ~ 10,000,000

如果副本停留在 8,500,000,需要的数据已经被淘汰,就只能全量同步。

从逻辑上看,backlog 是一个按 offset 定位的有界滑动窗口。Redis 8.8 的源码实现使用共享复制缓冲块,并通过 radix tree 索引部分缓冲块的 offset,以便快速定位部分重同步的起点;旧块会被增量裁剪,避免一次释放过多内存阻塞服务器。(GitHub)


3.3 PSYNC:决定全量还是增量

副本连接主节点时,会通过内部复制协议发送类似请求:

PSYNC <replication-id> <offset>

首次连接通常没有历史位置,相当于:

PSYNC ? -1

主节点主要判断两个条件:

条件结果
replication ID 能对应当前或保留的复制历史,并且缺失数据仍在 backlog 中部分重同步
ID 已失效,或所需 offset 已被 backlog 淘汰全量同步

部分重同步成功时,主节点返回 CONTINUE 并继续传输缺失的复制流。

无法部分重同步时,主节点返回类似:

FULLRESYNC <new-replication-id> <offset>

然后开始传输完整数据集。PSYNC 从 Redis 2.8 开始取代旧的 SYNC 协议,关键改进就是支持部分重同步。(Redis)


3.4 全量同步过程

经典全量同步可分为以下阶段:

  1. 建立复制连接 副本连接主节点,完成认证、能力协商和 PSYNC 请求。

  2. 主节点确认 FULLRESYNC 主节点通知副本新的 replication ID 和全量同步起始 offset。

  3. 生成数据快照 经典磁盘模式下,主节点触发后台 RDB 保存。父进程继续处理请求,子进程遍历内存数据生成 RDB。

  4. 保留快照之后的写入 生成 RDB 期间,主节点仍会接受写入。这些增量操作必须保留,否则副本加载完 RDB 后仍会落后。

  5. 向副本传输 RDB 主节点把快照发送给副本。采用无盘复制时,可以不先写入主节点本地磁盘。

  6. 副本加载快照 副本清理或替换旧数据集,再加载 RDB。这个过程可能使副本暂时不能正常提供数据服务,具体行为取决于版本和配置。

  7. 应用增量复制流 副本加载完成后,继续执行快照生成期间积累的复制命令。

  8. 进入在线复制状态 副本追上主节点后,开始持续接收新的复制流。

其核心不变量是:

副本最终状态
= 某一时刻的完整快照
+ 该时刻之后的有序复制流

Redis 官方文档描述了经典的“后台生成 RDB、缓存期间写入、发送 RDB、再发送增量命令”流程;现代版本具体使用的缓冲区和传输通道可能继续演进,但上述逻辑不变。(Redis)

全量同步的主要成本

  • 主节点执行 fork
  • Copy-on-Write 导致额外内存占用。
  • 生成 RDB 消耗 CPU 和磁盘带宽。
  • 传输完整数据集消耗网络带宽。
  • 副本加载 RDB 时占用 CPU,并可能阻塞请求。
  • 同步期间持续写入会扩大复制缓冲和客户端输出缓冲。
  • 同步失败后反复重试可能形成全量同步风暴。

3.5 部分重同步与 backlog 容量

部分重同步只发送断线期间缺失的复制流,成本近似与缺失字节数成正比,而不是与整个数据集大小成正比。

可以用下式估算 backlog:

backlog 容量
≈ 峰值复制流字节率
× 希望容忍的断线时间
× 安全系数

例如,复制流峰值约为 20 MiB/s,希望容忍副本断线 60 秒,安全系数取 2:

20 MiB/s × 60s × 2 = 2400 MiB

可考虑设置约 2.5~3 GiB,而不是使用一个与真实流量无关的固定值。

安全系数用于覆盖:

  • 突发写入;
  • 网络恢复后的握手时间;
  • 大 Value 写入;
  • Lua 或批量命令产生的集中复制流;
  • 副本自身短暂卡顿。

backlog 过大主要增加常驻内存;过小则会使短暂断线频繁退化为全量同步。生产中应关注:

  • master_repl_offset
  • 各副本的复制 offset
  • repl_backlog_size
  • repl_backlog_histlen
  • sync_full
  • sync_partial_ok
  • sync_partial_err
  • master_link_status
  • master_sync_in_progress

这些指标可通过 INFO replicationROLE 获取。需要注意,offset 差值表示复制字节差距,并不能直接等价为“落后多少秒”或“缺少多少业务请求”。(Redis)


3.6 无盘复制

无盘复制需要区分两个方向。

主节点侧无盘同步

启用 repl-diskless-sync 后,生成快照的子进程可以直接把 RDB 数据写入副本连接,而不是先在主节点落地临时 RDB 文件。

适合:

  • 主节点磁盘较慢;
  • 网络带宽充足;
  • 副本数量相对稳定;
  • 希望减少主节点磁盘 I/O。

风险:

  • 慢副本会延长网络传输时间;
  • 传输中断后可能需要重新开始;
  • 多副本同时同步会放大网络带宽消耗;
  • 无盘不等于没有 fork,也不消除 COW 内存风险。

副本侧无盘加载

repl-diskless-load 控制副本是否直接从网络流加载快照。不同模式可能选择立即清空旧数据,或者先构建新数据集再切换。

它可以减少副本磁盘 I/O,但可能增加内存峰值。主节点“无盘发送”和副本“无盘加载”是两个独立概念,不能混为一个开关。(GitHub)


3.7 复制延迟与读副本旧数据

副本通常默认只读。即便设置为可写,对副本的本地写入也不会可靠地反向传播给主节点,并可能在重新同步时被覆盖,因此不应把可写副本当作多主方案。

当主从连接中断或副本正在同步时,replica-serve-stale-data 决定副本是继续返回可能过期的数据,还是对大多数数据命令返回错误。默认偏向可用性,但业务可能因此读到旧值。(GitHub)

读写分离的收益

  • 分散查询流量。
  • 将报表、扫描、备份等操作从主节点隔离。
  • 降低主节点网络出口压力。
  • 为跨机房只读访问提供本地副本。

读写分离的典型一致性异常

异常示例常见处理
旧读主节点已更新价格,副本仍返回旧价格关键读取走主节点
读己之写失效用户修改头像后立即刷新,仍看到旧头像写后主节点粘滞读取
单调读失效第一次读到版本 10,切换副本后读到版本 9会话粘滞或版本校验
删除后复现主节点已删除 Key,副本仍返回旧数据删除后的关键查询走主节点
假性不存在数据已写主节点,副本尚未同步,返回 redis.Nil负查询走主节点或使用版本令牌
权限旧读权限已撤销,副本仍认为用户有权限权限、安全决策只读主节点或权威数据库

一个容易忽略的细节是:

SET key value
WAIT 1 500

只表示“至少有一个副本确认了该写入”,并不会告诉客户端究竟是哪一个副本。如果之后随机读取另一台副本,仍可能读到旧数据。


3.8 WAIT、WAITAOF 与写入保护

机制版本等待内容返回值主要限制
WAITRedis 3.0+当前连接之前的写入,被指定数量副本确认已确认副本数不保证 AOF 已刷盘,不提供强一致
WAITAOFRedis 7.2+当前连接之前的写入,在本地和/或副本完成 AOF fsync本地确认数、副本确认数依赖 AOF,磁盘延迟进入请求链路
min-replicas-to-write服务端配置写入时至少存在指定数量、延迟可接受的副本写入成功或被拒绝是准入保护,不是逐条写入确认

WAIT

WAIT numreplicas timeout

例如:

WAIT 1 500

表示等待最多 500 毫秒,希望至少一个副本确认当前连接此前的写入。

必须检查返回值:

返回 1:达到目标
返回 0:超时前没有副本达到目标

超时并不一定作为命令错误返回。忽略整数结果,就等于没有正确使用 WAIT

WAIT 具有连接级语义:它只等待同一连接之前的写入。放在 MULTI/EXEC 等不能阻塞的上下文中时不会按普通方式等待。其命令复杂度标记为 O(1),但实际请求耗时取决于副本网络和处理延迟。(Redis)

WAITAOF

WAITAOF numlocal numreplicas timeout

例如:

WAITAOF 1 1 1000

表示等待:

  • 当前主节点完成一次相关 AOF fsync;
  • 至少一个副本完成相关 AOF fsync;
  • 最多等待 1000 毫秒。

返回的是两个整数:

1) 本地 fsync 确认数
2) 副本 fsync 确认数

numlocal 只能是 01。本地没有启用 AOF 时,不能要求非零本地确认;该命令也不能在副本上执行。(Redis)

为什么二者仍不是强一致

即使写入得到一个副本确认,也可能发生:

  1. 副本 C 确认了写入。
  2. 主节点 A 故障。
  3. 故障转移系统选择了更旧的副本 B。
  4. B 成为新主节点。
  5. 只存在于 A 和 C 的写入不在新时间线上。

WAITWAITAOF 增加了数据副本数量或持久化副本数量,但没有提供共识协议中的多数派提交、唯一领导者任期和 fencing 机制。


3.9 已确认写入为什么仍可能丢失

未使用 WAIT

T1:客户端向 A 写入 x=1
T2:A 在本地执行并返回 OK
T3:复制数据尚未到达 B
T4:A 故障
T5:B 被提升

此时客户端已经收到成功,但新主节点没有该写入。

使用 WAIT 1 后

T1:A 写入 x=1
T2:C 确认收到
T3:WAIT 1 返回成功
T4:A 和 C 均不可用,或系统选择 B 提升
T5:B 不包含 x=1

写入仍可能丢失。

使用 WAITAOF 后

即使 A 和 C 都已 fsync,如果故障转移选择未包含该写入的 B,或者已持久化的节点同时丢失,新的主时间线仍可能不包含该数据。

因此,若业务要求:

  • 已确认交易绝不能回退;
  • 跨节点线性一致读写;
  • 严格多数派提交;
  • 不允许故障切换后覆盖已确认历史;

应把关系数据库或基于共识协议的存储作为权威数据源,而不是仅依赖 Redis 主从复制。


4. 命令与 Go 使用方法

4.1 redis-cli 命令

配置副本

在副本节点执行:

REPLICAOF 10.0.0.10 6379

取消复制并将当前节点转为主节点:

REPLICAOF NO ONE

这是管理类高风险命令,生产环境不应由普通业务账号执行。

查看复制状态

INFO replication

主节点重点关注:

role
connected_slaves
master_replid
master_replid2
master_repl_offset
second_repl_offset
repl_backlog_active
repl_backlog_size
repl_backlog_first_byte_offset
repl_backlog_histlen

副本重点关注:

role
master_host
master_port
master_link_status
master_last_io_seconds_ago
master_sync_in_progress
slave_repl_offset
slave_read_repl_offset
slave_read_only

兼容性原因导致部分字段仍保留 slave 命名。

也可以使用:

ROLE

主节点会返回当前 replication offset 和各副本最后确认的 offset;副本会返回上游地址、复制状态和当前 offset。(Redis)

查看相关配置

CONFIG GET repl-backlog-size
CONFIG GET repl-backlog-ttl
CONFIG GET repl-diskless-sync
CONFIG GET repl-diskless-load
CONFIG GET replica-read-only
CONFIG GET replica-serve-stale-data
CONFIG GET min-replicas-to-write
CONFIG GET min-replicas-max-lag

等待副本确认

SET order:1001 paid
WAIT 1 500

业务必须判断 WAIT 返回值是否达到 1。

等待 AOF fsync

Redis 7.2+:

SET order:1001 paid
WAITAOF 1 1 1000

使用前必须确认主节点及目标副本启用了 AOF。


4.2 Go:写入后等待副本确认

package replication

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

var ErrReplicaAckNotReached = errors.New("replica acknowledgment target not reached")

// SetAndWaitReplica 将 SET 和 WAIT 固定在同一个物理连接上。
func SetAndWaitReplica(
	parent context.Context,
	primary *redis.Client,
	key string,
	value any,
	ttl time.Duration,
) error {
	ctx, cancel := context.WithTimeout(parent, 2*time.Second)
	defer cancel()

	// WAIT 具有连接级语义,因此不能先用 primary.Set,
	// 再直接用 primary.Wait。连接池可能为两个命令选择不同连接。
	conn := primary.Conn()
	defer func() {
		_ = conn.Close()
	}()

	if err := conn.Set(ctx, key, value, ttl).Err(); err != nil {
		return fmt.Errorf("set %q: %w", key, err)
	}

	acked, err := conn.Wait(ctx, 1, 700*time.Millisecond).Result()
	if err != nil {
		return fmt.Errorf("wait for replica: %w", err)
	}
	if acked < 1 {
		return fmt.Errorf("%w: acknowledged=%d", ErrReplicaAckNotReached, acked)
	}

	return nil
}

关键点:

  • redis.Client 内部使用连接池,可以被多个 goroutine 并发使用。
  • Conn() 用于在一段操作中固定单个连接。
  • 固定连接不应被多个 goroutine 同时共享。
  • 使用完必须 Close(),否则会长期占用连接池资源。
  • WAIT 超时可能返回较小的确认数而不是普通错误,必须检查结果。
  • 写入已经成功而 WAIT 未达标时,不能直接重试非幂等命令。例如重新执行 INCR 可能造成重复计数。
  • 不建议把 WAIT 放进 TxPipeline;在 MULTI 语境下它不会按普通阻塞方式等待。

go-redis 提供 Wait 调用;其普通 Client 是连接池抽象,因此凡是依赖“此前由当前连接发出的写入”的命令,都需要特别注意连接固定。(Redis)


4.3 Go:按一致性要求选择主节点或副本

package replication

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

type ReadConsistency uint8

const (
	// EventualRead 允许读取副本,可能得到旧数据。
	EventualRead ReadConsistency = iota

	// ReadYourWrites 适合写后立即读取,直接访问主节点。
	ReadYourWrites
)

func getOnce(
	parent context.Context,
	client *redis.Client,
	key string,
) (value string, found bool, err error) {
	ctx, cancel := context.WithTimeout(parent, 300*time.Millisecond)
	defer cancel()

	value, err = client.Get(ctx, key).Result()
	switch {
	case err == nil:
		return value, true, nil
	case errors.Is(err, redis.Nil):
		return "", false, nil
	default:
		return "", false, fmt.Errorf("get %q: %w", key, err)
	}
}

func GetByConsistency(
	ctx context.Context,
	primary *redis.Client,
	replica *redis.Client,
	key string,
	consistency ReadConsistency,
) (string, bool, error) {
	if consistency == ReadYourWrites {
		return getOnce(ctx, primary, key)
	}

	value, found, err := getOnce(ctx, replica, key)
	if err == nil {
		return value, found, nil
	}

	// 副本连接异常时回退主节点。
	// 注意:副本返回 redis.Nil 不代表连接异常,可能只是复制尚未追上。
	return getOnce(ctx, primary, key)
}

工程上应明确:

  • 允许旧读的商品详情、榜单、统计类数据可以读取副本。
  • 登录态、权限、库存、支付状态等关键读取应访问主节点或权威数据库。
  • 副本返回 redis.Nil 也可能是旧读,不能把它自动解释为权威的“不存在”。
  • 对读己之写有要求时,可在写入后的短时间窗口内强制该用户读取主节点。
  • 更高级的做法是在数据中带业务版本号;副本版本低于客户端持有的最低版本时,再回退主节点。

4.4 Go:调用 WAITAOF

WAITAOF 返回两个整数,因此可以通过 Do 显式解码:

func WaitAOF(
	ctx context.Context,
	conn *redis.Conn,
	numLocal int,
	numReplicas int,
	timeout time.Duration,
) (local int64, replicas int64, err error) {
	raw, err := conn.Do(
		ctx,
		"WAITAOF",
		numLocal,
		numReplicas,
		timeout.Milliseconds(),
	).Result()
	if err != nil {
		return 0, 0, fmt.Errorf("WAITAOF: %w", err)
	}

	values, ok := raw.([]interface{})
	if !ok || len(values) != 2 {
		return 0, 0, fmt.Errorf("unexpected WAITAOF response: %#v", raw)
	}

	local, ok = values[0].(int64)
	if !ok {
		return 0, 0, fmt.Errorf("unexpected local fsync count: %#v", values[0])
	}

	replicas, ok = values[1].(int64)
	if !ok {
		return 0, 0, fmt.Errorf("unexpected replica fsync count: %#v", values[1])
	}

	return local, replicas, nil
}

调用时必须确保前置写入和 WAITAOF 使用同一个 redis.Conn,并分别检查本地和副本确认数。


5. 典型业务场景

场景是否适用副本读数据量要求一致性要求主要风险可替代方案
商品详情缓存适用通常允许秒级旧读价格、状态短暂过期关键字段读主节点
热点新闻、榜单适用中到大最终一致排名短暂回退本地缓存、CDN
报表和运营查询适用通常允许延迟大查询拖慢副本并扩大延迟专用分析库
用户头像、展示资料通常适用写后最好读己之写用户修改后看到旧值写后主节点粘滞
Session、登录态谨慎较高注销后副本仍认为有效主节点或权威认证服务
权限判断通常不适用权限撤销未及时生效主节点、关系数据库
库存扣减不适用副本读决策高并发超卖、回退权威数据库、原子写路径
支付、余额、订单终态不适用作为唯一事实源极高已确认状态在切换后丢失事务数据库、共识存储
灾难恢复副本可作为恢复手段之一取决于 RPO错误和删除也会同步独立备份、异地快照

6. 底层实现

6.1 核心数据结构

数据结构或状态作用为什么这样设计
replication ID标识复制历史防止把不同时间线的 offset 错误拼接
replication offset标识复制流字节位置可精确确定副本还缺哪些字节
secondary replication ID保留切换前历史提高故障切换后的部分重同步成功率
replication buffer blocks保存待发送复制流多副本共享,减少重复内存
replication backlog保留近期历史窗口短暂断线后无需重新传输全部数据
offset 索引定位部分同步起点避免线性扫描全部历史块
副本 ACK offset记录副本确认进度支持监控、WAIT 和写入保护
RDB 快照提供完整数据集基线顺序传输和加载比逐条重放全部历史更高效

现代 Redis 不应简单理解为“每个副本各有一份独立 backlog”。复制数据可以由多个副本共享引用,慢副本则会延长某些缓冲块的生命周期。Redis 8.8 源码还会增量裁剪 backlog,防止一次释放大量块造成明显停顿。(GitHub)


6.2 核心算法

正常复制

执行写命令
→ 生成对应复制协议数据
→ 增加 master replication offset
→ 追加到共享复制缓冲
→ 将数据发送给在线副本
→ 副本执行并汇报 ACK offset

PSYNC 判断

伪代码可以简化为:

if requested_replid 属于可识别的复制历史
   and requested_offset 仍在 backlog 覆盖范围内:
       返回 CONTINUE
       发送缺失复制流
else:
       返回 FULLRESYNC
       发送完整快照

WAIT

主节点记录当前客户端最后一次写入对应的复制 offset。执行 WAIT 时,检查有多少副本 ACK offset 已达到该位置;不足时挂起当前客户端,直到达到目标或超时。

因此 WAIT 的目标不是“等待未来所有写入”,而是:

等待当前连接在 WAIT 之前已经产生的复制位置。


6.3 时间复杂度与空间成本

操作时间或资源复杂度条件与说明
正常写入复制主节点网络成本约为 O(R × S)R 为副本数,S 为复制字节数
backlog 追加通常为摊销 O(S)必须复制对应协议字节
部分重同步O(G)G 为副本缺失的复制字节数
全量同步O(D + Δ)D 为数据集大小,Δ 为同步期间增量数据
WAIT命令复杂度 O(1)墙钟时间受副本 ACK 延迟影响
WAITAOF命令复杂度 O(1)墙钟时间可能受 fsync 尾延迟影响
backlog 空间O(B)B 为配置容量,但共享引用可能影响实际释放时机
全量同步额外内存与写入量和 COW 页面有关不是固定等于数据集大小

WAITWAITAOF 的 O(1) 只描述服务器命令处理复杂度,不意味着请求会快速返回。等待 1 秒仍然是 1 秒的业务延迟。


6.4 版本差异

版本相关变化
Redis 2.8+引入 PSYNC 和部分重同步
Redis 2.8.18+支持主节点侧无盘复制
Redis 3.0+提供 WAIT
Redis 4.0+PSYNC2 增强故障切换和重启后的部分重同步能力
Redis 5.0+使用 REPLICAOF 术语,旧协议字段仍可能保留 slave
Redis 7.2+提供 WAITAOF
Redis 8.x复制缓冲和全量同步路径继续演进,但 ID、offset、backlog、PSYNC 的核心模型不变

6.5 对延迟和吞吐量的影响

正常异步复制不会让每条写入等待副本,因此写延迟较低,但会带来:

  • 每增加一个副本,都增加主节点网络出口流量。
  • 大 Value 会同时放大客户端网络和复制网络开销。
  • 慢副本可能积累较多待发送数据。
  • 全量同步期间,快照、网络和增量缓冲会争抢资源。

开启 WAIT 后,请求尾延迟会接近较慢的目标副本 ACK 延迟。

开启 WAITAOF 后,延迟还可能包含:

  • 主节点 fsync 排队;
  • 副本网络延迟;
  • 副本执行延迟;
  • 副本 AOF fsync 延迟。

因此不应不分业务地对所有缓存写入使用最高确认级别。


7. 高性能、高并发、高可用分析

7.1 高性能

复制对性能的主要影响来自网络、内存和全量同步,而不是正常命令查找复杂度。

优化重点:

  1. 根据实际复制字节率规划带宽。
  2. 避免大 Value 和突发超大批量写入。
  3. 为 backlog 预留足够内存,降低全量同步概率。
  4. 监控 fork、COW、磁盘带宽和副本加载时间。
  5. 磁盘较慢、网络较快时评估无盘复制。
  6. 不要让过多副本直接连接同一个主节点。
  7. WAITWAITAOF 只用于真正需要的数据。

无盘复制减少主节点临时 RDB 磁盘写入,但不会消除 CPU、网络和 COW 成本。


7.2 高并发

高并发场景需要特别关注:

连接池占用

阻塞中的 WAITWAITAOF 会占用一个客户端连接。大量并发请求同时等待副本,可能造成:

  • 连接池耗尽;
  • 后续普通命令排队;
  • 请求超时;
  • 应用重试进一步放大流量。

重试和重复执行

以下状态必须区分:

写入失败
写入成功但 WAIT 未达标
写入成功但客户端读取响应超时

后两种情况下,Redis 中的写入可能已经存在。对于 INCR、扣减、消息投递等非幂等操作,直接重试会造成重复执行。

重连惊群

网络恢复后,大量副本同时重连。如果 backlog 无法覆盖,会同时请求全量同步,造成:

  • 多路 RDB 传输;
  • 主节点网络打满;
  • 副本同时清空和加载;
  • 应用读流量集中回退主节点。

7.3 高可用

主从复制只是高可用的底层数据通道,本身不负责:

  • 判断主节点是否真正故障;
  • 选举哪个副本成为新主节点;
  • 通知客户端新主节点地址;
  • 阻止旧主节点继续接受写入;
  • 自动解决网络分区和双主问题。

这些职责需要 Sentinel、Redis Cluster 或外部编排系统承担。

高可用设计中应分别定义:

  • RTO:多久可以恢复服务。
  • RPO:最多允许丢失多少已写数据。

增加副本数量可以提高可恢复性,但不会自动把 RPO 变为 0。副本越多,主节点网络成本也越高。


8. 常见错误与生产事故

8.1 backlog 过小导致全量同步风暴

现象:

  • 网络短暂抖动后主节点 CPU、磁盘和网络同时升高。
  • sync_full 快速增加。
  • 多个副本长时间处于同步状态。
  • 应用读取大量回退主节点。

根因:

副本断线期间产生的复制流超过 backlog 保留范围,部分重同步失败,只能全量同步。

排查方法:

  • 查看 repl_backlog_sizerepl_backlog_histlen
  • 比较峰值复制字节率与断线持续时间。
  • 查看 sync_partial_errsync_full
  • 检查网络抖动和副本暂停时间。

修复方案:

  • 按峰值复制流量重新计算 backlog。
  • 修复网络抖动或副本长时间停顿。
  • 分批恢复副本,避免同时全量同步。

如何预防:

持续采集复制字节率、全量同步次数和副本断线时长,并进行断网恢复演练。


8.2 删除后仍从副本读到旧数据

现象:

用户已被封禁或权限已撤销,但部分请求仍允许访问。

根因:

删除或权限更新已在主节点完成,但副本尚未应用相关复制流;业务又把安全决策路由到了副本。

排查方法:

  • 比较主节点和副本的 Key。
  • 查看 master_link_status 和 offset 差值。
  • 检查客户端读路由策略。
  • 检查是否启用了继续提供旧数据。

修复方案:

权限、Session 注销等关键读取改为访问主节点或权威数据库。

如何预防:

在数据分类阶段明确哪些业务允许旧读,不要仅按“GET 都走副本”做统一路由。


8.3 忽略 WAIT 返回值

现象:

代码执行了 WAIT 1 500,团队仍发现主节点故障后写入丢失。

根因:

开发者只检查了命令 error,没有检查返回的副本确认数。超时后 WAIT 返回 0,但业务仍按成功处理。

排查方法:

检查客户端日志和埋点中是否记录 acked 值。

修复方案:

显式判断:

if acked < required {
    // 进入降级、告警或不确定状态
}

如何预防:

封装统一写入函数,禁止业务代码直接忽略确认数量。


8.4 Go 连接池导致 WAIT 等错写入

现象:

压测中 WAIT 返回值与预期不一致;单线程测试正常,并发后出现问题。

根因:

代码先执行:

primary.Set(...)
primary.Wait(...)

两个命令可能使用不同物理连接,而 WAIT 等待的是当前连接此前的写入。

排查方法:

查看调用是否使用 Client.Conn()、普通 Pipeline 或其他连接固定机制。

修复方案:

在同一个 redis.Conn 上执行写入和 WAIT

如何预防:

把所有具有连接状态语义的操作封装起来,并在代码审查中检查连接池边界。(Redis)


8.5 全量同步造成主节点延迟和内存峰值

现象:

副本扩容时,主节点 P99 延迟突然升高,甚至被 OOM Killer 终止。

根因:

  • fork 期间页表复制开销。
  • 高写入导致大量 COW 页面。
  • RDB 生成和网络传输争用资源。
  • 同步期间增量写入持续积累。
  • 机器可用内存不足。

排查方法:

  • 查看 latest_fork_usec
  • 观察 RSS、used memory、COW 指标。
  • 检查磁盘和网络吞吐。
  • 检查是否多个副本同时全量同步。

修复方案:

  • 为 COW 预留内存。
  • 低峰期扩容。
  • 增大 backlog,减少不必要全量同步。
  • 评估无盘复制。
  • 分批添加副本。

如何预防:

把全量同步纳入容量评估,而不是只按稳定运行时的 used_memory 配置机器。


8.6 主节点关闭持久化并自动重启,副本被清空

现象:

主节点异常重启后变成空实例,随后所有副本数据也逐渐消失。

根因:

主节点未启用持久化,重启后数据集为空;副本把重新启动的空主节点当作合法复制源并同步其空数据集。

排查方法:

  • 检查主节点持久化配置。
  • 查看重启时间和复制日志。
  • 检查副本是否在主节点重启后发生全量同步。

修复方案:

从独立备份恢复数据;重新规划持久化和自动重启策略。

如何预防:

数据重要时,不应把“无持久化主节点自动重启”和普通主从复制简单组合。Redis 官方文档明确提示这种配置可能将空数据集传播到所有副本。(Redis)


8.7 把副本错误配置为写节点

现象:

部分写请求返回 READONLY;关闭只读限制后,又出现数据莫名消失。

根因:

客户端服务发现错误,把副本地址放入写连接池。关闭 replica-read-only 后,本地写入又会在重新同步时被主节点数据覆盖。

排查方法:

执行 ROLE,检查客户端实际连接地址和服务发现记录。

修复方案:

恢复副本只读,修复写路由;不要把可写副本当作多主方案。

如何预防:

为主、从使用不同 DNS、端口、ACL 用户或客户端配置,并对 READONLY 错误建立告警。


9. 方案选型与权衡

方案读一致性写入丢失风险延迟可用性适用场景
主节点读写主节点存活时读己之写较好异步切换仍可能丢失主节点压力较大权限、Session、关键状态
随机副本读最终一致不改变写入风险读扩展能力好商品详情、榜单、统计
写后主节点粘滞短期读己之写不改变写入风险较好用户资料修改后展示
业务版本校验可发现部分旧读不改变写入风险依赖回退设计跨副本读取、单调读
WAIT 1不直接保证后续随机副本新鲜显著降低单节点故障风险增加网络等待副本异常时退化较重要但允许极小风险的数据
WAITAOF 1 1同上增加已持久化副本数量受磁盘尾延迟影响磁盘异常时明显退化对 Redis 内数据耐久性要求较高
min-replicas-to-write不改变读一致性减少孤立主节点继续写入正常时较低副本不足时拒写希望用可用性换数据安全
共识存储或事务数据库可提供更强保证可按系统协议提供严格提交通常更高取决于多数派余额、支付、订单权威数据

不存在统一最佳方案。选型应先回答三个问题:

  1. 最多允许丢失多少数据?
  2. 写入最多允许增加多少延迟?
  3. 副本异常时,是继续写、拒绝写,还是降级到权威数据库?

10. 高频面试题

10.1 Redis 主从复制为什么默认是异步的?

问题

Redis 为什么不默认等待所有副本同步完成后再返回写入成功?

推荐回答

现场版:

因为等待副本会把网络延迟和副本抖动放进每次写入链路。Redis 默认选择异步复制来保持低延迟和高可用,代价是故障切换时可能丢失尚未复制的写入。

展开:

主节点本地执行完成后即可返回,复制流随后发送给副本。任何一个慢副本都不会默认阻塞主节点写入。业务可以针对少数重要写入使用 WAITWAITAOF,但这些机制仍不提供共识意义上的强一致。(Redis)

面试官追问

  1. 异步复制会产生哪些异常?
  2. 如何缩小数据丢失窗口?
  3. 为什么不能简单等待所有副本?

常见错误回答

“Redis 主从绝对不丢数据”或“只要有副本就是强一致”。

评分点

  • 初级:知道默认异步。
  • 中级:能说明性能与数据丢失权衡。
  • 高级:能联系 WAIT、分区、故障切换和 RPO。

10.2 replication ID 和 offset 分别解决什么问题?

问题

为什么不能只使用 offset?

推荐回答

现场版:

offset 表示某条复制流的位置,replication ID 表示它属于哪条历史。只比较 offset 可能把不同主节点、不同时间线上的相同数字误认为同一版本。

展开:

(replid, offset) 共同确定复制历史中的具体位置。副本提升后会生成新 ID,并暂时保留旧 ID 和切换边界,以提高后续部分同步的成功率。(Redis)

面试官追问

  1. 为什么会有两个 replication ID?
  2. offset 是命令数量吗?
  3. 主节点没有副本时 offset 是否增长?

常见错误回答

“offset 就是写请求数。”

评分点

  • 初级:知道二者名称。
  • 中级:能解释历史身份与字节位置。
  • 高级:能解释故障提升后的双 ID。

10.3 PSYNC 如何判断部分重同步还是全量同步?

问题

副本断线后重连,主节点如何决定同步方式?

推荐回答

现场版:

副本携带旧 replication ID 和已处理 offset。主节点能识别这条历史,且缺失字节仍在 backlog 中,就部分重同步;否则全量同步。

展开:

是否部分同步由“复制历史是否连续”和“所需数据是否仍保留”共同决定。只增大 backlog 不能解决 replication ID 已完全失效的问题。(Redis)

面试官追问

  1. 第一次连接发送什么?
  2. backlog 足够大是否一定部分同步?
  3. 主从切换后为什么仍可能部分同步?

常见错误回答

“只要副本重连就一定增量同步。”

评分点

  • 初级:知道两种同步方式。
  • 中级:能说出 ID 和 backlog 两个条件。
  • 高级:能解释第二 replication ID。

10.4 replication backlog 应该如何设置?

问题

把 backlog 设置为默认值是否足够?

推荐回答

现场版:

应按峰值复制字节率乘以目标断线容忍时间,再乘安全系数估算,不能只看数据集大小。

展开:

backlog 保存的是近期复制流,不是整个数据集。写入越快,同样容量能覆盖的断线时间越短。过小会触发全量同步,过大则增加常驻内存。

面试官追问

  1. 为什么不能按 QPS 计算?
  2. 大 Value 对 backlog 有什么影响?
  3. 应监控哪些指标?

常见错误回答

“数据集是 100 GB,所以 backlog 也应设 100 GB。”

评分点

  • 初级:知道过小会全量同步。
  • 中级:会使用带宽乘时间估算。
  • 高级:会结合突发流量、恢复时间和监控反馈调整。

10.5 全量同步为什么可能拖慢主节点?

问题

BGSAVE 由子进程执行,为什么主节点仍可能抖动?

推荐回答

现场版:

子进程负责遍历和写快照,但主进程仍承担 fork、页表复制、COW、增量复制缓冲和网络传输。高写入时 COW 和内存带宽竞争可能非常明显。

展开:

副本还需要加载 RDB;同步期间若写入持续增长,复制缓冲和输出缓冲也会增加。多个副本同时全量同步可能把网络出口打满。

面试官追问

  1. COW 为什么与写入量相关?
  2. 无盘复制能解决哪些成本?
  3. 如何排查全量同步风暴?

常见错误回答

“有子进程,所以对主进程完全没有影响。”

评分点

  • 初级:知道会生成 RDB。
  • 中级:能说明 fork、COW、磁盘、网络。
  • 高级:能分析内存峰值和同步风暴。

10.6 无盘复制是不是不使用磁盘?

问题

开启无盘复制后是否完全没有磁盘 I/O?

推荐回答

现场版:

它主要表示全量同步的 RDB 可以直接从生成进程传到副本,而不是先在主节点落地临时文件。它不代表 Redis 的 AOF、已有 RDB 或副本加载一定不使用磁盘。

展开:

还要区分主节点无盘发送与副本无盘加载。无盘复制适合慢磁盘、快网络,但不会消除 fork、COW 和网络成本。(GitHub)

面试官追问

  1. 多个副本同时请求同步怎么办?
  2. 网络中断有什么影响?
  3. 副本无盘加载的内存风险是什么?

常见错误回答

“开启后 Redis 永远不会访问磁盘。”

评分点

  • 初级:知道直接网络传输。
  • 中级:能区分主节点和副本侧配置。
  • 高级:能分析磁盘、网络和内存权衡。

10.7 读写分离有哪些一致性风险?

问题

把所有 GET 都发到副本是否合理?

推荐回答

现场版:

不合理。副本读是最终一致的,会出现旧读、读己之写失效、单调读失效和删除后旧值复现。是否读副本应按业务数据分类。

展开:

商品展示通常能容忍短暂旧读;权限撤销、Session 注销、库存和支付状态则不能。关键读取应走主节点或权威数据库。

面试官追问

  1. 如何实现读己之写?
  2. WAIT 1 后能否随机读任意副本?
  3. 副本返回不存在是否可信?

常见错误回答

“只要主从在同机房,副本就不会有延迟。”

评分点

  • 初级:知道可能读旧数据。
  • 中级:能说出读己之写问题。
  • 高级:能设计主节点粘滞、版本令牌和负查询策略。

10.8 WAIT 能否保证强一致?

问题

使用 WAIT 1 500 后是否可以保证不丢数据?

推荐回答

现场版:

不能。它只保证当前连接此前的写入被至少指定数量副本确认,降低丢失概率,但故障转移仍可能选择未确认该写入的副本。

展开:

WAIT 不保证副本 AOF 已 fsync,也不提供多数派提交和新主节点选择约束。返回值还可能小于目标值,业务必须检查。(Redis)

面试官追问

  1. WAIT 返回 0 是否代表写入失败?
  2. 为什么必须是同一连接?
  3. WAIT 0 或 timeout 0 分别意味着什么?

常见错误回答

“WAIT 成功后 Redis 就与 Raft 一样。”

评分点

  • 初级:知道等待副本。
  • 中级:知道检查返回值和连接语义。
  • 高级:能解释故障选择和共识差异。

10.9 WAIT 和 WAITAOF 有什么区别?

问题

二者分别保证了什么?

推荐回答

现场版:

WAIT 等待副本复制 offset 确认,重点是复制副本数量;WAITAOF 等待本地和/或副本完成 AOF fsync,进一步关注持久化。二者都不是强一致协议。

展开:

WAIT 从 Redis 3.0 提供;WAITAOF 从 Redis 7.2 提供。后者会把磁盘 fsync 尾延迟引入请求链路,并要求相关节点启用 AOF。(Redis)

面试官追问

  1. RDB-only 副本能否满足 WAITAOF
  2. WAITAOF 返回什么?
  3. 为什么 fsync 后仍可能丢失?

常见错误回答

“WAITAOF 就是 WAIT 的新名称。”

评分点

  • 初级:知道一个等复制、一个等 AOF。
  • 中级:知道版本和返回值。
  • 高级:能分析持久化副本与故障选择的区别。

10.10 为什么客户端收到成功后,主从切换仍可能丢数据?

问题

请给出一条完整故障时间线。

推荐回答

现场版:

主节点本地执行并返回成功后,复制尚未完成就故障;系统提升了较旧副本,新时间线不包含该写入,于是出现已确认写入丢失。

展开:

即使 WAIT 1 成功,如果确认写入的是副本 C,而最终提升的是副本 B,也可能丢失。关键是“拥有副本”不等于“该副本一定成为新主节点”。

面试官追问

  1. min-replicas-to-write 能完全避免吗?
  2. 如何降低 RPO?
  3. 什么业务不应把 Redis 当唯一事实源?

常见错误回答

“故障切换只影响可用性,不影响数据。”

评分点

  • 初级:知道异步复制可能丢失。
  • 中级:能给出主故障时间线。
  • 高级:能说明 WAIT 后的剩余风险。

10.11 min-replicas-to-write 和 WAIT 有何不同?

问题

两者能否相互替代?

推荐回答

现场版:

不能完全替代。min-replicas-to-write 是主节点写入准入条件,副本数量或延迟不达标时拒绝写;WAIT 是客户端在具体写入之后等待副本确认。

展开:

前者适合阻止孤立主节点长时间继续接受写入,但不会逐条证明某次写入已经到达副本;后者粒度更细,却增加请求等待并需要客户端正确处理。

面试官追问

  1. 为什么该配置可能降低可用性?
  2. 副本 lag 判断是否绝对精确?
  3. 能否二者同时使用?

常见错误回答

“配置了最小副本数,每次写都会同步复制。”

评分点

  • 初级:知道一个是配置、一个是命令。
  • 中级:能说明写前准入和写后确认。
  • 高级:能按业务设计组合策略。

10.12 Go 中如何正确执行 SET 后 WAIT?

问题

下面代码有什么问题?

client.Set(ctx, key, value, 0)
client.Wait(ctx, 1, time.Second)

推荐回答

现场版:

redis.Client 使用连接池,两条命令可能落到不同物理连接;而 WAIT 只等待当前连接此前的写入。应使用 client.Conn() 固定连接,并在同一连接上执行写入和 WAIT

展开:

固定连接只应由当前操作使用,完成后及时关闭归还。还要检查 WAIT 返回的确认数,处理“写成功但确认未达标”的不确定状态,避免非幂等重试。(Redis)

面试官追问

  1. redis.Client 是否并发安全?
  2. redis.Conn 是否应跨 goroutine 共享?
  3. 能否使用 TxPipeline

常见错误回答

“同一个 redis.Client 对象就一定是同一条 TCP 连接。”

评分点

  • 初级:知道使用 go-redis。
  • 中级:理解连接池和 redis.Nil
  • 高级:能处理连接级语义、幂等重试和连接池容量。

11. 一分钟面试回答

Redis 主从复制默认是异步的,主节点本地执行写入后通常不会等待副本,所以性能较好,但故障切换时可能丢失尚未复制的数据。Redis 使用 replication ID 标识复制历史,offset 标识复制流字节位置,backlog 保存近期复制流。副本重连时通过 PSYNC 携带 ID 和 offset;历史匹配且缺失数据仍在 backlog 中就部分重同步,否则主节点生成并传输 RDB,副本加载后再应用期间增量写入。读副本可以扩展查询能力,但会出现旧读、读己之写和单调读问题。WAIT 从 Redis 3.0 开始等待副本确认,WAITAOF 从 7.2 开始还能等待 AOF fsync,但二者都不提供强一致。Go 中要特别注意它们是连接级语义,写入和等待必须固定到同一个物理连接。


12. 本章总结

  1. Redis 复制默认追求低延迟和可用性,而不是强一致。
  2. replication ID 解决复制历史身份问题,offset 解决历史位置问题。
  3. backlog 决定副本短暂断线后能否进行部分重同步。
  4. 全量同步的本质是“完整快照加快照之后的复制流”。
  5. 无盘复制减少特定磁盘路径,但不消除 fork、COW、网络和内存成本。
  6. 副本读只能用于能够容忍旧数据的业务。
  7. WAIT 确认复制,WAITAOF 确认 AOF fsync,均不等于共识提交。
  8. 主从切换可能选择更旧副本,因此已返回成功的写入仍可能丢失。
  9. Go 连接池会隐藏物理连接边界,连接级命令必须显式固定连接。
  10. 复制、持久化、自动故障转移和备份必须分别设计。

13. 自测清单

  1. 为什么 replication offset 不能脱离 replication ID 单独判断数据版本?
  2. 副本断线 30 秒后,哪些条件决定能否进行部分重同步?
  3. replication backlog 应按数据集大小还是复制流量计算?
  4. 全量同步期间主节点为什么仍能处理写入?
  5. 全量同步中的 RDB 和增量复制流分别解决什么问题?
  6. 主节点无盘复制与副本无盘加载有什么区别?
  7. 为什么 WAIT 1 成功后随机读取另一台副本仍可能读到旧数据?
  8. WAIT 返回 0 时,前面的写命令是否一定没有执行?
  9. WAITAOF 1 1 1000 分别等待哪些确认?
  10. 为什么 primary.Set() 后直接调用 primary.Wait() 在 Go 连接池中可能不正确?

14. 官方资料

  • Redis 官方主从复制文档。(Redis)
  • Redis WAIT 命令文档。(Redis)
  • Redis WAITAOF 命令文档。(Redis)
  • Redis INFO 命令文档。(Redis)
  • Redis ROLE 命令文档。(Redis)
  • Redis 官方 redis.conf 复制配置说明。(GitHub)
  • Redis 8.8 src/replication.c 源码。(GitHub)
  • go-redis 官方命令实现与客户端源码。(GitHub)