mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
8581 字
23 分钟
Redis 主从复制与高可用
2024-08-20

单机 Redis 再快,宕机即丢数据,也无法横向扩展读能力。生产环境的 Redis 几乎都是多节点部署:主从复制提供数据冗余与读扩展,哨兵负责自动故障转移,Cluster 把数据分片到多个节点突破单机内存上限。这三套机制层层递进,构成 Redis 高可用的完整图景。

这一篇讲 Redis 怎么在多节点之间保持数据一致、怎么在节点故障时自动恢复。复制的细节远比想象中复杂:一次全量同步可能引发性能抖动,一次网络分区可能丢掉几十秒写入,一个配错的 backlog 可能导致从节点反复回退到全量复制。这些坑都有明确的根因和对策,理解原理才能在生产事故中快速定位。

前置知识#

Important
  • 了解 Redis 基本数据类型与持久化机制(RDB/AOF),参见 Redis 数据结构与持久化原理

  • 基本的分布式概念:CAP 定理、Leader 选举、Quorum

  • TCP 长连接与心跳机制

一、为什么需要复制与高可用#

单机 Redis 有三个绕不开的问题。

第一,单点故障。Redis 把数据放在内存里,进程崩溃或机器宕机,内存中的数据就没了。即使开了持久化,恢复也需要时间,这段时间内所有请求都打不到 Redis。对缓存场景,这可能导致后端数据库被打满;对存储场景,这是不可接受的数据不可用。

第二,读扩展瓶颈。单机 Redis 的 QPS 上限在 10 万级别,对于读多写少的场景(比如排行榜、热门商品详情),单机很快成为瓶颈。加机器是自然的需求,但多份数据之间怎么保持一致,就是复制要解决的问题。

第三,写扩展与内存瓶颈。单机内存有上限,一台 64 GB 的机器装不下 200 GB 的数据。即使装得下,单机的写性能也有限。Redis Cluster 通过分片把数据和写流量分散到多个节点。

这三类问题对应三套机制:主从复制解决冗余和读扩展,哨兵解决自动故障转移,Cluster 解决分片和写扩展。

flowchart TD A["单机 Redis 的三个问题"] --> B["单点故障"] A --> C["读扩展瓶颈"] A --> D["写扩展与内存瓶颈"] B --> E["主从复制<br/>数据冗余"] C --> E C --> F["哨兵<br/>自动故障转移"] D --> G["Cluster<br/>分片与写扩展"] E --> H["基础:复制机制"] F --> H G --> H style H fill:#c8e6c9,stroke:#2e7d32

复制是所有高可用方案的基础。无论是哨兵还是 Cluster,底层都依赖主从复制把数据从主节点同步到从节点。理解复制的原理,是理解后续一切的前提。

二、全量复制:RDB 与缓冲区#

全量复制是最基础的同步方式。从节点第一次连接主节点,或者断开太久无法增量同步时,主节点把当前全量数据发给从节点。

2.1 PSYNC 命令与握手过程#

Redis 2.8 之后用 PSYNC 命令替代了旧的 SYNC,支持增量复制。从节点连接主节点时,发送 PSYNC <runid> <offset>

  • runid 是从节点上次同步的主节点 ID,首次连接时为 ?
  • offset 是从节点上次同步到的复制偏移量,首次连接时为 -1

主节点收到 PSYNC 后,判断能否增量同步。如果不能,回复 +FULLRESYNC <runid> <offset>,触发全量复制。如果能,回复 +CONTINUE,进入增量复制。

sequenceDiagram participant S as 从节点 participant M as 主节点 Note over S,M: 首次连接 S->>M: PSYNC ? -1 M-->>S: +FULLRESYNC &lt;runid&gt; &lt;offset&gt; Note over M: 执行 BGSAVE 生成 RDB M->>S: 发送 RDB 文件 Note over M: 期间新写命令存入<br/>client output buffer M->>S: 发送缓冲区中的增量命令 Note over S: 清空旧数据<br/>加载 RDB S->>S: 执行增量命令追平进度 S->>M: ACK offset

2.2 BGSAVE 与 RDB 传输#

全量复制的核心是主节点执行一次 BGSAVE,把内存数据快照成 RDB 文件。BGSAVE 通过 fork() 创建子进程,利用 COW(Copy-On-Write)机制在不阻塞主线程的前提下生成快照:子进程共享主进程的内存页,写操作触发的页才被复制,读操作不受影响。

RDB 生成后,主节点把文件内容通过 socket 发给从节点。这里有两种模式:

# redis.conf 关键配置
repl-diskless-sync yes # 无盘复制:子进程直接把 RDB 通过 socket 发给从节点
repl-diskless-sync-delay 5 # 无盘复制延迟启动时间(秒),用于等待更多从节点一起同步

磁盘模式(repl-diskless-sync no)的流程是:子进程把 RDB 写到磁盘临时文件,写完后主线程读文件发给从节点。好处是磁盘上有 RDB 副本,可以复用给多个从节点;坏处是多了磁盘 I/O。

无盘模式(repl-diskless-sync yes)的流程是:子进程直接把 RDB 数据通过 socket 发给从节点,不落盘。好处是省去磁盘 I/O,对网络带宽充足的场景更快;坏处是无法复用,每个从节点都要单独生成一次 RDB。

Note

repl-diskless-sync 的默认值在 Redis 7.0 起从 no 改为 yes,无盘复制成为默认行为。6.2 及更早版本默认是 no(先写磁盘 RDB 再读磁盘发送)。如果沿用旧版配置或明确需要磁盘 RDB 副本,才显式设为 no

repl-diskless-sync-delay 让主节点延迟几秒再开始无盘同步,目的是等更多从节点连上来,用一次 fork 同时给多个从节点发数据。这个延迟是性能与同步及时性的权衡。

