# 第 27 章 Redis 原理与缓存架构


> 前面几章把数据放在磁盘上，本章把它放进内存：代价是容量与持久性，收益是微秒级延迟与全新的故障模式。

## 学习目标

读完本章，你应当能够：

1. 准确表述单线程模型（single-threaded model）的能力边界，说清 6.0 多线程 I/O 并行化了哪一段、哪一段仍是单线程，并列出会阻塞全库的操作；
2. 给出五种基本类型与三种扩展类型（BitMap、HyperLogLog、GEO）的底层编码与全部转换阈值，并解释阈值为什么取这个值；
3. 推导 SDS 相对 C 字符串的三个优势、listpack 相对 ziplist 解决的连锁更新（cascade update）、skiplist 的平均层数，并画出 dict 渐进式 rehash（incremental rehashing）的双表迁移过程与 `rehashidx` 的状态语义；
4. 区分过期删除（expiration）与内存淘汰（eviction），写出八种淘汰策略，拆解 LRU 与 LFU 在 `lru` 字段上的位布局；
5. 对比 RDB、AOF、混合持久化的丢数据窗口与恢复速度并给出生产配置，说清 `psync`/`repl_backlog` 导致全量重同步的根因；
6. 说清哨兵 SDOWN 与 ODOWN 的确认数、Cluster `16384` 槽与 `MOVED`/`ASK` 的区别，并对缓存穿透、击穿、雪崩与缓存数据库一致性给出方案及其不一致窗口。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| `fork` 与写时复制（copy-on-write, COW）的页表机制 | 见第 4 章 4.2 节；本章只用它解释 `BGSAVE` 的内存峰值 |
| `epoll` 的事件驱动模型与 I/O 多路复用 | 见第 7 章 7.4 节；本章只讲 Redis 事件循环怎么组织它 |
| 哈希表、跳表、双向链表、位图的数据结构语义 | 数据结构课程内容；本章直接讲编码实现与阈值 |
| RDB 与 AOF 的定义 | 本章 27.5 节直接给机制与参数，不复述定义 |

## 27.1 单线程模型的准确表述

### 27.1.1 为什么单线程还能这么快

"Redis 快是因为它是内存数据库"是必须避开的浅层回答：内存只是前提，Memcached 也在内存里。真正的原因是四条叠加。

第一，**命令执行路径上没有磁盘**：一次主存访问约 100 ns，一次 SSD 随机读约 100 μs，差三个数量级。第二，**I/O 多路复用让一个线程同时伺候上万个连接**：事件循环（event loop）从 `epoll` 取出就绪事件，逐个执行命令，再统一把回复写进缓冲区；没有多路复用，单线程撑不起高并发，有了它也**不需要**为每个连接开线程。第三，**避免了锁与上下文切换的固定开销**：多线程要保护共享键空间，每次读写都要加解锁（无竞争约 20 ns）或用更贵的原子操作，线程切换一次约 1~5 μs；单线程让"命令原子性"成为免费副产品——**一条命令执行期间不会有其他命令插进来**，这正是 `INCR`、`SETNX`、`LPUSH` 能当原子原语用的原因。第四，**专用数据结构与编码**：同一份数据用 `intset` 存比通用哈希表省几十倍内存，内存越省 cache line 命中率越高，而一次 L1 命中约 1 ns，与主存访问差 100 倍。

> [!IMPORTANT]
> 满分回答是四段：**纯内存 + I/O 多路复用 + 单线程免锁免切换 + 高效编码**。只答第一段就会被追问"那 Memcached 为什么不慢"。

代价同样清楚：**一条慢命令阻塞全部请求**，且**无法利用多核**，单机吞吐被单核 CPU 时间锁死。

### 27.1.2 6.0 的多线程 I/O 到底并行了什么

```
            主线程                                    I/O 线程（io-threads，常用 1~8 个）
 ① epoll_wait 收事件   ② 把可读连接分派给 I/O 线程 ──▶ ③ read() + 协议解析（并行）
                              ◀── 把完整命令交给主线程（无锁队列）
 ④ 单线程顺序执行全部命令 ★核心
 ⑤ 把回复分派给 I/O 线程 ──────▶ ⑥ write() 回写 socket（并行）
 ★ ③ 与 ⑥ 并行；④ 永远只有一个线程
```

**并行的是网络读写与协议解析（`read`/`write` 与 RESP 解析）；命令执行、字典查找、过期判断、复制传播全部仍由主线程串行完成。** 所以多线程 I/O 只对"大 value、高带宽、协议解析占比高"的场景有收益，对 `GET` 小 key 这类纯 CPU 负载收益接近零；`io-threads` 默认 1（等于关闭），官方建议最多 4~8，且必须同时开 `io-threads-do-reads yes` 才让读也并行。

> [!CAUTION]
> 答"6.0 之后 Redis 是多线程的、性能翻倍"会被判定为没读过实现。必须说清**只有网络 I/O 并行，命令执行仍单线程**，并顺势说明"所以 `KEYS` 仍然阻塞全库"。

### 27.1.3 哪些操作会阻塞整个实例

| 操作 | 阻塞时长由什么决定 | 生产替代方案 |
|---|---|---|
| `KEYS pattern` | 遍历全部键，`O(N)`，百万 key 时是百毫秒级 | `SCAN cursor MATCH p COUNT 100` 渐进遍历 |
| `FLUSHALL`/`FLUSHDB` | 释放全部对象内存，`O(N)` | `FLUSHALL ASYNC`（后台线程回收） |
| `DEL bigkey` | 释放大对象，`O(N)` | `UNLINK`（先摘链，后台回收） |
| `HGETALL`/`SMEMBERS`/`LRANGE 0 -1` 大对象 | `O(N)`，且要构造大回复占带宽 | `HSCAN`/`SSCAN` 分批；`ZRANGE` 带 `LIMIT` |
| `ZRANGE`/`ZRANGEBYSCORE` 大 `ZSET` | 返回元素数 × `log N`，可达数十毫秒 | 分页 + `LIMIT`；排行榜拆多段 |
| `SORT`/大集合运算 / `appendfsync always` / `BGSAVE` 的 `fork` | 前者 `O(N log N)`；`always` 每条等磁盘 fsync（机械盘 ms 级）；`fork` 随页表增长（32 GB 实例达百毫秒级） | 移到从库或离线计算；用默认 `everysec`（见 27.5.2 节）；`save ""` 改由从库或运维窗口保存 |
| 大 key 过期/淘汰时被释放 | 主线程同步释放内存 | 拆小 key；`lazyfree-lazy-expire yes` |

共性是：**代价由 key 数量级或元素数量级决定，而单线程没有让出机制**。标准追问是"那 `SCAN` 为什么就不阻塞"——它每次只遍历少量桶就返回，并用**反向二进制位迭代**保证 rehash 期间也能推进（见 27.3.5 节）；但它是**弱一致**的，可能重复返回元素，需要调用方去重。

## 27.2 五种基本类型与三种扩展类型：编码与转换阈值

Redis 对外只有类型（`TYPE` 的返回值），对内每个 key 挂的是某种**编码**（`OBJECT ENCODING` 可见）。高频细节题全部集中在"什么时候从紧凑编码变成通用编码"。

