From dda0d6927c2790924a88bbafc0e8d12b3c641edd Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Fri, 22 May 2026 00:25:59 +0800 Subject: [PATCH] vault backup: 2026-05-22 00:25:59 --- hhs/Redis/04-RDB持久化.md | 518 ++++++++++++++++++++++++++----------- hhs/Redis/05-AOF持久化.md | 249 ++++++++++++------ hhs/Redis/06-主从与哨兵.md | 493 ++++++++++++++++++++++++----------- 3 files changed, 888 insertions(+), 372 deletions(-) diff --git a/hhs/Redis/04-RDB持久化.md b/hhs/Redis/04-RDB持久化.md index f9edd7b..ff21b46 100644 --- a/hhs/Redis/04-RDB持久化.md +++ b/hhs/Redis/04-RDB持久化.md @@ -7,51 +7,59 @@ create time: 2026-05-15 18:12 ## 概述 -> [!SUMMARY] RDB 是什么? -> RDB(Redis Database)是 Redis 最早的持久化机制,通过在特定时间点将内存中的数据集写入磁盘形成**快照文件(.rdb)**,实现数据持久化。 +> [!QUESTION] Redis 的数据都存在内存里,那服务器一断电,数据岂不是全没了? +> 没错——这就是**持久化**存在的意义。Redis 提供两种持久化方案:**RDB** 和 **AOF**,本文先讲 RDB。 + +> [!SUMMARY] 一句话理解 RDB +> RDB(Redis Database)就是在**某个时间点**,把 Redis 内存中的**全部数据**"拍一张照片"存到磁盘上——这个"照片"就是一个 `.rdb` **快照文件**。 +> +> 你可以把它想象成**游戏存档**:打 Boss 之前你手动存了一次档,万一翻车了可以读档重来。但代价是——**存档之后到翻车之间**的操作全丢了。 ### 核心特点 -| 特性 | 说明 | -|------|------| -| ⚡ 恢复极快 | 二进制文件直接加载,避免命令回放 | -| 📦 文件紧凑 | 压缩存储,远小于等量 AOF 日志 | -| 🔧 架构简单 | 无额外复杂逻辑,适合备份和迁移 | -| ⚠️ 数据窗口丢失 | 两次快照之间的写操作不可恢复 | +| 特性 | 说明 | 类比 | +|------|------|------| +| ⚡ 恢复极快 | 二进制文件直接加载到内存,跳过命令回放 | 像从压缩包解压,比照着菜谱重新做菜快得多 | +| 📦 文件紧凑 | 使用 LZF 压缩,远小于等量 AOF 日志 | 一张照片的信息量 > 千言万语的描述 | +| 🔧 架构简单 | 无额外复杂逻辑,一个文件搞定备份/迁移 | "复制-粘贴"级别的简单 | +| ⚠️ 数据窗口丢失 | 两次快照之间的写操作不可恢复 | 存档点之后的操作无法回溯 | ### 何时使用 -- **大数据集冷备**:定期全量备份到 S3 / OSS -- **灾难恢复**:配合 CI/CD 快速重建实例 -- **大规模数据场景**:恢复速度优先于毫秒级数据一致性 +- **大数据集冷备**:定期全量备份到 S3 / OSS,作为最后一道防线 +- **灾难恢复**:服务器挂了?把 `.rdb` 文件丢到新机器上,秒级恢复 +- **大规模数据场景**:当你的 Redis 存了 50GB 数据,AOF 回放可能要几十分钟,RDB 几秒搞定 +- **主从复制**:新从节点首次全量同步时,主节点会自动触发 RDB 快照传给从节点 > [!QUESTION] 为什么 RDB 恢复比 AOF 快? -> RDB 是直接加载二进制快照到内存,AOF 则需要逐条回放命令——数据量越大差距越明显。 +> 打个比方:RDB 就像**拍了一张照片**,加载时直接"看照片"还原场景;AOF 则像**记了一本流水账**("先加了张三,再删了李四,又改了王五……"),恢复时需要从头到尾把账本念一遍——数据量越大,"念账本"的时间就越长。 ## 触发机制 ### 手动触发 ```bash -# 当前线程执行,阻塞主线程 ❌ +# 当前线程执行,阻塞主线程 ❌ 生产环境禁用! SAVE -# 主进程 fork 子进程在后台完成(推荐) +# 主进程 fork 子进程在后台完成(✅ 推荐) BGSAVE -# 查看上次 BGSAVE 状态 +# 查看上次 BGSAVE 成功的时间戳 LASTSAVE ``` -| 命令 | 行为 | 影响 | -|------|------|------| -| `SAVE` | 同步保存,主线程阻塞 | **生产环境禁用!** | -| `BGSAVE` | fork 子进程异步保存 | 主线程继续服务,但触发 fork 瞬间短暂停顿 | -| `BGREWRITEAOF` | AOF rewrite | 与 BGSAVE 可同时运行(不同子进程) | +| 命令 | 行为 | 适用场景 | 风险 | +|------|------|----------|------| +| `SAVE` | 同步阻塞,直到快照完成 | 紧急调试,或确认实例无流量时 | **主线程完全卡死**——所有客户端请求排队等待 | +| `BGSAVE` | fork 子进程异步完成 | 生产环境日常备份 | fork 瞬间有极短暂的阻塞(通常毫秒级) | + +> [!QUESTION] 为什么 SAVE 危险但 Redis 还要提供它? +> 因为 `SAVE` 可以在 **BGSAVE 正在执行时** 强制同步保存(BGSAVE 期间再次 BGSAVE 会被拒绝)。这是一个极端场景的"保底"手段。 ### 自动触发——条件持久化 -通过 `redis.conf` 配置: +通过 `redis.conf` 配置多条 `save` 规则,Redis 用一个**"脏键计数器"(dirty counter)** 来追踪: ```conf save 900 1 # 900 秒内至少有 1 个 key 被修改 → 触发快照 @@ -59,227 +67,447 @@ save 300 10 # 300 秒内至少有 10 个 key 被修改 save 60 10000 # 60 秒内至少有 10000 个 key 被修改 ``` -> [!QUESTION] 为什么要三档条件? -> - **低活跃度**:900s/1key 保证最终一致 -> - **中活跃度**:300s/10key 平衡频率和数据量 -> - **高活跃度**:60s/10000key 高频大量变更时快速落盘 +> [!INFO] 底层是怎么工作的? +> Redis 内部维护两个计数器: +> 1. **`dirty` 计数器**:自上次 BGSAVE 成功以来,总共被修改的 key 数量(每次写操作 +1) +> 2. **`lastsave` 时间戳**:上次成功完成 BGSAVE 的 Unix 时间戳 +> +> 每隔 100ms(`serverCron` 定时任务),Redis 会检查:**当前时间 - lastsave ≥ 阈值时间 && dirty ≥ 阈值次数**,两个条件**同时满足**才会触发 BGSAVE。 +> +> 所以 `save 900 1` 的意思是:"距离上次快照超过 900 秒**并且**期间有至少 1 个 key 被修改过"。如果 900 秒内没有任何写操作,即使规则匹配了也不会触发——因为没有新数据需要保存。 + +> [!QUESTION] 为什么要设三档而不是一档? +> 这是一个**渐进式频率**设计: +> - **低活跃度**(900s/1key):写入很少时,15 分钟存一次就够了——即使丢了 15 分钟的数据,也几乎没什么损失 +> - **中活跃度**(300s/10key):中等写入压力时,5 分钟存一次,平衡了 I/O 开销和数据安全 +> - **高活跃度**(60s/10000key):大量写入时,1 分钟存一次——虽然 fork 更频繁了,但此时丢 1 分钟的数据损失也更大 +> +> 本质上是"**数据越重要、变更越频繁,保存就越勤**"的策略。 ```bash -# 运行时动态修改 -CONFIG SET save "900 1\n300 10\n60 10000" +# 运行时动态修改(覆盖所有规则) +CONFIG SET save "900 1 300 10 60 10000" -# 临时关闭自动快照(谨慎!) +# 临时关闭自动快照(谨慎!相当于禁用 RDB 自动触发) CONFIG SET save "" ``` -> [!WARNING] CONFIG SET 不持久 -> `CONFIG SET` 仅在内存中生效,重启后失效。修改 `redis.conf` 并 reload 才是持久化的方式。 +> [!WARNING] CONFIG SET 不持久化 +> `CONFIG SET` 仅在内存中生效,Redis 重启后会恢复 `redis.conf` 的配置。若要永久生效,必须修改 `redis.conf` 并执行 `CONFIG REWRITE` 或手动重启。 -### 其他触发场景 +### 其他自动触发场景 -| 场景 | 说明 | -|------|------| -| **shutdown 保存** | `SHUTDOWN` / `SHUTDOWN NOSAVE` 会在关闭前执行一次完整 RDB 保存(除非显式传 NOSAVE) | -| **AOF rewrite** | `BGREWRITEAOF` 重写 AOF 时同样会 fork 子进程生成 RDB preamble(开启混合持久化时) | -| **主从同步** | 新从节点首次全量同步时,主节点会自动触发 BGSAVE 并将快照传给从节点 | +除了 `save` 规则外,以下场景也会自动触发 RDB: -> [!TIP] 避免频繁触发 -> 多个触发条件同时满足时,Redis 会去重,仅执行一次 BGSAVE。若已有 BGSAVE 在执行,后续的 SAVE 请求将被忽略。 +| 场景 | 说明 | 为什么需要? | +|------|------|-------------| +| **正常关闭** | 执行 `SHUTDOWN` 时自动触发一次 BGSAVE,保证关闭前数据落盘 | 优雅关闭 ≠ 数据丢失 | +| **主从全量同步** | 新从节点首次连接主节点时,主节点自动触发 BGSAVE 并将快照传给从节点 | 从节点需要一份完整的数据副本 | +| **AOF 重写** | 开启混合持久化后,`BGREWRITEAOF` 会先写 RDB 快照作为基线 | 后面"混合持久化"章节详解 | + +> [!TIP] Redis 如何避免"撞车"? +> Redis 同一时刻**只会有一个子进程在做持久化**。如果 BGSAVE 正在执行,此时再触发 SAVE/BGSAVE: +> - `BGSAVE` → 直接返回错误 `ERR Background save already in progress` +> - `SAVE` → 会等待 BGSAVE 完成后再执行 +> +> 这个设计避免了多个子进程争抢 I/O 资源。 ## RDB 文件结构 -Redis 7.0+ 引入了新格式,更紧凑且支持增量复制: +可以把 `.rdb` 文件想象成一个**快递包裹**:有快递单号(Header)、多个包裹(DB 里的数据)、包裹拆完的标记(EOF)、以及签收校验码(Footer)。 ```mermaid flowchart TD - H1["Header"] --> H2["Magic: REDIS"] - H1 --> H3["RDB Version"] - H1 --> H4["Reserved / CRC64"] + subgraph header["1 HEADER 文件头"] + A["Magic: REDIS07 + Version"] + B["Auxiliary Fields: redis-ver, ctime, used-mem"] + C["DB Selector: FE 00(选择 DB 0)"] + A --> B --> C + end - D1["DB Number
1 byte"] --> D2["Key Type
1 byte"] - D2 --> D3["Encoded Key + Value"] + subgraph data["2 DATA 数据段(每个非空 DB 重复一次)"] + D["Key Type: 1 byte(String=0, List=1, Set=2 ...)"] + E["Expire Info: 可选, TTL 时间戳"] + F["Encoded Key + Value: 直接序列化底层数据结构"] + D --> E --> F + F -.-> G["... 重复 N 个 Key-Value 对 ..."] + end - E1["EOF Marker"] --> E2["CRC64 Checksum
8 bytes"] + subgraph footer["3 FOOTER 文件尾"] + H["EOF Marker: 0xFF(数据结束)"] + I["CRC64 Checksum: 8 bytes(完整性校验)"] + H --> I + end - H1 & H2 & H3 & H4 ==> "Dataset Section" - D1 & D2 & D3 ==> "Dataset Section" - D3 -.-> "... N keys ..." - E1 & E2 ==> "Footer" + header --> data --> footer ``` -### 各段含义 +### 各段含义详解 -| 段落 | 内容 | 说明 | +**1️⃣ Header(文件头)** + +- **Magic 字符串**:以 `REDIS` 开头 + 4 字节版本号(如 `REDIS0009`),用于标识"这是一个 RDB 文件" +- **辅助字段(Auxiliary Fields)**:记录 Redis 版本、创建时间、内存占用等元信息——**不是数据本身,只是"快递单信息"** +- **DB Selector**:`FE` + DB 编号,告诉你"接下来的数据属于哪个 DB"(默认只有 DB 0 有数据,所以通常只出现一次) + +**2️⃣ Data(数据段)** + +每个 key-value 对的存储格式: + +| 字段 | 大小 | 说明 | |------|------|------| -| **Header** | Magic + Version + 校验 | `REDIS` 魔数标识文件类型;版本号用于兼容性判断;CRC64 用于完整性校验 | -| **DB Selector** | 数据库编号 | 默认 16 个 DB(0-15),只序列化非空 DB | -| **Key-Value Pairs** | 类型 + 编码 + 数据 | 每个 key 携带类型标记(String/List/Hash/ZSet…),底层编码直接序列化,因此文件极紧凑 | -| **EOF** | `0xFF` 终止符 | 标识数据段结束 | -| **Footer** | CRC64 校验和 | 8 字节,启动加载时校验文件完整性 | +| **Key Type** | 1 byte | 值的类型标记:String=0, List=1, Set=2, ZSet=3, Hash=4 … | +| **Expire Time** | 0 或 8 bytes | 如果 key 设置了过期时间,会附带一个毫秒级时间戳 | +| **Key** | 变长 | key 名(字符串编码) | +| **Value** | 变长 | **直接序列化底层数据结构**(ziplist、intset、skiplist 等),这是 RDB 文件紧凑的关键 | + +**3️⃣ Footer(文件尾)** + +- **EOF 标记**:`0xFF`,一个字节,宣告"数据到这里结束了" +- **CRC64 校验和**:8 字节,Redis 加载文件时用它校验完整性——如果校验失败,说明文件损坏,加载直接报错 > [!QUESTION] 为什么 RDB 文件比 AOF 小很多? -> 因为 RDB 直接序列化底层数据结构(如 ziplist、intset),而非记录产生这些数据的命令字符串。一个 Hash 用 ziplist 存储可能只有几十字节,但生成它的 HSET 命令可能有数百字节。 +> 核心在于存储方式不同。举个例子: +> +> 假设你用 `HSET user:1 name "Alice" age 25 city "Beijing"` 存了一个 Hash: +> - **AOF 记录的是**:这条命令字符串本身 → `*8\r\n$5\r\nHSET\r\n$6\r\nuser:1\r\n$4\r\nname\r\n...\` 约 **120+ 字节** +> - **RDB 记录的是**:Hash 类型标记 + ziplist 编码的紧凑字节流 → 约 **30-40 字节** +> +> 差距来自:RDB 不记录"怎么做的",只记录"结果是什么"——就像一张照片远比描述照片的文字更紧凑。 ## 关键配置参数 ```conf # ---- RDB 核心参数 ---- -dbfilename dump.rdb # 快照文件名 -dir /var/lib/redis # 工作目录,RDB/AOF 文件存放位置 -rdbcompression yes # 是否使用 LZF 压缩字符串(推荐开启,CPU vs 磁盘权衡) -rdbchecksum yes # 是否在文件末尾写入 CRC64 校验(推荐开启) -rdb-save-incremental-fsync yes # 写入磁盘时增量 fsync,避免一次性大量 I/O 阻塞 -rdb-del-sync-files no # 无盘复制时,是否删除用于同步的临时 RDB(Redis 7+) +dbfilename dump.rdb # 快照文件名(建议保留默认) +dir /var/lib/redis # 工作目录——RDB/AOF 文件都存放在这里 +rdbcompression yes # 是否使用 LZF 压缩字符串 +rdbchecksum yes # 是否在文件末尾写入 CRC64 校验 +rdb-save-incremental-fsync yes # 写入磁盘时是否增量 fsync +rdb-del-sync-files no # 无盘复制时,是否删除用于同步的临时 RDB(Redis 7+) ``` -> [!TIP] rdbcompression 的取舍 -> 开启压缩可以显著减少磁盘占用(通常压缩 40%-60%),代价是写入时多消耗一点 CPU。对于绝大多数场景,**保持 yes** 是最优解。只有在 CPU 极度敏感、磁盘充裕的特殊场景下才考虑关闭。 +### 参数逐个解释 + +| 参数 | 推荐值 | 为什么? | +|------|--------|----------| +| `dbfilename` | `dump.rdb` | 保持默认即可。如果一台机器跑多个 Redis 实例,建议用端口号区分,如 `dump-6379.rdb` | +| `dir` | `/var/lib/redis` | 快照文件存放目录。**必须确保该目录有足够磁盘空间**,且 Redis 进程有写权限 | +| `rdbcompression` | `yes` | 开启 LZF 压缩可减少 40%-60% 的磁盘占用,代价是写入时多消耗一点 CPU。**绝大多数场景推荐开启** | +| `rdbchecksum` | `yes` | 开启后在文件末尾写入 CRC64 校验码,加载时会验证完整性。**关闭可略微提升加载速度,但不值得冒险** | +| `rdb-save-incremental-fsync` | `yes` | 开启后,子进程每写入 32MB 就执行一次 `fsync`,避免一次性刷盘造成 I/O 尖峰。对 SSD 尤其友好 | + +> [!QUESTION] rdbcompression 关闭能提升多少性能? +> 压缩的 CPU 开销其实很小(LZF 是轻量压缩算法),瓶颈通常在磁盘 I/O 而非 CPU。关闭压缩后文件变大 → 写入时间更长 → 可能反而更慢。所以**除非你有明确的 CPU profiling 数据证明压缩是瓶颈,否则不要关**。 ## Fork 过程详解 +BGSAVE 的核心操作是 `fork()`——这是 Linux 的系统调用,能以**极低的成本**创建一份进程副本。整个流程如下: + ```mermaid sequenceDiagram - participant Server as Redis Server - participant Parent as 主进程 - child ForkGroup as Forked Child - participant Child as 写时复制子进程 - participant Disk as 磁盘 (.rdb) - end + participant Client as 客户端 + participant Main as 主进程 + participant Child as 子进程 + participant Disk as 磁盘 - Server->>Parent: BGSAVE 指令 - Parent->>Server: OK(立即返回) - Parent->>Child: fork() 创建子进程 - Note over Child: 子进程独享父进程
内存副本(写时复制 COW) - Child->>Disk: 遍历所有 key → 序列化 - Note over Child: 主进程仍可读写
COW 机制按需拷贝脏页 - Child->>Parent: 生成临时 .rdb.tmp - Parent->>Disk: RENAME 原子替换 - Parent->>Server: BGSAVE 完成通知 + Client->>Main: 发送 BGSAVE 命令 + Main->>Main: 创建子进程(fork) + Note over Main: fork 返回 0(子进程视角)
fork 返回 pid(主进程视角) + Main->>Client: 返回 OK(立即响应) + par 主进程继续服务 + Main->>Main: 正常处理读写请求 + and 子进程执行快照 + Child->>Child: 遍历所有 key-value + Child->>Disk: 序列化写入 dump.rdb.tmp + Note over Child,Disk: 写入过程中如果主进程修改了数据
内核通过 COW 机制保护子进程视图 + Child->>Main: 通知:快照完成 + end + Main->>Disk: rename dump.rdb.tmp -> dump.rdb(原子替换) + Main->>Main: 更新 lastsave 时间戳, 重置 dirty 计数器 ``` -> [!WARNING] COW 内存消耗 -> fork 后主进程若持续写入大值,会导致 COW 副本膨胀。例如你有一个 1GB 的值被频繁修改,fork 后每次写都可能多拷贝一页(4KB)。高峰期建议关闭自动 RDB,仅依赖 AOF。 +> [!TIP] 为什么 fork 这么快? +> `fork()` 并不会真的复制整个内存。Linux 使用**"写时复制"(Copy-On-Write, COW)**——fork 之后,父子进程**共享同一份物理内存页**,只在某一方**写入时**才真正拷贝那一页。所以 fork 一个 10GB 的 Redis 实例,实际瞬间开销只是创建页表(通常几十 MB),远小于 10GB。 ### 深入理解 COW(Copy-On-Write) -fork 后父子进程共享同一份物理内存页(只读),只有当某一方**写入**时,内核才会真正复制该页——这就是"写时复制"。 +> [!INFO] 图书馆类比 +> 想象一个图书馆(物理内存),里面有很多书(内存页)。 +> - **fork 之前**:只有主进程一个人在看书 +> - **fork 之后**:子进程也进了图书馆,两人**看同一本书**,不需要额外复印——这就是"共享" +> - **主进程想在书上做笔记(写入)**:图书馆员说"不行,这本书要保护",于是**复印了一份**给主进程,子进程继续看原版——这就是"写时复制" +> - **如果主进程不写**:从头到尾只有那一本书,零额外开销 ```mermaid flowchart TD F["Fork 完成"] --> S["父子进程共享
所有物理内存页"] S --> W{"主进程收到
写请求?"} - W -->|"否"| R["子进程继续读取
(零拷贝, 极快)"] + W -->|"否"| R["子进程继续读取原页
(零拷贝,极快)"] W -->|"是"| C["内核复制该内存页
(Copy-On-Write)"] - C --> N["主进程写入副本
子进程仍读原页"] + C --> N["主进程写入副本页
子进程仍读原页"] R & N --> D["子进程完成序列化"] - D --> DONE["RDB 文件生成"] + D --> DONE["RDB 文件生成完成"] ``` -> [!QUESTION] 为什么 Redis 占用内存会翻倍? -> 这是面试高频题。答案是:**不会翻倍,但最坏情况接近翻倍。** fork 本身不复制内存,只有**主进程实际写入的页面**才会被 COW。如果 BGSAVE 期间主进程几乎不写入,额外内存开销几乎为零。但若全量写入,就会逐步逼近 2 倍。 +> [!QUESTION] BGSAVE 期间 Redis 内存会翻倍吗? +> **不会翻倍,但最坏情况接近翻倍。** > -> **运维经验**:保持系统 `vm.overcommit_memory=1`(Linux 默认),否则 fork 可能因内核认为内存不足而失败。 +> fork 本身**不复制任何内存页**。只有当主进程**实际写入**某一页时,那一页才会被 COW 拷贝。两种极端情况: +> +> | 场景 | 额外内存开销 | +> |------|-------------| +> | BGSAVE 期间主进程**几乎不写入** | ≈ 0(只共享,不拷贝) | +> | BGSAVE 期间主进程**全量写入** | ≈ 接近翻倍(每一页都被拷贝) | +> | 实际生产环境 | 通常 **10%-30%**(只有被修改的 key 对应的页才会 COW) | + +> [!WARNING] fork 失败的常见原因 +> fork 需要内核分配页表空间,如果系统内存不足会失败。关键运维参数: +> - **`vm.overcommit_memory = 1`**:告诉内核"允许过度提交",这是 Redis 官方推荐的 Linux 设置 +> - **`vm.overcommit_ratio`**:默认 50,配合 overcommit_memory=2 使用 +> +> 如果你看到 `Can't save in background: fork: Cannot allocate memory`,大概率是这个参数没设置对。 ### TTL / 过期键处理 > [!QUESTION] RDB 快照会保存已过期的 key 吗? -> **不会。** Redis 在 fork 子进程前会先执行一次主动过期扫描,清理掉所有已超时的 key。 +> **不会。** 这是一个容易误解的点——很多人以为快照是"拍照片",会原封不动地保存内存里所有东西。但实际上,Redis 在"拍照"之前会先"打扫房间"。 具体流程: -1. `BGSAVE` 触发时,主线程先将当前所有已过期 key 从内存中删除 -2. 子进程 fork 完成后遍历剩余 key → 序列化到 .rdb 文件 -3. 因此:**RDB 文件中不会出现已过期键** +1. `BGSAVE` 触发时,主线程**先执行一次主动过期扫描**,把所有已超时的 key 从内存中删除 +2. 删除完成后,才 fork 子进程 +3. 子进程遍历剩余的 key → 序列化写入 `.rdb` 文件 +4. 结论:**`.rdb` 文件中只有有效数据,不包含过期键** ```mermaid flowchart LR - A["BGSAVE 触发"] --> B["主线程: 主动过期
清理超时 Key"] + A["BGSAVE 触发"] --> B["主线程: 主动过期扫描
清理所有已超时 Key"] B --> C["Fork 子进程"] - C --> D["子进程: 遍历剩余数据
序列化到 .rdb"] - D --> E[".rdb 无过期键"] - - style E fill:"#c4e8b0" + C --> D["子进程: 遍历有效数据
序列化写入 .rdb"] + D --> E["RDB 文件中无过期键"] + style E fill:#c4e8b0 ``` -> [!NOTE] 过期策略差异 -> 虽然 RDB 不保存过期键,但 AOF 可能包含过期命令(如之前的 SET k v EX 100)。AOF 重写时会主动过滤过期键,恢复时也只会加载有效数据。两者优先保证启动时的数据一致性。 +> [!NOTE] 一个细节:RDB 加载时也会二次检查 +> 即使某个 key 在快照时还没过期,但等到恢复加载时已经过期了——Redis 在加载 RDB 文件时会**再次检查 TTL**,过期的 key 不会被加载到内存。这保证了无论什么时候恢复,数据都是"新鲜"的。 ## 恢复流程 +Redis 启动时会**自动检测** `dir` 目录下的 `.rdb` 文件并加载——你不需要手动执行任何"恢复命令"。 + +### 场景一:正常重启(最常见) + ```bash -# 1. 停止 Redis -redis-cli SHUTDOWN NOSAVE +# 直接重启即可,Redis 自动加载 dump.rdb +redis-server /path/to/redis.conf +``` -# 2. 把 .rdb 放到 redis_dir(由 dir 配置决定) -cp dump.rdb /var/lib/redis/ +Redis 启动日志中你会看到类似: +``` +* DB loaded from disk: 0.052 seconds # ← 52ms 加载完成 +``` -# 3. 启动 Redis -redis-server --daemonize yes +### 场景二:从备份恢复(灾难恢复) + +当服务器故障需要从备份 `.rdb` 恢复时: + +```bash +# 1. 停止当前 Redis(如果还在运行) +redis-cli SHUTDOWN NOSAVE # NOSAVE = 不再生成新快照,直接关闭 + +# 2. 把备份的 .rdb 文件放到 Redis 工作目录 +cp /backup/dump.rdb /var/lib/redis/dump.rdb + +# 3. 启动 Redis,它会自动加载这个 .rdb +redis-server /path/to/redis.conf # 4. 验证数据恢复 -redis-cli DBSIZE -redis-cli KEYS "*" +redis-cli DBSIZE # 检查 key 总数 +redis-cli INFO keyspace # 查看各 DB 的 key 数量和过期 key 数量 +redis-cli GET some_known_key # 抽样验证已知 key 是否存在 ``` +### 场景三:迁移数据到新实例 + +```bash +# 在旧实例上手动触发一次快照 +redis-cli BGSAVE + +# 等待完成 +redis-cli LASTSAVE # 观察时间戳变化 + +# 复制 .rdb 到新服务器 +scp /var/lib/redis/dump.rdb user@new-server:/var/lib/redis/ + +# 在新服务器上启动 Redis(自动加载) +ssh user@new-server "redis-server /path/to/redis.conf" +``` + +> [!WARNING] 恢复时的常见坑 +> 1. **文件权限**:确保 Redis 进程对 `dir` 目录有**读写权限**(启动时需要读取,运行时需要写入新快照) +> 2. **磁盘空间**:`.rdb` 文件恢复时需要临时解压到内存,确保服务器**可用内存 ≥ RDB 文件大小** +> 3. **同时存在 AOF 和 RDB**:如果 `dir` 下同时有 `dump.rdb` 和 `appendonly.aof`,**Redis 优先加载 AOF**(因为 AOF 数据通常更新)。如果你确实想用 RDB 恢复,需要先移走 AOF 文件 +> 4. **版本兼容**:低版本 Redis 不能加载高版本生成的 RDB 文件(向后不兼容) + ## RDB vs AOF 对比 -| 维度 | RDB | AOF | -|------|-----|-----| -| 数据安全性 | 两次快照间数据丢失 | 每秒/每次可配置,接近零丢失 | -| 恢复速度 | ⚡ 极快(直接加载二进制文件) | 🐢 较慢(需要回放日志) | -| 文件大小 | ✅ 小(紧凑压缩) | ❌ 大(文本命令日志) | -| CPU 开销 | 低(偶尔 fork) | 高(持续追加写入) | -| 适用场景 | 备份、灾难恢复、大数据集冷备 | 对可用性要求高的在线服务 | -| 重启耗时 | 秒级 | 取决于日志长度(可 pre-fork) | +| 维度 | RDB | AOF | 为什么? | +|------|-----|-----|----------| +| **数据安全性** | ⚠️ 可能丢失两次快照间的数据 | ✅ 最多丢 1 秒数据(everysec 策略) | RDB 是"定期拍照",AOF 是"实时写日记" | +| **恢复速度** | ⚡ 极快(直接加载二进制文件) | 🐢 较慢(逐条回放命令) | 读一本 100 页的书 vs 回放 100 万条操作日志 | +| **文件大小** | ✅ 小(压缩后的二进制) | ❌ 大(文本命令持续追加) | 照片 vs 逐帧视频——信息密度不同 | +| **CPU 开销** | 低(偶尔 fork) | 高(每条写命令都要追加) | 定期批量 vs 持续实时的天然差异 | +| **对写入性能影响** | 极小(fork 子进程异步完成) | 有影响(每次写都要 append) | BGSAVE 不阻塞主线程,AOF fsync 可能短暂阻塞 | +| **适用场景** | 备份、灾难恢复、主从同步 | 对数据安全要求高的在线服务 | 各取所长 | -## Redis 7 混合持久化 +> [!QUESTION] 生产环境怎么选? +> **答案是:不要二选一,两个都开。** Redis 4.0+ 引入的混合持久化(下文详解)完美解决了这个矛盾——RDB 保证恢复速度,AOF 保证数据安全。 -结合 RDB + AOF 的优点: +## Redis 混合持久化(推荐) -```conf -aof-use-rdb-preamble yes -``` +### 为什么需要混合持久化? -开启后,AOF rewrite 不再只追加命令日志,而是先写入一份 RDB 快照,再追加后续命令: +回顾一下两者的痛点: + +| 方案 | 优点 | 痛点 | +|------|------|------| +| RDB | 恢复快、文件小 | 两次快照间的数据可能丢失 | +| AOF | 数据几乎不丢 | 恢复慢、文件大 | + +> [!QUESTION] 能不能把两者结合起来——用 RDB 加速恢复 + 用 AOF 保证数据安全? +> 可以!Redis 4.0 引入了**混合持久化(Hybrid Persistence)**,正是为了解决这个矛盾。 + +### 原理:AOF 文件 = RDB 快照 + 增量日志 + +开启混合持久化后,AOF **重写**时不再只写命令日志,而是: ```mermaid flowchart LR - T1["传统 AOF 重建
逐条命令回放"] -->|"O(N) 慢"| R1["重建耗时: 长"] + A["AOF 重写触发"] --> B["先写入一份 RDB 快照
(覆盖重写时刻的全量数据)"] + B --> C["再追加重写期间的增量命令"] + C --> D["最终 AOF 文件"] - H1["RDB 二进制快照
全量快速还原"] -->|"秒级加载"| R2["重建耗时: 短"] - H2["AOF 增量日志
仅保留变更后操作"] -->|"精确到毫秒"| R2 - - style R1 fill:"#f9d0c4" - style R2 fill:"#c4e8b0" + style D fill:#c4e8b0 ``` -> [!TIP] 混合持久化几乎是无脑开启的配置——启动快 + 不丢数据。 +也就是说,生成的 AOF 文件结构变成了: + +``` +┌──────────────────────────────────────┐ +│ RDB 二进制快照(全量数据基线) │ ← 启动时直接加载,秒级完成 +├──────────────────────────────────────┤ +│ 增量 AOF 命令(快照之后的写操作) │ ← 逐条回放,保证数据不丢 +└──────────────────────────────────────┘ +``` + +> [!INFO] 用"写日记"来类比 +> - **纯 RDB** = 定期拍照(恢复快,但照片之间的事不知道) +> - **纯 AOF** = 每天写日记(信息完整,但要从头读到尾才能恢复全部记忆) +> - **混合持久化** = 定期拍照 + 每天写日记。恢复时先看最近的照片(RDB 部分),再读照片之后的日记(AOF 部分)——既快又完整 + +### 如何开启 + +```conf +# redis.conf +aof-use-rdb-preamble yes # Redis 4.0+, 默认就是 yes +appendonly yes # 必须开启 AOF +``` + +### 恢复速度对比 + +```mermaid +flowchart LR + T1["传统 AOF 恢复
回放数百万条命令"] -->|"O(N) 慢"| R1["耗时: 分钟级别"] + + H1["RDB 快照加载
秒级还原全量数据"] -->|"O(1) 快"| R2["耗时: 秒级"] + H2["回放少量增量 AOF
仅重写后的命令"] -->|"极少"| R2 + + style R1 fill:#f9d0c4 + style R2 fill:#c4e8b0 +``` + +> [!TIP] 混合持久化几乎是"无脑开启"的配置 +> - 恢复速度 ≈ RDB(因为大部分数据在 RDB 部分,AOF 增量部分很少) +> - 数据安全 ≈ AOF(增量日志保证不丢数据) +> - 开销 = AOF rewrite 的 fork 开销(和纯 AOF 一样) +> +> Redis 5.0+ 已默认开启,建议检查你的配置确认一下。 ## 最佳实践 > [!SUMMARY] 核心原则:RDB 是备份手段,不是实时数据保障 +> 不要把 RDB 当成"实时安全网"——它的定位是**定期快照 + 灾难恢复兜底**。实时数据安全交给 AOF。 -1. **不要只依赖 RDB**:生产环境推荐 `AOF + RDB` 混合持久化,AOF 保证数据安全性,RDB 保证快速恢复 -2. **合理设置触发频率**:默认三档(900s/300s/60s)适合大部分场景;写入密集型业务可适当提高频率,但注意 fork 开销 -3. **监控 fork 耗时**:`INFO persistence` 中的 `rdb_last_bgsave_time_sec`,超过 1s 就需要关注 -4. **备份策略**:定期将 `.rdb` 文件拷贝到异地存储(S3/OSS),建议保留多个历史版本 -5. **磁盘选择**:RDB 文件写入对磁盘 I/O 有要求,建议使用 SSD 并配合 `rdb-save-incremental-fsync` - -> [!WARNING] 大 Key 是 RDB 的天敌 -> 一个 100MB 的 String key,序列化时会导致子进程写入耗时剧增,同时 COW 也可能产生大量页面复制。发现大 Key 后应优先拆分。 - -## 运维要点 +1. **不要只依赖 RDB**:生产环境推荐 `AOF + 混合持久化`,AOF 保证数据安全,RDB 加速恢复 +2. **合理设置 save 频率**:默认三档适合大部分场景。写入密集型可适当提高频率,但要注意: + - 频率越高 → fork 越频繁 → CPU 和内存压力越大 + - 经验法则:`rdb_last_bgsave_time_sec` 超过 **1 秒**就需要关注 +3. **备份策略(自动化)**: ```bash -# 监控 RDB 相关指标 +#!/bin/bash +# redis-backup.sh — 每日定时备份脚本示例 +REDIS_CLI="redis-cli" +BACKUP_DIR="/backup/redis" +DATE=$(date +%Y%m%d_%H%M%S) + +# 1. 触发一次快照 +$REDIS_CLI BGSAVE + +# 2. 等待完成(轮询 lastsave) +LAST=$($REDIS_CLI LASTSAVE) +while [ "$($REDIS_CLI LASTSAVE)" == "$LAST" ]; do sleep 1; done + +# 3. 拷贝并压缩 +cp /var/lib/redis/dump.rdb "$BACKUP_DIR/dump-$DATE.rdb" +gzip "$BACKUP_DIR/dump-$DATE.rdb" + +# 4. 保留最近 7 天的备份,删除更早的 +find "$BACKUP_DIR" -name "dump-*.rdb.gz" -mtime +7 -delete + +echo "Backup completed: dump-$DATE.rdb.gz" +``` + +4. **监控关键指标**:通过 `INFO persistence` 关注以下字段: + +| 指标 | 含义 | 告警阈值建议 | +|------|------|-------------| +| `rdb_last_bgsave_status` | 上次 BGSAVE 是否成功 | ≠ `ok` 立即告警 | +| `rdb_last_bgsave_time_sec` | 上次 BGSAVE 耗时 | > 1s 关注,> 5s 告警 | +| `rdb_last_save_time` | 上次成功快照的时间戳 | 距今 > 1h 未触发则排查 | +| `rdb_changes_since_last_save` | 上次快照后的变更 key 数 | 持续增长说明 save 规则可能失效 | + +5. **磁盘选择**:RDB 写入对 I/O 有要求,建议使用 SSD 并保持 `rdb-save-incremental-fsync yes` + +> [!WARNING] 大 Key 是 RDB 的天敌 +> 一个 100MB 的 String key,序列化时子进程写入耗时剧增(单个 key 可能就要写几秒),同时 COW 也会产生大量页面复制。 +> +> **排查方法**:`redis-cli --bigkeys` 扫描大 key,发现后优先拆分。详见 [[hhs/Redis/11-运维与性能调优]]。 + +## 运维速查手册 + +```bash +# ---- 查看 RDB 状态 ---- INFO persistence -# rdb_last_bgsave_status: OK -# rdb_last_save_time: 1715000000 ← 最后成功时间戳 +# 重点关注这些字段: +# rdb_last_bgsave_status: ok ← 上次是否成功 +# rdb_last_bgsave_time_sec: 2 ← 上次耗时几秒 +# rdb_last_save_time: 1715000000 ← 上次成功时间戳(Unix) +# rdb_changes_since_last_save: 1234 ← 自上次快照后的变更 key 数 -# 检查文件大小 +# ---- 查看快照文件 ---- ls -lh /var/lib/redis/dump.rdb +# -rw-rw---- 1 redis redis 1.2G May 22 03:00 dump.rdb -# 压缩备份(已内置 LZ4 压缩,通常不需要额外压缩) -tar czf redis-backup.tar.gz dump.rdb +# ---- 手动触发一次快照并等待完成 ---- +redis-cli BGSAVE +# 等待:轮询 LASTSAVE 直到时间戳变化 +while [ "$(redis-cli LASTSAVE)" == "$OLD" ]; do sleep 0.5; done + +# ---- 检查 RDB 文件是否完整(不启动 Redis 的情况下) ---- +# Redis 自带工具,用于检查 .rdb 文件的完整性 +redis-check-rdb /var/lib/redis/dump.rdb +# 输出 OK 则文件完整,否则会告诉你哪里损坏了 ``` ## 关联笔记 diff --git a/hhs/Redis/05-AOF持久化.md b/hhs/Redis/05-AOF持久化.md index a4386a1..04f762e 100644 --- a/hhs/Redis/05-AOF持久化.md +++ b/hhs/Redis/05-AOF持久化.md @@ -7,7 +7,12 @@ create time: 2026-05-15 18:13 ## 概述 -AOF(Append Only File)以**追加写日志**的方式记录每个写操作。相比 RDB 的快照式恢复,AOF 提供更高的数据安全性——你可以根据策略做到**不丢数据、丢 1 秒、或丢 N 条**。代价是文件更大、恢复更慢。 +AOF(Append Only File)以**追加写日志**的方式记录每个写操作。如果你用过 MySQL 的 binlog 或 Kafka 的 commit log,这个思想是一样的:**不存最终状态,而是存每一步变化过程**。 + +相比 [[hhs/Redis/04-RDB持久化|RDB]] 的快照式恢复,AOF 提供更高的数据安全性——你可以根据策略做到**不丢数据、丢 1 秒、或丢 N 条**。代价是文件更大、恢复更慢。 + +> [!question] RDB 已经能持久化了,为什么还需要 AOF? +> RDB 是「拍照」——每隔 N 秒拍一张快照,两次拍照之间的数据变化全部丢失。AOF 是「录像」——每个写操作都被记录,最多只丢最后一次 fsync 之后的操作。打个比方:RDB 好比每小时存一次文档,AOF 好比开了自动保存。 ## 核心机制 @@ -16,19 +21,32 @@ AOF(Append Only File)以**追加写日志**的方式记录每个写操作。 ```mermaid flowchart LR Client["客户端写入"] --> Server["Redis 主线程
执行命令 + 返回结果"] - Server --> Buffer["aof_buffer
内核/用户态缓冲区"] - Buffer --> FSyncPolicy{"刷新策略?"} + Server --> Buffer["aof_buf
用户态缓冲区"] + Buffer --> FSyncPolicy{"appendfsync 策略?"} FSyncPolicy -->|everysec| KernelBuf["操作系统页缓存"] - KernelBuf --> Disk["磁盘 aof 文件"] + KernelBuf --> Disk["磁盘 AOF 文件"] FSyncPolicy -->|always| Disk - FSyncPolicy -->|no| OSPage["交给 OS 自己 flush"] + FSyncPolicy -->|no| OSPage["交给操作系统自行 flush"] style Server fill:#f9d,stroke:#96c style Disk fill:#dfd,stroke:#6c6 ``` -> [!TIP] 一条命令是怎么落到磁盘的? -> 1. 客户端发送命令 → 2. Redis 在主线程执行并返回结果 → 3. 同时将命令追加到 `aof_buffer` → 4. 根据 `appendfsync` 策略最终刷入磁盘。整个过程对主线程几乎是异步的。 +> [!tip] 一条命令是怎么落到磁盘的? +> 1. 客户端发送 `SET mykey hello` → 2. Redis 主线程执行命令、更新内存、返回 OK → 3. **同时**将命令以 RESP 协议格式追加到 `aof_buf`(用户态缓冲区) → 4. 根据 `appendfsync` 策略调用 `fsync()` 最终刷入磁盘 +> +> 注意步骤 2 和 3 是同步的,但步骤 4 是异步的——这意味着**命令返回成功 ≠ 数据落盘**。 + +实际落盘的 AOF 文件长这样(RESP 协议格式): + +``` +*3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$5\r\nhello\r\n +*3\r\n$3\r\nSET\r\n$5\r\nmykey\r\n$5\r\nworld\r\n +*2\r\n$3\r\nDEL\r\n$5\r\nmykey\r\n +``` + +> [!question] 为什么 AOF 用 RESP 协议而不是人类可读的纯文本? +> RESP 是 Redis 自身的通信协议,选择它的原因很简单:**不需要额外的序列化/反序列化开销**,Redis 直接把发给客户端的响应原样写入 AOF,读取时也直接用 RESP 解析器回放。这比发明一套新格式要高效得多。 ### 刷新策略配置 @@ -38,18 +56,53 @@ appendfsync everysec # 每秒 fsync <- 推荐(最佳平衡) # appendfsync no # 完全依赖 OS -> 性能最好,风险最高 ``` -| 策略 | 丢失风险 | 吞吐量 (ops/s) | 适用场景 | -|------|---------|---------------|---------| -| `always` | 零丢失 | ~900 | 金融级要求(极少用) | -| `everysec` | 最多丢 1 秒 | ~74k | **生产标准配置** | -| `no` | OS 决定,可能丢大量 | ~93k | 可接受全量丢失的场景 | +在理解这三种策略之前,先搞清楚 `fsync` 到底在做什么: -> [!TIP] everysec 的性能真相 -> `everysec` 不会阻塞主线程——它把 fsync 放到单独的后台线程。即使某个 fsync 花了 2s(磁盘卡顿),也只是这一秒的写入被推迟到下一周期,不会影响主线程。 +> [!info] fsync 的本质:把"快递柜"里的包裹真正送到你手里 +> 当 Redis 调用 `write()` 时,数据只是从用户态拷贝到了**内核页缓存**(类似放进了快递柜),操作系统会择机将其刷到物理磁盘。而 `fsync()` 是强制要求操作系统**立即**把页缓存的数据持久化到磁盘——相当于要求快递柜立即派送。 +> +> 如果不调用 `fsync()`,数据在页缓存中时如果发生断电,这些数据就丢了。 + +理解了 fsync,三种策略的区别就一目了然: + +| 策略 | fsync 时机 | 丢失风险 | 吞吐量 (ops/s) | 适用场景 | +|------|-----------|---------|---------------|---------| +| `always` | 每条写命令后 | **零丢失** | ~900 | 金融级要求(极少用) | +| `everysec` | 每秒一次(后台线程) | 最多丢 ~1 秒 | ~74k | **生产标准配置** | +| `no` | 从不主动 fsync,交给 OS | OS 决定,可能丢 30 秒+ | ~93k | 可接受数据丢失的场景 | + +> [!warning] everysec "最多丢 1 秒" 是有前提条件的 +> 这个说法成立的前提是 `fsync()` 能在 1 秒内完成。如果磁盘 IO 阻塞(比如同一块盘上跑着其他 IO 密集任务),一次 `fsync()` 可能卡住几秒甚至十几秒——此时未 fsync 的数据量会持续累积,丢失窗口也随之扩大。不过好在 Redis 的 `everysec` 模式下,`fsync()` 在**后台线程**执行,不会阻塞主线程处理新请求。 + +> [!tip] 为什么 always 这么慢? +> `always` 是在**主线程**中同步调用 `fsync()`。一次 `fsync()` 耗时约 0.2~2ms(取决于磁盘),这意味着每条写命令都要等磁盘确认,吞吐量直接从万级跌到百级——这就是"数据安全 vs 性能"的经典权衡。 ## AOF 重写(Rewrite) -AOF 文件会不断增长,需要定期重写来去除无效命令(如 key 被 DEL 后又 SET)。 +AOF 文件只会**追加**、永远不会自动清理。时间一长,大量"垃圾命令"会让文件体积失控: + +```mermaid +flowchart LR + subgraph AOF_LOG["appendonly.aof(实际内容)"] + CMD1["SET counter 0"] + CMD2["SET counter 1"] + CMD3["SET counter 2"] + CMD4["INCR counter"] + CMD5["DEL temp_key"] + CMD6["SET temp_key abc"] + CMD7["DEL temp_key"] + end + subgraph REALITY["内存中的最终状态"] + MEM["counter = 3
temp_key 不存在"] + end + AOF_LOG -.->|"7 条命令 → 真实状态只有 1 条"| REALITY + + style AOF_LOG fill:#fdd,stroke:#c66 + style REALITY fill:#dfd,stroke:#090 +``` + +> [!question] 为什么不直接"删除"过期命令? +> AOF 是顺序追加的日志文件,就像流水账本——你不能从账本中间撕掉一页而不影响后面所有页码。**只能"重写":把当前内存的真实状态重新写一份新的、精简的 AOF 文件来替换旧文件。** ### 重写触发条件 @@ -71,85 +124,106 @@ auto-aof-rewrite-min-size 64mb # AOF 最小绝对大小(避免刚启动就 ### 重写过程(类似 RDB fork) +重写并不是简单地"删减旧 AOF",而是**从内存中的真实数据出发,生成全新的精简 AOF 文件**。整个过程分三步: + ```mermaid sequenceDiagram - participant S as Redis Server - participant P as 主进程 - child CG as Rewriter Child - participant C as 重写子进程 - participant Log as appendonly.aof - participant NewLog as appendonly.aof_rewrite_tmp - end + participant P as "主进程(继续服务)" + participant C as "重写子进程" + participant Old as "appendonly.aof(旧)" + participant Tmp as "aof_rewrite_tmp(新)" + participant Buf as "aof_rewrite_buf" - S->>P: BGREWRITEAOF - P->>S: OK - P->>C: fork() 子进程 - C->>NewLog: fork 时刻起遍历内存 -> 重写为精简命令 - Note over P,C: Main process continues serving requests - loop 每个新写命令 - P->>Log: 追加原始命令 - P->>C: 同时写入 aof_rewrite_buf
(重写期间的新操作) - end - C->>NewLog: 追加 aof_rewrite_buf 中的内容 - C->>P: 完成 - P->>Log: rename(原 -> 旧.bak, 新 -> 当前) + P->>P: "收到 BGREWRITEAOF 命令" + P->>C: "fork() 创建子进程" + Note over C: "第一步:遍历内存所有 key
生成精简命令写入新文件" + C->>Tmp: "SET key1 val1
SET key2 val2
HSET hash f1 v1 ..." + Note over P: "主进程继续正常处理请求" + P->>Old: "新命令照常追加到旧 AOF" + P->>Buf: "同时复制一份到重写缓冲区" + Note over C: "第二步:内存遍历完成" + C->>Buf: "请求主进程发送缓冲区内容" + Note over C: "第三步:把重写期间的新命令追加到新文件末尾" + Buf->>Tmp: "追加增量命令" + C->>P: "重写完成通知主进程" + P->>P: "原子 rename:旧文件 → .bak,新文件 → appendonly.aof" ``` -> [!WARNING] 重写的内存开销 -> fork 后重写子进程也使用 COW 机制。如果你的 AOF 正在持续增长且写入量大,可能需要评估可用内存是否足够支撑 fork 的 COW 副本。在 100GB+ 的 Redis 实例上,一次 fork 可能导致额外的几 GB 内存占用。 +> [!tip] 关键理解:为什么需要"重写缓冲区"? +> 子进程在遍历内存生成新 AOF 的过程中(可能持续几十秒甚至几分钟),主进程还在不断接收新写命令。这些"重写期间的增量命令"被暂存在 `aof_rewrite_buf` 中,等子进程遍历完内存后,再将这些增量追加到新文件末尾——这样才能保证新 AOF 不丢任何数据。 + +> [!warning] 重写的内存开销 +> fork 后重写子进程使用 COW(Copy-On-Write)机制,和 RDB 的 BGSAVE 一样。如果你的实例内存很大(100GB+)且写入频繁,fork 可能导致额外几 GB 的内存占用。在内存紧张的环境中,建议控制 AOF 文件大小、及时触发重写,避免单次重写数据量过大。 ## AOF 开启方式 ```conf -appendonly yes # 开启 AOF -appendfilename "appendonly.aof" # AOF 文件名 +appendonly yes # 开启 AOF(默认 no) +appendfilename "appendonly.aof" # AOF 文件名(Redis 7 会在此基础上创建子目录) dir /var/lib/redis # AOF/RDB 文件存放目录 ``` +> [!tip] 在线开启/关闭 AOF +> 可以通过 `CONFIG SET appendonly yes` 在运行时开启 AOF,但需要注意:这会触发一次阻塞式的 AOF 初始化(类似 BGSAVE),**生产环境建议在低峰期操作**。关闭 AOF 同理:`CONFIG SET appendonly no`。 + +Redis 7.x 的多文件目录结构: + ```bash -# Redis 7.x 目录结构(新版本使用多文件目录而非单文件) ls /var/lib/redis/appendonlydir/ -total 256M -drwxr-x--- 2 redis redis 4.0K May 15 18:00 . -drwxr-x--- 2 redis redis 4.0K May 15 18:00 .. --rw-r----- 1 redis redis 16K May 15 18:00 manifest.aof --rw-r----- 1 redis redis 128M May 15 18:00 base.rdb --rw-r----- 1 redis redis 128M May 15 18:01 incr-0000000000000003.aof +# total 256M +# manifest.aof <- 文件清单(记录哪些文件组成完整 AOF) +# base.rdb <- 最近一次重写生成的 RDB 全量快照 +# incr-0000000000000003.aof <- 增量 AOF(记录重写之后的新写操作) ``` -### 思考:为什么 AOF 会持续增长? - -想象以下操作序列:`SET x 1 -> SET x 2 -> SET x 3 -> DEL x`。如果不做处理,AOF 中会记录这 4 条命令,但 key `x` 已经不存在了——这些写入了磁盘却没有任何价值。这就是为什么需要**重写**。 +> [!question] 为什么 Redis 7 要把单文件拆成目录结构? +> 旧版单个 AOF 文件有一个痛点:重写时需要先生成临时文件,再 `rename` 替换。如果 AOF 有 50GB,rename 操作虽然原子但磁盘 IO 开销巨大。拆成多文件后,重写只需替换 `base.rdb` 和新增 `incr-*.aof`,IO 开销大幅降低,也更方便做增量备份。 ## 混合持久化(Hybrid Persistence) -混合持久化是 Redis 4.0 引入、Redis 7 默认开启的特性——在 AOF 重写时,先用 RDB 快照全量备份当前内存状态,再用 AOF 追加增量变化。 +混合持久化是 Redis 4.0 引入、Redis 7 默认开启的特性,它解决了 AOF 重写的一个尴尬问题:**重写时到底该生成 RDB 还是 AOF?** + +> [!question] 纯 AOF 重写的痛点 +> 纯 AOF 重写需要把每个 key 的当前状态都转成 SET/HSET 等命令写入文件。当实例有几十万个 key 时,这些命令序列化+解析的过程会显著拖慢恢复速度。而 RDB 是二进制格式,加载速度比逐条执行 AOF 命令快 10~100 倍。 +> +> 混合持久化的答案很简单:**两种都用**。重写时先写 RDB 全量快照作为"底子",再追加一小段 AOF 增量命令作为"补丁"。 ```conf # redis.conf aof-use-rdb-preamble yes # 开启混合持久化(默认值) ``` -```mermaid -flowchart LR - BG["BGREWRITEAOF"] --> Fork["fork 子进程"] - Fork --> RDBPart["RDB 全量部分
二进制快照,写入极快"] - Fork --> AOFPart["AOF 增量部分
仅含 fork 后的写操作"] - RDBPart --> Merge["合并为临时文件"] - AOFPart --> Merge - Merge --> Rename["原子 rename 替换"] +重写后生成的文件结构如下: - style RDBPart fill:#bbf,stroke:#66c - style AOFPart fill:#dfd,stroke:#090 +```mermaid +flowchart TD + subgraph NEW_AOF["重写后的新 AOF 文件"] + direction LR + RDB["RDB 二进制块
(全量数据快照)"] --> AOF["AOF 增量块
(仅重写期间的新写操作)"] + end + + Fork["fork 子进程"] --> RDB + Fork --> AOF + RDB --> Merge["合并写入临时文件"] + AOF --> Merge + Merge --> Rename["原子 rename 替换旧文件"] + + style RDB fill:#bbf,stroke:#66c + style AOF fill:#dfd,stroke:#090 style Merge fill:#ffd,stroke:#cc0 ``` **好处有两个:** -1. **恢复速度快**:启动时先加载 RDB 全量快照(二进制格式比解析 AOF 命令快 10~100 倍),再回放少量 AOF 增量命令 -2. **文件体积小**:相比纯 AOF 记录每条操作指令,混合模式下文件通常缩小 70%~90% +1. **恢复速度快**:启动时先加载 RDB 二进制快照(顺序读、无需解析命令语法),再回放少量 AOF 增量命令。即使 AOF 增量段有几十 MB,相比从头解析几十 GB 的纯 AOF 命令也快得多 +2. **文件体积小**:RDB 本身就是压缩的二进制格式,比等量数据的纯 AOF 文本命令小 70%~90% -> [!WARNING] 恢复流程的变化 -> 混合模式下,Redis 启动时先读取 `base.rdb` 还原全部数据,再逐条执行增量 `.aof` 段中的命令。如果只保留 AOF 段而删除 `base.rdb`,恢复将退化为纯 AOF 模式,速度大幅下降。 +> [!warning] Redis 7 的多文件目录结构 +> Redis 7 不再使用单个 AOF 文件,而是采用多文件目录结构: +> - `base.rdb`:AOF 重写时生成的 RDB 全量快照 +> - `incr-*.aof`:增量 AOF 文件(记录重写之后的新写操作) +> - `manifest.aof`:文件清单,记录哪些文件属于同一个 AOF 实例 +> +> 恢复时 Redis 会按 manifest 加载:先加载 `base.rdb`,再按顺序回放所有 `incr-*.aof`。如果删除了 `base.rdb`,恢复退化为纯 AOF 模式,速度会大幅下降。 ## AOF vs RDB 对比 @@ -167,29 +241,48 @@ flowchart LR ## AOF 故障恢复 +断电或异常退出时,AOF 文件可能出现**尾部截断**(最后一条命令不完整)。Redis 启动时会自动检测并尝试修复。 + +### 常见故障场景与修复 + ```bash -# 如果 AOF 损坏(断电等原因导致截断) -redis-server --repair appendonly.aof -# 或 Redis 7 下修复整个目录 -redis-server --fix appendonlydir/aof-manifest +# 场景一:AOF 文件末尾被截断(最常见——断电导致最后一条命令写了一半) +# Redis 启动时会自动丢弃末尾不完整的命令,无需手动干预 + +# 场景二:AOF 文件损坏严重(如磁盘坏块导致文件中间损坏) +# 需要手动修复:找到最后一个合法命令,截断之后的内容 +redis-check-aof --fix appendonly.aof + +# 场景三:Redis 7 多文件目录结构 +redis-check-aof --fix appendonlydir/appendonly.aof.1.incr.aof ``` +> [!tip] 实际修复流程 +> 1. 先备份损坏的 AOF 文件:`cp appendonly.aof appendonly.aof.bak` +> 2. 运行 `redis-check-aof --fix`(交互式,会告诉你损坏位置并确认是否截断) +> 3. 重新启动 Redis,检查数据完整性 +> 4. 如果数据缺失严重,从 RDB 冷备份恢复 + 回放最近的增量 AOF + ### fsync 失败时的行为 -``` -fsync -> EIO(磁盘错误) - ↓ -ERROR "I/O error writing to APPEND ONLY FILE..." - ↓ -立即 SHUTDOWN NOSAVE(停止服务防止数据进一步损坏) +```mermaid +flowchart TD + FS["fsync() 调用"] --> OK{"返回成功?"} + OK -->|"是"| CONT["继续正常服务"] + OK -->|"否:EIO 磁盘错误"| LOG["记录日志
I/O error writing to APPEND ONLY FILE"] + LOG --> SD["执行 SHUTDOWN NOSAVE
立即停止服务"] + + style SD fill:#fdd,stroke:#c00 + style CONT fill:#dfd,stroke:#090 ``` -> [!NOTE] Redis 为什么会主动 shutdown? -> 这是一种「fail-fast」设计哲学。AOF 的核心价值就是数据安全,当底层磁盘出现问题时,继续运行只会导致更多数据静默损坏。宁可停机也不能让用户在「不确定」的数据上继续业务。 +> [!note] Redis 为什么会主动 shutdown? +> 这是一种「fail-fast」设计哲学。AOF 的核心价值就是数据安全,当底层磁盘出现问题时,继续运行只会导致更多数据静默损坏。**宁可停机也不能让用户在"不确定"的数据上继续业务**——这和数据库遇到 WAL 写入失败时的处理逻辑是一样的。 -> [!WARNING] AOF 不是万能的 -> - AOF 修复工具只能恢复大部分,极端情况下会有部分命令丢失 -> - 定期做冷备份(RDB 迁移到 OSS/S3)仍然是必须的兜底手段 +> [!warning] AOF 不是万能的 +> - AOF 修复工具只能处理末尾截断,中间损坏只能截断丢失后面的数据 +> - 定期做冷备份(RDB 快照传到 OSS/S3)仍然是必须的兜底手段 +> - 生产环境建议部署**主从架构**:即使主节点 AOF 出问题,从节点仍有完整数据 ## AOF vs RDB 选型决策树 diff --git a/hhs/Redis/06-主从与哨兵.md b/hhs/Redis/06-主从与哨兵.md index b502bf5..501309f 100644 --- a/hhs/Redis/06-主从与哨兵.md +++ b/hhs/Redis/06-主从与哨兵.md @@ -7,13 +7,29 @@ create time: 2026-05-15 18:13 ## 概述 -在生产环境中,单节点 Redis 存在两大问题:**写入能力有上限**、**宕机即停服**。本节介绍两个构建基础高可用的核心机制—— +### 从单机困境说起 + +假设你的 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-集群方案]] @@ -40,79 +56,120 @@ flowchart TD ### 异步 vs 半同步 -> [!QUESTION] 为什么 Redis 选择异步复制? -> 如果等 Replica 确认后 Master 才返回客户端,那网络延迟 = 写入延迟。对于追求低延迟的场景(微秒级),这是不可接受的。 +复制的核心矛盾是:**性能** 和 **数据安全** 不可兼得。我们先看两种策略的区别: -Redis 默认**异步复制**——Master 写完即返回客户端,不等待 Replica 确认。这保证了极致低延迟,代价是极端情况下可能丢失数据: - -```text -场景: 写 A=1 → Master 返回 OK → Master 宕机(还没传到 Replica)→ 重启后 A 不存在 +```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 ``` -若业务需要 **至少一份副本落盘** 的强一致性保证,可配合 Lua + `wait` 命令实现弱半同步: +> [!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: 确保至少 N 个副本成功写入 +// Go: 写入后,确保至少 1 个副本已经同步了这条命令 +client.Set(ctx, "order:12345", orderData, 0) + numReplicas := 1 // 至少 1 个 replica 收到 -timeout := 100 // 超时 100ms -result := client.Do(ctx, "WAIT", numReplicas, timeout).Int() -// result == 1 → 至少有 1 个副本已同步 +timeout := 100 // 最多等 100ms,超时就放弃 +replicaCount := client.Do(ctx, "WAIT", numReplicas, timeout).Int() + +if replicaCount >= 1 { + // 放心:就算 Master 立刻宕机,至少有 1 个 Replica 有这份数据 +} else { + // 超时了,数据可能只在 Master 上 → 业务层面考虑重试或告警 +} ``` > [!TIP] WAIT 的代价 -> 阻塞当前线程直到满足条件或超时。高频写场景慎用,建议仅在关键事务中使用。 +> `WAIT` 会阻塞当前连接直到满足条件或超时。高频写场景慎用,建议仅在关键业务路径上使用(如订单创建、扣款等),普通缓存写入不需要。 ### 全量同步 vs 增量同步 +主从同步分为两种模式,理解它们的前提是知道一个关键概念:**Replication Offset(复制偏移量)**——你可以把它想象成一条数据"流水线"上的**刻度尺**。Master 每写入一条命令,刻度就往后移一点;Replica 同步到哪里了,也有自己的刻度。只要两个刻度对得上,就能"增量同步"。 + > [!NOTE] PSync:一次连接,多次复用 -> Redis 2.8+ 引入 PSYNC 协议替代旧版 SYNC,核心优势:**断线重连后可增量同步**。 +> Redis 2.8+ 引入 PSYNC 协议替代旧版 SYNC,核心优势:**断线重连后可增量同步**。旧版 SYNC 每次断线都得全量传输(相当于每次请假都得抄一整本笔记),PSYNC 改为"只抄你缺的那几页"。 ```mermaid sequenceDiagram participant R as Replica participant M as Master - Note over R,M: ── 阶段 1: 全量同步(首次或断线过长)── - R->>M: PSYNC ? -1 (首次连接) - M->>M: BGSAVE → dump.rdb.tmp - M-->>R: +CONTENTS dump.rdb - R->>R: 重写本地数据集 + Note over R,M: "阶段 1: 全量同步(首次连接或断线过久)" + R->>M: PSYNC ? -1 (首次连接, 没有 offset 记录) + M->>M: 触发 BGSAVE, 生成 RDB 快照文件 + M-->>R: +FULLRESYNC , 然后发送 RDB 文件 + R->>R: 清空旧数据, 用 RDB 全量覆盖 - loop 同步期间的新命令 + Note over M: "RDB 生成期间, 新的写入命令不能丢" + loop "RDB 传输期间的新写入" M->>M: 追加到 repl-backlog-buffer end - M-->>R: +CONTENTS buf (缓冲命令流) - R->>R: 重放缓冲命令 + M-->>R: 发送 backlog 中缓存的命令流 + R->>R: 重放缓冲命令, 追上 Master 的进度 - Note over R,M: ── 阶段 2: 增量同步(后续心跳)── - loop 通常 1 秒一次 - R->>M: PSync - M->>R: 发送 offset 之后的命令 + Note over R,M: "阶段 2: 增量同步(后续心跳, 每秒一次)" + loop "正常运行期间" + R->>M: PSYNC + M->>R: 只发送 offset 之后的增量命令 end ``` +> [!QUESTION] 全量同步的代价是什么? +> 全量同步需要 Master 执行 `BGSAVE`(fork 子进程生成 RDB 文件),然后通过网络传给 Replica。如果数据量大(比如 10GB),这个过程会**消耗大量 CPU、内存和网络带宽**。所以我们的目标是:**尽量避免全量同步,让增量同步成为常态**。 + #### 什么情况下触发全量同步? -| 触发条件 | 说明 | -|---------|------| -| 首次连接 | Replica 为空,必须下载完整 RDB | -| Master 重启 | Run ID 改变,无法增量恢复 | -| Replica 手动 SLAVEOF | 主动请求全量同步 | -| Backlog 过小 | 断线时间超出 repl-backlog-size 缓存容量 | -| config-resetstat 执行 | 元数据重置导致无法增量 | +| 触发条件 | 为什么? | 能避免吗? | +|---------|------|------| +| **首次连接** | Replica 是空的,没有数据也没有 offset,只能全量下载 | ❌ 必然发生 | +| **Master 重启** | Master 重启后 Run ID 变了,Replica 认不出"老朋友" | ⚠️ 持久化可缓解(重启后 Run ID 不变) | +| **Replica 手动执行 SLAVEOF** | 相当于主动"认新主人",必须重新来过 | ❌ 手动触发,通常可控 | +| **Backlog 溢出** | 断线太久,Master 的"缓冲笔记本"已经翻页覆盖了断线前的内容 | ✅ **增大 `repl-backlog-size`** | +| **config-resetstat 执行** | 元数据被重置,offset 信息丢失 | ⚠️ 少用此命令 | -### 复制背调(Replication Backlog) +> [!WARNING] 生产环境最常见的"不必要全量同步" +> 绝大多数情况是 **Backlog 过小**导致的。网络抖动几秒,Backlog 不够用,Replica 被迫全量同步——这在网络不稳定的环境中会反复发生,严重拖垮 Master 性能。**加大 Backlog 是性价比最高的优化手段**。 -每个 Master 内部维护一个固定大小的环形缓冲区(默认 1MB): +### 复制背压(Replication Backlog) + +每个 Master 内部维护一个固定大小的 **环形缓冲区**(默认仅 1MB),这是实现增量同步的关键: ```mermaid flowchart LR - WriteHead["写入指针"] -->|"新命令流入"| Buffer["repl-backlog-buffer
环形缓冲区(默认1MB)"] - ReadHead["读取指针"] -->|"命令回放给 Replica"--> Buffer - Buffer -->|"溢出"`覆盖最旧数据`" + WriteHead["写入指针(新命令)"] -->|"追加写入"| Buffer["repl-backlog-buffer
环形缓冲区"] + Buffer -->|"读取并发送"| ReadHead["读取指针(发给 Replica)"] + Buffer -->|"空间不足时"| Overwrite["覆盖最旧数据"] ``` +> [!NOTE] 什么是"环形缓冲区"? +> 想象一个**圆形的笔记本**,只有固定的 100 页。你从第 1 页开始记笔记,记满了就回到第 1 页覆盖重写。Replica 落后得越多,它需要"回看"的内容就越远。如果它的位置已经被覆盖了,就只能全量重新抄一本——这就是全量同步。 +> +> 所以 Backlog 的大小直接决定了:**Replica 最多能断线多久还能增量恢复**。 + ```conf # 相关配置 repl-backlog-size 256mb # 缓冲区大小,增大允许更长断线恢复 @@ -123,68 +180,90 @@ repl-backlog-ttl 3600 # 无人订阅时多久自动释放(秒) - backlog = 1MB → 约支持 **2 秒** 断线恢复 - backlog = 256MB → 约支持 **8 分钟** 断线恢复 -### 大键分裂问题(Big Key Split) +### 大 Key 问题 -当某个 key 体积很大时(如几 MB 的 Hash/Set),一次 `SET` 会阻塞整个 Redis 进程数毫秒甚至更久。对 Replica 来说,这个命令在网络上传输也会成为瓶颈。 +> [!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 分别评估 +> scan 0 MATCH * COUNT 100000 # 逐页扫描,用 type/hashlist/set 分别评估 > ``` -解决方案: -- 写入侧:**分拆大 key** 为多个小 key(如按用户 ID 哈希分散) -- 同步侧:增大 `repl-diskless-sync-delay` 让多个 Replica 同时接收,避免重复传输 +**解决方案**: +- **写入侧**:拆分大 key 为多个小 key(例如一个存储用户全部订单的 Hash → 按月份拆分为 `user:orders:2026-05`、`user:orders:2026-06` 等多个小 Hash) +- **同步侧**:启用无盘复制(`repl-diskless-sync yes`),Master 直接通过 socket 把 RDB 发给所有 Replica,避免写磁盘再读磁盘的双重开销 ## 二、级联复制(拓扑优化) -当 Replica 数量增多时,每个都连 Master 会造成带宽压力: +### 为什么要级联? + +假设 Master 需要服务 10 个 Replica,每个 Replica 每秒同步 ~500KB 的命令流,那 Master 每秒要输出 **5MB 的复制流量**。而且每个 Replica 断线重连都可能触发全量同步——Master 的网络带宽和 CPU(BGSAVE 的 fork)会成为瓶颈。 + +> [!QUESTION] 怎么降低 Master 的压力? +> 答案:不让所有 Replica 都直接连 Master,而是让部分 Replica "转发"数据给其他 Replica,形成**树形拓扑**。 ```mermaid flowchart LR - Master["Master"] - R1["Replica-1
(也接受从连接)"] + Master["Master
只服务 2 个 Replica"] + R1["Replica-1
(同时作为下游的 Master)"] R2["Replica-2"] - R3["Replica-3"] - R4["Replica-4"] + R3["Replica-3
(从 R1 同步)"] + R4["Replica-4
(从 R1 同步)"] - Master --> R1 - Master --> R2 - R1 --> R3 - R1 --> R4 + Master -->|"直接同步"| R1 + Master -->|"直接同步"| R2 + R1 -->|"级联同步"| R3 + R1 -->|"级联同步"| R4 ``` > [!TIP] 最佳实践 -> 只有 1~2 个 Replica 直接连 Master,其余通过级联获取数据。降低 Master 的网络和 CPU 负担。 +> - 只有 1~2 个 Replica 直连 Master,其余通过级联获取数据 +> - 级联的 Replica 也能配置为接受下游连接(Redis 原生支持,无需额外设置) +> - **缺点**:级联层级越深,末端 Replica 的数据延迟越大。通常 **两层** 是合理的上限 ## 三、只读副本注意事项 -```conf -replica-read-only yes # 默认值 -``` +Replica 默认是 **只读** 的(`replica-read-only yes`),这不是 Redis 故意限制你,而是有充分的技术原因: -> [!WARNING] 禁止在 Replica 上写操作 -> 即使关闭 `read-only`,Replica 上的写入在主从重新同步时会被清掉。而且 `DEL` 一个不存在的 key 也会触发额外命令发送给 Master。 +> [!WARNING] 为什么不应该在 Replica 上写数据? +> 想象一下:你在 Replica 上写入了 `SET foo bar`,但 Master 上没有这个写入。下一次同步发生时,Master 会把自己的数据集"覆盖"到 Replica 上——你写的数据**悄无声息地消失了**。更糟糕的是,如果你在 Replica 上 `DEL` 一个不存在的 key,这个命令可能会被传播到 Master,导致 Master 上原本存在的数据被删掉。 +> +> **结论**:在 Replica 上写数据,本质上是在制造**数据不一致的定时炸弹**。 -### Replica 的其他关键配置 +### Replica 的关键配置详解 ```conf # --- 副本如何发现 Master --- -replica-announce-ip 192.168.1.20 # 对外宣告的 IP(Docker/NAT 环境下必设) +# 在 Docker/NAT 环境下,容器内部 IP 和宿主机 IP 不同。 +# 如果不设置 announce-ip,Sentinel 和其他节点可能会记录容器内部 IP, +# 导致跨网络访问失败。 +replica-announce-ip 192.168.1.20 # 对外宣告的 IP replica-announce-port 6379 # 对外宣告的端口 # --- Replica 是否可被查询 --- -replica-lazy-flush no # FULLRESYNC 后清空旧数据策略:no=立即 flush -replica-serve-stale-data yes # Master 不可达时是否继续服务请求(默认是) -replica-read-only yes # 只读保护 +# 这组配置决定:当 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 # 无盘复制开关 -repl-diskless-sync-delay 5 # 等待其他副本同时连接的延迟(秒) -repl-timeout 60 # 同步超时阈值 +repl-diskless-sync no # 无盘复制:Master 不写 RDB 文件, + # 直接通过 socket 发给 Replica(适合 SSD 环境) +repl-diskless-sync-delay 5 # 开启无盘复制后,等几秒让多个 Replica 同时接收 +repl-timeout 60 # 同步超时阈值(秒),超时则断开重连 ``` +> [!QUESTION] `replica-serve-stale-data` 应该设为 yes 还是 no? +> 这取决于你的业务对**可用性 vs 一致性**的取舍: +> - **电商首页**、**推荐列表**等场景:设为 `yes`,返回稍微过期的数据总比报错好 +> - **库存扣减**、**余额查询**等场景:设为 `no`,宁可不可用也不能返回错误数据 + ## 四、核心参数调优参考 > [!NOTE] 根据业务特点选择合适的参数组合 @@ -222,8 +301,23 @@ redis-cli INFO replication ## 五、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"] @@ -233,36 +327,52 @@ flowchart TD MasterNode["Master"] RepNode["Replica"] - S1 <-->|心跳 PING/PONG| S2 - S1 <-->|心跳 PING/PONG| S3 - S2 <-->|心跳 PING/PONG| S3 + S1 <-->|"互相探测心跳"| S2 + S1 <-->|"互相探测心跳"| S3 + S2 <-->|"互相探测心跳"| S3 - S1 <--> MasterNode - S2 <--> MasterNode - S3 <--> MasterNode + S1 <-->|"监控 Master/Replica 状态"| MasterNode + S2 <-->|"监控 Master/Replica 状态"| MasterNode + S3 <-->|"监控 Master/Replica 状态"| MasterNode - MasterNode <--> RepNode + MasterNode <-->|"主从同步"| RepNode ``` > [!QUESTION] 为什么需要奇数个 Sentinel? -> Sentinel 选举采用多数派原则(Quorum)。3 个节点中 2 个达成共识即可;5 个中需要 3 个。偶数增加 1 个 Sentinel 不会带来额外投票优势,反而浪费资源。 +> 故障转移需要**多数派投票**才能决策(和选举类似)。3 个 Sentinel 中有 2 个同意就能执行;但如果只有 2 个,一旦 1 个挂了,剩下的 1 个永远达不到多数,故障转移就无法执行。偶数个(比如 4 个)也浪费——4 个节点允许 1 个故障,和 3 个一样,却多消耗了一份资源。 +> +> **记住这个公式:N 个 Sentinel 允许 (N-1)/2 个故障**。3 个允许 1 个故障,5 个允许 2 个故障。 ### 核心功能 -#### 1. 监控(Monitoring) +Sentinel 有三大核心职责:**监控**、**通知**和**故障转移**。我们逐一拆解。 -```bash -# Sentinel 定期向 Master/Replica 发 PING -# Master 必须响应:PING → PONG 或 INFO -# Replica 同理 +#### 1. 监控(Monitoring)——"心跳检测" -# 三种下线判断 -1. SDOWN (Subjectively Down) — 某个 Sentinel 认为不可达 -2. ODOWN (Objectively Down) — Quorum 数量的 Sentinel 都认为不可达 -3. Failover in progress — 进入故障转移流程 +每个 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
(主观下线: 我觉得它挂了)"] + D --> E{"询问其他 Sentinel:
你们觉得 Master 还活着吗?"} + E -->|"Quorum 个 Sentinel
都认为挂了"| F["标记 Master 为 ODOWN
(客观下线: 大家都确认它挂了)"] + E -->|"没达到 Quorum"| G["可能是网络局部问题
保持 SDOWN, 继续观察"] + F --> H["进入故障转移流程"] ``` -#### 2. 选主(Leader Election) +> [!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 @@ -270,98 +380,170 @@ sequenceDiagram participant S2 as Sentinel-2 participant S3 as Sentinel-3 - S1->>S2: 提议自己为 leader (SEND ME YOUR VOTE) - S2->>S1: OK (投票给 S1) - S3->>S1: OK (投票给 S1) - S1->>S2: 我当选 leader - Note over S1,S3: ⚡ 任意 Sentinel 都可以主动发起选举 + 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 误操作 -当 Master 被判定 ODOWN 后,Sentinel Leader 执行以下流程: +### 故障转移步骤详解 + +Sentinel Leader 当选后,按以下顺序执行故障转移。整个过程通常在 **几秒到几十秒** 内完成: ```mermaid flowchart TD - A["1. 选出最佳 Replica"] --> B["评估标准:
复制偏移量 > 优先级 > RunID"] - B --> C["SLAVEOF NO ONE
提升为新 Master"] - C --> D["其他 Replica → SLAVEOF new-master"] - D --> E["更新集群元数据"] - E --> F["通知客户端新地址"] + A["Step 1: 选出最佳 Replica"] --> B["Step 2: 提升为新 Master"] + B --> C["Step 3: 让其他 Replica 转向新 Master"] + C --> D["Step 4: 通知客户端"] + D --> E["Step 5: 旧 Master 回来后自动降级"] ``` -**最佳 Replica 选择标准**(按优先级排序): -1. **复制偏移量最大** — 离最新数据的 Replica 优先 -2. **`replica-priority` 最小** — 值越小越优先当选(0 = 不可当选) -3. **Run ID 字典序最小** — 作为最终 tie-breaker +**Step 1:选出最佳 Replica** — "谁来接班?" + +从所有健康的 Replica 中选出数据最新的一个,选择标准按优先级排序: + +| 优先级 | 评估维度 | 含义 | 举例 | +|--------|---------|------|------| +| 1(最高) | **复制偏移量最大** | 同步进度最接近 Master | Replica-A offset=10000, Replica-B offset=9998 → 选 A | +| 2 | **`replica-priority` 最小** | 配置中人为指定的优先级 | priority=10 优先于 priority=100;priority=0 表示"永不选我" | +| 3(最低) | **Run ID 字典序最小** | 纯粹的 tie-breaker | 两个 Replica 前两项完全相同,选 Run ID 字母序靠前的 | + +**Step 2:提升为新 Master** + +Sentinel 向选中的 Replica 发送 `SLAVEOF NO ONE`,使其断开与旧 Master 的复制关系,变成独立的 Master。 + +**Step 3:让其他 Replica 转向新 Master** + +Sentinel 向其余 Replica 发送 `SLAVEOF `,它们开始从新 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 -# ↑ name ↑ host port quorum(达成SDOWN共识的个数) +# ↑ Master地址 ↑ 端口 ↑ Quorum(至少几个 Sentinel 同意才判定 ODOWN) -sentinel auth-pass mymaster your-password +# ── 认证 ── +sentinel auth-pass mymaster your-password # Master 设置了密码时必填 -# 多久无响应即标记 SDOWN(毫秒) -sentinel down-after-milliseconds mymaster 30000 +# ── 三个最重要的时间参数 ── -# 故障转移超时(毫秒),超过则失败重试 -sentinel failover-timeout mymaster 180000 +# 1. 主观下线阈值:Master 多久没回 PING 就标记 SDOWN +# 不要太低!网络抖动 1~2 秒在生产环境很常见 +sentinel down-after-milliseconds mymaster 30000 # 30 秒(推荐 15~30 秒) -# 最多多少 Replica 同时与新 Master 同步 +# 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` 不宜太短,防止频繁重试加重系统负载 -> - `parallel-syncs` 设为 1 可避免转移期间所有 Replica 同时不可用 -> - **生产环境建议手动维护 Sentinel 配置**,避免自动感知的不确定性 -> - Redis 6+ 启用 ACL 后,需配置 `sentinel user/myuser password/mypass` 而非 `auth-pass` +> - **`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 下的客户端连接 -Go 和 Python 生态都提供了原生支持——客户端内部自动发现 Master/Replica,故障时重新连接。 +> [!QUESTION] 故障转移后,客户端怎么知道新 Master 的地址? +> 传统方式是客户端硬编码 Redis 地址——Master 换了 IP,所有客户端都得改配置重启。Sentinel 方案下,客户端**直接连接 Sentinel**(而不是 Redis),由 Sentinel 告诉客户端"谁是当前的 Master"。 +> +> 主流语言的 Redis 客户端库都内置了 Sentinel 支持,原理是: +> 1. 启动时向 Sentinel 查询当前 Master 地址 +> 2. 连接 Master 进行读写 +> 3. 如果连接失败,重新向 Sentinel 查询新地址 + +**Go(go-redis)示例**——客户端自动发现和切换: ```go -// go-redis: 原生 Sentinel 模式 rdb, err := redis.NewFailoverClient(&redis.FailoverOptions{ - MasterName: "mymaster", - SentinelAddrs: []string{"sento1:26379", "sento2:26379", "sento3:26379"}, + MasterName: "mymaster", // Sentinel 中配置的 Master 名称 + SentinelAddrs: []string{"sentinel1:26379", "sentinel2:26379", "sentinel3:26379"}, Password: "your-password", - MaxRetries: 3, + MaxRetries: 3, // 连接失败重试次数 RetryDelay: 500 * time.Millisecond, }) -// 读请求走 Replica,写请求走 Master -_ = rdb.Get(ctx, "key") + +// 客户端自动感知 Master:读请求走 Replica(如果配置了读写分离),写请求走 Master +_ = rdb.Set(ctx, "order:12345", data, 0) // 写 → Master +val, _ := rdb.Get(ctx, "order:12345").Result() // 读 → 可能走 Replica ``` +**Python(redis-py)示例**: + ```python -# redis-py Sentinel from redis.sentinel import Sentinel -sentinel = Sentinel([('sento1', 26379), ('sento2', 26379), ('sento3', 26379)]) -master = sentinel.master_for('mymaster', password='your-password') # 写 -slave = sentinel.slave_for('mymaster', password='your-password') # 读 +# 连接 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 -### 部署前 +### 部署前 Checklist -- [ ] **Sentinel 节点数 ≥ 3**,奇数个,部署在不同可用区或机器上 -- [ ] **Master + Replica 数量建议 1:2 ~ 1:3**,过多会影响同步延迟 -- [ ] **`replica-priority` 设值**:不可作为 Master 的设为 `0` -- [ ] **持久化策略一致**:Master 和 Replica 的 RDB/AOF 策略应相同 +- [ ] **Sentinel 节点数 ≥ 3,且部署在不同机器/可用区** — 避免单机故障导致 Sentinel 丧失多数派 +- [ ] **Master + Replica 数量建议 1:2 ~ 1:3** — 太多 Replica 会加重 Master 同步负担;太少则冗余不足 +- [ ] **配置 `replica-priority`** — 不应被选为 Master 的 Replica 设为 `0`(如配置较低的从节点) +- [ ] **持久化策略保持一致** — Master 和 Replica 都应开启 RDB 或 AOF,否则故障转移后新 Master 无持久化可能导致数据丢失 ### 监控项 +日常巡检重点关注以下指标(建议每秒采样,配合 Prometheus/Grafana 看趋势): + ```bash -# 关键指标(每秒采样) -INFO replication # connected_slaves, master_repl_offset -INFO memory # used_memory_human (同步期间会突增) -INFO stats # instantaneous_ops_per_sec +# 复制状态 —— 最核心,异常时第一时间查这个 +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 ``` ### 故障排查流程 @@ -379,29 +561,42 @@ flowchart TD ### 常见坑点汇总 -| 坑 | 现象 | 解决 | -|---|------|------| -| Docker 环境下 IP 错乱 | Sentinel 记录了容器的内部 IP | 设置 `replica-announce-ip` | -| 脑裂(Split Brain) | 网络分区后 Master 仍处理写请求 | 用 `min-replicas-to-write 1` 保护 | -| OOM during BGSAVE | fork 子进程导致内存翻倍 | 关闭 THP,增大 swap,限制 maxmemory | +| 坑 | 现象 | 根因 | 解决方案 | +|---|------|------|------| +| **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] 防脑裂配置 +> [!NOTE] 脑裂是什么?怎么防? +> **脑裂**指的是:Master 实际还活着(只是和 Sentinel 之间网络断了),但 Sentinel 以为它挂了,选出了一个新 Master。这时候**两个 Master 同时接受写入**。网络恢复后,旧 Master 变成新 Master 的 Replica,它的数据会被覆盖——写入旧 Master 的数据就丢了。 +> +> **防御措施**:在 Master 端配置"至少要有 N 个 Replica 在线才接受写入": > ```conf -> # Master 端配置:至少 N 个 Replica 在线才接受写入 +> # 至少 1 个 Replica 在线且延迟不超过 10 秒,Master 才接受写入 > min-replicas-to-write 1 -> # 对应的最大允许延迟(秒),超时视为掉线 > min-replicas-max-lag 10 > ``` +> 这样当 Master 被隔离时(没有 Replica 在线),它会**拒绝写入**,而不是默默接受数据最终丢失的写操作。 -## 六、局限性与替代方案 +## 七、局限性与替代方案 -| 问题 | Sentinel 无法解决 | -|------|----------------| -| 单 Master 写入瓶颈 | ❌ 只有一个 Master | -| 数据量超限单机容量 | ❌ | -| 跨机房容灾 | ❌(Sentinel 跨网段延迟太高) | +Sentinel 方案虽然解决了"高可用"问题,但它有明确的边界。理解这些边界才能选对方案: -> [!TIP] 需要水平扩展?看 Cluster 方案 → [[hhs/Redis/07-集群方案]] +| 场景 | 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-集群方案]] ## 关联笔记