2.3 发送期间的增量命令怎么处理#

BGSAVE 生成 RDB 需要时间,这期间主节点还在接受客户端写命令。这些增量命令如果不存下来,从节点加载完 RDB 后就落后了。

Redis 的做法是为每个正在全量同步的从节点维护一个 client output buffer(客户端输出缓冲区)。主节点在执行写命令后,除了写自己的数据,还会把命令追加到所有从节点的输出缓冲区。RDB 发送完毕后,主节点继续把缓冲区里的命令发给从节点。

# 从节点输出缓冲区限制
# 格式:硬限制 软限制 软限制持续秒数
client-output-buffer-limit replica 256mb 64mb 60

这条配置的含义是:从节点输出缓冲区超过 256 MB 立即断开连接,超过 64 MB 且持续 60 秒也断开。断开后从节点会重新连接,重新触发全量同步。如果缓冲区一直超限,从节点就会陷入全量同步的死循环。

Warning

全量同步期间主节点写入过快,或者从节点网络太慢,都会导致输出缓冲区超限。一旦超限断开,从节点重连又触发全量同步,形成同步风暴。生产环境中如果发现从节点反复全量同步,先查这个缓冲区。

2.4 从节点加载 RDB#

从节点收到 RDB 后,先清空本地数据,再加载 RDB。加载期间从节点无法提供服务,这个阻塞时间和数据量成正比。加载完 RDB 后,从节点继续接收并执行主节点发来的增量命令,追平到全量同步开始那一刻的状态。

从节点追平后,会定期向主节点发送 REPLCONF ACK <offset>,报告自己的复制偏移量。主节点根据这个 ACK 判断从节点的同步进度。

# 查看主从复制状态
# 在从节点执行
redis-cli INFO replication
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:0
master_sync_in_progress:0
slave_repl_offset:1048576
slave_read_repl_offset:1048576

master_link_status:up 表示与主节点的连接正常,slave_repl_offset 是从节点已同步的偏移量,master_sync_in_progress:0 表示当前没有全量同步在进行。

2.5 全量复制的代价#

全量复制开销很大:

环节开销影响因素
BGSAVEfork 子进程,页表拷贝阻塞主线程实例内存大小
RDB 传输占用主节点网络带宽与 CPU数据量、网络带宽
RDB 加载从节点阻塞,无法服务数据量
输出缓冲区占用主节点内存全量同步期间的写入量

一个 10 GB 的实例,全量同步可能耗时几十秒到几分钟。这期间主节点的 fork 会造成短暂阻塞,网络带宽被 RDB 传输占满,从节点在加载 RDB 期间完全不可用。如果多个从节点同时全量同步,主节点的负担会成倍增加。

Note

全量复制是最后的兜底方案,正常生产环境应该尽量走增量复制。判断全量复制的触发条件是否合理,是排查复制问题的关键。

三、增量复制:replid 与 offset#

全量复制代价太高,Redis 需要一种机制在短暂断连后只同步缺失的部分。增量复制就是为此设计,核心是 master_replidoffsetrepl_backlog 这三个东西。

3.1 复制偏移量 offset#

主节点和从节点各自维护一个复制偏移量(replication offset):

  • 主节点每向从节点发送 N 字节的复制流,就把自己的 master_repl_offset 加 N
  • 从节点每收到 N 字节,把自己的 slave_repl_offset 加 N

正常情况下两者相等。如果不等,差值就是从节点的落后量。

# 主节点查看
redis-cli INFO replication | grep repl_offset
# master_repl_offset:1048576
# repl_backlog_active:1
# repl_backlog_size:1048576
# repl_backlog_first_byte_offset:1
# repl_backlog_histlen:1048576

master_repl_offset 是主节点发送的总字节数,repl_backlog_size 是 backlog 环形缓冲区的大小,repl_backlog_first_byte_offset 是缓冲区中最早还保留的字节偏移量,repl_backlog_histlen 是缓冲区中当前有效数据的长度。

3.2 master_replid 与实例身份#

每个 Redis 实例启动时生成一个 40 字符的十六进制 runid(实例 ID)。主从握手时,从节点记录主节点的 runid。断连重连后,从节点把上次记录的 runid 发给主节点,主节点据此判断从节点之前连的是不是自己。

如果主节点重启了,runid 会变化,从节点拿着旧 runid 来连,主节点发现不匹配,直接拒绝增量复制,触发全量同步。这是 runid 机制的核心作用:识别主节点是否发生过重启。

Redis 4.0 引入了 replidreplid2 两个字段,改进了这个机制:

  • replid 是当前实例的复制 ID
  • replid2 是故障转移后从节点提升为主节点时,继承的原主节点 replid

故障转移时,新主节点的 replid 变成自己的,但 replid2 保留原主节点的 replid。其他从节点拿着原主节点的 replid 来连新主节点,新主节点通过 replid2 匹配,可以继续增量复制,避免一次全量同步。

# 查看主节点的 replid
redis-cli INFO replication | grep replid
# master_replid:83714433e6a28e78e8a2e6b6e3a9b6e3a9b6e3a9
# master_replid2:0000000000000000000000000000000000000000
# second_repl_offset:-1

master_replid2 全零表示当前实例没有经历过故障转移,second_repl_offset 为 -1 表示没有继承的偏移量。

3.3 repl_backlog 环形缓冲区#

repl_backlog 是主节点维护的一个环形缓冲区,保存最近的复制流。它是增量复制能成立的关键。

环形缓冲区的工作方式:

  1. 主节点发送复制流时,同时写入 backlog
  2. 写到末尾后绕回头部继续写,覆盖最老的数据
  3. 从节点断连重连时,如果请求的 offset 还在 backlog 范围内,主节点就把 backlog 中从该 offset 到当前 offset 的数据发给从节点