```
类型        小/特殊形态编码                        超过阈值后
String  int（可解析为 long）─┐
        embstr（≤44 字节）──┼──────────────────▶ raw（>44 字节）
        raw ────────────────┘
List    listpack（小）─────────────────────────▶ quicklist（每节点是 listpack）
Hash    listpack（小）─────────────────────────▶ hashtable
Set     intset（全整数且小）──▶ listpack（7.2+）──▶ hashtable
ZSet    listpack（小）─────────────────────────▶ skiplist + dict
BitMap / HyperLogLog / GEO 不改变 TYPE，只是 String / ZSet 的特殊用法
```

| 类型 | 阈值配置项 | 默认值 | 语义 |
|---|---|---|---|
| String | 无编码阈值项 | 44 字节 | ≤44 用 `embstr`（与 `robj` 一次分配），>44 用 `raw` |
| List | `list-max-listpack-size` | 128 | 单个 listpack 元素数上限；另受 `list-max-ziplist-size` 的 -2 档（8 KB）约束 |
| Hash | `hash-max-listpack-entries` | 128 | 字段数超过即转 `hashtable` |
| Hash | `hash-max-listpack-value` | 64 | 任一字段名或值长度超过 64 字节即转 |
| Set | `set-max-intset-entries` | 512 | 全整数且元素数 ≤512 用 `intset` |
| Set | `set-max-listpack-entries` | 128 | 含非整数元素时，元素数 ≤128 用 listpack（7.2 起） |
| ZSet | `zset-max-listpack-entries` | 128 | 成员数超过即转 `skiplist` |
| ZSet | `zset-max-listpack-value` | 64 | 任一成员长度超过 64 字节即转 |

> [!TIP]
> 阈值题模板是"**默认值 + 触发条件 + 为什么**"。`embstr` 分界 44 字节的由来：`embstr` 要求 `robj` 与 SDS 在**一次 `malloc`** 中分配，32 位 `robj` 占 16 字节、`sdshdr8` 头占 3 字节、加结尾 `\0`，`64 − 16 − 3 − 1 = 44` 恰好填满一个 64 字节分配单元。问 Hash 何时变 `hashtable`：字段数 > 128，或任一字段名/值长度 > 64；允许调大是因为 listpack 连续内存、省指针且局部性好，代价是每次修改都要搬移内存。

编码转换是**单向**的：升到通用编码后，即使元素数降回阈值以下也**不会**自动降回来（重建结构成本高且会导致编码抖动）。要真正回收内存只能重建 key（`HSCAN` 遍历 + 写新 key + `RENAME`）。三种扩展类型不改变 `TYPE`：**BitMap** 用 `SETBIT`/`BITCOUNT`/`BITOP` 把 String 当位数组，`offset` 上限 `2³²−1`（512 MB / 8），但**稀疏位图会浪费**（`SETBIT k 100000000 1` 立刻分配 12.5 MB）；**HyperLogLog（HLL）** 固定 `16384` 个 6 位寄存器、加头部约 12 KB，**标准误差 0.81%**（官方口径 `1.04/√16384`），只支持 `PFADD`/`PFCOUNT`/`PFMERGE`，**不能取回元素、不能删除单个元素**；**GEO** 底层就是 `ZSet`，`GEOADD` 把经纬度按 geohash 编成 52 位整数当 score，因此**天然继承 `skiplist` 编码与 128/64 阈值**，`GEOSEARCH` 是"用 `ZSet` 范围查询筛候选，再用 Haversine 距离精筛"。

## 27.3 内部实现：SDS、listpack、quicklist、skiplist、dict

### 27.3.1 SDS 相对 C 字符串的三个优势

SDS（simple dynamic string）在 `char buf[]` 前放了 `len` 与 `alloc` 两个字段（按长度选 `sdshdr5/8/16/32/64`，头部分别为 1/3/5/9/17 字节），由此得到三点：**`O(1)` 取长度**（C 字符串要 `strlen` 遍历到 `\0`，是 `O(N)`）；**二进制安全（binary safe）**（靠 `len` 而不是 `\0` 判边界，因此能存图片、Protobuf、含 `\0` 的内容，C 字符串遇到 `\0` 就被截断）；**预分配与惰性释放**（拼接不越界时按"小于 1 MB 翻倍、否则每次多给 1 MB"预留，把连续 `APPEND` 的 `malloc` 次数从 `O(N)` 降到 `O(log N)`；缩短时**不立即 `free`** 只改 `len`，把空间留给下次增长，减少 `malloc`/`free` 与内存碎片）。

### 27.3.2 listpack 相对 ziplist 解决的核心问题

`ziplist` 每个节点存 `prevlen`（前驱长度）以便**反向遍历**，而它是变长编码：前驱长度 < 254 时占 1 字节，≥ 254 时占 5 字节。

```
 ziplist 连锁更新（cascade update）：
 初始：z1(253B) z2(253B) z3(253B) z4(253B) …   每个 prevlen 都只占 1 字节
 把 z1 长度改成 254B 后：z2.prevlen 需从 1B 扩到 5B → z2 变大 4B、长度跨过 253→257
   → z3.prevlen 也要扩 → z3 变大 → 继续触发 z4 …（最坏 O(N²) 字节搬移）
 listpack 的解法：不存前驱长度，只存自身长度编码（encoding + data + backlen），
   backlen 逆序编码、用于从后往前定位 → 任何节点长度变化都不影响其他节点
```

`listpack` 因为不再引用前驱长度，**连锁更新在结构上被消除**；此外它的整数编码更紧凑，节点数相同时通常比 ziplist 更省。

> [!IMPORTANT]
> 问"listpack 为什么替代 ziplist"，只答"更省内存"是次要理由，**核心答案是消除连锁更新**。追问时要能画出上面 `prevlen` 1→5 字节的扩散过程。

### 27.3.3 quicklist 与 listpack 的分层关系

`quicklist` 是**双向链表，每个节点是一个 `listpack`**（7.0 起，早期是 ziplist）：`[listpack:[a b c]] ◀─▶ [listpack:[d e f]] ◀─▶ [listpack:[g h]]`，单个 listpack 最多 `list-max-listpack-size` 个元素（默认 128），或按 `list-max-ziplist-size = -2` 控制在 8 KB 内。它回答的是"为什么不直接用链表、也不直接用 listpack"：纯链表每元素两个 `prev/next` 指针，指针开销大于数据；纯 listpack 是连续内存，`LPUSH` 到千万长列表时每次插入都要重分配并搬移；`quicklist` 用"链表解决增长、listpack 解决局部紧凑"折中，`-2`（8 KB）这一档是 cache line 与 `memcpy` 成本的经验折中。

### 27.3.4 skiplist 与平衡树的对比

`ZSet` 超过阈值后用 **`skiplist` + `dict` 双结构**：`dict` 提供成员→分值的 `O(1)` 查找（`ZSCORE`），`skiplist` 按分值有序、提供范围查询。

| 维度 | skiplist | 平衡树（红黑/AVL） |
|---|---|---|
| 范围查询 `ZRANGEBYSCORE` | 找到起点后在**最底层链表**顺序走，跳转少、缓存友好 | 需中序遍历或后继指针，跨层跳转多 |
| 实现与并发 | 插入删除只改前后指针，无需旋转再平衡，天然适合无锁实现 | 旋转、染色、删除修复分支多，旋转影响较大子树 |
| 内存与最坏复杂度 | 平均每元素 1.33 个指针；概率保证 `O(log N)`（期望） | 每节点固定 3 指针 + 颜色位；严格 `O(log N)` |

