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

学习目标

读完本章,你应当能够:

  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 倍。

重要

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

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

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

1
2
3
4
5
6
            主线程                                    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 才让读也并行。

小心

答"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 可见)。高频细节题全部集中在"什么时候从紧凑编码变成通用编码"。

1
2
3
4
5
6
7
8
9
类型        小/特殊形态编码                        超过阈值后
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
Listlist-max-listpack-size128单个 listpack 元素数上限;另受 list-max-ziplist-size 的 -2 档(8 KB)约束
Hashhash-max-listpack-entries128字段数超过即转 hashtable
Hashhash-max-listpack-value64任一字段名或值长度超过 64 字节即转
Setset-max-intset-entries512全整数且元素数 ≤512 用 intset
Setset-max-listpack-entries128含非整数元素时,元素数 ≤128 用 listpack(7.2 起)
ZSetzset-max-listpack-entries128成员数超过即转 skiplist
ZSetzset-max-listpack-value64任一成员长度超过 64 字节即转

提示

阈值题模板是"默认值 + 触发条件 + 为什么"。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 字节。

1
2
3
4
5
6
 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 更省。

重要

问"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 为限做定时搬迁,保证空闲时也能推进。

1
2
3
4
5
6
 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 最小者优先后者用于希望快过期者先走的场景

警告

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

27.4.3 近似 LRU 与 LFU 的位布局

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 近似 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 命令:

维度RDBAOFRDB + 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 保留多久。

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

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

重要

复制的本质是异步的:主库写成功并向客户端返回时,命令可能还没到从库。所以"主库确认 = 数据已持久"是错的,主库宕机会丢最后一段写。要缩小窗口只能靠 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。

1
2
3
4
5
# 只要花括号内内容相同,两个 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 有序、可重放,失败可重试直到成功,因此能收敛。

1
2
3
4
5
6
7
 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,数据库只做异步落盘备份)。

小心

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

27.10 热 key 与大 key

27.10.1 识别方法

手段命令代价与限制
找大 keyredis-cli --bigkeys内部用 SCAN 采样 + TYPE + STRLEN/LLEN/HLEN,采样不是全量,可能漏;ZSet 只报元素数
精确内存MEMORY USAGE key / --memkeys逐 key 计算,开销明显,建议在从库执行
找热 keyredis-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 内存峰值更严重。

1
2
3
4
5
6
# 拆分大 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 保证"校验 + 删除"的原子性。

1
2
3
4
5
6
// 为什么要一条 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; }
1
2
3
4
5
6
7
8
-- 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 只是更省内存"
skiplistp = 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 就不会丢数据"
backlogrepl_backlog 被覆盖即触发全量重同步答"从库重连总是增量同步"
哨兵SDOWN 由单哨兵判定,ODOWN 需 quorum,选主需多数派把 quorum 当成选主票数
Cluster16384 槽是心跳位图大小与规模的折中;MOVED 改映射,ASK 不改把两者当同一种重定向
一致性Cache Aside + 删除重试 + TTL 兜底才收敛;延迟双删只减小窗口答"延迟双删保证一致性"

自测题

Q1(辨析)有人主张"Redis 6.0 引入了多线程,因此 KEYS * 不再阻塞主线程"。请判断对错,并说明 6.0 多线程实际覆盖的代码路径与 KEYS 的判定依据。

答案与解析**错,而且错两处。** 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` 范围查询分页。

Q2(计算)某业务用 Hash 存用户画像,字段数约 300,其中 signature 字段的值约 200 字节。请判断该 key 的实际编码并说明依据,再给出两种降低内存占用的改造方向。

答案与解析编码是 **`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(回退要记录原编码并重建结构,且会导致编码频繁抖动)。

Q3(推导)某 Redis 实例峰值写入速率约 20 MB/s,运维把 repl-backlog-size 保持默认 1 MB。请推导一次主从网络抖动 3 秒后会发生什么,并计算避免全量重同步至少应配置多大 backlog。

答案与解析从库断线 3 秒、主库持续接收写入,累计约 `20 MB/s × 3 s = 60 MB`;而 `repl_backlog` 是**定长环形缓冲**(默认 1 MB),写满后新数据覆盖最旧数据,因此从库断开时的 `offset` 早已被覆盖。从库重连发 `PSYNC `,主库发现该 `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`,从库消费太慢会触顶并被主库断开,这又是一次全量同步。

Q4(设计)一个商品详情接口 QPS 约 5 万,热点商品 key 集中,当前方案是"查 Redis 未命中就查库并回填"。请指出热点商品缓存过期瞬间会发生什么,给出三层以上改造方案,并说明每层解决的到底是穿透、击穿还是雪崩。

答案与解析现象:热点 key 过期瞬间,对该 key 的请求**同时**未命中并全部打到数据库,连接池打满、接口全面超时——这是**缓存击穿**,不是雪崩(雪崩是大量 key 同时失效或 Redis 整体不可用)。改造方案:(1) **互斥锁重建(击穿)**:未命中先 `SET lock:item:1 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 节。

Q5(设计/辨析)团队用"先更新数据库,再删缓存"实现一致性,但监控发现偶发脏数据长期驻留缓存。请分析最可能的并发时序,解释"延迟双删"为什么不能彻底解决,并给出可落地的工程正解。

答案与解析时序即 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) 真需要强一致只有两条路——用分布式锁把"读回填"与"写删缓存"互斥,或**让缓存成为唯一数据源**、数据库只做异步落盘。 **面试官会追问**:为什么推荐"删缓存"而不是"更新缓存"?——并发写下会出现旧值覆盖新值(方案 ③),且缓存值需复杂计算时"更新"会做无效工作。

面试高频问题与陷阱

面试问法考查角度陷阱 / 加分点对应本章小节
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 token27.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 争议与四种分布式锁的理论对比)。