flowchart LR subgraph backlog["repl_backlog 环形缓冲区"] direction TB B1["offset: 0"] --> B2["offset: 100"] B2 --> B3["offset: 200<br/>当前写入位置"] B3 -.->|"绕回覆盖"| B4["offset: 300<br/>(旧数据被覆盖)"] B4 -.-> B1 end W["主节点写入<br/>复制流"] --> B3 S1["从节点 A<br/>offset=150"] -.->|"在范围内<br/>增量同步"| B2 S2["从节点 B<br/>offset=50"] -.->|"已被覆盖<br/>回退全量"| B1 style B3 fill:#bbdefb,stroke:#1565c0 style S1 fill:#c8e6c9,stroke:#2e7d32 style S2 fill:#ffcdd2,stroke:#c62828

判断从节点能否增量同步,需要同时满足两个条件:

  1. 从节点请求的 offset 大于等于 backlog 的 first_byte_offset(请求的字节还在缓冲区内)
  2. 从节点请求的 offset 小于等于主节点当前的 master_repl_offset(没有超前于主节点)

两条都满足,才能增量同步。只要有一条不满足,就回退到全量复制。

# backlog 大小配置
repl-backlog-size 1mb # 默认 1 MB,生产环境通常要调大
repl-backlog-ttl 3600 # backlog 空闲多久后释放(秒),0 表示永不释放

3.4 增量复制的判断逻辑#

主节点收到 PSYNC <runid> <offset> 后的判断流程:

flowchart TD A["收到 PSYNC runid offset"] --> B{"runid 匹配?<br/>(或 replid2 匹配)"} B -->|否| F["FULLRESYNC 全量复制"] B -->|是| C{"offset 在 backlog 范围内?"} C -->|否| F C -->|是| D["+CONTINUE"] D --> E["发送 backlog 中<br/>offset 到当前的差异数据"] style F fill:#ffcdd2,stroke:#c62828 style E fill:#c8e6c9,stroke:#2e7d32

这个判断在源码 masterTryPartialResynchronization 函数中。简化后的逻辑:

// Redis 源码 replication.c(简化)
char *masterTryPartialResynchronization(client *c) {
long long psync_offset;
char *master_replid = c->argv[1]->ptr;
// 1. 检查 replid 是否匹配(当前 replid 或 replid2)
if (strcasecmp(master_replid, server.replid) != 0 &&
strcasecmp(master_replid, server.replid2) != 0)
{
// replid 不匹配,需要全量
goto need_full_resync;
}
// 2. 如果匹配的是 replid2,调整 offset
if (strcasecmp(master_replid, server.replid2) == 0) {
psync_offset = server.second_repl_offset;
} else {
psync_offset = c->argv[2]->ptr 解析为 long long;
}
// 3. 检查 offset 是否在 backlog 范围内
if (!server.repl_backlog ||
psync_offset < server.repl_backlog_off ||
psync_offset > (server.repl_backlog_off + server.repl_backlog_histlen))
{
// offset 已被覆盖,需要全量
goto need_full_resync;
}
// 4. 增量复制:发送 backlog 中差异数据
addReply(c, shared.cont);
// ... 计算 psync_len 并发送 ...
return;
}

3.5 backlog 大小怎么定#

backlog 太小,从节点断连稍久就会被覆盖,回退全量。backlog 太大,浪费内存。合理的大小取决于两个因素:

  1. 从节点可能断连的最大时长
  2. 主节点的写入速率

估算公式:backlog_size = max_disconnect_seconds × write_bytes_per_second

例如主节点每秒写入 1 MB,从节点可能断连 30 秒,那么 backlog 至少需要 30 MB。

# 查看主节点每秒写入量(近似)
redis-cli INFO stats | grep -E "total_net_input_bytes|instantaneous_ops_per_sec"
# total_net_input_bytes:客户端输入总字节数
# instantaneous_ops_per_sec:当前每秒命令数
# 估算写入速率(采样两次)
redis-cli INFO stats | grep total_net_input_bytes
sleep 10
redis-cli INFO stats | grep total_net_input_bytes
# 差值除以 10 就是每秒写入字节数
Tip

生产环境 backlog 默认 1 MB 几乎肯定不够。建议根据写入速率调到几十 MB 甚至上百 MB。内存开销相比全量复制的代价微不足道。

四、哨兵 Sentinel:监控与故障转移#

主从复制解决了数据冗余,但主节点宕机后,需要人工把从节点提升为主节点,再改所有客户端的连接地址。这个过程手动完成,耗时且容易出错。哨兵(Sentinel)就是把这个过程自动化。

4.1 哨兵的职责#

哨兵是一个独立的进程,与 Redis 主从节点分开部署。它的职责有四个:

职责说明
监控定期向主节点、从节点、其他哨兵发送 PING,检测是否存活
通知节点异常时通知运维或客户端(可通过脚本回调)
自动故障转移主节点宕机时,自动选一个从节点提升为主节点
配置中心客户端连哨兵查询当前主节点地址,主节点切换后哨兵通知客户端
flowchart TB subgraph sentinels["哨兵集群"] SE1["Sentinel 1"] SE2["Sentinel 2"] SE3["Sentinel 3"] end subgraph redis["Redis 主从"] M["Master"] S1["Slave 1"] S2["Slave 2"] end subgraph clients["客户端"] C1["Client"] end SE1 -->|"监控"| M SE1 -->|"监控"| S1 SE2 -->|"监控"| M SE3 -->|"监控"| M SE1 <-.->|"互相通信"| SE2 SE2 <-.->|"互相通信"| SE3 C1 -->|"查询主节点地址"| SE1 C1 -->|"读写"| M M -->|"复制"| S1 M -->|"复制"| S2 style M fill:#bbdefb,stroke:#1565c0 style sentinels fill:#fff9c4,stroke:#f9a825