**平均层数怎么来的**：`t_zset.c` 里 `ZSKIPLIST_P = 0.25`，层数由"连续 `random() < 0.25` 就升一层"生成，服从参数 `p = 0.25` 的几何分布，于是平均层数 `= 1/(1 − p) = 1/(1 − 0.25) = 1/0.75 ≈ 1.33`（平均每元素 1.33 个指针）；最大层数 `ZSKIPLIST_MAXLEVEL = 32`，足以覆盖 `4³²` 量级元素。

### 27.3.5 dict 的渐进式 rehash

`dict` 内部有两个哈希表 `ht[0]` 与 `ht[1]`，正常状态只用 `ht[0]`。扩容或缩容时不是一次性搬完，而是**渐进式 rehash**：`rehashidx` 从 `-1` 改为 `0`，此后每次对 dict 增删改查都**顺带把 `ht[0]` 里 `rehashidx` 那个桶的整条链迁到 `ht[1]`，然后 `rehashidx++`**；全部迁完后 `ht[0] ← ht[1]`、`ht[1]` 置空、`rehashidx = -1`。此外 `serverCron` 会以 1 ms 为限做定时搬迁，保证空闲时也能推进。

```
 rehashidx = -1         → 未 rehash，只用 ht[0]
 rehashidx = k (≥0)     → ht[0][0..k-1] 已迁完，ht[0][k..] 未迁，ht[1] 正在接收
        ht[0]（旧，size=4）              ht[1]（新，size=8）
   bucket 0  ──▶ [k1] [k5]   已迁走（空）
   bucket 1  ──▶ [k2]        rehashidx 指向这里，下次访问搬它
   bucket 2  ──▶ [k3] [k7]   未迁          bucket 3 ──▶ (空)  未迁
```

rehash 期间的操作规则才是真正的考点：**查找 / 删除 / 更新**先在 `ht[0]` 找、找不到再在 `ht[1]` 找；**新增一律只插入 `ht[1]`**，这保证 `ht[0]` 键数只减不增、rehash 必在有限步内结束。

| 操作 | 条件（`load_factor = used / size`） | 是否允许在 `BGSAVE`/`BGREWRITEAOF` 期间触发 |
|---|---|---|
| 扩容 | `load_factor ≥ 1` 且当前允许 resize | 是 |
| 强制扩容 | `load_factor ≥ dict_force_resize_ratio = 5` | **是**（哪怕正在 `BGSAVE`） |
| 缩容 | `load_factor < 0.1` 且允许 resize | 否 |

为什么 `BGSAVE` 期间要把阈值从 1 抬到 5：`BGSAVE` 依赖 `fork` 的 COW，子进程遍历时会持续读所有页；若这期间频繁 rehash 增删桶，被改动的页会触发写时复制，**内存占用可能翻倍**。所以子进程存在时 Redis 主动抑制按需扩容，只保留 `load_factor ≥ 5` 这条**保命阈值**——完全不扩会让单条链退化成链表、查询变 `O(N)`，比多占内存更危险。

## 27.4 过期删除与内存淘汰：两套独立机制

必须分开：**过期删除（expiration）** 是 key 设了 TTL、到期后被删掉，触发者是**时间**；**内存淘汰（eviction）** 是 `maxmemory` 用满了要腾地方，触发者是**内存压力**。一个 key 可以永不过期却被淘汰，也可以已过期但尚未被删除就已被淘汰。

### 27.4.1 惰性删除与定期删除

**惰性删除（lazy expiration）**：每次访问 key 先 `expireIfNeeded`，过期就删并返回空；优点是零额外开销，缺点是**不访问就永不释放**。**定期删除（active expiration）**：`serverCron` 默认每 100 ms（`hz` 默认 10）执行一次，从过期字典 `expires` 中**随机抽样 20 个** key，删掉其中已过期的；若过期比例超过 25%，**立刻再抽 20 个**重复；单次执行有时间上限（约 25% 的 CPU 预算），到点中断、下周期继续。所以扫描是"随机采样 + 比例自适应"而非全量遍历，这也是"过期 key 不会立刻消失"的原因。

### 27.4.2 八种淘汰策略

| 策略 | 候选集合 | 选择依据 | 适用场景 |
|---|---|---|---|
| `noeviction` | 无 | 不淘汰，写命令返回 `OOM` 错误（读仍可用） | 数据不可丢（当存储用），**默认值** |
| `allkeys-lru` | 全部 key | 近似 LRU | 通用缓存，最常用；`allkeys-random` 适合访问均匀的场景 |
| `allkeys-lfu` | 全部 key | 近似 LFU（4.0+） | 有稳定热点、要抗突发扫描 |
| `volatile-lru` / `volatile-lfu` | 仅设了 TTL 的 key | 近似 LRU / 近似 LFU | 同一实例混放缓存与持久数据，LFU 版更抗扫描 |
| `volatile-random` / `volatile-ttl` | 仅设了 TTL 的 key | 随机 / 剩余 TTL 最小者优先 | 后者用于希望快过期者先走的场景 |

> [!WARNING]
> 用 `volatile-*` 却忘了给缓存 key 设 TTL，内存打满后**一条也淘汰不掉**，写请求全部 `OOM`。这是"Redis 写不进去但内存没满"类故障的常见根因。

### 27.4.3 近似 LRU 与 LFU 的位布局

Redis 不为每个 key 维护真正的 LRU 链表（那要多两个指针，内存代价不可接受），而是在 `robj` 里复用 24 位 `lru` 字段：

```
 近似 LRU：整个 24 位存"最后一次访问的秒级时间戳"
   淘汰时随机采样 maxmemory-samples（默认 5）个 key，删其中最久未访问的，并维护
   一个大小 16 的候选池；samples 越大越接近真 LRU，CPU 与准确率折中
 近似 LFU：同一个 24 位一分为二（4.0+，字段改名 lfu）
   ┌────────────────────────── 24 bit ──────────────────────────┐
   │ 高 16 bit：ldt（last decrement time，分钟级）│ 低 8 bit：logc │
   └────────────────────────────────────────────┴────────────────┘
   logc 是对数计数器，初值 5；每次访问按概率 1/(logc×lfu_log_factor + 1) 自增
     （lfu_log_factor 默认 10）→ 访问 100 次约到 10、10 万次约到 18，255 封顶
   计数衰减：距上次递减超过 lfu-decay-time（默认 1 分钟）就减 1
     → 昨天热今天冷的 key 会自动降级，这正是 LFU 相对 LRU 的关键优势
 为什么不用精确 LRU：精确实现要双向链表 + 每 key 两指针 + 每次访问改链表，内存与
   单线程常数开销都不划算；采样法 samples = 5 已可接受，调到 10 就非常接近
```

## 27.5 持久化三件套

### 27.5.1 RDB：`BGSAVE` 的 fork 与 COW

`BGSAVE` 由主进程 `fork` 出子进程，**子进程**把那一刻的内存快照写成 RDB，主进程继续处理命令。快照之所以是"那一刻"的，靠 `fork` 的 COW：父子共享全部物理页且页表被标记只读；主进程此后写某 key 会触发**写保护缺页**，内核把那一页复制给主进程（详见第 4 章 4.2 节），于是子进程看到的内存视图在 `fork` 时刻被冻结。代价与坑：`fork` 要复制页表，实例越大越慢（32 GB 实例可达百毫秒级停顿）；写入越频繁被复制的页越多，**内存峰值可能接近 2 倍**；`save 3600 1 | save 300 100 | save 60 10000` 是默认自动保存规则（满足任一即触发），生产上常写 `save ""` 关掉，改由从库保存或运维窗口显式 `BGSAVE`；`stop-writes-on-bgsave-error yes`（默认）会在最近一次保存失败时**拒绝所有写命令**，初衷是防止"持久化坏了却没人发现"，但在磁盘抖动时会放大成"缓存整体不可写"，不少团队评估后改为 `no` 并配监控。

