vault backup: 2026-05-25 23:07:45
This commit is contained in:
+97
-13
@@ -271,25 +271,41 @@ flowchart TD
|
||||
### TTL / 过期键处理
|
||||
|
||||
> [!QUESTION] RDB 快照会保存已过期的 key 吗?
|
||||
> **不会。** 这是一个容易误解的点——很多人以为快照是"拍照片",会原封不动地保存内存里所有东西。但实际上,Redis 在"拍照"之前会先"打扫房间"。
|
||||
> **最终不会。** 这是一个容易误解的点——很多人以为快照是"拍照片",会原封不动地保存内存里所有东西。实际上,Redis 通过**两道防线**确保过期键不会进入最终数据。
|
||||
|
||||
具体流程:
|
||||
1. `BGSAVE` 触发时,主线程**先执行一次主动过期扫描**,把所有已超时的 key 从内存中删除
|
||||
2. 删除完成后,才 fork 子进程
|
||||
3. 子进程遍历剩余的 key → 序列化写入 `.rdb` 文件
|
||||
4. 结论:**`.rdb` 文件中只有有效数据,不包含过期键**
|
||||
**第一道防线:子进程遍历时惰性跳过**
|
||||
|
||||
BGSAVE 触发时,Redis **不会**在 fork 前专门做一次"全量过期清理"(这在数据量大时太慢了)。实际流程是:
|
||||
|
||||
1. 主线程 fork 子进程
|
||||
2. 子进程遍历所有 key,序列化写入 `.rdb` 文件
|
||||
3. 在遍历每个 key 时,子进程会**检查 TTL 是否已过期**——如果已过期,**直接跳过,不写入文件**
|
||||
|
||||
> [!NOTE] 那日常的过期清理呢?
|
||||
> Redis 平时通过两种机制清理过期键:
|
||||
> - **惰性删除**:访问 key 时发现已过期 → 立即删除
|
||||
> - **定期删除**:`serverCron` 每 100ms 随机抽样一批设置了 TTL 的 key,删除其中已过期的
|
||||
>
|
||||
> 所以到 BGSAVE 触发时,内存中**大部分**过期键其实已经被日常清理掉了。子进程遍历时再跳过漏网之鱼,双重保障。
|
||||
|
||||
**第二道防线:加载时二次检查**
|
||||
|
||||
即使某个 key 在快照时还没过期,但等到恢复加载时已经过期了——Redis 在加载 RDB 文件时会**再次检查 TTL**,过期的 key 不会被加载到内存。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["BGSAVE 触发"] --> B["主线程: 主动过期扫描<br/>清理所有已超时 Key"]
|
||||
B --> C["Fork 子进程"]
|
||||
C --> D["子进程: 遍历有效数据<br/>序列化写入 .rdb"]
|
||||
D --> E["RDB 文件中无过期键"]
|
||||
style E fill:#c4e8b0
|
||||
A["BGSAVE 触发"] --> B["Fork 子进程"]
|
||||
B --> C["子进程遍历所有 Key"]
|
||||
C --> D{"Key 是否<br/>已过期?"}
|
||||
D -->|"是"| E["跳过,不写入 .rdb"]
|
||||
D -->|"否"| F["序列化写入 .rdb"]
|
||||
F --> G["RDB 文件只含有效数据"]
|
||||
E --> C
|
||||
style G fill:#c4e8b0
|
||||
```
|
||||
|
||||
> [!NOTE] 一个细节:RDB 加载时也会二次检查
|
||||
> 即使某个 key 在快照时还没过期,但等到恢复加载时已经过期了——Redis 在加载 RDB 文件时会**再次检查 TTL**,过期的 key 不会被加载到内存。这保证了无论什么时候恢复,数据都是"新鲜"的。
|
||||
> [!QUESTION] 为什么要分两道防线,不能一次性清理干净?
|
||||
> 因为**时间是流动的**。假设一个 key 的 TTL 还剩 1 秒时触发了 BGSAVE,子进程遍历时它还活着(写入了 .rdb),但 2 秒后加载时它已过期。所以加载时的二次检查是**必须的**——它处理的是"快照时还活着、但恢复时已过期"的时间差。
|
||||
|
||||
## 恢复流程
|
||||
|
||||
@@ -510,6 +526,74 @@ redis-check-rdb /var/lib/redis/dump.rdb
|
||||
# 输出 OK 则文件完整,否则会告诉你哪里损坏了
|
||||
```
|
||||
|
||||
## 常见故障排查
|
||||
|
||||
> [!WARNING] BGSAVE 失败?按这个顺序排查
|
||||
> 以下是最常见的三类故障,按发生概率排序:
|
||||
|
||||
**1. fork 失败:`Can't save in background: fork: Cannot allocate memory`**
|
||||
|
||||
```bash
|
||||
# 检查当前 overcommit 设置
|
||||
cat /proc/sys/vm/overcommit_memory
|
||||
# 如果输出 0 或 2 → 需要改为 1
|
||||
|
||||
# 临时修改(立即生效,重启失效)
|
||||
echo 1 > /proc/sys/vm/overcommit_memory
|
||||
|
||||
# 永久修改(写入 sysctl)
|
||||
echo "vm.overcommit_memory = 1" >> /etc/sysctl.conf && sysctl -p
|
||||
```
|
||||
|
||||
> [!INFO] 为什么 overcommit_memory 必须设为 1?
|
||||
> fork 需要内核分配页表空间,但此时并不会真正使用等量的物理内存(COW)。`overcommit_memory=0`(默认)时,内核会用启发式算法估算——对于大内存 Redis 实例,这个估算可能过于保守而拒绝 fork。设为 1 是告诉内核"不要猜了,直接允许"。
|
||||
|
||||
**2. 磁盘空间不足:写入 .rdb 时报错**
|
||||
|
||||
```bash
|
||||
# 检查 dir 目录的可用空间
|
||||
df -h /var/lib/redis
|
||||
|
||||
# 检查实际文件大小
|
||||
ls -lh /var/lib/redis/dump.rdb
|
||||
|
||||
# 紧急处理:临时关闭 RDB 自动触发
|
||||
redis-cli CONFIG SET save ""
|
||||
```
|
||||
|
||||
**3. RDB 文件损坏:加载时 `Short read or OOM loading DB`**
|
||||
|
||||
```bash
|
||||
# 使用 Redis 自带工具检查
|
||||
redis-check-rdb /var/lib/redis/dump.rdb
|
||||
|
||||
# 如果文件损坏且有备份 → 用备份替换
|
||||
# 如果没有备份 → 看 AOF 是否可用(Redis 优先加载 AOF)
|
||||
```
|
||||
|
||||
## 安全性考虑
|
||||
|
||||
> [!WARNING] RDB 文件是明文存储的!
|
||||
> `.rdb` 文件虽然看起来是"二进制",但其中的 key 名称和字符串类型的 value 都是**明文可读**的。用 `strings dump.rdb | head` 就能看到大部分内容。
|
||||
|
||||
**生产环境建议**:
|
||||
|
||||
1. **文件权限**:确保 `.rdb` 文件权限为 `600`(仅 Redis 用户可读写)
|
||||
```bash
|
||||
chmod 600 /var/lib/redis/dump.rdb
|
||||
```
|
||||
2. **备份加密**:备份到远端存储前,先加密再上传
|
||||
```bash
|
||||
# 使用 openssl 加密备份
|
||||
openssl enc -aes-256-cbc -salt -pbkdf2 \
|
||||
-in dump.rdb -out dump.rdb.enc -pass pass:$REDIS_BACKUP_KEY
|
||||
```
|
||||
3. **传输加密**:跨机器复制时使用 `scp` 或 `rsync over SSH`,避免明文传输
|
||||
4. **敏感数据脱敏**:如果存了密码、Token 等敏感信息,建议在应用层加密后再写入 Redis——不要依赖 Redis 自身的"安全性"
|
||||
|
||||
> [!TIP] Redis 企业版(Redis Enterprise)支持透明的 RDB 文件加密
|
||||
> 社区版不支持原生加密。如果你有强合规需求,可以考虑企业版或者在应用层做客户端加密。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/05-AOF持久化]] — AOF 持久化机制
|
||||
|
||||
+120
-7
@@ -67,12 +67,17 @@ appendfsync everysec # 每秒 fsync <- 推荐(最佳平衡)
|
||||
|
||||
| 策略 | fsync 时机 | 丢失风险 | 吞吐量 (ops/s) | 适用场景 |
|
||||
|------|-----------|---------|---------------|---------|
|
||||
| `always` | 每条写命令后 | **零丢失** | ~900 | 金融级要求(极少用) |
|
||||
| `always` | 每条写命令后 | **零丢失** | ~900(注) | 金融级要求(极少用) |
|
||||
| `everysec` | 每秒一次(后台线程) | 最多丢 ~1 秒 | ~74k | **生产标准配置** |
|
||||
| `no` | 从不主动 fsync,交给 OS | OS 决定,可能丢 30 秒+ | ~93k | 可接受数据丢失的场景 |
|
||||
|
||||
> [!note] 吞吐量数据来源
|
||||
> 以上数据来自 Redis 官方基准测试(`redis-benchmark`,HDD 磁盘)。实际生产环境中,`always` 在 SSD 上可达数万 ops/s,`everysec` 可达十万级。**具体性能以实际压测为准**,这里主要体现三者之间的相对差距。
|
||||
|
||||
> [!warning] everysec "最多丢 1 秒" 是有前提条件的
|
||||
> 这个说法成立的前提是 `fsync()` 能在 1 秒内完成。如果磁盘 IO 阻塞(比如同一块盘上跑着其他 IO 密集任务),一次 `fsync()` 可能卡住几秒甚至十几秒——此时未 fsync 的数据量会持续累积,丢失窗口也随之扩大。不过好在 Redis 的 `everysec` 模式下,`fsync()` 在**后台线程**执行,不会阻塞主线程处理新请求。
|
||||
> 这个说法成立的前提是 `fsync()` 能在 1 秒内完成。如果磁盘 IO 阻塞(比如同一块盘上跑着其他 IO 密集任务),一次 `fsync()` 可能卡住几秒甚至十几秒——此时未 fsync 的数据量会持续累积,丢失窗口也随之扩大。
|
||||
>
|
||||
> 补充一个实现细节:`everysec` 默认在**主线程**执行 `fsync()`,只有当 Redis 检测到上次 `fsync()` 耗时超过 2 秒时,才会自动降级到**后台线程**执行——这样避免长时间 fsync 阻塞主线程,但代价是主线程在降级期间的写入数据安全性进一步降低。
|
||||
|
||||
> [!tip] 为什么 always 这么慢?
|
||||
> `always` 是在**主线程**中同步调用 `fsync()`。一次 `fsync()` 耗时约 0.2~2ms(取决于磁盘),这意味着每条写命令都要等磁盘确认,吞吐量直接从万级跌到百级——这就是"数据安全 vs 性能"的经典权衡。
|
||||
@@ -122,6 +127,23 @@ auto-aof-rewrite-min-size 64mb # AOF 最小绝对大小(避免刚启动就
|
||||
>
|
||||
> 这本质上是一个「文件瘦身频率」的工程权衡题。
|
||||
|
||||
### 手动触发重写
|
||||
|
||||
除了自动触发,运维中经常需要手动重写——比如刚做了一次大批量数据导入,AOF 文件暴涨。
|
||||
|
||||
```bash
|
||||
# 手动触发 AOF 重写(后台异步执行,不阻塞主线程)
|
||||
BGREWRITEAOF
|
||||
|
||||
# 查看重写是否正在进行
|
||||
INFO persistence
|
||||
# aof_rewrite_in_progress: 1 ← 正在重写中
|
||||
# aof_rewrite_scheduled: 0 ← 是否有排队中的重写请求
|
||||
```
|
||||
|
||||
> [!warning] 重写互斥
|
||||
> 和 BGSAVE 一样,Redis 同一时刻**只允许一个子进程**做持久化。如果 BGSAVE 正在执行,`BGREWRITEAOF` 会排队等待(`aof_rewrite_scheduled: 1`),等 BGSAVE 完成后自动触发。
|
||||
|
||||
### 重写过程(类似 RDB fork)
|
||||
|
||||
重写并不是简单地"删减旧 AOF",而是**从内存中的真实数据出发,生成全新的精简 AOF 文件**。整个过程分三步:
|
||||
@@ -171,7 +193,7 @@ Redis 7.x 的多文件目录结构:
|
||||
```bash
|
||||
ls /var/lib/redis/appendonlydir/
|
||||
# total 256M
|
||||
# manifest.aof <- 文件清单(记录哪些文件组成完整 AOF)
|
||||
# appendonly.aof.manifest <- 文件清单(记录哪些文件组成完整 AOF)
|
||||
# base.rdb <- 最近一次重写生成的 RDB 全量快照
|
||||
# incr-0000000000000003.aof <- 增量 AOF(记录重写之后的新写操作)
|
||||
```
|
||||
@@ -179,10 +201,43 @@ ls /var/lib/redis/appendonlydir/
|
||||
> [!question] 为什么 Redis 7 要把单文件拆成目录结构?
|
||||
> 旧版单个 AOF 文件有一个痛点:重写时需要先生成临时文件,再 `rename` 替换。如果 AOF 有 50GB,rename 操作虽然原子但磁盘 IO 开销巨大。拆成多文件后,重写只需替换 `base.rdb` 和新增 `incr-*.aof`,IO 开销大幅降低,也更方便做增量备份。
|
||||
|
||||
### AOF 关键配置参数汇总
|
||||
|
||||
除了前面讲过的 `appendfsync`,还有几个配置项直接影响 AOF 的行为和稳定性:
|
||||
|
||||
```conf
|
||||
# ---- AOF 核心参数 ----
|
||||
appendonly yes # 开启 AOF
|
||||
appendfilename "appendonly.aof" # AOF 文件名
|
||||
appendfsync everysec # 刷新策略(always / everysec / no)
|
||||
|
||||
# ---- 重写相关 ----
|
||||
auto-aof-rewrite-percentage 100 # AOF 增长百分比触发重写
|
||||
auto-aof-rewrite-min-size 64mb # AOF 最小绝对大小才触发重写
|
||||
no-appendfsync-on-rewrite no # 重写期间是否暂停 fsync(见下方说明)
|
||||
|
||||
# ---- 容错相关 ----
|
||||
aof-load-truncated yes # 启动加载时是否允许截断末尾损坏的命令
|
||||
aof-use-rdb-preamble yes # 重写时是否使用 RDB 作为前缀(混合持久化开关)
|
||||
|
||||
# ---- Redis 7 新增 ----
|
||||
aof-timestamp-enable no # 是否在 AOF 中记录时间戳(用于 PITR)
|
||||
aof-ignore-errors no # fsync 失败时是否忽略错误继续运行
|
||||
```
|
||||
|
||||
| 参数 | 推荐值 | 要点 |
|
||||
|------|--------|------|
|
||||
| `no-appendfsync-on-rewrite` | `no` | 设为 `yes` 时,AOF 重写期间暂停 fsync 以减少磁盘争抢,但代价是**重写期间的数据安全性降级为 `no` 策略**——只在磁盘 IO 严重瓶颈时考虑 |
|
||||
| `aof-load-truncated` | `yes` | 设为 `yes` 时,Redis 启动发现 AOF 末尾不完整会自动丢弃末尾损坏命令并继续加载。设为 `no` 则直接报错退出——更安全但需要人工介入修复 |
|
||||
| `aof-timestamp-enable` | 按需 | Redis 7 的实验性功能,开启后 AOF 中会嵌入时间戳注释,配合 `redis-check-aof --timestamp` 可实现**按时间点恢复(PITR)**——比如"恢复到今天 14:30 的状态"。目前仍是实验特性,生产环境谨慎使用 |
|
||||
|
||||
> [!question] `no-appendfsync-on-rewrite` 什么时候该开?
|
||||
> 当你观察到 AOF 重写期间主线程出现明显延迟(`redis-cli --latency` 抖动),且 `INFO persistence` 显示 `aof_delayed_fsync` 持续增长时,可以考虑临时开启。这相当于用**重写期间的数据安全换性能**,是一把双刃剑。
|
||||
|
||||
## 混合持久化(Hybrid Persistence)
|
||||
|
||||
> [!tip] 混合持久化是 AOF 重写的"终极形态"
|
||||
> Redis 4.0 引入、Redis 7 默认开启。核心思路:AOF **重写**时先写 RDB 全量快照作为"底子",再追加增量 AOF 命令作为"补丁"——恢复时先加载 RDB(秒级),再回放少量增量 AOF(补齐数据)。
|
||||
> Redis 4.0 引入、Redis 5.0 开始默认开启(`aof-use-rdb-preamble yes`)。核心思路:AOF **重写**时先写 RDB 全量快照作为"底子",再追加增量 AOF 命令作为"补丁"——恢复时先加载 RDB(秒级),再回放少量增量 AOF(补齐数据)。
|
||||
>
|
||||
> **在 AOF 视角下**:重写后生成的文件不再全是文本命令,而是 `RDB 二进制基线 + AOF 增量日志` 的混合结构。好处是恢复速度快(接近纯 RDB)且数据安全性高(接近纯 AOF)。
|
||||
>
|
||||
@@ -217,7 +272,11 @@ ls /var/lib/redis/appendonlydir/
|
||||
redis-check-aof --fix appendonly.aof
|
||||
|
||||
# 场景三:Redis 7 多文件目录结构
|
||||
redis-check-aof --fix appendonlydir/appendonly.aof.1.incr.aof
|
||||
# 先查看 manifest 了解文件组成
|
||||
cat /var/lib/redis/appendonlydir/appendonly.aof.manifest
|
||||
# 修复具体的增量文件(找到损坏的那个)
|
||||
redis-check-aof --fix /var/lib/redis/appendonlydir/appendonly.aof.1.incr.aof
|
||||
# 注意:base.rdb 损坏需要用 redis-check-rdb 修复
|
||||
```
|
||||
|
||||
> [!tip] 实际修复流程
|
||||
@@ -233,14 +292,19 @@ flowchart TD
|
||||
FS["fsync() 调用"] --> OK{"返回成功?"}
|
||||
OK -->|"是"| CONT["继续正常服务"]
|
||||
OK -->|"否:EIO 磁盘错误"| LOG["记录日志<br/>I/O error writing to APPEND ONLY FILE"]
|
||||
LOG --> SD["执行 SHUTDOWN NOSAVE<br/>立即停止服务"]
|
||||
LOG --> CFG{"aof-ignore-errors<br/>配置?"}
|
||||
CFG -->|"no (默认)"| SD["执行 SHUTDOWN NOSAVE<br/>立即停止服务"]
|
||||
CFG -->|"yes (Redis 7+)"| IGN["忽略错误,继续服务<br/>但数据可能丢失"]
|
||||
|
||||
style SD fill:#fdd,stroke:#c00
|
||||
style IGN fill:#ffd,stroke:#c90
|
||||
style CONT fill:#dfd,stroke:#090
|
||||
```
|
||||
|
||||
> [!note] Redis 为什么会主动 shutdown?
|
||||
> 这是一种「fail-fast」设计哲学。AOF 的核心价值就是数据安全,当底层磁盘出现问题时,继续运行只会导致更多数据静默损坏。**宁可停机也不能让用户在"不确定"的数据上继续业务**——这和数据库遇到 WAL 写入失败时的处理逻辑是一样的。
|
||||
> 默认行为是一种「fail-fast」设计哲学。AOF 的核心价值就是数据安全,当底层磁盘出现问题时,继续运行只会导致更多数据静默损坏。**宁可停机也不能让用户在"不确定"的数据上继续业务**——这和数据库遇到 WAL 写入失败时的处理逻辑是一样的。
|
||||
>
|
||||
> Redis 7 引入了 `aof-ignore-errors` 配置项,设为 `yes` 时 Redis 会忽略 AOF 写入错误继续运行。但除非你完全理解后果(数据静默丢失),否则**不建议开启**。
|
||||
|
||||
> [!warning] AOF 不是万能的
|
||||
> - AOF 修复工具只能处理末尾截断,中间损坏只能截断丢失后面的数据
|
||||
@@ -267,6 +331,55 @@ flowchart TD
|
||||
> [!TIP] 生产环境黄金组合
|
||||
> `AOF everysec + 混合持久化 + 定时 rdb 冷备到对象存储`——几乎覆盖所有常见场景。
|
||||
|
||||
## 运维速查手册
|
||||
|
||||
### AOF 监控关键指标
|
||||
|
||||
```bash
|
||||
INFO persistence
|
||||
```
|
||||
|
||||
重点关注以下字段:
|
||||
|
||||
| 指标 | 含义 | 告警建议 |
|
||||
|------|------|----------|
|
||||
| `aof_enabled` | AOF 是否开启 | 应为 `1` |
|
||||
| `aof_rewrite_in_progress` | 是否正在重写 | 长时间为 `1` 需排查 |
|
||||
| `aof_rewrite_scheduled` | 是否有排队中的重写 | 长时间为 `1` 说明重写被阻塞 |
|
||||
| `aof_last_bgrewrite_status` | 上次重写是否成功 | ≠ `ok` 立即告警 |
|
||||
| `aof_last_write_status` | 上次 AOF 写入是否成功 | ≠ `ok` 说明磁盘可能有问题 |
|
||||
| `aof_current_size` | 当前 AOF 文件大小 | 持续膨胀未触发重写需检查配置 |
|
||||
| `aof_base_size` | 上次重写后 AOF 大小 | 结合 `aof_current_size` 计算增长比 |
|
||||
| `aof_delayed_fsync` | 被延迟的 fsync 次数 | 持续增长说明磁盘 IO 成为瓶颈 |
|
||||
|
||||
> [!question] 如何快速判断 AOF 是否健康?
|
||||
> 一个简单公式:`aof_current_size / aof_base_size`。如果比值远大于 `auto-aof-rewrite-percentage` 的配置值(默认 2.0),说明自动重写可能没有正常触发——检查 `auto-aof-rewrite-min-size` 和日志。
|
||||
|
||||
### 常用运维命令
|
||||
|
||||
```bash
|
||||
# ---- 手动触发 AOF 重写 ----
|
||||
BGREWRITEAOF
|
||||
|
||||
# ---- 查看 AOF 文件大小 ----
|
||||
ls -lh /var/lib/redis/appendonlydir/
|
||||
# Redis 7: 查看各组件文件
|
||||
ls -lh /var/lib/redis/appendonlydir/base.rdb
|
||||
ls -lh /var/lib/redis/appendonlydir/incr-*.aof
|
||||
|
||||
# ---- 查看 AOF 文件内容(前 10 条命令,RESP 格式) ----
|
||||
head -20 /var/lib/redis/appendonlydir/incr-0000000000000003.aof
|
||||
|
||||
# ---- 运行时动态调整 fsync 策略 ----
|
||||
redis-cli CONFIG SET appendfsync always # 临时切到最安全模式(如做批量导入前)
|
||||
redis-cli CONFIG SET appendfsync everysec # 恢复推荐配置
|
||||
```
|
||||
|
||||
> [!tip] 运维实战技巧
|
||||
> **大批量数据导入时**:临时切到 `appendfsync no` 或 `appendfsync everysec`,导入完成后再切回 `always`(如果需要)。避免每条写命令都 fsync 导致导入耗时翻数十倍。
|
||||
>
|
||||
> **磁盘空间告急时**:手动触发 `BGREWRITEAOF` 可以立即压缩 AOF 文件体积,通常能减少 50%-80% 的空间占用。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/Redis/04-RDB持久化]] — RDB 快照机制
|
||||
|
||||
@@ -230,7 +230,7 @@ flowchart LR
|
||||
Replica 默认是 **只读** 的(`replica-read-only yes`),这不是 Redis 故意限制你,而是有充分的技术原因:
|
||||
|
||||
> [!WARNING] 为什么不应该在 Replica 上写数据?
|
||||
> 想象一下:你在 Replica 上写入了 `SET foo bar`,但 Master 上没有这个写入。下一次同步发生时,Master 会把自己的数据集"覆盖"到 Replica 上——你写的数据**悄无声息地消失了**。更糟糕的是,如果你在 Replica 上 `DEL` 一个不存在的 key,这个命令可能会被传播到 Master,导致 Master 上原本存在的数据被删掉。
|
||||
> 想象一下:你在 Replica 上写入了 `SET foo bar`,但 Master 上没有这个写入。下一次同步发生时,Master 会把自己的数据集"覆盖"到 Replica 上——你写的数据**悄无声息地消失了**。注意 Redis 复制是**单向的**(Master → Replica),你在 Replica 上的写入不会反向传播到 Master,但这本身就意味着 Replica 和 Master 之间的数据已经不一致了。
|
||||
>
|
||||
> **结论**:在 Replica 上写数据,本质上是在制造**数据不一致的定时炸弹**。
|
||||
|
||||
@@ -254,8 +254,9 @@ replica-lazy-flush no # FULLRESYNC 时清空旧数据的策略
|
||||
|
||||
# --- 副本同步相关 ---
|
||||
repl-diskless-sync no # 无盘复制:Master 不写 RDB 文件,
|
||||
# 直接通过 socket 发给 Replica(适合 SSD 环境)
|
||||
repl-diskless-sync-delay 5 # 开启无盘复制后,等几秒让多个 Replica 同时接收
|
||||
# 直接通过 socket 发给 Replica(适合磁盘 I/O 慢的环境,如云盘/HDD)
|
||||
repl-diskless-sync-delay 5 # 开启无盘复制后,等几秒让多个 Replica 同时连接并接收 RDB 流,
|
||||
# 减少 Master 重复生成 RDB 的次数(不是随机延迟,有明确优化意图)
|
||||
repl-timeout 60 # 同步超时阈值(秒),超时则断开重连
|
||||
```
|
||||
|
||||
@@ -416,6 +417,9 @@ flowchart TD
|
||||
| 2 | **`replica-priority` 最小** | 配置中人为指定的优先级 | priority=10 优先于 priority=100;priority=0 表示"永不选我" |
|
||||
| 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。
|
||||
|
||||
+15
-7
@@ -75,7 +75,7 @@ rdb.Set(ctx, "foo", "bar", 0) // 自动路由到对应节点
|
||||
|
||||
### Gossip 协议运作方式
|
||||
|
||||
每个节点每秒向其他节点发送 PING/PONG,维持集群状态的一致性:
|
||||
每个节点每秒向其他节点发送 PING/PONG,维持集群状态的一致性。选择的节点数量动态调整——集群越小越接近全发,集群越大越倾向于随机子集:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -87,7 +87,7 @@ sequenceDiagram
|
||||
B->>A: PONG (含自身 + 选中节点的状态)
|
||||
end
|
||||
|
||||
Note over A: Gossip 收敛原理:<br/>每次随机选 3 个节点交换信息,<br/>N 个节点 ~ log₃(N) 轮即可全同步
|
||||
Note over A: Gossip 收敛原理:<br/>集群越大 fanout 越大,约 O(sqrt(N)),<br/>O(log(N)) 轮即可全同步
|
||||
```
|
||||
|
||||
**Gossip 传播的三类信息:**
|
||||
@@ -96,7 +96,7 @@ sequenceDiagram
|
||||
3. **FAIL** — 某节点不可达,广播给其他节点
|
||||
|
||||
> [!QUESTION] 思考
|
||||
> 为什么 Gossip 不直接发给所有节点,而是随机选 3 个?当集群扩展到 1000 台节点时,这样做的好处是什么?
|
||||
> Gossip 节点选择策略会随集群规模动态调整——小集群(<100 节点)向所有节点发 PING,大集群才随机选择子集。为什么不直接广播给所有节点?这样做的带宽开销是怎样的?
|
||||
|
||||
### PING/PONG 报文详解
|
||||
|
||||
@@ -120,9 +120,9 @@ CLUSTER SETSLOT <slot_id> MIGRATING <target_node_id>
|
||||
# 在目标节点执行(确认阶段)
|
||||
CLUSTER SETSLOT <slot_id> IMPORTING <source_node_id>
|
||||
|
||||
# 逐 key 迁移(客户端负责搬 data)
|
||||
for key in $(redis-cli -h source-cluster-scan $slot --count 100); do
|
||||
redis-cli -h source MIGRATE target "" $key 0 5000
|
||||
# 逐 key 迁移(GETKEYSINSLOT 返回指定 slot 中的 key,每次最多 100 个)
|
||||
for key in $(redis-cli -h $SRC -p $SRC_PORT CLUSTER GETKEYSINSLOT $slot 100); do
|
||||
redis-cli -h $SRC -p $SRC_PORT MIGRATE $DST $DST_PORT $key 0 5000
|
||||
done
|
||||
|
||||
# 完成迁移
|
||||
@@ -202,7 +202,8 @@ val, _ := rdb.Get(ctx, "key").Result()
|
||||
|------|------|---------|
|
||||
| 不支持多数据库 | 固定只有 db 0 | 应用层模拟 db(key 前缀) |
|
||||
| 命令子集受限 | `KEYS`, `MIGRATE`, `SORT` 等不可用 | 用 `SCAN` 替代 `KEYS` |
|
||||
| Lua 脚本有限 | 不支持非确定性的函数(time/random) | 使用 `EVALSHA` 减少网络传输 |
|
||||
| 跨节点多 key 命令受限 | `DEL`, `MGET`, `MSET`, `RENAME` 等多 key 操作要求 key 同 slot | 用 hash tag 或应用层拆分为多次单 key 调用 |
|
||||
| Lua 脚本有限 | 所有 key **必须映射到同一 slot**;不支持非确定性函数(time/random) | 同 Lua 内 key 用同一 hash tag;用 `EVALSHA` 减少网络传输 |
|
||||
| Pipeline 受限 | 同一 pipeline 中所有 key 必须在同一节点 | 用 hash tag `{user:100}:x` 对齐 |
|
||||
| MULTI/EXEC 受限 | 同上,跨 key 事务不支持 | 用 Lua 脚本实现原子操作 |
|
||||
|
||||
@@ -376,6 +377,13 @@ let val = await cluster.get("key")
|
||||
| `maxmemory-policy` | `allkeys-lru` | 集群场景统一设置 LRU 淘汰,避免部分节点 OOM |
|
||||
| `appendonly` | `yes` | 集群推荐 AOF + 每秒刷盘,兼顾性能与安全 |
|
||||
| `cluster-replica-validity-factor` | 10 | 乘以 timeout 作为复制链路容忍阈值,0 表示不拒绝主从链接 |
|
||||
| `cluster-require-full-coverage` | `yes`(默认) | `yes` 时任意 slot 无主节点则集群整体拒绝写入;生产环境若追求可用性可设 `no`,允许部分 slot 不可用时其余 slot 正常服务 |
|
||||
| `cluster-allow-reads-when-down` | `no` | 设为 `yes` 时集群即使处于 FAIL 状态也允许读请求(前提:该 key 所在 slot 有可达节点),适合读密集型业务容忍短暂降级 |
|
||||
|
||||
> [!IMPORTANT] `cluster-require-full-coverage` 的取舍
|
||||
> 当某个 Master 和它的所有 Replica 同时宕机时,该 Master 负责的 slot 就"裸奔"了。
|
||||
> - **`yes`(默认)**:集群整体拒绝读写,宁可停机也不允许数据不一致——适合对一致性要求极高的金融场景。
|
||||
> - **`no`**:集群只拒绝访问故障 slot 的请求,其他 slot 继续服务——适合追求可用性的互联网业务,牺牲部分可用 slot 换取整体不宕机。
|
||||
|
||||
### 架构最佳实践
|
||||
|
||||
|
||||
+43
-24
@@ -7,7 +7,7 @@ create time: 2026-05-15 18:14
|
||||
|
||||
## 概述
|
||||
|
||||
Sorted Set(有序集合)是 Redis 中**功能最丰富、场景最广泛**的数据结构。它的底层由 skiplist + hashtable 组成,天然支持按分数排序和 O(1) 的成员查找。本节聚焦实战中常用的高级模式。
|
||||
Sorted Set(有序集合)是 Redis 中**功能最丰富、场景最广泛**的数据结构。底层采用双编码策略:小集合(默认 ≤128 个元素,Redis 7.0+ 使用 listpack 替代 ziplist)用连续内存紧凑存储;超过阈值后自动切换为 **skiplist + hashtable** 组合,天然支持按分数排序和 O(log N) 的范围查询。本节聚焦实战中常用的高级模式。
|
||||
|
||||
## 排行榜系统
|
||||
|
||||
@@ -30,6 +30,10 @@ ZREVRANK leaderboard "user:101" # 2
|
||||
ZRANGEBYSCORE leaderboard 3000 4000
|
||||
```
|
||||
|
||||
> [!WARNING] ZRANGEBYSCORE 已废弃(Redis 6.2+)
|
||||
> `ZRANGEBYSCORE` 在 Redis 6.2 起被统一到 `ZRANGE ... BYSCORE` 语法。本文档保留旧命令便于理解,但新代码建议写成:
|
||||
> `ZRANGE leaderboard 3000 4000 BYSCORE WITHSCORES`
|
||||
|
||||
### Go 代码示例
|
||||
|
||||
```go
|
||||
@@ -112,6 +116,9 @@ ZREM delayed_tasks <completed-tasks>
|
||||
LPUSH processing_queue <completed-tasks>
|
||||
```
|
||||
|
||||
> [!CAUTION] 多步操作需保证原子性
|
||||
> 上面的"查询 → 删除 → 转存"三步并非原子操作,多个消费者同时拉取会导致**重复消费**。生产环境应将这段逻辑封装到 Lua 脚本中,通过 `EVAL` 一次执行,或使用 `BZPOPMIN` 的阻塞弹出模型天然规避此问题。
|
||||
|
||||
> [!WARNING] 延迟队列不是消息队列
|
||||
> 对于大量并发任务,ZSet 的 O(log N) 插入和维护成本会累积。考虑专用 MQ(Kafka/RabbitMQ)更合适。Redis ZSet 适合**规模适中(万级以内)**的延迟任务场景。
|
||||
|
||||
@@ -121,9 +128,9 @@ ZSet 天然适合做**固定时间窗口内的去重计数**(如每分钟独
|
||||
|
||||
```bash
|
||||
# key = page:home:uv,score = unix timestamp ms,member = visitor_id
|
||||
ZREMRANGEBYSCORE page:home:uv $now_ms 60000 # 清除 60s 前的数据
|
||||
ZREMRANGEBYSCORE page:home:uv 0 $((now_ms - 60000)) # 清除 60s 窗口外的旧数据
|
||||
ZADD page:home:uv $now_ms visitor:a visitor:b visitor:c
|
||||
SCARD page:home:uv # 当前窗口内独立访客数
|
||||
ZCARD page:home:uv # 当前窗口内独立访客数(ZSet 用 ZCARD,不是 SCARD)
|
||||
```
|
||||
|
||||
```go
|
||||
@@ -148,9 +155,10 @@ count, _ := rdb.ZCard(ctx, "page:home:uv").Result()
|
||||
|
||||
```bash
|
||||
# === 固定窗口限流 ===
|
||||
# key = limit:user:1001, score = unix timestamp(每秒一个请求)
|
||||
ZREMRANGEBYSCORE limit:user:1001 $now_ts $(($now_ts - 1)) # 清除 1s 前数据
|
||||
ZADD limit:user:1001 $now_ts req:$RANDOM # 记录本次
|
||||
# 按整秒截断:窗口起点 = floor(now_s / 1) * 1
|
||||
WINDOW_START=$((now_ts - now_ts % 1)) # 当前秒的起始时间戳
|
||||
ZREMRANGEBYSCORE limit:user:1001 0 $((WINDOW_START - 1)) # 清除上一个窗口之前的数据
|
||||
ZADD limit:user:1001 $now_ts req:$RANDOM # 记录本次请求
|
||||
ZCARD limit:user:1001 # 当前窗口 QPS
|
||||
|
||||
# === 滑动窗口限流(更精准)===
|
||||
@@ -206,16 +214,23 @@ ZADD rank:us 800 "vip_user:A" 900 "user:C"
|
||||
|
||||
# 合并求和(默认分数相加)
|
||||
ZUNIONSTORE global:top 2 rank:cn rank:us AGGREGATE SUM
|
||||
# AGGREGATE 三种模式:
|
||||
# SUM — 分数求和(默认,适合多维度累加)
|
||||
# MAX — 取各集合中的最大分数(适合"最高分"场景)
|
||||
# MIN — 取各集合中的最小分数(适合"保底分"场景)
|
||||
|
||||
# 取全局 Top 10
|
||||
ZRANGE global:top 0 9 WITHSCORES REVERSE
|
||||
ZREVRANGE global:top 0 9 WITHSCORES
|
||||
# → vip_user:A(1800), user:C(900), user:B(500)...
|
||||
```
|
||||
|
||||
```go
|
||||
keys := []string{"rank:cn", "rank:us", "rank:jp"}
|
||||
// 合并三个区域排行榜,取分数最高的 20 名
|
||||
rdb.ZUnionStore(ctx, "global:top20", keys)
|
||||
// 第一个参数是目标 key,结果写入 "global:top"
|
||||
rdb.ZUnionStore(ctx, "global:top", &redis.ZStore{
|
||||
Keys: keys,
|
||||
Aggregate: "SUM", // 可选: "SUM" / "MAX" / "MIN"
|
||||
})
|
||||
top20, _ := rdb.ZRevRangeWithScores(ctx, "global:top20", 0, 19).Result()
|
||||
```
|
||||
|
||||
@@ -232,26 +247,30 @@ GEOADD cities 116.4074 "beijing" 121.4737 "shanghai" 108.9690 "nanning"
|
||||
|
||||
# 计算两点距离(米)
|
||||
GEOPOS cities beijing shanghai # 获取经纬度坐标
|
||||
GEODIST cities beijing nanning km # 距离(km/m/fi 单位)
|
||||
GEODIST cities beijing nanning km # 距离(km/m/ft/mi 单位)
|
||||
|
||||
# 附近的人(半径 50km 内)
|
||||
GEORADIUS cities 116.4074 39.9042 50 km WITHDIST WITHCOORD
|
||||
# 附近的人(半径 50km 内)—— Redis 6.2+ 推荐
|
||||
GEOSEARCH cities FROMLONLAT 116.4074 39.9042 BYRADIUS 50 km WITHDIST WITHCOORD ASC COUNT 5
|
||||
# ASC: 从近到远(默认)
|
||||
|
||||
# 新版本 API(推荐)
|
||||
GEORADIUSBYMEMBER cities shanghai 100 km DESC COUNT 5
|
||||
# DESC: 从远到近(默认从近到远)
|
||||
# 以某个成员为中心搜索
|
||||
GEOSEARCH cities FROMMEMBER shanghai BYRADIUS 100 km DESC COUNT 5
|
||||
# DESC: 从远到近
|
||||
```
|
||||
|
||||
> [!WARNING] GEORADIUS / GEORADIUSBYMEMBER 已废弃
|
||||
> 这两个命令在 Redis 6.2 后被 `GEOSEARCH`(查询)和 `GEOSEARCHSTORE`(查询并存储)取代。旧命令仍可使用,但新代码应直接用 `GEOSEARCH`。
|
||||
|
||||
### 常见场景
|
||||
|
||||
| 场景 | 命令 | 注意 |
|
||||
|------|------|------|
|
||||
| 附近门店 | GEORADIUSBYMEMBER | 限流 + 分页 |
|
||||
| 打车接单范围 | GEOADD + GEORADIUS | 结合 spatial index |
|
||||
| 围栏检测 | GEOPOS + 数学计算 | 超出范围触发告警 |
|
||||
| 附近门店 | `GEOSEARCH FROMMEMBER` | 限流 + `COUNT` 分页 |
|
||||
| 打车接单范围 | `GEOSEARCH FROMLONLAT` | 结合空间索引,`ASC` 按距离排序 |
|
||||
| 围栏检测 | `GEOPOS` + Haversine 公式 | 超出范围触发告警 |
|
||||
|
||||
> [!TIP] Geo 底层就是 Sorted Set
|
||||
> `GEOPOS` 本质是 `ZSCORE`(存的是 encode 后的 lat+lng),所以也可以用 ZREVRANGE 做通用查询。Geo 只是一层语法糖。
|
||||
> `GEOPOS` 本质是 `ZSCORE` + GeoHash 解码(score 存的是 52 位 GeoHash 编码值,不是原始经纬度),所以也可以用 `ZSCORE` 读出编码值,或用 `ZREVRANGE` 做通用范围查询。Geo 只是一层语法糖。
|
||||
|
||||
## 社交关系 —— 共同好友 / 标签匹配
|
||||
|
||||
@@ -282,12 +301,12 @@ ZADD rec:user:2 "go" 0.95 "python" 1.0 "flask" 0.6 "k8s" 0.9
|
||||
ZUNIONSTORE temp:rec 2 rec:user:1 rec:user:2 AGGREGATE SUM
|
||||
|
||||
# 分数越高 = 共同兴趣越多越强
|
||||
ZRANGEBYSCORE temp:rec 1.5 2.0 REVERSE WITHSCORES
|
||||
ZREVRANGEBYSCORE temp:rec 2.0 1.5 WITHSCORES
|
||||
# → go(1.95), k8s(1.6)
|
||||
```
|
||||
|
||||
> [!WARNING] 大集合运算警告
|
||||
> `SINTER` / `SUNION` / `ZUNIONSTORE` 的时间复杂度是 O(N × M),在大型集合上可能阻塞主线程。建议预计算 + 定时刷新,或在低峰期异步完成。
|
||||
> `SINTER` / `SUNION` / `ZUNIONSTORE` 的时间复杂度是 O(N × M)(N 为最小集合的元素数,M 为集合个数),在大型集合上可能阻塞主线程。建议预计算 + 定时刷新,或在低峰期异步完成。
|
||||
|
||||
## 优先级队列
|
||||
|
||||
@@ -337,12 +356,12 @@ mindmap
|
||||
ZRANGEBYSCORE + min/max
|
||||
避免 OFFSET 深翻页
|
||||
编码降级
|
||||
<64元素 <64B→ziplist
|
||||
≥128元素→skiplist+hashtable
|
||||
小集合→listpack 紧凑编码
|
||||
大集合→skiplist+hashtable
|
||||
```
|
||||
|
||||
> [!TIP] 编码转换的触发条件
|
||||
> `maxziplist` entries(默认 128)和 `maxziplistvalue`(默认 64 bytes)控制着 ziplist ↔ skiplist 的转变。在数据量较大时建议调大 `zset-max-ziplist-entries` 以利用 ziplist 的内存紧凑性,但要权衡查询性能。
|
||||
> 配置参数 `zset-max-ziplist-entries`(默认 128)和 `zset-max-ziplist-value`(默认 64 bytes)控制着 listpack(Redis 7.0 前为 ziplist)↔ skiplist 的转变。小集合优先使用 listpack 紧凑编码以节省内存,超过阈值自动升级为 skiplist + hashtable。在数据量较大时可适当调大 entries 阈值以延迟升级,但需权衡 listpack 的 O(N) 插入性能。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
|
||||
Reference in New Issue
Block a user