哨兵本身也要高可用,所以至少部署 3 个,形成哨兵集群。哨兵之间通过 pub/sub 互相发现,定期交换各自对被监控节点的判断。

4.2 主观下线与客观下线#

哨兵判断主节点故障分两步,避免误判:

主观下线(SDOWN,Subjectively Down):单个哨兵发现主节点在 down-after-milliseconds 时间内没有响应 PING,就标记为主观下线。这只是这个哨兵自己的判断,可能是网络抖动。

客观下线(ODOWN,Objectively Down):一个哨兵把主观下线判断通过 pub/sub 发给其他哨兵。如果超过 quorum 个哨兵都同意主节点主观下线,就标记为客观下线,确认故障,进入故障转移流程。

# sentinel.conf 关键配置
sentinel monitor mymaster 127.0.0.1 6379 2
# 含义:监控名为 mymaster 的主节点,地址 127.0.0.1:6379,quorum 为 2
sentinel down-after-milliseconds mymaster 30000
# 30 秒无响应判定为主观下线
sentinel parallel-syncs mymaster 1
# 故障转移后,多少个从节点同时和新主节点做全量同步
sentinel failover-timeout mymaster 180000
# 故障转移超时时间(毫秒),超时则重试

quorum 的值取决于哨兵总数。3 个哨兵通常 quorum 设为 2,5 个哨兵 quorum 设为 3。这样即使一个哨兵故障,剩下的仍能达成多数。quorum 不是“选举 Leader 的票数”,而是“多少个哨兵同意才认定客观下线”,这两个概念容易混淆。

4.3 Leader 选举#

确认客观下线后,哨兵之间要选出一个 Leader 来执行故障转移。Leader 选举基于 Raft 协议的简化版本:

sequenceDiagram participant S1 as Sentinel 1 participant S2 as Sentinel 2 participant S3 as Sentinel 3 Note over S1,S3: 三个哨兵都发现 Master 客观下线 S1->>S2: 请求投票(term+1) S1->>S3: 请求投票(term+1) S2-->>S1: 同意 S3-->>S1: 同意 Note over S1: S1 获得多数票<br/>成为 Leader S1->>S2: 我是 Leader S1->>S3: 我是 Leader Note over S1: Leader 开始执行故障转移

Raft 选举的要点:

  1. 每个哨兵都有一个纪元(epoch),每次选举递增
  2. 想当 Leader 的哨兵先投自己一票,然后向其他哨兵发 SENTINEL IS-MASTER-DOWN-BY-ADDR 请求票
  3. 一个纪元内每个哨兵只能投一票,先到先得
  4. 获得多数票(超过半数)的哨兵成为 Leader
  5. 如果本轮无人获得多数,等待随机时间后进入下一轮

因为先到先得和随机超时,通常几轮内就能选出 Leader。3 个哨兵的集群,只要 2 个活着就能完成选举。

4.4 选择新主节点#

Leader 哨兵负责从从节点中选一个提升为新主节点。选择规则按优先级排序:

  1. 过滤:排除断线的、最近 5 秒没回复过哨兵 PING 的从节点
  2. 优先级:选 slave-priority 值最小的(值越小优先级越高,0 表示永不提升)
  3. 复制偏移量:优先级相同,选 slave_repl_offset 最大的(数据最接近主节点)
  4. runid:偏移量也相同,选 runid 字典序最小的(纯粹为了确定性)
# 从节点配置优先级
slave-priority 100 # 默认 100,值越小越优先被选为新主

选出新主节点后,Leader 执行:

# 1. 对新主节点发送,让它成为主节点
SLAVEOF NO ONE
# 2. 对其他从节点发送,让它们复制新主节点
SLAVEOF <new_master_ip> <new_master_port>
# 3. 更新哨兵配置,后续客户端查询会返回新主节点地址

parallel-syncs 控制有多少个从节点同时和新主节点做全量同步。值越大故障恢复越快,但新主节点的负载也越大。通常设为 1,让从节点逐个同步。

4.5 旧主节点回来怎么办#

故障转移完成后,如果旧主节点恢复上线,它还以为自己是主节点。哨兵会把它降级为从节点,让它复制新主节点的数据。

这个过程中有一个数据丢失风险:旧主节点在宕机前接受了写入,但还没来得及同步给从节点。这些写入在旧主节点降级为从节点、加载新主节点的数据后会被覆盖丢失。这就是脑裂的雏形,后面专门讲。

# 哨兵查看集群状态
redis-cli -p 26379 SENTINEL masters
redis-cli -p 26379 SENTINEL master mymaster
redis-cli -p 26379 SENTINEL replicas mymaster
redis-cli -p 26379 SENTINEL sentinels mymaster

五、Cluster:分片与 Gossip#

哨兵解决了高可用,但数据还在一个主节点上,单机内存和写性能是天花板。Redis Cluster 把数据分片到多个节点,突破单机限制。

5.1 16384 个哈希槽#

Redis Cluster 把所有 key 映射到 16384 个槽(slot),每个节点负责一部分槽。key 到槽的映射用 CRC16 算法:

# 槽计算公式
SLOT = CRC16(key) mod 16384
# Redis CLI 计算示例
redis-cli CLUSTER KEYSLOT mykey
# (integer) 14687
redis-cli CLUSTER KEYSLOT "{user}:1"
# (integer) 5474
redis-cli CLUSTER KEYSLOT "{user}:2"
# (integer) 5474

{user}:1{user}:2 映射到同一个槽,这是哈希标签(hash tag)机制。key 中 {} 之间的内容会被用作 CRC16 的输入,这样不同 key 只要哈希标签相同就落在同一个槽,同一个节点上。多键操作(MSETMGET)要求涉及的 key 都在同一个槽,哈希标签就是为此设计。