### 27.5.2 AOF：`appendfsync` 的三种策略

| 策略 | 行为 | 丢数据窗口 | 性能 |
|---|---|---|---|
| `always` | 每条写命令都 `fsync` | 不丢已向客户端确认的命令 | 最差，机械盘上每条都等 ms 级磁盘确认 |
| `everysec` | 后台线程每秒 `fsync` 一次（**默认**） | **最多约 1 秒**的写 | 好，工程默认 |
| `no` | 交给操作系统决定何时刷 | 不可控，通常数十秒 | 最好但不可接受 |

**AOF 重写**（`BGREWRITEAOF`）解决文件膨胀：不读旧 AOF，而是像 `BGSAVE` 一样 `fork` 子进程，**按当前内存数据直接生成最小命令集**（100 次 `INCR` 重写成一条 `SET`），重写期间的新命令写入 `aof_rewrite_buf`，子进程完成后追加。触发条件是两条同时满足：`auto-aof-rewrite-percentage 100`（体积比**上次重写后**翻倍）与 `auto-aof-rewrite-min-size 64mb`（绝对体积超过 64 MB，防止小实例反复重写）。

### 27.5.3 混合持久化：为什么它同时兼顾恢复速度与丢失窗口

`aof-use-rdb-preamble yes`（4.0 起默认开启）让 AOF 重写产出的文件前半段是 **RDB 格式全量快照**，后半段是**重写开始后的增量 AOF 命令**：

| 维度 | RDB | AOF | RDB + AOF（混合） |
|---|---|---|---|
| 文件内容与触发 | 某一时刻的全量二进制快照；`save` 规则 / `BGSAVE` | 写命令序列（可重写为最小集）；每次写追加 / `BGREWRITEAOF` | 前半段 RDB 快照 + 后半段增量 AOF；由 AOF 重写触发 |
| 丢数据窗口 | 两个快照间隔，分钟级 | `always` 近 0、`everysec` ≤1 s、`no` 数十秒 | 与 `appendfsync` 一致，`everysec` 下 ≤1 s |
| 恢复速度 | 快 | 慢（逐条重放命令） | 快（先载入 RDB，再重放尾部增量） |
| 文件体积与主要成本 | 小；`fork` 停顿与 COW 内存峰值 | 大（重写后变小）；持续写盘 I/O 与重写开销 | 中；兼具两者 |

生产建议：开启 AOF（`everysec`）并开启混合持久化，`save` 规则按业务放宽或交由从库保存，让主库的 `fork`/I/O 抖动集中在一处、读请求由从库分担。

## 27.6 主从复制：`psync`、`repl_backlog` 与全量重同步

续传由三个东西描述：**`replid`（复制数据集标识）、`offset`（复制偏移量）、`repl_backlog`（复制积压缓冲区，默认 1 MB 的环形缓冲）**。从库重连时发 `PSYNC <replid> <offset>`：若主库判定该 `offset` 仍在 backlog 范围内，回 `+CONTINUE` 只补增量（**部分重同步**）；若已被覆盖，则回 `+FULLRESYNC <replid> <offset>` 走全量重同步。

**为什么 backlog 太小会导致全量重同步**：backlog 是定长环形缓冲，主库持续把写命令塞进去，旧数据被新数据覆盖。从库断线期间主库仍在接收写入，若"断线时长 × 写入速率 > backlog 容量"，从库请求的起始 `offset` 已被覆盖，主库**无法**只补增量。后果是连锁的：全量重同步要 `BGSAVE` 与传输 RDB，于是"网络抖动"被放大成"主库抖动"，可能引发第二轮超时重连。容量估算：`backlog ≥ 峰值写入速率 × 可容忍最长断线时间`，峰值 10 MB/s、希望容忍 60 s 则至少 600 MB；`repl-backlog-ttl`（默认 3600 s）决定最后一个从库断开后 backlog 保留多久。

```
 ① 主库 BGSAVE 生成 RDB            ② 传输 RDB 到从库           ③ 传播缓冲区补齐
 ┌──────────┐                     ┌──────────┐               ┌────────────┐
 │ fork 子进 │───RDB 文件─────────▶│ 从库清空  │──期间主库新写─▶│ replication │
 │ 程写快照  │   (可能数百 MB)      │ 旧数据并  │  命令先存进    │ buffer 里的 │
 │ 主库继续  │                     │ 载入 RDB  │  内存缓冲区    │ 命令补发    │
 │ 服务命令  │                     │          │               │             │
 └──────────┘                     └──────────┘               └────────────┘
 三阶段耗时由 磁盘 / 网络 / 积压命令量 决定，任一环节慢都拉长"从库不可用"时间
```

从库默认 `replica-read-only yes` 只读；`replica-serve-stale-data`（默认 `yes`）决定失联时是否继续用可能过期的数据响应，设为 `no` 则在同步完成前对所有读返回错误。

> [!IMPORTANT]
> 复制的本质是**异步**的：主库写成功并向客户端返回时，命令可能还没到从库。所以"主库确认 = 数据已持久"是错的，主库宕机会丢最后一段写。要缩小窗口只能靠 `WAIT` 或半同步确认（性能换一致性）；要消除窗口只能上 Raft 类协议，见第 34 章 34.3 节。

## 27.7 哨兵与 Cluster

### 27.7.1 哨兵的四项职责与下线判定

哨兵（Sentinel）的职责恰好四项：**监控**（持续 `PING` 主从与其他哨兵）、**通知**（把故障事件发到 API/脚本）、**故障转移**（主库不可用时选新主）、**配置提供者**（客户端向哨兵问"当前主库是谁"）。下线判定分两级：**主观下线（subjectively down, SDOWN）** 是单个哨兵自己的 `PING` 超过 `down-after-milliseconds`（默认 30 s）无有效回复，由**单个哨兵**判定；**客观下线（objectively down, ODOWN）** 是认为 SDOWN 的哨兵数量达到 `quorum`（配置项，常见 2），由**多个哨兵统计后共同判定**。

`quorum` 只用于**判定 ODOWN**；真正的**领导者选举**是另一道门槛：Raft 式投票，候选者需拿到多数派票数（`majority = 哨兵总数/2 + 1`）。所以"3 个哨兵、quorum 2"下允许 1 个哨兵故障仍能完成故障转移；若只部署 2 个哨兵，任何 1 个故障都会导致无法达成多数派、**永远无法自动切换**——这就是哨兵最少部署 3 个（奇数）的原因。**故障转移后旧主库的身份**：哨兵会把它作为**新主库的从库**重新加入，并让它执行一次全量重同步（清空自己的数据），所以"旧主复活后还能不能写"的答案是**不能**。

### 27.7.2 Cluster：16384 个槽、`MOVED` 与 `ASK`

Cluster 把键空间切成 `16384` 个槽（slot），每个主节点负责一段槽区间，槽归属由 `CRC16(key) mod 16384` 决定。**为什么是 16384 而不是 65536**：槽位图是按位表示的 bitmap，节点间心跳（`PING`/`PONG`）要携带"我负责哪些槽"；16384 位 = 2 KB，65536 位 = 8 KB，在每秒多次心跳、节点数较多的规模下这 6 KB 差值会被放大成可观的带宽。同时 16384 对常见集群规模（官方建议不超过 1000 个节点）已经足够，槽位图还能压进普通网络包。这是**心跳包大小与最大节点规模之间的权衡**，不是哈希均匀性的需要。

