第 27 章 Redis 原理与缓存架构
前面几章把数据放在磁盘上,本章把它放进内存:代价是容量与持久性,收益是微秒级延迟与全新的故障模式。
学习目标
读完本章,你应当能够:
- 准确表述单线程模型(single-threaded model)的能力边界,说清 6.0 多线程 I/O 并行化了哪一段、哪一段仍是单线程,并列出会阻塞全库的操作;
- 给出五种基本类型与三种扩展类型(BitMap、HyperLogLog、GEO)的底层编码与全部转换阈值,并解释阈值为什么取这个值;
- 推导 SDS 相对 C 字符串的三个优势、listpack 相对 ziplist 解决的连锁更新(cascade update)、skiplist 的平均层数,并画出 dict 渐进式 rehash(incremental rehashing)的双表迁移过程与
rehashidx的状态语义; - 区分过期删除(expiration)与内存淘汰(eviction),写出八种淘汰策略,拆解 LRU 与 LFU 在
lru字段上的位布局; - 对比 RDB、AOF、混合持久化的丢数据窗口与恢复速度并给出生产配置,说清
psync/repl_backlog导致全量重同步的根因; - 说清哨兵 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 到底并行了什么
| |
并行的是网络读写与协议解析(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 可见)。高频细节题全部集中在"什么时候从紧凑编码变成通用编码"。
| |
| 类型 | 阈值配置项 | 默认值 | 语义 |
|---|---|---|---|
| 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 字节即转 |
提示
阈值题模板是"默认值 + 触发条件 + 为什么"。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 字节。
| |
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 为限做定时搬迁,保证空闲时也能推进。
| |
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 字段:
| |
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 保留多久。
| |
从库默认 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。
| |
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 有序、可重放,失败可重试直到成功,因此能收敛。
| |
延迟双删为什么只能减小窗口而不能消除:做法是"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 识别方法
| 手段 | 命令 | 代价与限制 |
|---|---|---|
| 找大 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 内存峰值更严重。
| |
配套配置:lazyfree-lazy-expire yes、lazyfree-lazy-eviction yes、lazyfree-lazy-server-del yes(分别让过期、淘汰与 RENAME 等隐式删除走后台释放)。
27.11 分布式锁的 Redis 实现与坑
本章只讲命令层面的正确写法与失败场景;四种分布式锁实现的理论对比与
Redlock争议的完整论证见第 34 章 34.4 节。
正确的最小实现是一条命令完成"互斥 + 过期",用唯一 value 标识持有者,释放时用 Lua 保证"校验 + 删除"的原子性。
| |
| |
三个必须知道的坑。一是锁续期(看门狗, 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 的判定依据。
答案与解析
**错,而且错两处。** 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` 早已被覆盖。从库重连发 `PSYNCQ4(设计)一个商品详情接口 QPS 约 5 万,热点商品 key 集中,当前方案是"查 Redis 未命中就查库并回填"。请指出热点商品缓存过期瞬间会发生什么,给出三层以上改造方案,并说明每层解决的到底是穿透、击穿还是雪崩。
答案与解析
现象:热点 key 过期瞬间,对该 key 的请求**同时**未命中并全部打到数据库,连接池打满、接口全面超时——这是**缓存击穿**,不是雪崩(雪崩是大量 key 同时失效或 Redis 整体不可用)。改造方案:(1) **互斥锁重建(击穿)**:未命中先 `SET lock:item:1Q5(设计/辨析)团队用"先更新数据库,再删缓存"实现一致性,但监控发现偶发脏数据长期驻留缓存。请分析最可能的并发时序,解释"延迟双删"为什么不能彻底解决,并给出可落地的工程正解。
答案与解析
时序即 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 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争议与四种分布式锁的理论对比)。