flowchart LR subgraph nodes["Cluster 节点"] subgraph n1["节点 A"] A1["槽 0-5460"] A2["Slave A"] end subgraph n2["节点 B"] B1["槽 5461-10922"] B2["Slave B"] end subgraph n3["节点 C"] C1["槽 10923-16383"] C2["Slave C"] end end K1["key1"] -->|"CRC16 mod 16384"| A1 K2["key2"] -->|"CRC16 mod 16384"| B1 K3["key3"] -->|"CRC16 mod 16384"| C1 A1 -->|"主从复制"| A2 B1 -->|"主从复制"| B2 C1 -->|"主从复制"| C2 style A1 fill:#bbdefb,stroke:#1565c0 style B1 fill:#c8e6c9,stroke:#2e7d32 style C1 fill:#fff9c4,stroke:#f9a825

每个主节点有自己的从节点做数据冗余。主节点宕机,从节点提升为新主节点,机制和哨兵类似,但由 Cluster 内部自动完成,不需要额外的哨兵进程。

5.2 槽分配与迁移#

Cluster 创建时把 16384 个槽分配到各主节点。查看分配:

redis-cli -p 7000 CLUSTER NODES
# 节点 ID 抶态 标志 IP:Port 主节点 ID ping pong epoch link 状态 槽范围
a1b2c3... myself master 127.0.0.1:7000 - 0 0 1 connected 0-5460
d4e5f6... slave 127.0.0.1:7004 a1b2c3... 0 0 4 connected
b7c8d9... master 127.0.0.1:7001 - 0 0 2 connected 5461-10922
e0f1a2... slave 127.0.0.1:7005 b7c8d9... 0 0 5 connected
c3d4e5... master 127.0.0.1:7002 - 0 0 3 connected 10923-16383
f6a7b8... slave 127.0.0.1:7006 c3d4e5... 0 0 6 connected

CLUSTER NODES 的输出每行一个节点,关键字段:

字段含义
节点 ID40 字符十六进制,集群内唯一标识
flagsmyself/master/slave/fail?/fail/handshake 等状态标记
IP节点地址
主节点 ID从节点记录其主节点 ID,主节点为 -
ping/pong最近发送和收到 PONG 的时间戳
epoch当前节点的配置纪元
槽范围主节点负责的槽区间,如 0-54605461-5460 10923-10923(多段)

扩容时把一部分槽从旧节点迁移到新节点。迁移是逐个 key 进行的:

# 1. 通知目标节点准备导入槽 5461
CLUSTER SETSLOT 5461 IMPORTING <source_node_id>
# 2. 通知源节点准备迁出槽 5461
CLUSTER SETSLOT 5461 MIGRATING <target_node_id>
# 3. 逐个迁移 key
CLUSTER GETKEYSINSLOT 5461 100 # 每次取 100 个 key
MIGRATE 127.0.0.1 7001 "" 0 5000 KEYS key1 key2 ...
# 4. 迁移完毕,通知所有节点更新槽归属
CLUSTER SETSLOT 5461 NODE <target_node_id>

迁移期间,客户端访问正在迁移的槽可能收到 ASK 重定向,指向目标节点。这是 Cluster 处理在线扩缩容的关键机制。

5.3 MOVED 与 ASK 重定向#

客户端把命令发给错误的节点时,会收到重定向响应:

MOVED:槽已经稳定地属于另一个节点,客户端应该更新本地路由表,后续请求直接发到新节点。

# 客户端向节点 A 请求槽 5461 的 key
# 但 5461 已迁到节点 B
redis-cli -p 7000 GET mykey
# (error) MOVED 5461 127.0.0.1:7001

ASK:槽正在迁移中,源节点还有部分 key 没迁走。这次请求临时转发到目标节点,但客户端不更新路由表,下次还是先问源节点。

# 槽 5461 正在从 A 迁到 B
# key1 还在 A 上,A 直接处理
# key2 已经在 B 上,A 回复 ASK
redis-cli -p 7000 GET key2
# (error) ASK 5461 127.0.0.1:7001

两者的区别在于永久性。MOVED 是“槽换主人了,记住”,ASK 是“这个 key 临时在别人那,这次过去找,下次还是先问我”。

重定向触发场景客户端动作是否更新路由表
MOVED槽归属已确定变更重新发到新节点
ASK槽正在迁移,key 临时在目标节点临时发到目标节点,带 ASKING 标记

客户端收到 ASK 后,向目标节点发送请求时要带上 ASKING 命令,否则目标节点会拒绝(因为槽还没正式归它)。这是迁移期间保证一致性的细节。

5.4 Gossip 协议#

Cluster 的节点之间通过 Gossip 协议交换状态信息。每个节点定期向少数几个随机节点发送 PING,PING 中携带自己已知的其他节点信息(一部分)。收到 PING 的节点回复 PONG,也携带自己知道的信息。这样信息像病毒一样在集群内传播,最终所有节点都知道彼此的状态。

flowchart LR A["节点 A"] -->|"PING<br/>携带 B,C,D 的信息"| B["节点 B"] B -->|"PONG<br/>携带 A,E,F 的信息"| A A -->|"PING"| C["节点 C"] C -->|"PONG"| A D["节点 D"] -.->|"从 Gossip 得知"| A E["节点 E"] -.->|"从 Gossip 得知"| A style A fill:#bbdefb,stroke:#1565c0

Gossip 的关键参数:

# cluster.conf 默认配置(源码 cluster.h 中定义)
cluster-node-timeout 15000 # 节点失联多久判定为故障(毫秒)
cluster-announce-ip "" # 节点对外通告的 IP,默认空表示用自动探测到的 IP(NAT 环境需显式指定)
cluster-announce-port 0 # 节点对外通告的端口
cluster-announce-bus-port 0 # 集群总线端口(默认数据端口+10000)