| 重定向 | 语义 | 客户端该做什么 | 典型时机 |
|---|---|---|---|
| `MOVED <slot> <ip:port>` | **永久**重定向：该槽归属已确定在这个节点 | 更新本地槽→节点映射缓存，之后直接访问新节点 | 槽已迁移完成、或客户端映射过旧 |
| `ASK <slot> <ip:port>` | **临时**重定向：这个 key 正在迁移，本次去目标节点问 | 本次请求带 `ASKING` 发给目标节点，**不要**改本地映射 | 槽迁移进行中，key 已搬走而槽未完全移交 |

区别的根因：`MOVED` 表示"映射错了"，要改缓存；`ASK` 表示"映射没错，但这个 key 已经搬走了"，改缓存会在迁移反复时来回抖动。客户端正确姿势是**缓存槽映射 + 按重定向纠正**（智能客户端），一次性拉取全量映射用 `CLUSTER SLOTS`（新版 `CLUSTER SHARDS`）。多 key 命令（`MGET`、`SUNION`、事务、Lua 脚本）**要求所有 key 落在同一个槽**，否则报 `CROSSSLOT`，解法是 **hash tag**：只对 `{...}` 内的内容做 `CRC16`。

```bash
# 只要花括号内内容相同，两个 key 就落到同一槽，MGET 才不会 CROSSSLOT
# tag 要用稳定的业务键；用随机数会让同一用户的 key 散到不同槽，聚合查询全部退化
MGET "{user:1001}:name" "{user:1001}:age"
# 反例：tag 不同 → 不同槽 → CROSSSLOT
MGET "{user:1001}:name" "{user:1002}:age"
```

**Cluster 为什么不是强一致**：写入路径是"主节点本地执行 + 异步复制到从库"，只有在"主节点崩溃且其从库也未收到该写"时才会丢。它**没有做多数派写入确认**（为了延迟与可用性），也没有像 Raft 那样要求提交前多数派落盘。所以官方文档明确说明 Cluster 只提供**有限的一致性保证**，在网络分区、主从切换窗口内可能丢写；要更强保证只能在应用层用 `WAIT`/多数派确认或换一致性协议，代价是延迟。

## 27.8 缓存三件套

| 问题 | 触发条件 | 现象 | 解法 |
|---|---|---|---|
| 缓存穿透（penetration） | 查询**根本不存在**的数据 | 缓存与库都没有，请求全部落到数据库 | 布隆过滤器挡非法键；缓存空值并设较短 TTL（如 30~60 s）；入口参数校验与限流 |
| 缓存击穿（breakdown） | **单个热点 key** 过期的瞬间 | 大量并发同时打到库，可能压垮库 | 互斥锁/单飞重建（只放一个请求查库）；热点 key 逻辑过期不设 TTL、由后台刷新；提前预热 |
| 缓存雪崩（avalanche） | **大量 key 同时过期**，或 Redis 整体不可用 | 数据库瞬时承受全量流量 | 过期时间加随机抖动；多级缓存（本地 + Redis）；熔断降级；Redis 高可用（哨兵/Cluster） |

三者必须分清：穿透是"查不到"，击穿是"某条热点查不到但并发极高"，雪崩是"一批同时失效或整体宕机"。最常见的错误回答是把击穿与雪崩都答成"加随机过期时间"——加随机解决不了穿透，也救不了单热点 key 的瞬间并发。

**布隆过滤器（Bloom filter）**：用 `k` 个独立哈希函数把元素映射到位数组的 `k` 个位置置 1；判定"存在"时只要有一位为 0 就**一定不存在**（无假阴性），全部为 1 时**可能存在**（有假阳性）。误判率近似 `(1 − e^(−kn/m))^k`，其中 `n` 为元素数、`m` 为位数。两个工程约束：一是**不能删除元素**——把某一位清零会连带影响共享该位的其他元素，产生假阴性（把存在的判成不存在），比假阳性更危险，要支持删除需换**计数布隆过滤器（counting Bloom filter）**，每位用计数器，代价是数倍内存；二是误判率与内存成正比，需按规模预留：`n = 1000 万`、`k = 7`、误判率约 1% 时 `m ≈ 9.6 × 10⁷` 位 ≈ 11.4 MB。**Cuckoo filter（布谷鸟过滤器）** 用两个候选桶 + 指纹存储，支持删除且空间效率常优于计数布隆，代价是实现复杂、插入可能失败需踢出重放；工程上更常见的替代是**分层布隆 + 定期重建**，或用 `SETNX` 缓存空值兜底。

## 27.9 缓存与数据库一致性：四种方案的完整对比

前提必须说清：**在"缓存 + 数据库"双写结构下，只要不是分布式事务、也不让缓存成为唯一数据源，就做不到强一致，只能做到最终一致，而不同方案的不一致窗口大小不同。**

| 方案 | 时序 | 不一致窗口与根因 | 评价 |
|---|---|---|---|
| ① 先更新数据库，再删缓存（Cache Aside） | 写库 → 删缓存 | 读线程在"缓存已失效、库尚未更新完"之间读到**旧值并回填**，窗口是"更新库 + 删缓存"的持续时间 | **推荐做法**，窗口最小；仍需处理删缓存失败 |
| ② 先删缓存，再更新数据库 | 删缓存 → 写库 | 删完后、写库前有并发读，读到旧值回填；写库完成后**该旧值长期驻留**，窗口被放大 | 需要"延迟双删"补救，见下 |
| ③ 更新数据库同时更新缓存 | 写库 → 写缓存 | 并发写 A、B：A 先写库、B 后写库，但 B 先写缓存、A 后写缓存 → 缓存被 A 的旧值覆盖并**长期错误** | 不推荐，并发写必出旧值覆盖 |
| ④ 订阅 binlog 异步删除（canal 类） | 库提交 → binlog → 消费者删缓存 | 删缓存前的窗口（通常毫秒~百毫秒级），之后必然一致 | **最终一致且不侵入业务**，可重试、可回放 |

四者关键差异在"**会不会留下长期脏数据**"：①②③ 都可能让**旧值长期驻留缓存**，直到下次被覆盖或过期；④ 的窗口有明确上界，且 binlog **有序、可重放**，失败可重试直到成功，因此能收敛。

```
 Cache Aside 的不一致窗口（方案 ① 的固有缺口）
 t0  读线程 R：GET cache 未命中
 t1  读线程 R：读数据库，拿到旧值 V1     ← R 已经开始，但还没回填
 t2  写线程 W：UPDATE db 把值改成 V2
 t3  写线程 W：DEL cache（此时缓存已空，删除无效果）
 t4  读线程 R：SET cache = V1            ★脏数据写入并长期驻留
 R 横跨了 W 的整个写过程 → 根因是"读缓存 + 读库 + 回填"不是原子操作
```

**延迟双删为什么只能减小窗口而不能消除**：做法是"`DEL` → `UPDATE` → `sleep` → 再 `DEL`"，它只能覆盖"`t4` 发生在第二次 `DEL` 之前"的情况。失效原因有两条：一是 `sleep` 时长没有理论下界，只能凭经验（常取 500 ms~1 s，应不小于一次读请求的最坏耗时）；二是即使睡够，`R` 仍可能因 GC、网络重试、线程调度在第二次 `DEL` 之后才回填。所以它**只把窗口从"长期"压到"通常很小"**，不提供保证。

