779 lines
38 KiB
Markdown
779 lines
38 KiB
Markdown
---
|
||
tags: [Redis, 缓存, 高可用, 主从, Sentinel]
|
||
create time: 2026-05-15 18:13
|
||
---
|
||
|
||
# Redis 主从复制与 Sentinel
|
||
|
||
## 概述
|
||
|
||
### 从单机困境说起
|
||
|
||
假设你的 Redis 跑在一台服务器上,所有读写都走这一个实例。随着业务增长,两个现实问题会逐渐暴露:
|
||
|
||
1. **读性能瓶颈**:热点 key 被反复访问,单实例 QPS 顶到天花板(通常 10w 左右),请求开始排队
|
||
2. **单点故障风险**:服务器宕机 = Redis 全线不可用,数据甚至可能丢失
|
||
|
||
> [!QUESTION] 想一想:如果让你来设计解决方案,你会怎么做?
|
||
> - 瓶颈在"读"→ 能不能让**多个节点分摊读请求**?
|
||
> - 风险在"单点"→ 能不能让数据**自动备份到其他机器**?
|
||
> - 故障了靠人恢复太慢 → 能不能**自动检测并切换**?
|
||
|
||
这就是 **主从复制** 和 **Sentinel** 要解决的问题。
|
||
|
||
### 两大核心机制
|
||
|
||
| 机制 | 解决什么问题 | 一句话说明 |
|
||
|------|------------|----------|
|
||
| **主从复制(Replication)** | 数据冗余 + 读扩展 | Master 处理写操作,Replica 自动同步数据并承担读请求 |
|
||
| **Sentinel(哨兵)** | 人工恢复慢 | 自动检测 Master 故障,提升最佳 Replica 为新 Master |
|
||
|
||
通俗地说:主从复制是"抄作业"机制——Master 做完(写入),Replica 抄一份;Sentinel 是"监考老师"——发现 Master"交不了卷"(宕机),立刻指定一个 Replica"接替答题"。
|
||
|
||
> [!SUMMARY] 知识定位
|
||
> - ✅ 本方案适合 **读多写少** 的场景(8:2 ~ 9:1)
|
||
> - ⚠️ 写入仍然集中在单点,若需水平写入 → [[hhs/Redis/07-集群方案]]
|
||
> - 🔧 这是学习 Redis Cluster 的前置知识
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Master["Master<br/>写操作入口"]
|
||
R1["Replica-1"]
|
||
R2["Replica-2"]
|
||
R3["Replica-3"]
|
||
|
||
Master -->|"全量/增量同步"| R1
|
||
Master -->|"全量/增量同步"| R2
|
||
Master -->|"全量/增量同步"| R3
|
||
|
||
ClientRW["写请求"] --> Master
|
||
ClientR["读请求"] --> R1
|
||
ClientR --> R2
|
||
ClientR --> R3
|
||
```
|
||
|
||
## 一、主从复制原理
|
||
|
||
### 异步 vs 半同步
|
||
|
||
复制的核心矛盾是:**性能** 和 **数据安全** 不可兼得。我们先看两种策略的区别:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph async["异步复制(Redis 默认)"]
|
||
A1["Master 写入"] -->|"立即返回 OK"| A2["客户端"]
|
||
A1 -.->|"后台异步发送"| A3["Replica"]
|
||
end
|
||
subgraph semisync["半同步(手动启用)"]
|
||
B1["Master 写入"] -->|"等待 Replica 确认"| B2["Replica"]
|
||
B2 -->|"确认收到"| B3["客户端收到 OK"]
|
||
end
|
||
```
|
||
|
||
> [!QUESTION] 为什么 Redis 默认选择异步复制?
|
||
> 想象一下:你去超市买东西,收银员每收一笔钱都要先打电话给总部确认到账了才给你小票——你愿意等吗?对追求低延迟的 Redis 来说,**等 Replica 确认 = 强制每次写入多等一次网络往返**(通常 0.1~1ms),这对微秒级响应的场景是不可接受的。
|
||
|
||
**异步复制** 的工作方式:Master 写完就返回客户端,Replica 的事"后台慢慢同步"。代价是极端情况下可能丢数据:
|
||
|
||
```text
|
||
场景: 写 A=1 → Master 返回 OK → Master 突然宕机(数据还没来得及传到 Replica)
|
||
→ Replica 上没有 A=1 → 用 Replica 接管后,这个写入就丢了
|
||
```
|
||
|
||
> [!NOTE] 这个丢数据的概率高吗?
|
||
> 正常情况下极低——因为 Master 到 Replica 的同步几乎是实时的(毫秒级),只有在 Master 写入后**立刻宕机**的极短窗口内才会丢。这就像你刚存完钱、银行还没来得及记账就停电了——概率很小,但在金融场景下不能接受。
|
||
|
||
#### 如果业务要求"至少一份副本确认"呢?
|
||
|
||
Redis 提供了 `WAIT` 命令,可以让客户端**阻塞等待**指定数量的副本确认同步完成。这不算严格的半同步(因为 Master 已经先写入并返回了),但能大幅降低丢数据的概率:
|
||
|
||
```go
|
||
// Go: 写入后,确保至少 1 个副本已经同步了这条命令
|
||
client.Set(ctx, "order:12345", orderData, 0)
|
||
|
||
numReplicas := 1 // 至少 1 个 replica 收到
|
||
timeout := 100 // 最多等 100ms,超时就放弃
|
||
replicaCount := client.Do(ctx, "WAIT", numReplicas, timeout).Int()
|
||
|
||
if replicaCount >= 1 {
|
||
// 放心:就算 Master 立刻宕机,至少有 1 个 Replica 有这份数据
|
||
} else {
|
||
// 超时了,数据可能只在 Master 上 → 业务层面考虑重试或告警
|
||
}
|
||
```
|
||
|
||
> [!TIP] WAIT 的代价
|
||
> `WAIT` 会阻塞当前连接直到满足条件或超时。高频写场景慎用,建议仅在关键业务路径上使用(如订单创建、扣款等),普通缓存写入不需要。
|
||
|
||
### 全量同步 vs 增量同步
|
||
|
||
回到"抄作业"的类比:你缺了一整学期的笔记,那就得**全量抄一本**(全量同步);你只是昨天请了一天假,那就**只抄昨天那几页**(增量同步)。Redis 的主从同步也一样,分这两种模式:
|
||
|
||
- **全量同步(Full Resync)**:Master 把**整个数据集**打包成 RDB 文件发给 Replica。适用于首次连接、断线太久等场景。代价大,应尽量避免
|
||
- **增量同步(Partial Resync)**:Master 只发送 **Replica 缺失的那部分命令**。适用于正常运行期间的短暂断线。代价小,是常态
|
||
|
||
> [!QUESTION] 那 Redis 怎么判断应该走"全量"还是"增量"呢?
|
||
> 这就要说到两个关键的"身份标识"——Run ID 和 Replication Offset。
|
||
|
||
#### 前置概念:两个关键标识
|
||
|
||
要理解全量/增量的判断逻辑,需要先搞清楚两个东西:
|
||
|
||
**1. Run ID —— "你是哪个 Master?"**
|
||
|
||
每个 Redis 实例启动时会随机生成一个 40 字符的唯一标识符(如 `8b1f8a3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a`)。Replica 第一次连接 Master 时会记住它的 Run ID。之后每次重连,Replica 先报出"我记得的 Run ID",Master 对比:
|
||
|
||
- **Run ID 匹配** → "你还认识我" → 有可能增量同步
|
||
- **Run ID 不匹配** → "我不认识你"(Master 重启了 / 换了台机器)→ 只能全量同步
|
||
|
||
**2. Replication Offset —— "你同步到哪了?"**
|
||
|
||
你可以把它想象成一条**数据流水线上的刻度尺**。Master 和 Replica 各自维护一个 offset:
|
||
|
||
- Master 每写入一条命令,offset 就往后移(比如从 `1000` 到 `1050`)
|
||
- Replica 每同步一条命令,自己的 offset 也往后移
|
||
- 只要 Master 的 **backlog 缓冲区** 里还保留着 Replica 需要的那段数据(即 Replica 的 offset 在 backlog 覆盖范围内),就能增量同步
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
subgraph Identity["两个身份标识"]
|
||
RunID["Run ID<br/>回答: 你是哪个 Master?"]
|
||
Offset["Replication Offset<br/>回答: 我同步到哪了?"]
|
||
end
|
||
|
||
RunID -->|"匹配"| CheckOffset["检查 Offset"]
|
||
RunID -->|"不匹配"| FullSync["只能全量同步"]
|
||
CheckOffset -->|"Offset 在 backlog 范围内"| PartialSync["增量同步"]
|
||
CheckOffset -->|"Offset 已被覆盖"| FullSync
|
||
```
|
||
|
||
> [!NOTE] PSYNC 协议:让增量同步成为可能
|
||
> Redis 2.8 之前用的是 `SYNC` 命令,**每次断线都全量传输**——相当于你每次请假都得抄一整本笔记,不管你只缺一天还是缺一个月。Redis 2.8+ 引入了 `PSYNC` 协议,Replica 重连时带上 Run ID 和自己的 Offset,Master 就能判断"你只缺了一点"还是"你缺太多了",从而决定增量还是全量。
|
||
|
||
#### 全量同步:逐步拆解
|
||
|
||
全量同步发生在 **首次连接** 或 **断线太久导致增量同步不可能** 的场景。整个过程分为三个阶段:
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant R as Replica
|
||
participant M as Master
|
||
|
||
Note over R,M: "阶段 1: 握手与身份确认"
|
||
R->>M: PSYNC ? -1
|
||
Note right of R: "? = 我不知道你的 Run ID<br/>-1 = 我没有 offset 记录"
|
||
M->>M: 执行 BGSAVE 生成 RDB 快照
|
||
M-->>R: "+FULLRESYNC <runId> <offset>"
|
||
Note left of M: "意思是: 好, 全量同步,<br/>我的 Run ID 是 xxx,<br/>当前 offset 是 yyy"
|
||
M-->>R: 发送 RDB 文件
|
||
R->>R: 清空旧数据, 用 RDB 全量覆盖
|
||
|
||
Note over R,M: "阶段 2: RDB 传输期间的新写入不能丢"
|
||
Note over M: "BGSAVE 期间, 客户端仍在写入"
|
||
loop "RDB 传输期间的新写入"
|
||
M->>M: 新命令追加到 repl-backlog-buffer
|
||
end
|
||
|
||
Note over R,M: "阶段 3: 发送缓冲命令, 追赶进度"
|
||
M-->>R: 发送 backlog 中缓存的增量命令
|
||
R->>R: 逐条重放, 追上 Master 最新进度
|
||
```
|
||
|
||
> [!NOTE] 阶段 2 为什么不会丢数据?
|
||
> 你可能会担心:BGSAVE 是某一时刻的快照,那快照之后到 RDB 传输完成这段时间的写入怎么办?答案是 **backlog 缓冲区**。Master 在生成 RDB 的同时,会把所有新写入的命令追加到 backlog 里,等 RDB 传完后再把这些缓冲命令发给 Replica。所以 Replica 先拿到"截止到某个时间点的完整快照",再拿到"快照之后的增量命令",两者拼起来就是完整的最新数据。
|
||
|
||
#### 增量同步:逐步拆解
|
||
|
||
增量同步是 **正常运行时的常态**。Replica 短暂断线后重连,只需要补齐断线期间缺失的命令:
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant R as Replica
|
||
participant M as Master
|
||
|
||
Note over R,M: "Replica 短暂断线后重连"
|
||
R->>M: PSYNC <runId> <myOffset>
|
||
Note right of R: "我认识你, Run ID 是 xxx,<br/>我的 offset 是 10000"
|
||
M->>M: 检查 runId 匹配<br/>检查 offset 10000 是否在 backlog 范围内
|
||
|
||
alt "offset 在 backlog 范围内 → 增量同步"
|
||
M-->>R: "+CONTINUE"
|
||
M-->>R: 发送 offset 10000 之后的增量命令
|
||
Note left of M: "只发你缺的 10001 ~ 10500,<br/>不用重传整个数据集"
|
||
R->>R: 重放增量命令, offset 追到 10500
|
||
else "offset 已被覆盖 → 退化为全量同步"
|
||
M-->>R: "+FULLRESYNC"
|
||
Note left of M: "你缺的太多了, 增量同步<br/>不可能了, 转为全量重传"
|
||
end
|
||
```
|
||
|
||
> [!QUESTION] 怎么理解"offset 在 backlog 范围内"?
|
||
> 回到"圆形笔记本"的比喻——backlog 是一个固定大小的环形缓冲区(默认仅 1MB)。Master 不断往里写新命令,写满了就从头覆盖最旧的内容。当 Replica 断线重连时,它报出自己的 offset,Master 检查:
|
||
>
|
||
> - 这个 offset 对应的数据**还在 backlog 里**(没被覆盖)→ 只需发送 backlog 中这段数据 → 增量同步
|
||
> - 这个 offset 对应的数据**已经被覆盖了**(断线太久,backlog 装不下)→ 只能全量同步
|
||
|
||
#### PSYNC 完整决策流程
|
||
|
||
把上面的逻辑串起来,PSYNC 的完整判断过程如下:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Start["Replica 发送<br/>PSYNC <runId> <offset>"] --> CheckRunID{"runId 匹配?"}
|
||
|
||
CheckRunID -->|"不匹配<br/>(Master 重启或新 Master)"| FullSync["返回 +FULLRESYNC<br/>全量同步"]
|
||
|
||
CheckRunID -->|"匹配"| CheckOffset{"offset 对应的数据<br/>还在 backlog 里?"}
|
||
|
||
CheckOffset -->|"是"| PartialSync["返回 +CONTINUE<br/>增量同步"]
|
||
CheckOffset -->|"否<br/>(已被覆盖)"| FullSync
|
||
|
||
CheckRunID -->|"runId = '?'<br/>(首次连接)"| FullSync
|
||
```
|
||
|
||
> [!NOTE] 一句话总结 PSYNC 的决策逻辑
|
||
> 先看你"认不认识我"(Run ID),再看你"缺的那部分我还记不记得"(Offset 是否在 backlog 内)。两个条件都满足才走增量,否则全量。
|
||
|
||
#### 全量同步的代价
|
||
|
||
> [!QUESTION] 全量同步开销有多大?
|
||
> 全量同步需要 Master 执行 `BGSAVE`(fork 子进程生成 RDB 文件),然后通过网络传给 Replica。如果数据量大(比如 10GB),这个过程会**消耗大量 CPU、内存和网络带宽**。更糟糕的是,如果多个 Replica 同时触发全量同步,Master 会被拖垮。
|
||
>
|
||
> 所以我们的目标是:**尽量避免全量同步,让增量同步成为常态**。
|
||
|
||
#### 什么情况下触发全量同步?
|
||
|
||
| 触发条件 | 为什么? | 能避免吗? |
|
||
|---------|------|------|
|
||
| **首次连接** | Replica 是空的,没有 Run ID 也没有 offset,PSYNC 只能发 `? -1` | ❌ 必然发生 |
|
||
| **Master 重启** | 重启后 Run ID 变了,Replica 发的旧 Run ID 匹配不上 | ⚠️ 持久化可缓解(配置 `save` 后重启 Run ID 不变) |
|
||
| **Replica 执行 SLAVEOF 指向新 Master** | 新 Master 的 Run ID 和旧的不同,必须全量重来 | ❌ 手动触发,通常可控 |
|
||
| **Backlog 溢出** | 断线太久,Replica 需要的 offset 已被环形缓冲区覆盖 | ✅ **增大 `repl-backlog-size`** |
|
||
| **config-resetstat 执行** | 重置 INFO 统计计数器,可能干扰监控判断,但不会直接影响复制 offset | ⚠️ 少用此命令 |
|
||
|
||
> [!WARNING] 生产环境最常见的"不必要全量同步"
|
||
> 绝大多数情况是 **Backlog 过小**导致的。网络抖动几秒,Backlog 不够用,Replica 被迫全量同步——这在网络不稳定的环境中会反复发生,严重拖垮 Master 性能。**加大 Backlog 是性价比最高的优化手段**,具体配置和估算方法见下一节。
|
||
|
||
### 复制背压(Replication Backlog)
|
||
|
||
每个 Master 内部维护一个固定大小的 **环形缓冲区**(64-bit 系统上默认 1MB,Redis 7.0+ 默认 64MB),这是实现增量同步的关键:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
WriteHead["写入指针(新命令)"] -->|"追加写入"| Buffer["repl-backlog-buffer<br/>环形缓冲区"]
|
||
Buffer -->|"读取并发送"| ReadHead["读取指针(发给 Replica)"]
|
||
Buffer -->|"空间不足时"| Overwrite["覆盖最旧数据"]
|
||
```
|
||
|
||
> [!NOTE] 什么是"环形缓冲区"?
|
||
> 想象一个**圆形的笔记本**,只有固定的 100 页。你从第 1 页开始记笔记,记满了就回到第 1 页覆盖重写。Replica 落后得越多,它需要"回看"的内容就越远。如果它的位置已经被覆盖了,就只能全量重新抄一本——这就是全量同步。
|
||
>
|
||
> 所以 Backlog 的大小直接决定了:**Replica 最多能断线多久还能增量恢复**。
|
||
|
||
```conf
|
||
# 相关配置
|
||
repl-backlog-size 256mb # 缓冲区大小,增大允许更长断线恢复
|
||
repl-backlog-ttl 3600 # 无人订阅时多久自动释放(秒)
|
||
```
|
||
|
||
**经验估算公式**:假设业务 QPS = 10,000,每条命令平均 50 字节,则每秒消耗 ~500KB 缓冲区。
|
||
- backlog = 1MB → 约支持 **2 秒** 断线恢复
|
||
- backlog = 256MB → 约支持 **8 分钟** 断线恢复
|
||
|
||
### REPLCONF ACK:Master 如何感知 Replica 状态
|
||
|
||
> [!QUESTION] Master 一直在往 Replica 发数据,但它是单向发送的——Master 怎么知道 Replica 是否还活着、同步到哪了?
|
||
> 前面讲的都是 "Master → Replica" 的数据流。但复制是双向"互动"的:Master 需要知道每个 Replica 的实时状态,才能做运维决策(比如判断延迟、拒绝写入保护数据一致性)。这个感知能力靠的是 **Replica 主动向 Master 汇报**。
|
||
|
||
**工作原理**:Replica 每秒向 Master 发送一次 `REPLCONF ACK <offset>` 命令,告诉 Master 两件事:
|
||
|
||
1. **"我还活着"** — 心跳保活
|
||
2. **"我的同步进度是 offset=xxx"** — 用于计算延迟
|
||
|
||
Master 收到后更新该 Replica 的状态。通过 `INFO replication` 可以看到:
|
||
|
||
```text
|
||
connected_slaves:3
|
||
slave0:ip=192.168.1.21,port=6379,state=online,offset=12345600,lag=0
|
||
slave1:ip=192.168.1.22,port=6379,state=online,offset=12345600,lag=0
|
||
slave2:ip=192.168.1.23,port=6379,state=online,offset=12344800,lag=2
|
||
```
|
||
|
||
- **offset**:该 Replica 当前同步到哪里了(对比 Master 的 `master_repl_offset` 可知差距)
|
||
- **lag**:Master 有多久(秒)没收到这个 Replica 的 ACK,lag 越大说明 Replica 越"失联"
|
||
|
||
如果 Master 在 `repl-timeout`(默认 60 秒)内没有收到某 Replica 的 ACK,就将其标记为 `state=offline`。
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant R as Replica
|
||
participant M as Master
|
||
|
||
Note over M: "Master 持续写入, offset 推进"
|
||
R->>M: REPLCONF ACK 10000
|
||
Note left of M: "记录: slave0 offset=10000, lag=0"
|
||
M->>M: 继续写入, offset 到 10100
|
||
R->>M: REPLCONF ACK 10050
|
||
Note left of M: "记录: slave0 offset=10050, lag=1"
|
||
|
||
Note over R: "Replica 断线..."
|
||
Note over M: "repl-timeout(60s) 内没收到 ACK"
|
||
Note left of M: "标记: slave0 state=offline"
|
||
```
|
||
|
||
> [!NOTE] REPLCONF ACK 的实际意义:写入保护
|
||
> 这个机制不只是"看看状态"——它直接支撑了**脑裂场景下的写入保护**。配合以下配置:
|
||
> ```conf
|
||
> min-replicas-to-write 1 # 至少 1 个 Replica 在线
|
||
> min-replicas-max-lag 10 # 且 lag 不超过 10 秒
|
||
> ```
|
||
> Master 根据 REPLCONF ACK 汇报的 lag 值判断:**在线且低延迟的 Replica 数量不满足条件时,Master 直接拒绝写入**。这样即使 Master 被网络隔离(脑裂),它也不会默默接受写入——因为没有 Replica 能同步这些数据,写入最终会丢失。
|
||
>
|
||
> 简单说:**没有 REPLCONF ACK → Master 对 Replica 状态一无所知 → 无法做写入保护 → 脑裂时必然丢数据**。
|
||
|
||
> [!QUESTION] REPLCONF ACK 和 Sentinel 的心跳有什么区别?
|
||
> 两者都是"心跳检测",但**方向和目的不同**:
|
||
>
|
||
> | 对比 | REPLCONF ACK | Sentinel PING |
|
||
> |------|-------------|---------------|
|
||
> | 方向 | Replica → Master | Sentinel → Master/Replica |
|
||
> | 频率 | 每秒 1 次 | 每秒 1 次 |
|
||
> | 目的 | Master 感知 Replica 存活和同步进度 | Sentinel 感知节点存活,触发故障转移 |
|
||
> | 判定失联后 | Master 标记 Replica 为 offline,触发 min-replicas 保护 | Sentinel 标记 SDOWN/ODOWN,触发选主和 Failover |
|
||
>
|
||
> 两者互补,缺一不可。
|
||
|
||
### 大 Key 问题
|
||
|
||
> [!QUESTION] 一个 key 有 5MB 大小,会有什么问题?
|
||
> - **写入阻塞**:Redis 是单线程的,写入一个 5MB 的 Hash/Set 会阻塞其他所有请求几毫秒甚至更久
|
||
> - **同步瓶颈**:这条命令不仅要在 Master 上执行,还要通过网络传给每个 Replica,相当于同一条 5MB 的数据在内网里传 N 次
|
||
> - **内存尖峰**:`BGSAVE` 时 fork 子进程,大 key 会导致 COW(Copy-On-Write)产生大量内存拷贝
|
||
|
||
> [!SUMMARY] 如何发现大 Key?
|
||
> ```bash
|
||
> redis-cli --bigkeys # 在线扫描(谨慎!高 QPS 环境有性能影响)
|
||
> scan 0 MATCH * COUNT 100000 # 逐页扫描,用 type/hashlist/set 分别评估
|
||
> ```
|
||
|
||
**解决方案**:
|
||
- **写入侧**:拆分大 key 为多个小 key(例如一个存储用户全部订单的 Hash → 按月份拆分为 `user:orders:2026-05`、`user:orders:2026-06` 等多个小 Hash)
|
||
- **同步侧**:启用无盘复制(`repl-diskless-sync yes`),Master 直接通过 socket 把 RDB 发给所有 Replica,避免写磁盘再读磁盘的双重开销
|
||
|
||
## 二、级联复制(拓扑优化)
|
||
|
||
### 为什么要级联?
|
||
|
||
假设 Master 需要服务 10 个 Replica,每个 Replica 每秒同步 ~500KB 的命令流,那 Master 每秒要输出 **5MB 的复制流量**。而且每个 Replica 断线重连都可能触发全量同步——Master 的网络带宽和 CPU(BGSAVE 的 fork)会成为瓶颈。
|
||
|
||
> [!QUESTION] 怎么降低 Master 的压力?
|
||
> 答案:不让所有 Replica 都直接连 Master,而是让部分 Replica "转发"数据给其他 Replica,形成**树形拓扑**。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
Master["Master<br/>只服务 2 个 Replica"]
|
||
R1["Replica-1<br/>(同时作为下游的 Master)"]
|
||
R2["Replica-2"]
|
||
R3["Replica-3<br/>(从 R1 同步)"]
|
||
R4["Replica-4<br/>(从 R1 同步)"]
|
||
|
||
Master -->|"直接同步"| R1
|
||
Master -->|"直接同步"| R2
|
||
R1 -->|"级联同步"| R3
|
||
R1 -->|"级联同步"| R4
|
||
```
|
||
|
||
> [!TIP] 最佳实践
|
||
> - 只有 1~2 个 Replica 直连 Master,其余通过级联获取数据
|
||
> - 级联的 Replica 也能配置为接受下游连接(Redis 原生支持,无需额外设置)
|
||
> - **缺点**:级联层级越深,末端 Replica 的数据延迟越大。通常 **两层** 是合理的上限
|
||
|
||
## 三、只读副本注意事项
|
||
|
||
Replica 默认是 **只读** 的(`replica-read-only yes`),这不是 Redis 故意限制你,而是有充分的技术原因:
|
||
|
||
> [!WARNING] 为什么不应该在 Replica 上写数据?
|
||
> 想象一下:你在 Replica 上写入了 `SET foo bar`,但 Master 上没有这个写入。下一次同步发生时,Master 会把自己的数据集"覆盖"到 Replica 上——你写的数据**悄无声息地消失了**。注意 Redis 复制是**单向的**(Master → Replica),你在 Replica 上的写入不会反向传播到 Master,但这本身就意味着 Replica 和 Master 之间的数据已经不一致了。
|
||
>
|
||
> **结论**:在 Replica 上写数据,本质上是在制造**数据不一致的定时炸弹**。
|
||
|
||
### Replica 的关键配置详解
|
||
|
||
```conf
|
||
# --- 副本如何发现 Master ---
|
||
# 在 Docker/NAT 环境下,容器内部 IP 和宿主机 IP 不同。
|
||
# 如果不设置 announce-ip,Sentinel 和其他节点可能会记录容器内部 IP,
|
||
# 导致跨网络访问失败。
|
||
replica-announce-ip 192.168.1.20 # 对外宣告的 IP
|
||
replica-announce-port 6379 # 对外宣告的端口
|
||
|
||
# --- Replica 是否可被查询 ---
|
||
# 这组配置决定:当 Replica 和 Master 断开连接时,还能不能对外服务?
|
||
replica-serve-stale-data yes # yes=断线时仍返回旧数据(可用性优先)
|
||
# no=断线时直接报错(一致性优先)
|
||
replica-read-only yes # 只读保护,防止误写入导致数据不一致
|
||
replica-lazy-flush no # FULLRESYNC 时清空旧数据的策略:
|
||
# no=立即 flush(默认),yes=惰性清理
|
||
|
||
# --- 副本同步相关 ---
|
||
repl-diskless-sync no # 无盘复制:Master 不写 RDB 文件,
|
||
# 直接通过 socket 发给 Replica(适合磁盘 I/O 慢的环境,如云盘/HDD)
|
||
repl-diskless-sync-delay 5 # 开启无盘复制后,等几秒让多个 Replica 同时连接并接收 RDB 流,
|
||
# 减少 Master 重复生成 RDB 的次数(不是随机延迟,有明确优化意图)
|
||
repl-timeout 60 # 同步超时阈值(秒),超时则断开重连
|
||
```
|
||
|
||
> [!QUESTION] `replica-serve-stale-data` 应该设为 yes 还是 no?
|
||
> 这取决于你的业务对**可用性 vs 一致性**的取舍:
|
||
> - **电商首页**、**推荐列表**等场景:设为 `yes`,返回稍微过期的数据总比报错好
|
||
> - **库存扣减**、**余额查询**等场景:设为 `no`,宁可不可用也不能返回错误数据
|
||
|
||
## 四、核心参数调优参考
|
||
|
||
> [!NOTE] 根据业务特点选择合适的参数组合
|
||
|
||
### 方案 A:极致性能优先
|
||
|
||
```conf
|
||
# 适用于读 QPS > 50k,对一致性要求不高
|
||
repl-backlog-size 256mb # 大容量 backlog 减少全量同步
|
||
repl-diskless-sync yes # 无盘复制加速 RDB 传输
|
||
replica-lazy-flush no # 确保同步前快速清理旧数据
|
||
```
|
||
|
||
### 方案 B:一致性优先
|
||
|
||
```conf
|
||
# 适用于金融、账务等场景
|
||
repl-backlog-size 512mb # 更大缓冲区容纳更多增量命令
|
||
```
|
||
|
||
```go
|
||
// WAIT 是运行时命令,不能写在 redis.conf 里,需在业务代码中调用
|
||
// 写入后等待至少 1 个副本确认同步,最多阻塞 100ms
|
||
client.Do(ctx, "WAIT", 1, 100)
|
||
```
|
||
|
||
### 常用调试命令
|
||
|
||
```bash
|
||
# 查看当前复制状态
|
||
redis-cli INFO replication
|
||
|
||
# 输出示例:
|
||
# role:master ← 或 replica
|
||
# connected_slaves:3
|
||
# master_repl_offset:12345678
|
||
# repl_backlog_active:1
|
||
# repl_backlog_size:268435456
|
||
```
|
||
|
||
## 五、Sentinel 哨兵机制
|
||
|
||
### 为什么需要 Sentinel?
|
||
|
||
上文介绍了主从复制,但有一个关键问题没解决:**Master 宕机了怎么办?**
|
||
|
||
> [!QUESTION] 没有 Sentinel 时,Master 故障的处理流程是什么?
|
||
> 1. 运维人员收到告警(可能是半夜 3 点)
|
||
> 2. 登录服务器,确认 Master 确实挂了
|
||
> 3. 手动挑一个数据最新的 Replica,执行 `SLAVEOF NO ONE` 提升为新 Master
|
||
> 4. 让其他 Replica 指向新 Master
|
||
> 5. 通知客户端切换连接地址
|
||
>
|
||
> 整个过程可能需要 **几分钟甚至更久**,而且容易出错。Sentinel 要做的就是把这整套流程**自动化**。
|
||
|
||
### 架构组成
|
||
|
||
Sentinel 本身也是一个 Redis 进程(只是不处理普通数据读写),它的工作就是**盯着 Master 和 Replica,必要时自动故障转移**。
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
S1["Sentinel-1"]
|
||
S2["Sentinel-2"]
|
||
S3["Sentinel-3"]
|
||
|
||
MasterNode["Master"]
|
||
RepNode["Replica"]
|
||
|
||
S1 <-->|"互相探测心跳"| S2
|
||
S1 <-->|"互相探测心跳"| S3
|
||
S2 <-->|"互相探测心跳"| S3
|
||
|
||
S1 <-->|"监控 Master/Replica 状态"| MasterNode
|
||
S2 <-->|"监控 Master/Replica 状态"| MasterNode
|
||
S3 <-->|"监控 Master/Replica 状态"| MasterNode
|
||
|
||
MasterNode <-->|"主从同步"| RepNode
|
||
```
|
||
|
||
> [!QUESTION] 为什么需要奇数个 Sentinel?
|
||
> 故障转移需要**多数派投票**才能决策(和选举类似)。3 个 Sentinel 中有 2 个同意就能执行;但如果只有 2 个,一旦 1 个挂了,剩下的 1 个永远达不到多数,故障转移就无法执行。偶数个(比如 4 个)也浪费——4 个节点允许 1 个故障,和 3 个一样,却多消耗了一份资源。
|
||
>
|
||
> **记住这个公式:N 个 Sentinel 允许 (N-1)/2 个故障**。3 个允许 1 个故障,5 个允许 2 个故障。
|
||
|
||
### 核心功能
|
||
|
||
Sentinel 有三大核心职责:**监控**、**通知**和**故障转移**。我们逐一拆解。
|
||
|
||
#### 1. 监控(Monitoring)——"心跳检测"
|
||
|
||
每个 Sentinel 每隔 1 秒向 Master 和 Replica 发送 `PING` 命令,期待收到 `PONG` 回复。如果在 `down-after-milliseconds` 时间内没有收到回复,Sentinel 就认为这个节点"有问题了"。
|
||
|
||
但一个 Sentinel 的判断可能是误判(比如只是 Sentinel 和 Master 之间的网络抖动),所以需要多个 Sentinel 协同确认:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["Sentinel-1 向 Master 发 PING"] --> B{"收到 PONG?"}
|
||
B -->|"是"| C["一切正常, 继续监控"]
|
||
B -->|"否"| D["标记 Master 为 SDOWN<br/>(主观下线: 我觉得它挂了)"]
|
||
D --> E{"询问其他 Sentinel:<br/>你们觉得 Master 还活着吗?"}
|
||
E -->|"Quorum 个 Sentinel<br/>都认为挂了"| F["标记 Master 为 ODOWN<br/>(客观下线: 大家都确认它挂了)"]
|
||
E -->|"没达到 Quorum"| G["可能是网络局部问题<br/>保持 SDOWN, 继续观察"]
|
||
F --> H["进入故障转移流程"]
|
||
```
|
||
|
||
> [!NOTE] SDOWN vs ODOWN,一句话总结
|
||
> - **SDOWN**(Subjectively Down)= "我觉得它挂了"——单个 Sentinel 的主观判断
|
||
> - **ODOWN**(Objectively Down)= "大家都确认它挂了"——达到 Quorum 个 Sentinel 的客观共识
|
||
>
|
||
> 只有 ODOWN 才会触发真正的故障转移,SDOWN 只是一个"预警"状态。
|
||
|
||
#### 2. 选主(Leader Election)——"谁来操刀故障转移"
|
||
|
||
当 Master 被判定 ODOWN 后,**不能所有 Sentinel 都去执行故障转移**(否则会乱套)。需要先选举一个 **Sentinel Leader** 来统一操刀:
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant S1 as Sentinel-1
|
||
participant S2 as Sentinel-2
|
||
participant S3 as Sentinel-3
|
||
|
||
Note over S1,S3: "Master 被判定 ODOWN, 需要选出一个 Leader"
|
||
S1->>S2: "我要当 Leader, 请投我一票"
|
||
S1->>S3: "我要当 Leader, 请投我一票"
|
||
S2->>S1: "同意, 投给你了"
|
||
S3->>S1: "同意, 投给你了"
|
||
Note over S1: "获得多数票, 我是 Leader 了"
|
||
S1->>S1: "开始执行故障转移..."
|
||
```
|
||
|
||
> [!TIP] 投票规则细节
|
||
> - **先到先得**:每个 Sentinel 在一轮投票中只能投一票,投给第一个来拉票的
|
||
> - **一轮只选一个**:如果多个 Sentinel 同时发起选举,票数不够就都不当选,等随机超时后重新发起(类似以太网的 CSMA/CD 避免冲突)
|
||
> - **任期制**:每次故障转移都有一个 `configEpoch`(类似 Raft 的 term),防止旧 Leader 误操作
|
||
|
||
### 故障转移步骤详解
|
||
|
||
Sentinel Leader 当选后,按以下顺序执行故障转移。整个过程通常在 **几秒到几十秒** 内完成:
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["Step 1: 选出最佳 Replica"] --> B["Step 2: 提升为新 Master"]
|
||
B --> C["Step 3: 让其他 Replica 转向新 Master"]
|
||
C --> D["Step 4: 通知客户端"]
|
||
D --> E["Step 5: 旧 Master 回来后自动降级"]
|
||
```
|
||
|
||
**Step 1:选出最佳 Replica** — "谁来接班?"
|
||
|
||
从所有健康的 Replica 中选出数据最新的一个,选择标准按优先级排序:
|
||
|
||
| 优先级 | 评估维度 | 含义 | 举例 |
|
||
|--------|---------|------|------|
|
||
| 1(最高) | **`replica-priority` 最小** | 配置中人为指定的优先级 | priority=10 优先于 priority=100;priority=0 表示"永不选我" |
|
||
| 2 | **复制偏移量最大** | 同步进度最接近 Master | Replica-A offset=10000, Replica-B offset=9998 → 选 A |
|
||
| 3(最低) | **Run ID 字典序最小** | 纯粹的 tie-breaker | 两个 Replica 前两项完全相同,选 Run ID 字母序靠前的 |
|
||
|
||
> [!WARNING] 如果所有 Replica 都不健康怎么办?
|
||
> Sentinel **不会强行执行故障转移**。如果所有 Replica 都已断线、数据严重滞后或不可达,Sentinel 宁可让系统保持不可用状态,也不会选出一个数据严重落后的 Replica 当 Master——这体现了"宁缺毋滥"的设计哲学。此时运维需要介入,排查 Replica 为什么全部掉线。
|
||
|
||
**Step 2:提升为新 Master**
|
||
|
||
Sentinel 向选中的 Replica 发送 `SLAVEOF NO ONE`,使其断开与旧 Master 的复制关系,变成独立的 Master。
|
||
|
||
**Step 3:让其他 Replica 转向新 Master**
|
||
|
||
Sentinel 向其余 Replica 发送 `SLAVEOF <new-master-ip> <port>`,它们开始从新 Master 同步数据。
|
||
|
||
> [!NOTE] `parallel-syncs` 的作用
|
||
> Sentinel 配置中的 `parallel-syncs` 控制**同时转向新 Master 的 Replica 数量**。设为 1 表示一个接一个地切换,避免所有 Replica 同时中断服务去同步(同步期间可能短暂不可用)。
|
||
|
||
**Step 4:通知客户端**
|
||
|
||
Sentinel 通过 Pub/Sub 机制在 `+switch-master` 频道广播新 Master 的地址。支持 Sentinel 协议的客户端库(如 go-redis)会自动订阅并切换。
|
||
|
||
**Step 5:旧 Master 回来后怎么办?**
|
||
|
||
旧 Master 恢复上线后,Sentinel 会自动把它配置为新 Master 的 Replica——它从"领导"变成了"下属",不会再抢回 Master 身份。
|
||
|
||
### Sentinel 配置文件
|
||
|
||
```conf
|
||
# ── 核心配置:监控哪个 Master ──
|
||
sentinel monitor mymaster 192.168.1.10 6379 2
|
||
# ↑ Master地址 ↑ 端口 ↑ Quorum(至少几个 Sentinel 同意才判定 ODOWN)
|
||
|
||
# ── 认证 ──
|
||
sentinel auth-pass mymaster your-password # Master 设置了密码时必填
|
||
|
||
# ── 三个最重要的时间参数 ──
|
||
|
||
# 1. 主观下线阈值:Master 多久没回 PING 就标记 SDOWN
|
||
# 不要太低!网络抖动 1~2 秒在生产环境很常见
|
||
sentinel down-after-milliseconds mymaster 30000 # 30 秒(推荐 15~30 秒)
|
||
|
||
# 2. 故障转移超时:一次 Failover 最多花多长时间
|
||
# 超时则认为本次转移失败,等下次重试
|
||
sentinel failover-timeout mymaster 180000 # 3 分钟
|
||
|
||
# 3. 并行同步数:同时转向新 Master 的 Replica 个数
|
||
# 设为 1 = 逐个切换,保证至少有部分 Replica 始终可用
|
||
sentinel parallel-syncs mymaster 1
|
||
```
|
||
|
||
> [!WARNING] Sentinel 配置陷阱
|
||
> - **`down-after-milliseconds` 不要太低**(如 1s)——网络抖动会触发误判,导致不必要的故障转移。建议 15~30 秒
|
||
> - **`failover-timeout` 不宜太短**——如果 Master 数据量大,Replica 同步需要时间,太短会导致 Failover 反复超时失败
|
||
> - **`parallel-syncs` 设为 1**——虽然切换慢一点,但保证服务不停;设为 0 或很大则所有 Replica 同时中断服务去同步
|
||
> - **生产环境建议手动维护 Sentinel 配置文件**——虽然 Sentinel 有自动感知功能,但配置文件中的信息更可控
|
||
> - **Redis 6+ ACL 兼容**——启用 ACL 后,用 `sentinel user myuser >password` 替代 `auth-pass`
|
||
|
||
### Sentinel 下的客户端连接
|
||
|
||
> [!QUESTION] 故障转移后,客户端怎么知道新 Master 的地址?
|
||
> 传统方式是客户端硬编码 Redis 地址——Master 换了 IP,所有客户端都得改配置重启。Sentinel 方案下,客户端**直接连接 Sentinel**(而不是 Redis),由 Sentinel 告诉客户端"谁是当前的 Master"。
|
||
>
|
||
> 主流语言的 Redis 客户端库都内置了 Sentinel 支持,原理是:
|
||
> 1. 启动时向 Sentinel 查询当前 Master 地址
|
||
> 2. 连接 Master 进行读写
|
||
> 3. 如果连接失败,重新向 Sentinel 查询新地址
|
||
|
||
**Go(go-redis)示例**——客户端自动发现和切换:
|
||
|
||
```go
|
||
rdb, err := redis.NewFailoverClient(&redis.FailoverOptions{
|
||
MasterName: "mymaster", // Sentinel 中配置的 Master 名称
|
||
SentinelAddrs: []string{"sentinel1:26379", "sentinel2:26379", "sentinel3:26379"},
|
||
Password: "your-password",
|
||
MaxRetries: 3, // 连接失败重试次数
|
||
RetryDelay: 500 * time.Millisecond,
|
||
})
|
||
|
||
// 客户端自动感知 Master:读请求走 Replica(如果配置了读写分离),写请求走 Master
|
||
_ = rdb.Set(ctx, "order:12345", data, 0) // 写 → Master
|
||
val, _ := rdb.Get(ctx, "order:12345").Result() // 读 → 可能走 Replica
|
||
```
|
||
|
||
**Python(redis-py)示例**:
|
||
|
||
```python
|
||
from redis.sentinel import Sentinel
|
||
|
||
# 连接 Sentinel 节点
|
||
sentinel = Sentinel([
|
||
('sentinel1', 26379),
|
||
('sentinel2', 26379),
|
||
('sentinel3', 26379),
|
||
])
|
||
|
||
# 获取 Master 和 Slave 连接(自动发现 + 故障切换)
|
||
master = sentinel.master_for('mymaster', password='your-password') # 用于写
|
||
slave = sentinel.slave_for('mymaster', password='your-password') # 用于读
|
||
|
||
master.set('key', 'value') # 写入 Master
|
||
value = slave.get('key') # 从 Replica 读取
|
||
```
|
||
|
||
## 六、生产实践 checklist
|
||
|
||
### 部署前 Checklist
|
||
|
||
- [ ] **Sentinel 节点数 ≥ 3,且部署在不同机器/可用区** — 避免单机故障导致 Sentinel 丧失多数派
|
||
- [ ] **Master + Replica 数量建议 1:2 ~ 1:3** — 太多 Replica 会加重 Master 同步负担;太少则冗余不足
|
||
- [ ] **配置 `replica-priority`** — 不应被选为 Master 的 Replica 设为 `0`(如配置较低的从节点)
|
||
- [ ] **持久化策略保持一致** — Master 和 Replica 都应开启 RDB 或 AOF,否则故障转移后新 Master 无持久化可能导致数据丢失
|
||
|
||
### 监控项
|
||
|
||
日常巡检重点关注以下指标(建议每秒采样,配合 Prometheus/Grafana 看趋势):
|
||
|
||
```bash
|
||
# 复制状态 —— 最核心,异常时第一时间查这个
|
||
redis-cli INFO replication
|
||
# 关键字段:
|
||
# connected_slaves:3 ← 在线 Replica 数量,突然减少说明有 Replica 掉线
|
||
# master_repl_offset:12345678 ← Master 当前写入偏移量,持续增长说明正常
|
||
# repl_backlog_active:1 ← Backlog 是否活跃
|
||
# slave0:offset=12345600,lag=0 ← 每个 Replica 的同步偏移量和延迟
|
||
|
||
# 内存 —— BGSAVE 期间内存会临时翻倍(fork 子进程)
|
||
redis-cli INFO memory
|
||
# 关键字段:
|
||
# used_memory_human:2.50GB ← 同步期间如果骤增,可能触发 OOM
|
||
|
||
# 每秒操作数 —— 发现流量突增或突降
|
||
redis-cli INFO stats
|
||
# 关键字段:
|
||
# instantaneous_ops_per_sec:15000
|
||
```
|
||
|
||
### 故障排查流程
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
A["发现 Master 异常"] --> B{"Sentinel 是否已 ODOWN?"}
|
||
B -->|"是"| C["自动 Failover 中..."]
|
||
B -->|"否"| D["检查网络 / CPU / 大 Key 阻塞"]
|
||
C --> E["Failover 成功?"]
|
||
E -->|"是"| F["客户端已自动切换 ✓"]
|
||
E -->|"否"| G["手动干预:<br/>SENTINEL FAILOVER mymaster"]
|
||
D --> H["问题解决后<br/>重新加入复制拓扑"]
|
||
```
|
||
|
||
### 常见坑点汇总
|
||
|
||
| 坑 | 现象 | 根因 | 解决方案 |
|
||
|---|------|------|------|
|
||
| **Docker 环境 IP 错乱** | Sentinel 把容器内部 IP(如 172.17.0.x)当成 Master 地址告诉客户端,客户端连不上 | Docker 网络与宿主机网络不同 | 设置 `replica-announce-ip` 为宿主机 IP |
|
||
| **脑裂(Split Brain)** | 网络分区后出现两个 Master 同时接受写入,分区恢复后一个 Master 的数据被覆盖丢失 | Master 与 Sentinel 网络断开,但与部分客户端仍连通 | 配置 `min-replicas-to-write` 保护 |
|
||
| **BGSAVE 时 OOM** | 同步期间 Redis 内存突然翻倍,被 OOM Killer 杀掉 | fork 子进程使用 COW 机制,内存紧张时几乎复制整个数据集 | 关闭 THP、增大 swap、预留 50% 内存空间 |
|
||
|
||
> [!NOTE] 脑裂是什么?怎么防?
|
||
> **脑裂**指的是:Master 实际还活着(只是和 Sentinel 之间网络断了),但 Sentinel 以为它挂了,选出了一个新 Master。这时候**两个 Master 同时接受写入**。网络恢复后,旧 Master 变成新 Master 的 Replica,它的数据会被覆盖——写入旧 Master 的数据就丢了。
|
||
>
|
||
> **防御措施**:在 Master 端配置"至少要有 N 个 Replica 在线才接受写入":
|
||
> ```conf
|
||
> # 至少 1 个 Replica 在线且延迟不超过 10 秒,Master 才接受写入
|
||
> min-replicas-to-write 1
|
||
> min-replicas-max-lag 10
|
||
> ```
|
||
> 这样当 Master 被隔离时(没有 Replica 在线),它会**拒绝写入**,而不是默默接受数据最终丢失的写操作。
|
||
|
||
## 七、局限性与替代方案
|
||
|
||
Sentinel 方案虽然解决了"高可用"问题,但它有明确的边界。理解这些边界才能选对方案:
|
||
|
||
| 场景 | Sentinel 能解决吗? | 为什么? | 应该用什么? |
|
||
|------|----------------|---------|-----------|
|
||
| Master 宕机自动恢复 | ✅ | 这正是 Sentinel 的核心功能 | — |
|
||
| 读请求太多,单节点扛不住 | ✅ | 增加 Replica 分摊读压力 | 主从复制 |
|
||
| **写请求太多,单 Master 瓶颈** | ❌ | 只有一个 Master 处理所有写入 | [[hhs/Redis/07-集群方案]] Cluster |
|
||
| **数据量超过单机内存** | ❌ | 每个节点都存全量数据 | Cluster(数据分片) |
|
||
| **跨机房容灾** | ❌ | 跨网段延迟太高,Sentinel 心跳会误判 | 异地多活架构 |
|
||
|
||
> [!QUESTION] 什么时候该从 Sentinel 升级到 Cluster?
|
||
> 当你遇到以下任一情况时,就该考虑 Cluster 了:
|
||
> - 单 Master 写 QPS 持续超过 5w,垂直扩展(换更强的机器)已到极限
|
||
> - 数据量超过单机内存(如 100GB+),且无法通过压缩优化
|
||
> - 需要水平扩展写入能力(多个 Master 分片处理不同 key 的写入)
|
||
>
|
||
> → 详见 [[hhs/Redis/07-集群方案]]
|
||
|
||
## 关联笔记
|
||
|
||
- [[hhs/Redis/07-集群方案]] — Cluster 架构(水平扩展)
|
||
- [[hhs/Redis/11-运维与性能调优]] — 监控与告警配置
|
||
- [[hhs/Redis/05-AOF持久化]] — AOF 与 RDB 结合的高可用策略
|
||
- [[hhs/EXAM/Week05]] — Docker Compose 中的部署示例
|