cluster-node-timeout 是核心参数。节点 A 超过这个时间没收到节点 B 的 PONG,就标记 B 为 PFAIL(疑似故障)。超过这个时间的一半没有 PONG 交换,就会触发更频繁的 Gossip。

Gossip 传播故障信息后,超过半数的主节点都标记 B 为 PFAIL,B 的状态就升级为 FAIL。然后 B 的从节点发起故障转移,提升为新主节点。

5.5 Cluster 故障转移#

Cluster 的故障转移和哨兵类似,但由从节点自己发起,不需要外部组件:

sequenceDiagram participant A as 节点 A participant B as 节点 B(故障) participant S as B 的从节点 participant C as 节点 C Note over B: B 宕机 A->>A: 超时未收到 B 的 PONG<br/>标记 B 为 PFAIL C->>C: 同样标记 B 为 PFAIL Note over A,C: Gossip 传播<br/>半数主节点同意<br/>B 升级为 FAIL S->>S: 检测到 B 为 FAIL<br/>发起故障转移 S->>A: 请求投票(FAILOVER_AUTH_REQUEST) S->>C: 请求投票 A-->>S: 同意(FAILOVER_AUTH_ACK) C-->>S: 同意 Note over S: 获得多数主节点投票<br/>成为新主节点 S->>S: SLAVEOF NO ONE<br/>接管 B 的槽 Note over S: Gossip 传播新主节点信息

从节点发起故障转移的选举规则:

从节点用复制偏移量计算自己的 rank:offset 越大(数据越接近主节点),rank 数值越小(rank 0 表示数据最新)。rank 越小的从节点越早发起选举,从而越容易抢到多数主节点的投票。主节点对它收到的第一个有效 FAILOVER_AUTH_REQUEST 投赞成票,先到先得,并不额外挑选“最优”从节点。

这里和哨兵的晋升规则有明显区别:哨兵的 Leader 会按 slave-priorityslave_repl_offsetrunid 三级排序主动挑出最优从节点提升;而 Cluster 的故障转移由从节点自己发起,slave-priority 不参与自动选举的排名计算,只用于手动故障转移等场景。

从节点获得超过半数主节点的投票后,成为新主节点,接管原主节点的槽。整个过程不需要哨兵参与,Cluster 自治。

5.6 哨兵模式与 Cluster 对比#

维度哨兵模式Cluster
数据分片不支持,单主全量数据支持,16384 槽分布到多节点
容量上限单机内存多机内存之和,可水平扩展
读写扩展读可扩展(加从节点),写受单机限制读写都可扩展(加主节点)
故障转移哨兵集群负责,外部组件Cluster 内部自治
客户端复杂度低,连哨兵查主节点地址即可高,需处理 MOVED/ASK 重定向
多键操作支持,所有 key 在同一主节点受限,需同一槽(哈希标签)
运维复杂度中等较高,扩缩容涉及槽迁移
适用场景数据量不大、写量不高、需要多键操作数据量超单机内存、高并发读写

选择标准很简单:数据量在单机内存范围内、写量不高,用哨兵;数据量超单机内存、写量需要分摊,用 Cluster。

六、脑裂与数据丢失#

主从复制最大的隐患不是性能,而是数据丢失。最典型的场景是脑裂(split-brain):网络分区导致主节点和从节点、哨兵之间失联,哨兵认为主节点故障,提升从节点为新主节点。这时出现两个主节点,旧主节点还在接受写入。分区恢复后,旧主节点降级为从节点,同步新主节点的数据,它在分区期间接受的写入全部丢失。

6.1 脑裂的成因#

sequenceDiagram participant C as 客户端 participant M as 旧主节点 M participant S as 从节点 S participant SE as 哨兵 Note over M,S: 网络分区,M 与 S、SE 断开 C->>M: 继续写入(M 以为自己还是主) SE->>S: 标记 M 故障,提升 S 为新主 Note over M: M 接受了 N 条写入 C->>S: 客户端切换到新主 S 写入 Note over M,S: 网络恢复 SE->>M: 通知 M 已是 S 的从节点 M->>M: SLAVEOF S,清空数据同步 S Note over M: M 分区期间的 N 条写入丢失

脑裂的本质是“主节点失联但还活着,仍在接受写入”。哨兵因为收不到主节点的响应而判定它故障,但实际上它还在服务部分客户端。这种情况下提升从节点,就会出现双主。

数据丢失量取决于网络分区持续时间和旧主节点的写入速率。分区几秒,丢几百条;分区几分钟,丢几万条。

6.2 min-slaves-to-write 防脑裂#

Redis 提供两个配置项来缓解脑裂:

# redis.conf
min-slaves-to-write 1 # 至少 1 个从节点同步正常,主节点才接受写入
min-slaves-max-lag 10 # 从节点的同步延迟不超过 10 秒

这两个配置配合工作。主节点每秒检查从节点的同步状态,如果同步正常的从节点数量少于 min-slaves-to-write,或者这些从节点的延迟超过 min-slaves-max-lag,主节点就拒绝写入。

脑裂场景下,旧主节点和所有从节点断开,同步正常的从节点数为 0,主节点拒绝写入,避免了数据丢失。

Important

min-slaves-to-write 只能减少脑裂的数据丢失,不能完全杜绝。如果网络分区只隔离了部分从节点,旧主节点仍有足够数量的从节点同步正常,它会继续接受写入,分区恢复后这些写入仍可能丢失。

这两个值的权衡:

  • min-slaves-to-write 设太高,从节点抖动会导致主节点频繁拒绝写入,影响可用性
  • 设太低,防不住脑裂
  • 通常设为 1,配合 min-slaves-max-lag 设为 10 到 30 秒