**工程正解是"先更新数据库再删缓存 + 删除失败重试"**：把 `DEL` 投递到消息队列或异步任务，失败就重试（配合幂等与最大重试次数），此时正确性依赖"缓存删除最终成功"而不是"时序恰好正确"。再叠加两条实践：**缓存 key 必设 TTL** 作为最终兜底（与 27.4 节淘汰策略相呼应，用 `volatile-*` 就必须保证 key 有 TTL），即使删除全部失败也能自愈；**强一致只能靠两件事**——用分布式锁把"读缓存 + 回填"与"写库 + 删缓存"互斥（见 27.11 节，吞吐代价大），或**让缓存承担唯一数据源**（读写全走 Redis，数据库只做异步落盘备份）。

> [!CAUTION]
> 答"用延迟双删就能保证一致性"会被立刻追问"sleep 多久、为什么是这个数、比它更慢的请求怎么办"。正确姿势是主动说明"延迟双删只减小窗口；要收敛必须让删除可重试、并有 TTL 兜底"。

## 27.10 热 key 与大 key

### 27.10.1 识别方法

| 手段 | 命令 | 代价与限制 |
|---|---|---|
| 找大 key | `redis-cli --bigkeys` | 内部用 `SCAN` 采样 + `TYPE` + `STRLEN`/`LLEN`/`HLEN`，**采样不是全量**，可能漏；`ZSet` 只报元素数 |
| 精确内存 | `MEMORY USAGE key` / `--memkeys` | 逐 key 计算，开销明显，建议在从库执行 |
| 找热 key | `redis-cli --hotkeys` | 依赖 `maxmemory-policy` 为 `*-lfu`，否则直接报错 |
| 实时观察 | `MONITOR` | **生产慎用**：每个命令都要回传并格式化，会显著拖慢实例并泄露数据 |
| 离线分析 | `SCAN` 采样 + 客户端/代理埋点统计 | 无侵入，但采样窗口内可能错过突发热点 |

### 27.10.2 热 key 与大 key 的解法

热 key 的四条解法：**本地缓存（进程内）** 把热点放 JVM/Go 进程内的 LRU（如 Caffeine），把对 Redis 的 QPS 降一到两个数量级，代价是**多实例本地缓存的一致性**；**key 打散为多副本**，把 `hot:item:1` 复制成 `hot:item:1:0`…`:9` 并随机读一个（若都落在同一节点则无效，需与分片规划一起设计）；**读写分离**，从库承担读流量，注意复制延迟与 27.6 节的丢数据窗口；**限流与降级**，热点接口在网关层限流，超出部分返回兜底数据。大 key 的危害链条是：**单线程上释放或序列化大对象会阻塞全库**、大 value 传输**打满网卡**（一次 `GET` 10 MB 的 value 在千兆网卡上约 80 ms）、`DEL` 的 `O(N)` 释放**同步阻塞**、Cluster 迁移槽时**迁移卡顿**、`BGSAVE` 的 COW 内存峰值更严重。

```bash
# 拆分大 Hash：按业务维度拆而不是按序号取模，否则一次 HGETALL 变成 N 次往返
HSCAN user:1001:profile 0 COUNT 100
# 删除大 key：UNLINK 先摘链立即返回、内存交后台线程，避免 DEL 的 O(N) 阻塞主线程
UNLINK big:hash:1001
# 分批遍历：SCAN 系列弱一致、可能重复返回，需要调用方按元素去重
HSCAN big:hash:1001 0 COUNT 500
```

配套配置：`lazyfree-lazy-expire yes`、`lazyfree-lazy-eviction yes`、`lazyfree-lazy-server-del yes`（分别让过期、淘汰与 `RENAME` 等隐式删除走后台释放）。

## 27.11 分布式锁的 Redis 实现与坑

> 本章只讲命令层面的正确写法与失败场景；四种分布式锁实现的理论对比与 `Redlock` 争议的完整论证见第 34 章 34.4 节。

正确的最小实现是一条命令完成"互斥 + 过期"，用**唯一 value** 标识持有者，释放时用 Lua 保证"校验 + 删除"的原子性。

```java
// 为什么要一条 SET NX PX：拆成 SETNX + EXPIRE 两条命令时，若进程在两条命令之间崩溃，
// 锁就没有过期时间、会永久残留，后续所有请求全部阻塞（经典的死锁式故障）
String token = UUID.randomUUID().toString();
String ok = jedis.set("lock:order:1001", token, SetParams.setParams().nx().px(30_000));
// 没抢到锁就快速失败，不要自旋等待——自旋会把 Redis 与业务线程一起拖死
if (!"OK".equals(ok)) { return false; }
```

```lua
-- KEYS[1] = 锁 key，ARGV[1] = 本次持有的唯一 token
-- 为什么必须校验 value：否则 A 的锁超时释放后 B 拿到锁，A 执行完直接 DEL 会把 B 的锁删掉，
-- 互斥被无声破坏。Lua 在 Redis 内串行执行，保证"比较 + 删除"不会被中途插入
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
```

三个必须知道的坑。**一是锁续期（看门狗, watchdog）**：业务执行时间可能超过 `PX`，锁会提前过期；Redisson 的做法是默认 30 s 过期并起后台任务每 10 s（`leaseTime/3`）续期一次，直到业务显式解锁，它**只在"进程存活但业务未跑完"时有效**，进程被 `SIGKILL`、网络分区或 GC 长暂停时续期线程也停了。**二是主从切换导致双持有**：锁写在主库，主库崩溃时该锁可能还没复制到从库，从库升主后另一客户端能抢到同一把锁，互斥被破坏——这是"基于异步复制的锁天生不安全"的根源，`Redlock` 想用"多数派独立节点加锁"缓解，但其有效性依赖时钟假设，存在公认争议（见第 34 章 34.4 节）。**三是必须有 fencing token 兜底**：若下游资源支持，应带上**单调递增的 fencing token**，让资源侧拒绝旧 token 的写入，这是"锁过期但旧持有者仍在写"的唯一彻底解法。

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 单线程模型 | 命令执行永远单线程；6.0 多线程只并行网络读写与协议解析 | 答"6.0 后命令执行也并行了" |
| 阻塞源 | `KEYS`/`FLUSHALL`/大 key 删除/大 `ZSET` 范围查询都是主线程 `O(N)` 操作 | 答"单线程所以 `KEYS` 也很快" |
| String 编码阈值 | ≤44 字节用 `embstr`（与 `robj` 一次分配），超过用 `raw` | 说不出 `64 − 16 − 3 − 1 = 44` 的由来 |
| Hash/ZSet 阈值 | 元素数 128，且 value/成员长度 64 字节 | 只记 128，漏掉 64 字节这条 |
| listpack | 不存 `prevlen`，从结构上消除 ziplist 的连锁更新 | 答"listpack 只是更省内存" |
| skiplist | `p = 0.25` 时平均 1.33 层；选它是因为范围查询友好与实现简单 | 答"跳表比红黑树快"却说不出为什么 |
| 渐进式 rehash | 新增只写 `ht[1]`；查询两表都查；`BGSAVE` 期间阈值抬到 5 | 答"rehash 期间不能增删" |
| 过期 vs 淘汰 | 过期由时间触发（惰性 + 每 100 ms 抽 20 个），淘汰由内存压力触发 | 把两者混为一谈 |
| LFU 位布局 | 24 位 = 16 位时间 + 8 位对数计数，计数随 `lfu-decay-time` 衰减 | 答"LFU 就是访问次数计数" |
| 持久化 | 混合持久化 = RDB 前导 + 增量 AOF；`everysec` 窗口 ≤1 s | 答"开了 AOF 就不会丢数据" |
| backlog | `repl_backlog` 被覆盖即触发全量重同步 | 答"从库重连总是增量同步" |
| 哨兵 | SDOWN 由单哨兵判定，ODOWN 需 `quorum`，选主需多数派 | 把 `quorum` 当成选主票数 |
| Cluster | `16384` 槽是心跳位图大小与规模的折中；`MOVED` 改映射，`ASK` 不改 | 把两者当同一种重定向 |
| 一致性 | Cache Aside + 删除重试 + TTL 兜底才收敛；延迟双删只减小窗口 | 答"延迟双删保证一致性" |

