主从复制与数据一致性
系统梳理 Redis 主从复制、异步复制、replication ID、offset、backlog、全量同步、部分重同步、读写分离、旧读、WAIT、WAITAOF 与一致性边界。
第 15 章:主从复制与数据一致性
1. 本章定位
上一章解决的是“Redis 进程重启后如何恢复数据”,本章解决的是“一个 Redis 节点故障时,其他节点如何拥有数据副本,以及这些副本究竟有多新”。
主从复制主要提供三个能力:
- 为故障转移准备副本。
- 将部分只读流量分散到副本。
- 为离线分析、备份等任务提供隔离节点。
Redis 默认使用异步复制。主节点执行写命令后通常不会等待副本确认,因此复制延迟、网络分区和故障切换都可能造成旧读或数据丢失。WAIT、WAITAOF 可以缩小风险窗口,但不会把 Redis 主从集群变成强一致的共识系统。(Redis)
必须区分以下概念:
- 复制不等于持久化:副本可能复制主节点的错误操作或空数据集。
- 复制不等于备份:误删除也会被同步到副本。
- 复制不等于自动故障转移:单纯主从复制不会自动选举新主节点。
- 高可用不等于强一致:切换期间仍可能丢失已经返回成功的写入。
2. 学习目标
完成本章后,应能够:
- 解释 Redis 异步复制模型及其数据丢失窗口。
- 说明 replication ID、replication offset 和 replication backlog 的关系。
- 根据副本状态判断会触发部分重同步还是全量同步。
- 完整描述全量同步过程中 RDB、增量写入和副本加载的顺序。
- 根据写入带宽和允许断线时间估算 replication backlog 容量。
- 识别读写分离中的旧读、读己之写失效和单调读失效。
- 正确区分
WAIT、WAITAOF和min-replicas-to-write。 - 使用
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
正常情况下,主节点把写命令、过期删除、淘汰等导致数据变化的操作编码为复制流,副本按相同顺序执行。
默认流程是:
- 主节点在本地执行写命令。
- 主节点向客户端返回结果。
- 复制数据通过网络发送到副本。
- 副本接收并应用复制流。
- 副本周期性向主节点汇报已处理的复制位置。
因此,当客户端收到 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 全量同步过程
经典全量同步可分为以下阶段:
-
建立复制连接 副本连接主节点,完成认证、能力协商和
PSYNC请求。 -
主节点确认 FULLRESYNC 主节点通知副本新的 replication ID 和全量同步起始 offset。
-
生成数据快照 经典磁盘模式下,主节点触发后台 RDB 保存。父进程继续处理请求,子进程遍历内存数据生成 RDB。
-
保留快照之后的写入 生成 RDB 期间,主节点仍会接受写入。这些增量操作必须保留,否则副本加载完 RDB 后仍会落后。
-
向副本传输 RDB 主节点把快照发送给副本。采用无盘复制时,可以不先写入主节点本地磁盘。
-
副本加载快照 副本清理或替换旧数据集,再加载 RDB。这个过程可能使副本暂时不能正常提供数据服务,具体行为取决于版本和配置。
-
应用增量复制流 副本加载完成后,继续执行快照生成期间积累的复制命令。
-
进入在线复制状态 副本追上主节点后,开始持续接收新的复制流。
其核心不变量是:
副本最终状态
= 某一时刻的完整快照
+ 该时刻之后的有序复制流
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_sizerepl_backlog_histlensync_fullsync_partial_oksync_partial_errmaster_link_statusmaster_sync_in_progress
这些指标可通过 INFO replication 和 ROLE 获取。需要注意,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 与写入保护
| 机制 | 版本 | 等待内容 | 返回值 | 主要限制 |
|---|---|---|---|---|
WAIT | Redis 3.0+ | 当前连接之前的写入,被指定数量副本确认 | 已确认副本数 | 不保证 AOF 已刷盘,不提供强一致 |
WAITAOF | Redis 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 只能是 0 或 1。本地没有启用 AOF 时,不能要求非零本地确认;该命令也不能在副本上执行。(Redis)
为什么二者仍不是强一致
即使写入得到一个副本确认,也可能发生:
- 副本 C 确认了写入。
- 主节点 A 故障。
- 故障转移系统选择了更旧的副本 B。
- B 成为新主节点。
- 只存在于 A 和 C 的写入不在新时间线上。
WAIT 和 WAITAOF 增加了数据副本数量或持久化副本数量,但没有提供共识协议中的多数派提交、唯一领导者任期和 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 页面有关 | 不是固定等于数据集大小 |
WAIT 或 WAITAOF 的 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 高性能
复制对性能的主要影响来自网络、内存和全量同步,而不是正常命令查找复杂度。
优化重点:
- 根据实际复制字节率规划带宽。
- 避免大 Value 和突发超大批量写入。
- 为 backlog 预留足够内存,降低全量同步概率。
- 监控
fork、COW、磁盘带宽和副本加载时间。 - 磁盘较慢、网络较快时评估无盘复制。
- 不要让过多副本直接连接同一个主节点。
- 将
WAIT、WAITAOF只用于真正需要的数据。
无盘复制减少主节点临时 RDB 磁盘写入,但不会消除 CPU、网络和 COW 成本。
7.2 高并发
高并发场景需要特别关注:
连接池占用
阻塞中的 WAIT 或 WAITAOF 会占用一个客户端连接。大量并发请求同时等待副本,可能造成:
- 连接池耗尽;
- 后续普通命令排队;
- 请求超时;
- 应用重试进一步放大流量。
重试和重复执行
以下状态必须区分:
写入失败
写入成功但 WAIT 未达标
写入成功但客户端读取响应超时
后两种情况下,Redis 中的写入可能已经存在。对于 INCR、扣减、消息投递等非幂等操作,直接重试会造成重复执行。
重连惊群
网络恢复后,大量副本同时重连。如果 backlog 无法覆盖,会同时请求全量同步,造成:
- 多路 RDB 传输;
- 主节点网络打满;
- 副本同时清空和加载;
- 应用读流量集中回退主节点。
7.3 高可用
主从复制只是高可用的底层数据通道,本身不负责:
- 判断主节点是否真正故障;
- 选举哪个副本成为新主节点;
- 通知客户端新主节点地址;
- 阻止旧主节点继续接受写入;
- 自动解决网络分区和双主问题。
这些职责需要 Sentinel、Redis Cluster 或外部编排系统承担。
高可用设计中应分别定义:
- RTO:多久可以恢复服务。
- RPO:最多允许丢失多少已写数据。
增加副本数量可以提高可恢复性,但不会自动把 RPO 变为 0。副本越多,主节点网络成本也越高。
8. 常见错误与生产事故
8.1 backlog 过小导致全量同步风暴
现象:
- 网络短暂抖动后主节点 CPU、磁盘和网络同时升高。
sync_full快速增加。- 多个副本长时间处于同步状态。
- 应用读取大量回退主节点。
根因:
副本断线期间产生的复制流超过 backlog 保留范围,部分重同步失败,只能全量同步。
排查方法:
- 查看
repl_backlog_size和repl_backlog_histlen。 - 比较峰值复制字节率与断线持续时间。
- 查看
sync_partial_err、sync_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 | 不改变读一致性 | 减少孤立主节点继续写入 | 正常时较低 | 副本不足时拒写 | 希望用可用性换数据安全 |
| 共识存储或事务数据库 | 可提供更强保证 | 可按系统协议提供严格提交 | 通常更高 | 取决于多数派 | 余额、支付、订单权威数据 |
不存在统一最佳方案。选型应先回答三个问题:
- 最多允许丢失多少数据?
- 写入最多允许增加多少延迟?
- 副本异常时,是继续写、拒绝写,还是降级到权威数据库?
10. 高频面试题
10.1 Redis 主从复制为什么默认是异步的?
问题
Redis 为什么不默认等待所有副本同步完成后再返回写入成功?
推荐回答
现场版:
因为等待副本会把网络延迟和副本抖动放进每次写入链路。Redis 默认选择异步复制来保持低延迟和高可用,代价是故障切换时可能丢失尚未复制的写入。
展开:
主节点本地执行完成后即可返回,复制流随后发送给副本。任何一个慢副本都不会默认阻塞主节点写入。业务可以针对少数重要写入使用 WAIT 或 WAITAOF,但这些机制仍不提供共识意义上的强一致。(Redis)
面试官追问
- 异步复制会产生哪些异常?
- 如何缩小数据丢失窗口?
- 为什么不能简单等待所有副本?
常见错误回答
“Redis 主从绝对不丢数据”或“只要有副本就是强一致”。
评分点
- 初级:知道默认异步。
- 中级:能说明性能与数据丢失权衡。
- 高级:能联系
WAIT、分区、故障切换和 RPO。
10.2 replication ID 和 offset 分别解决什么问题?
问题
为什么不能只使用 offset?
推荐回答
现场版:
offset 表示某条复制流的位置,replication ID 表示它属于哪条历史。只比较 offset 可能把不同主节点、不同时间线上的相同数字误认为同一版本。
展开:
(replid, offset) 共同确定复制历史中的具体位置。副本提升后会生成新 ID,并暂时保留旧 ID 和切换边界,以提高后续部分同步的成功率。(Redis)
面试官追问
- 为什么会有两个 replication ID?
- offset 是命令数量吗?
- 主节点没有副本时 offset 是否增长?
常见错误回答
“offset 就是写请求数。”
评分点
- 初级:知道二者名称。
- 中级:能解释历史身份与字节位置。
- 高级:能解释故障提升后的双 ID。
10.3 PSYNC 如何判断部分重同步还是全量同步?
问题
副本断线后重连,主节点如何决定同步方式?
推荐回答
现场版:
副本携带旧 replication ID 和已处理 offset。主节点能识别这条历史,且缺失字节仍在 backlog 中,就部分重同步;否则全量同步。
展开:
是否部分同步由“复制历史是否连续”和“所需数据是否仍保留”共同决定。只增大 backlog 不能解决 replication ID 已完全失效的问题。(Redis)
面试官追问
- 第一次连接发送什么?
- backlog 足够大是否一定部分同步?
- 主从切换后为什么仍可能部分同步?
常见错误回答
“只要副本重连就一定增量同步。”
评分点
- 初级:知道两种同步方式。
- 中级:能说出 ID 和 backlog 两个条件。
- 高级:能解释第二 replication ID。
10.4 replication backlog 应该如何设置?
问题
把 backlog 设置为默认值是否足够?
推荐回答
现场版:
应按峰值复制字节率乘以目标断线容忍时间,再乘安全系数估算,不能只看数据集大小。
展开:
backlog 保存的是近期复制流,不是整个数据集。写入越快,同样容量能覆盖的断线时间越短。过小会触发全量同步,过大则增加常驻内存。
面试官追问
- 为什么不能按 QPS 计算?
- 大 Value 对 backlog 有什么影响?
- 应监控哪些指标?
常见错误回答
“数据集是 100 GB,所以 backlog 也应设 100 GB。”
评分点
- 初级:知道过小会全量同步。
- 中级:会使用带宽乘时间估算。
- 高级:会结合突发流量、恢复时间和监控反馈调整。
10.5 全量同步为什么可能拖慢主节点?
问题
BGSAVE 由子进程执行,为什么主节点仍可能抖动?
推荐回答
现场版:
子进程负责遍历和写快照,但主进程仍承担 fork、页表复制、COW、增量复制缓冲和网络传输。高写入时 COW 和内存带宽竞争可能非常明显。
展开:
副本还需要加载 RDB;同步期间若写入持续增长,复制缓冲和输出缓冲也会增加。多个副本同时全量同步可能把网络出口打满。
面试官追问
- COW 为什么与写入量相关?
- 无盘复制能解决哪些成本?
- 如何排查全量同步风暴?
常见错误回答
“有子进程,所以对主进程完全没有影响。”
评分点
- 初级:知道会生成 RDB。
- 中级:能说明 fork、COW、磁盘、网络。
- 高级:能分析内存峰值和同步风暴。
10.6 无盘复制是不是不使用磁盘?
问题
开启无盘复制后是否完全没有磁盘 I/O?
推荐回答
现场版:
它主要表示全量同步的 RDB 可以直接从生成进程传到副本,而不是先在主节点落地临时文件。它不代表 Redis 的 AOF、已有 RDB 或副本加载一定不使用磁盘。
展开:
还要区分主节点无盘发送与副本无盘加载。无盘复制适合慢磁盘、快网络,但不会消除 fork、COW 和网络成本。(GitHub)
面试官追问
- 多个副本同时请求同步怎么办?
- 网络中断有什么影响?
- 副本无盘加载的内存风险是什么?
常见错误回答
“开启后 Redis 永远不会访问磁盘。”
评分点
- 初级:知道直接网络传输。
- 中级:能区分主节点和副本侧配置。
- 高级:能分析磁盘、网络和内存权衡。
10.7 读写分离有哪些一致性风险?
问题
把所有 GET 都发到副本是否合理?
推荐回答
现场版:
不合理。副本读是最终一致的,会出现旧读、读己之写失效、单调读失效和删除后旧值复现。是否读副本应按业务数据分类。
展开:
商品展示通常能容忍短暂旧读;权限撤销、Session 注销、库存和支付状态则不能。关键读取应走主节点或权威数据库。
面试官追问
- 如何实现读己之写?
WAIT 1后能否随机读任意副本?- 副本返回不存在是否可信?
常见错误回答
“只要主从在同机房,副本就不会有延迟。”
评分点
- 初级:知道可能读旧数据。
- 中级:能说出读己之写问题。
- 高级:能设计主节点粘滞、版本令牌和负查询策略。
10.8 WAIT 能否保证强一致?
问题
使用 WAIT 1 500 后是否可以保证不丢数据?
推荐回答
现场版:
不能。它只保证当前连接此前的写入被至少指定数量副本确认,降低丢失概率,但故障转移仍可能选择未确认该写入的副本。
展开:
WAIT 不保证副本 AOF 已 fsync,也不提供多数派提交和新主节点选择约束。返回值还可能小于目标值,业务必须检查。(Redis)
面试官追问
WAIT返回 0 是否代表写入失败?- 为什么必须是同一连接?
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)
面试官追问
- RDB-only 副本能否满足
WAITAOF? WAITAOF返回什么?- 为什么 fsync 后仍可能丢失?
常见错误回答
“WAITAOF 就是 WAIT 的新名称。”
评分点
- 初级:知道一个等复制、一个等 AOF。
- 中级:知道版本和返回值。
- 高级:能分析持久化副本与故障选择的区别。
10.10 为什么客户端收到成功后,主从切换仍可能丢数据?
问题
请给出一条完整故障时间线。
推荐回答
现场版:
主节点本地执行并返回成功后,复制尚未完成就故障;系统提升了较旧副本,新时间线不包含该写入,于是出现已确认写入丢失。
展开:
即使 WAIT 1 成功,如果确认写入的是副本 C,而最终提升的是副本 B,也可能丢失。关键是“拥有副本”不等于“该副本一定成为新主节点”。
面试官追问
min-replicas-to-write能完全避免吗?- 如何降低 RPO?
- 什么业务不应把 Redis 当唯一事实源?
常见错误回答
“故障切换只影响可用性,不影响数据。”
评分点
- 初级:知道异步复制可能丢失。
- 中级:能给出主故障时间线。
- 高级:能说明 WAIT 后的剩余风险。
10.11 min-replicas-to-write 和 WAIT 有何不同?
问题
两者能否相互替代?
推荐回答
现场版:
不能完全替代。
min-replicas-to-write是主节点写入准入条件,副本数量或延迟不达标时拒绝写;WAIT是客户端在具体写入之后等待副本确认。
展开:
前者适合阻止孤立主节点长时间继续接受写入,但不会逐条证明某次写入已经到达副本;后者粒度更细,却增加请求等待并需要客户端正确处理。
面试官追问
- 为什么该配置可能降低可用性?
- 副本 lag 判断是否绝对精确?
- 能否二者同时使用?
常见错误回答
“配置了最小副本数,每次写都会同步复制。”
评分点
- 初级:知道一个是配置、一个是命令。
- 中级:能说明写前准入和写后确认。
- 高级:能按业务设计组合策略。
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)
面试官追问
redis.Client是否并发安全?redis.Conn是否应跨 goroutine 共享?- 能否使用
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. 本章总结
- Redis 复制默认追求低延迟和可用性,而不是强一致。
- replication ID 解决复制历史身份问题,offset 解决历史位置问题。
- backlog 决定副本短暂断线后能否进行部分重同步。
- 全量同步的本质是“完整快照加快照之后的复制流”。
- 无盘复制减少特定磁盘路径,但不消除
fork、COW、网络和内存成本。 - 副本读只能用于能够容忍旧数据的业务。
WAIT确认复制,WAITAOF确认 AOF fsync,均不等于共识提交。- 主从切换可能选择更旧副本,因此已返回成功的写入仍可能丢失。
- Go 连接池会隐藏物理连接边界,连接级命令必须显式固定连接。
- 复制、持久化、自动故障转移和备份必须分别设计。
13. 自测清单
- 为什么 replication offset 不能脱离 replication ID 单独判断数据版本?
- 副本断线 30 秒后,哪些条件决定能否进行部分重同步?
- replication backlog 应按数据集大小还是复制流量计算?
- 全量同步期间主节点为什么仍能处理写入?
- 全量同步中的 RDB 和增量复制流分别解决什么问题?
- 主节点无盘复制与副本无盘加载有什么区别?
- 为什么
WAIT 1成功后随机读取另一台副本仍可能读到旧数据? WAIT返回 0 时,前面的写命令是否一定没有执行?WAITAOF 1 1 1000分别等待哪些确认?- 为什么
primary.Set()后直接调用primary.Wait()在 Go 连接池中可能不正确?