# 查看从节点同步状态
redis-cli INFO replication
# 关注每个从节点的 lag 字段
# slave0:ip=127.0.0.1,port=6380,state=online,offset=1048576,lag=0
# lag 表示从节点最后一次 ACK 距今多少秒

6.3 复制延迟与数据丢失#

除了脑裂,复制本身就是异步的,主节点接受写入后不等从节点确认就返回成功。如果主节点在写入后立即宕机,而这条写入还没同步给从节点,故障转移后这条写入就丢了。

这种丢失是异步复制的固有问题,无法完全避免。可以减少丢失窗口,但无法消除。min-slaves-to-write 在这里也有作用:强制至少一个从节点同步了数据,主节点才接受新写入,间接减小了丢失窗口。

完全避免数据丢失需要同步复制,但同步复制会显著降低写入性能,Redis 在设计上选择了异步复制换取性能。如果业务要求零丢失,应该用 Redis 的事务和 Lua 脚本保证原子性,配合持久化策略,而不是依赖复制。

七、踩坑与排查#

理论讲完,看几个实际会遇到的坑。这些坑的共同点是表现隐蔽,等监控告警时已经造成影响。

7.1 脑裂导致写入丢失#

现象:业务反馈部分写入“成功”了但查询不到,时间点对应一次主节点切换。

根因:网络分区期间旧主节点接受了写入,分区恢复后旧主节点降级为从节点,数据被新主节点的数据覆盖。

排查

# 1. 查看哨兵故障转移日志
redis-cli -p 26379 SENTINEL master mymaster
# 关注 failover 相关时间戳
# 2. 对比旧主节点和新主节点的数据
# 如果旧主节点还有日志,看它分区期间的写入命令
# AOF 文件中可以找到这段时间的写命令记录

解决:开启 min-slaves-to-writemin-slaves-max-lag。分区期间旧主节点因没有同步正常的从节点而拒绝写入,客户端收到错误后重试到新主节点。

Note

待补充真实案例。脑裂导致的数据丢失在生产中确实发生,但具体丢失的数据量和恢复方式需要真实事故记录。

7.2 全量复制风暴#

现象:主节点 CPU 和网络飙升,多个从节点同时请求全量同步,主节点负载过高导致延迟尖峰。

根因:多个从节点同时断连(比如网络抖动),断连时间超过 backlog 能覆盖的范围,全部回退全量复制。主节点为每个从节点执行 BGSAVE,多个 fork 叠加,内存和 CPU 承压。

排查

# 查看从节点同步状态
redis-cli INFO replication
# 关注每个从节点的 state 和 master_sync_in_progress
# 查看主节点 fork 情况
redis-cli INFO stats | grep -E "latest_fork_usec|total_forks"

解决

  1. 调大 backlog:让从节点断连后能走增量复制,避免回退全量。根据写入速率和可能断连时长计算大小。
  2. 级联复制:从节点不从主节点直接复制,而是从另一个从节点复制,分散主节点的 RDB 生成负担。
# 从节点 B 从从节点 A 复制,而不是从主节点复制
# 在从节点 B 上配置
redis-cli SLAVEOF <slave_a_ip> <slave_a_port>
  1. 错峰同步:新加从节点时,避免多个同时加入,逐个加入,等前一个同步完再加下一个。
  2. 无盘复制:网络带宽充足时开启 repl-diskless-sync yes,配合 repl-diskless-sync-delay 等多个从节点一起同步。

7.3 backlog 太小反复全量#

现象:从节点复制状态反复在 connectsync 之间跳动,master_repl_offsetslave_repl_offset 差距大,INFO replication 里频繁看到 master_sync_in_progress:1

根因:backlog 太小,从节点短暂断连(几秒)后请求增量复制,但请求的 offset 已经被覆盖,只能全量。全量还没完成又因为超时断开,再次请求增量,又回退全量,陷入死循环。

排查

# 主节点查看 backlog 信息
redis-cli INFO replication | grep -E "repl_backlog"
# repl_backlog_size:1048576 # backlog 大小
# repl_backlog_first_byte_offset:1 # 最早保留的 offset
# repl_backlog_histlen:1048576 # 当前有效数据长度
# 如果 first_byte_offset 远大于从节点请求的 offset,说明已被覆盖

解决:调大 repl-backlog-size。先用上一节的方法估算写入速率,然后根据从节点可能断连的最大时长计算。生产环境常见配置 64 MB 到 256 MB。

# 动态调整 backlog 大小(不需要重启)
redis-cli CONFIG SET repl-backlog-size 256mb
# 调整后确认
redis-cli CONFIG GET repl-backlog-size

7.4 repl-timeout 导致断连#

现象:从节点日志报 Connection with master lost,主节点日志报 replica timeout,但网络实际没问题。

根因repl-timeout 默认 60 秒。如果主节点生成 RDB 时间超过 60 秒(数据量大),从节点等不到 RDB 传输开始,认为主节点失联,主动断开。或者 RDB 传输期间主从之间没有心跳,超过 60 秒后从节点断开。

排查

# 查看主节点 RDB 生成耗时
redis-cli INFO stats | grep latest_fork_usec
# 如果 latest_fork_usec 超过 60 秒(60000000 微秒),就是这个问题
# 查看从节点复制超时配置
redis-cli CONFIG GET repl-timeout

解决:对大实例调大 repl-timeout

# 大实例建议调到 180 秒或更长
redis-cli CONFIG SET repl-timeout 180

同时检查 repl-ping-replica-period(主节点向从节点发心跳的间隔,默认 10 秒)。心跳间隔应该远小于 repl-timeout,否则正常的心跳也可能被判超时。经验值是 repl-timeout 至少是心跳间隔的 6 倍。

八、运维要点#

8.1 核心监控指标#

复制相关的监控围绕“主从是否同步”和“同步是否健康”展开:

指标获取方式关注点
master_link_status从节点 INFO replicationup 正常,down 需立即排查
slave_repl_offset 差值主节点 master_repl_offset 减从节点 slave_repl_offset差值持续增大说明同步跟不上
master_last_io_seconds_ago从节点 INFO replication从节点与主节点最后通信距今秒数,超过 repl-timeout 一半需警惕
master_sync_in_progress从节点 INFO replication1 表示正在全量同步,持续为 1 说明全量同步卡住
connected_slaves主节点 INFO replication已连接从节点数,和预期不符需排查
lag主节点 INFO replication 中每个 slave 的 lag 字段从节点最后一次 ACK 距今秒数,超过 min-slaves-max-lag 会触发写入拒绝
repl_backlog_histlen主节点 INFO replicationbacklog 当前有效数据长度,持续接近 repl_backlog_size 说明 backlog 快满
# 一键查看复制健康度(在从节点执行)
redis-cli INFO replication | grep -E "master_link_status|slave_repl_offset|master_last_io|sync_in_progress"
# 在主节点查看所有从节点的 lag
redis-cli INFO replication | grep slave

8.2 关键参数速查#

参数默认值推荐值说明
repl-backlog-size1 MB64-256 MBbacklog 大小,太小导致回退全量
repl-backlog-ttl36000backlog 空闲释放时间,0 表示永不释放
repl-timeout6060-180复制超时,大实例调大
repl-ping-replica-period1010主节点发心跳间隔,应远小于 repl-timeout
repl-diskless-syncyes(7.0+) / no(6.2-)yes(网络充足)无盘复制,省磁盘 I/O
repl-diskless-sync-delay55-30无盘复制延迟,等更多从节点一起同步
min-slaves-to-write01防脑裂,至少 N 个从节点同步正常才接受写入
min-slaves-max-lag1010-30从节点最大允许延迟,配合上一项
client-output-buffer-limit replica256mb 64mb 60256mb 64mb 60从节点输出缓冲区限制,超限断开
slave-priority100100从节点优先级,0 表示永不提升为主
# 批量查看复制相关配置
redis-cli CONFIG GET '*' | grep -E '^repl-|^min-slaves-|^slave-priority'

8.3 min-slaves 配置的权衡#

min-slaves-to-write 是一把双刃剑。设高了,从节点抖动会导致主节点拒绝写入,影响可用性。设低了,防不住脑裂。

权衡的思路:

  1. 先评估业务对可用性和数据一致性的要求。缓存场景偏向可用性,可以不设或设为 1;存储场景偏向一致性,必须设。
  2. 从节点数量是动态的,min-slaves-to-write 设为“常态从节点数减一”比较稳妥。比如常态 3 个从节点,设为 2,允许一个从节点抖动。
  3. min-slaves-max-lag 不要设太小,否则正常的复制延迟波动就会触发拒绝写入。10 秒到 30 秒是常见范围。
  4. 配置后要在监控中关注 connected_slaves 和每个 slave 的 lag,确认从节点状态稳定。

8.4 故障转移演练#

纸上谈兵不如实际演练。定期模拟主节点故障,验证故障转移是否符合预期:

# 1. 模拟主节点宕机(在主节点执行)
redis-cli SHUTDOWN NOSAVE
# 2. 观察哨兵是否在 down-after-milliseconds 内检测到
redis-cli -p 26379 SENTINEL master mymaster
# 关注 flags 字段是否出现 S_DOWN 或 O_DOWN
# 3. 观察故障转移过程
redis-cli -p 26379 SENTINEL failover mymaster
# 或查看哨兵日志
# 4. 确认新主节点
redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster
# 5. 验证客户端是否自动切换到新主节点
# 取决于客户端实现,多数 Redis 客户端支持哨兵模式
Tip

故障转移演练要在非高峰期进行,并提前通知业务方。演练时关注三件事:故障检测时间、新主选举时间、客户端切换时间。这三个时间加起来就是业务感知到的不可用时长。

8.5 槽迁移在线扩容#

Cluster 扩容时迁移槽,Redis 提供了 redis-cli --cluster 工具简化操作:

# 1. 加入新节点到集群
redis-cli --cluster add-node 127.0.0.1:7003 127.0.0.1:7000
# 2. 给新节点分配从节点
redis-cli --cluster add-node 127.0.0.1:7007 127.0.0.1:7000 --cluster-slave --cluster-master-id <new_node_id>
# 3. 重新分片,把一部分槽迁到新节点
redis-cli --cluster reshard 127.0.0.1:7000
# 交互式输入:迁移多少槽、目标节点 ID、源节点
# 4. 检查集群状态
redis-cli --cluster check 127.0.0.1:7000
redis-cli --cluster info 127.0.0.1:7000

迁移过程中关注 CLUSTER NODES 中各节点的 cluster-require-full-coverage 配置。默认 yes 表示任何一个槽不在服务,整个集群拒绝写入。如果允许部分槽不可用时其他槽继续服务,设为 no,但可能导致跨槽操作不一致。

8.6 核心机制速查#

机制核心设计关键取舍
全量复制BGSAVE 生成 RDB + 缓冲区存增量简单可靠 vs 开销大
增量复制replid + offset + backlog 环形缓冲高效 vs 断连过久回退全量
哨兵故障转移SDOWN 到 ODOWN 到 Leader 选举自动化 vs 配置复杂
Cluster 分片16384 槽 + CRC16 + MOVED/ASK水平扩展 vs 客户端复杂、多键受限
Gossip节点间随机传播状态去中心化 vs 传播延迟
防脑裂min-slaves-to-write + max-lag减少丢数据 vs 可能影响可用性

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Redis 主从复制与高可用
https://blog.souloss.cn/posts/middleware/cache/redis-replication-and-ha/
作者
Souloss
发布于
2024-08-20
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时