## 自测题

**Q1**（辨析）有人主张"Redis 6.0 引入了多线程，因此 `KEYS *` 不再阻塞主线程"。请判断对错，并说明 6.0 多线程实际覆盖的代码路径与 `KEYS` 的判定依据。
<details><summary>答案与解析</summary>
**错，而且错两处。** 6.0 的多线程 I/O 只并行 `read()`/`write()` 与 RESP 协议解析，命令的查找、执行、回复构造、复制传播、过期判断全在主线程串行，所以它优化的是"网络与协议解析占比高"的负载，对纯 CPU 命令收益接近零。第二处：`KEYS` 的代价是**遍历整个键空间**（`O(N)`），逻辑就在命令执行路径上，与 I/O 线程无关，百万 key 的实例上可阻塞几十到几百毫秒。正确用法是 `SCAN cursor MATCH p COUNT 100`，但要补充它是**弱一致**的，可能重复返回元素、需要调用方去重。
**面试官会追问**：(1) `SCAN` 为什么在 rehash 期间也不漏？——游标是**反向二进制位迭代**，rehash 时桶掩码变大，用高位反向递增可覆盖既存元素（见 27.3.5 节）。(2) 还有哪些命令会阻塞？——见 27.1.3 节：`FLUSHALL` 用 `ASYNC`、大 key `DEL` 用 `UNLINK`、大 `ZSET` 范围查询分页。
</details>

**Q2**（计算）某业务用 Hash 存用户画像，字段数约 300，其中 `signature` 字段的值约 200 字节。请判断该 key 的实际编码并说明依据，再给出两种降低内存占用的改造方向。
<details><summary>答案与解析</summary>
编码是 **`hashtable`**。两个条件**都**越界，任一满足即转换：`hash-max-listpack-entries` 默认 128，字段数 300 > 128；`hash-max-listpack-value` 默认 64，`signature` 的值 200 字节 > 64。常见错误是只答第一条而漏掉 value 长度阈值——面试官正是用 64 字节这一项区分"背过"与"看过配置"。改造方向：(1) **拆 key**，按字段用途拆成 `profile:base`（少量短字段）与 `profile:ext`（长文本），让前者回到 listpack，而 listpack 是连续内存、无哈希表指针开销；(2) **长 value 移出 Hash**，把 200 字节签名单独存成 String key 或落到对象存储、Hash 里只留引用。
**面试官会追问**：编码能自动退回 listpack 吗？——**不能**。转换是单向的，删字段让数量降到 128 以下也不会降级，需要重建 key（回退要记录原编码并重建结构，且会导致编码频繁抖动）。
</details>

**Q3**（推导）某 Redis 实例峰值写入速率约 20 MB/s，运维把 `repl-backlog-size` 保持默认 1 MB。请推导一次主从网络抖动 3 秒后会发生什么，并计算避免全量重同步至少应配置多大 backlog。
<details><summary>答案与解析</summary>
从库断线 3 秒、主库持续接收写入，累计约 `20 MB/s × 3 s = 60 MB`；而 `repl_backlog` 是**定长环形缓冲**（默认 1 MB），写满后新数据覆盖最旧数据，因此从库断开时的 `offset` 早已被覆盖。从库重连发 `PSYNC <replid> <offset>`，主库发现该 `offset` 超出 backlog 范围 → 回复 `+FULLRESYNC` 触发全量重同步；全量重同步要 `BGSAVE` 并传输整个 RDB，RDB 越大 `fork` 停顿越久、COW 内存峰值越高（可能接近 2 倍内存），于是"网络抖动"被放大成"主库抖动"，可能引发第二轮超时重连。
容量估算：`backlog ≥ 峰值写入速率 × 可容忍最长断线时间`。容忍 3 秒需 `20 × 3 = 60 MB`，工程上留 2~3 倍余量即 128 MB 或 256 MB；容忍 60 秒则需 1200 MB，此时应重新评估是否值得。
**面试官会追问**：(1) backlog 是每个从库一份吗？——不是，主库只有一个共享 backlog。(2) 为什么不能无限大？——它是常驻内存的环形缓冲，从库断开后由 `repl-backlog-ttl`（默认 3600 s）决定何时释放。(3) 全量同步期间主库的新写命令存哪？——存在该从库对应的 `replication buffer`，从库消费太慢会触顶并被主库断开，这又是一次全量同步。
</details>

**Q4**（设计）一个商品详情接口 QPS 约 5 万，热点商品 key 集中，当前方案是"查 Redis 未命中就查库并回填"。请指出热点商品缓存过期瞬间会发生什么，给出三层以上改造方案，并说明每层解决的到底是穿透、击穿还是雪崩。
<details><summary>答案与解析</summary>
现象：热点 key 过期瞬间，对该 key 的请求**同时**未命中并全部打到数据库，连接池打满、接口全面超时——这是**缓存击穿**，不是雪崩（雪崩是大量 key 同时失效或 Redis 整体不可用）。改造方案：(1) **互斥锁重建（击穿）**：未命中先 `SET lock:item:1 <token> NX PX 3000`，只让拿到锁的请求查库回填，其余短暂等待或返回旧值，锁必须唯一 value + Lua 释放（见 27.11 节）；(2) **逻辑过期 / 不设 TTL（击穿）**：value 里存逻辑过期时间、Redis key 不设 TTL，读到逻辑过期就返回旧值并异步触发单线程刷新，于是不存在"无数据可返回"的窗口；(3) **本地缓存 + 多级缓存（热点并发与部分雪崩）**：进程内缓存承担绝大部分读，短 TTL + 广播失效，Redis 作二级；(4) **TTL 加随机抖动（雪崩）**；(5) **布隆过滤器 + 空值缓存（穿透）**：商品下架、非法 ID 这类"查不到"的请求与击穿解法完全不同，漏了这层用随机 ID 就能绕过所有缓存；(6) **熔断降级（雪崩的最后一道墙）**。
**面试官会追问**：(1) 异步刷新由谁做、失败了怎么办？——由拿到刷新权的线程或定时任务做，失败要记录并重试，且必须有兜底 TTL 防永久脏数据。(2) 5 万 QPS 全打到一个节点怎么扛？——这是**热 key** 问题，要用 key 打散多副本 + 从库读 + 本地缓存，见 27.10.2 节。
</details>

**Q5**（设计/辨析）团队用"先更新数据库，再删缓存"实现一致性，但监控发现偶发脏数据长期驻留缓存。请分析最可能的并发时序，解释"延迟双删"为什么不能彻底解决，并给出可落地的工程正解。
<details><summary>答案与解析</summary>
时序即 27.9 节的 Cache Aside 缺口：读线程 `R` 在 `GET` 未命中后读库拿到旧值 `V1`，写线程 `W` 在此期间完成 `UPDATE`（写成 `V2`）与 `DEL cache`（此时缓存已空，删除无效果），随后 `R` 才回填 `SET cache = V1`——**脏数据写入并长期驻留**；根因是"读缓存 + 读库 + 回填"不是原子操作，而 `DEL` 只发生在写线程一侧。
**延迟双删为什么不能彻底解决**：做法是"`DEL` → `UPDATE` → `sleep` → 再 `DEL`"，它只能覆盖回填发生在第二次 `DEL` 之前的情况。失效原因有两条：(1) `sleep` 时长没有理论下界，只能凭经验（常取 500 ms~1 s）；(2) 即使睡够，`R` 仍可能因 GC、网络重试、线程调度在第二次 `DEL` 之后才回填。所以它只把窗口从"长期"压到"通常很小"。
**工程正解**：(1) 保持 Cache Aside，因为它在四种方案里留下长期脏数据的概率最低；(2) 把删缓存做成**可重试**——删除动作投递到消息队列，失败重试并告警直到成功，消费者要幂等；(3) **所有缓存 key 必设 TTL** 作为最终兜底；(4) 更强一致性走 binlog 订阅（canal 类），由数据库日志驱动删除，天然有序、可重放、对业务零侵入，窗口有明确上界；(5) 真需要强一致只有两条路——用分布式锁把"读回填"与"写删缓存"互斥，或**让缓存成为唯一数据源**、数据库只做异步落盘。
**面试官会追问**：为什么推荐"删缓存"而不是"更新缓存"？——并发写下会出现旧值覆盖新值（方案 ③），且缓存值需复杂计算时"更新"会做无效工作。
</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| Redis 为什么用单线程还这么快？6.0 的多线程又解决了什么？ | 单线程模型的能力边界 | 错答"因为它是内存数据库"，或说"6.0 之后命令执行也是多线程"。加分点：I/O 线程只并行 `read`/`write` 与协议解析，命令执行仍串行 | 27.1.1、27.1.2 |
| 线上 Redis 突然卡住几百毫秒，你会从哪些命令或配置开始排查？ | 阻塞源与替代方案 | 错答"加内存/加机器"。加分点：`KEYS`、`FLUSHALL`、大 key `DEL`、大 `ZSET` 范围查询、`appendfsync always`、`fork` 停顿，并给出 `SCAN`/`UNLINK`/分页/`everysec` 的替代 | 27.1.3 |
| String 的 `embstr` 和 `raw` 分界是多少字节，为什么是这个数？Hash/ZSet 又什么时候变通用编码？ | 编码阈值与分配单元推导 | 错答"大概 40 多字节吧"，或只记元素数 128 而漏掉 value 64 字节阈值。加分点：`64 − 16(robj) − 3(sdshdr8) − 1(\0) = 44` 恰好填满一个分配单元；转换单向，降回阈值不会自动变回 | 27.2 |
| listpack 为什么要替代 ziplist？跳表为什么不用红黑树实现？渐进式 rehash 期间的增删改查怎么处理？ | 底层结构的设计取舍与 dict 状态机 | 错答"listpack 更省内存"却说不出 `prevlen` 1→5 字节连锁更新；跳表题答"跳表更快"却说不出范围查询走底层链表与平均 1.33 层；rehash 题答"期间会加锁或拒绝写入"，而不是"查询两表都查、新增只写 `ht[1]`" | 27.3.2、27.3.4、27.3.5 |
| 过期删除和内存淘汰是同一件事吗？八种淘汰策略说一遍。LRU 是真 LRU 吗？ | 两套机制的区分与 `lru` 位布局 | 错答把两者混为一谈，或漏掉 `noeviction` 是默认值、`volatile-*` 在 key 无 TTL 时等于无法淘汰；"Redis 用链表实现 LRU"也是错的，实际是 `maxmemory-samples` 默认 5 的采样近似 | 27.4、27.4.3 |
| 开了 AOF 是不是就不会丢数据了？从库重连为什么有时做全量同步而不是增量？ | 持久化窗口与复制续传 | 错答"开了 AOF 就万无一失"、"网络断了就必须全量同步"。加分点：`everysec` 最多丢约 1 秒；`repl_backlog` 被新写覆盖就只能 `+FULLRESYNC`，并给出 `backlog ≥ 写入速率 × 容忍断线时间` | 27.5.2、27.6 |
| 哨兵的主观下线和客观下线有什么区别？Cluster 为什么是 16384 个槽、`MOVED` 和 `ASK` 又有什么区别？ | 哨兵判定、集群规格与重定向语义 | 错答"一个哨兵发现就算客观下线"、把 `quorum` 当选主票数、或说"16384 是为了哈希更均匀"。加分点：选主要多数派；槽位图 2 KB vs 8 KB 的心跳带宽权衡；`MOVED` 改映射、`ASK` 不改 | 27.7.1、27.7.2 |
| 缓存穿透、击穿、雪崩分别是什么？各自的解法是什么？ | 三件套的区分 | 错答把三者都答成"加随机过期时间"；把 HyperLogLog 的 0.81% 标准误差说成布隆过滤器误判率 | 27.8 |
| 先更新数据库再删缓存，和先删缓存再更新数据库，哪个对？延迟双删能保证一致性吗？ | 一致性方案的时序分析 | 错答"延迟双删能保证一致"。加分点：画出读线程横跨写过程的回填时序，说明 `sleep` 无理论下界、重试 + TTL 兜底才收敛，binlog 订阅可把窗口压到有界 | 27.9 |
| 怎么发现热 key 和大 key？Redis 分布式锁怎么写、锁提前过期和主从切换会出什么问题？ | 热大 key 治理与锁的失效场景 | 错答"用 `MONITOR` 看"（生产会让实例明显变慢）；锁题错答用 `SETNX` + `EXPIRE` 两条命令，或释放时不校验 value。加分点：`UNLINK` + `HSCAN` 分批；看门狗、双持有与 fencing token | 27.10、27.11 |

## 延伸阅读

- Redis 官方文档：*Persistence*（RDB/AOF/混合持久化的格式与 `appendfsync` 语义）、*Replication*（`psync`、`repl_backlog`、`replica-read-only`）、*Sentinel*（SDOWN/ODOWN 与 `quorum`）、*Cluster specification*（槽分配、`MOVED`/`ASK`、`CLUSTER SLOTS`）、*Memory optimization*（各类型编码阈值与 `OBJECT ENCODING`）。
- Redis 官方文档：*Key eviction*（八种 `maxmemory-policy` 与近似 LRU/LFU 的采样实现）、*Keyspace notifications*。
- Redis 源码：`t_zset.c`（`ZSKIPLIST_P = 0.25`、`ZSKIPLIST_MAXLEVEL = 32`）、`listpack.c` 与 `ziplist.c`（对照 `prevlen` 与 `backlen`）、`dict.c`（`rehashidx` 与 `_dictRehashStep`）、`server.h`（`robj.lru` 的 24 位复用与 LFU 位域拆分）。
- 见第 4 章 4.2 节（`fork` 与 COW 的页表机制）、第 7 章 7.4 节（`epoll` 的红黑树与就绪链表）、第 34 章 34.3、34.4 节（一致性模型谱系、Raft、`Redlock` 争议与四种分布式锁的理论对比）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch27/  

