一、数据结构与底层(R01–R04)
R01. 实现“排行榜(按分数排序、取 Top N、查询某用户排名)”应使用 Redis 的哪种数据结构?
考点:数据结构选型
A. String B. List C. Hash D. ZSet(有序集合)
答案:D
【考点】五种基本数据类型的适用场景。
【结论】 排行榜需要“按分数排序 + 取 Top N + 查某用户排名”,这是 ZSet(有序集合)的标准场景 —— 选 D。
【逐项辨析】
- A String:错。String 只能存整块值,没有排序能力 —— 要取 Top N 必须把全部数据拉到应用层自行排序,且无法直接查询某成员的排名。
- B List:错。List 是按插入顺序排列的链表,不是按分值排序;取 Top N 只能按插入位置取,“排名”也不是按分数的排名。
- C Hash:错。Hash 适合按字段存取一个对象(如购物车),字段之间没有序关系,既不能排序也不能取 Top N。
- D ZSet(有序集合):正确。 每个成员绑定一个
score,内部按score有序,ZADD/ZREVRANGE/ZREVRANK直接覆盖“写入 / 取 Top N / 查排名”三个动作;复杂度分别是ZADD O(log N)、ZREVRANK O(log N)、ZREVRANGE O(log N + M)(M 为返回成员数,取 Top N 时即O(log N + N))。
【知识点】 Redis 五种基本类型的“能力矩阵”—— 选型的关键是看需要哪种访问模式:
| 类型 | 底层结构 | 核心能力 | 典型场景 |
|---|---|---|---|
| String | SDS | 单值读写、计数、位操作 | 缓存对象、计数器、分布式锁(SET NX) |
| List | quicklist(双向链表 + listpack) | 两端插入 / 弹出、按位置访问 | 消息队列、最新列表、时间线 |
| Hash | 哈希表 / listpack | 按字段读写对象的属性 | 购物车、对象字段局部更新 |
| Set | 哈希表 / intset | 去重、交并差集 | 标签、共同好友、抽奖去重 |
| ZSet | 跳表 + 哈希表 | 按分值排序、范围查询、排名 | 排行榜、延时队列、热搜榜 |
排行榜的四个动作与 ZSet 命令的对应:
| 需求 | 命令 | 复杂度 |
|---|---|---|
| 写入某用户分数 | ZADD | O(log N) |
| 取 Top N | ZREVRANGE key 0 N-1 WITHSCORES | O(log N + N) |
| 查某用户排名 | ZREVRANK(降序)/ ZRANK(升序) | O(log N) |
| 取分数区间成员 | ZRANGEBYSCORE | O(log N + M) |
为什么 ZSet 能做到 O(log N):因为“排序”这件事被下沉到了数据结构内部 —— 写入时即维护有序性,所以查询排名不需要遍历全部数据。用 List / Hash 做排行榜,排序成本被推迟到应用层,必然是全量遍历 O(N)。
【记忆锚点】 「要排序、要排名、要 Top N —— 一律 ZSet」 —— 选型的判据是“数据需不需要按某个分值维持有序”。
【易混对比】
- ZSet vs Set:Set 只去重、无序;ZSet 去重 + 按
score有序。ZSet 的成员同样唯一,但多了一个score。 - ZSet vs List:List 按插入位置排序(谁先来谁靠前),ZSet 按分值排序(分数高者靠前)—— 排行榜要的是后者。
- ZSet vs Hash:Hash 是“成员 → 多个字段”的映射(一个对象);ZSet 是“成员 → 一个分数”的映射(一个排序维度)。
- 换问法:若题干改成“实现消息队列 / 最新 N 条评论”,答案是 List;改成“统计 UV / 去重”,答案是 Set;改成“存储商品多个属性”,答案是 Hash。
【自测】 要实时统计“直播间人气榜”,支持按人气值排序、查询主播当前排名,且要求“人气值相同时按主播加入时间先后排名”。用哪种结构最合适?同分如何处理?
答:ZSet。同分可把“加入时间戳”编码进成员名 —— 因为 ZSet 在
score相同时按成员字典序排列(Redis 固有权衡:score 相同则按成员名升序,见【知识点】),把时间戳前置于成员名即可实现“同分按先后”的稳定排序。与 R02 连考;互联网面试高频。
【知识关联】
- 补题关联:补-27(业务场景与数据结构匹配)、补-28(编码选型)。
- 面试/工程:选型第一问是“访问模式”:要排序/范围 → ZSet;要精确去重计数 → HyperLogLog(有误差);要位运算/连续签到 → Bitmap;要树状关系 → 不要用原生 Redis 硬扛,可用邻接表+应用层或专门图库。结构选错的代价是:内存暴涨或复杂度从 O(1) 退化到 O(N)。
- 面试追问:① 统计 UV 用 Set 还是 HLL?(精确用 Set,海量用 HLL) ② 延迟队列常用什么?(ZSet:score=到期时间戳,轮询
ZRANGEBYSCORE+ Lua 取出)
【拓展延伸】
- 变式问法:给出业务描述选结构;或反向“下列匹配不正确的是”。
- 参数/命令:
ZADD/ZRANGEBYSCORE、PFADD/PFCOUNT、SETBIT/GETBIT/BITCOUNT、GEOADD/GEOSEARCH;编码阈值hash-max-listpack-entries等见补-28。
R02. Redis 中 ZSet(有序集合)的底层实现是?
考点:ZSet 的底层实现
A. 双向链表 B. 红黑树 C. 跳表(skiplist)+ 哈希表(dict) D. 普通数组
答案:C
【考点】跳表与哈希表的组合设计。
【结论】 ZSet 采用跳表 + 哈希表双结构:跳表管“按 score 有序”,哈希表管“成员 → score 的 O(1) 查找” —— 选 C。
【逐项辨析】
- A 双向链表:错。双向链表查询是
O(N),无法支撑O(log N)的排名与范围查询;Redis 的 List 用的才是双向链表(quicklist)。 - B 红黑树:错。Redis 的 ZSet 并没有采用红黑树 —— 红黑树是 Java
TreeMap、C++std::map的实现,属张冠李戴。 - C 跳表(skiplist)+ 哈希表(dict):正确。 两个结构各司其职,缺一不可(见下)。
- D 普通数组:错。数组插入 / 删除是
O(N),无法在动态增删下维持O(log N)的有序访问。
【知识点】 为什么 ZSet 必须是“两个结构”?因为 ZSet 同时提供两类接口,而这两类接口的最优结构恰好相反:
| 操作 | 需要的能力 | 只靠跳表 | 只靠哈希表 | 双结构 |
|---|---|---|---|---|
ZSCORE key member | 按成员定位 | O(log N) | O(1) | O(1)(哈希表) |
ZRANGE / ZREVRANK | 按分值有序、范围与排名 | O(log N) | 无法做 | O(log N)(跳表) |
推论:只用跳表 → 按成员查 score 要 O(log N),差一个量级;只用哈希表 → 无序,范围查询与排名根本无法实现。所以 Redis 让跳表与哈希表指向同一批成员,哈希表只存“成员 → score”映射,跳表负责排序与范围。
跳表的原理:在有序链表之上建立多级索引,每层按一定概率(Redis 中晋升概率为 1/4)抽取部分节点作为上层索引,查找时从最高层开始“跳跃”、逐层下降,平均查找长度为 O(log N)。相比平衡树,跳表不需要旋转,范围查询只需在底层链表顺序前进,实现更简单。
内存优化:当元素较少时(默认成员数 ≤ 128 且每个成员长度 ≤ 64 字节),ZSet 会改用 listpack(Redis 7.0 之前为 ziplist)压缩存储,此时不建跳表与哈希表;超过阈值才转换为跳表 + 哈希表。
【记忆锚点】 「跳表管排序与范围,哈希表管按成员查分」 —— 两个结构指向同一批成员,一个负责“有序”,一个负责“定位”。
【易混对比】
- 跳表 vs 红黑树:都能
O(log N)查找,但跳表实现更简单、范围查询更自然(底层本身就是有序链表)、无需旋转;Redis 选跳表,Java 的有序容器选红黑树。 - ZSet 的两种编码:小数据量用 listpack,大数据量用跳表 + 哈希表。Set 小数据量则用 intset(纯整数)或 listpack + 哈希表。
- listpack vs ziplist:Redis 7.0 起用 listpack 取代 ziplist 作为小对象压缩编码,解决了 ziplist 的连锁更新(cascade update)问题。
- 换问法:若题干问“ZSet 小数据量时的编码”,答案是 listpack(旧版本 ziplist),而不是跳表。
【自测】 为什么 Redis 用跳表而不是红黑树来实现 ZSet?
答:三点 —— ① 范围查询更自然:跳表底层就是有序链表,找到起点后顺序前进即可;红黑树需中序遍历。② 实现更简单:跳表靠概率平衡,无需旋转与再平衡逻辑。③ 局部调整更容易:增删只影响相邻指针,无需全局重平衡。与 R01、R03 连考;互联网面试高频。
【知识关联】
- 补题关联:补-27(ZSet 适用场景)、补-28(listpack 小对象优化的同一思想)。
- 面试/工程:排行榜
ZINCRBY/ZREVRANGE、延时队列、滑动窗口限流(ZSet 时间戳)都建立在“有序 + 按 member 查 score O(1)”上。面试常追问“为什么不是只用跳表”——按 member 查会退化到 O(log N),批量校验 member 是否存在也不再是 O(1)。 - 面试追问:① 跳表与平衡树比优势?(实现简单、范围扫描友好、无需旋转) ② 小 ZSet 会退化成什么?(listpack/ziplist 连续内存,牺牲 O(log N) 换更小内存)
【拓展延伸】
- 变式问法:问“ZSCORE 依赖哪个结构”→ 哈希表;问“ZRANGE 依赖哪个”→ 跳表;问“元素很少时编码是什么”→ listpack。
- 参数/命令:
zset-max-listpack-entries(旧名 zset-max-ziplist-entries)、zset-max-listpack-value;OBJECT ENCODING查看当前编码;ZADD GT/LT/NX/XX条件更新。
R03. Redis 的 String 底层使用 SDS(简单动态字符串)而非 C 原生字符串,其好处不包括?
考点:SDS 简单动态字符串
A. 记录字符串长度,获取长度是 O(1)(C 字符串需遍历,O(N)) B. 二进制安全,可存储图片、序列化对象等含 \0 的数据 C. 自动扩容并预留空间,避免缓冲区溢出、减少内存重分配次数 D. 支持事务的 ACID 特性
答案:D
【考点】SDS 相对 C 字符串的四项改进。
【结论】 题干问的是“不包括”,正确答案是 D —— SDS 与事务的 ACID 毫无关系;A、B、C 三项都是 SDS 的真实好处。
【逐项辨析】
- A 记录字符串长度、取长度为
O(1):确是 SDS 的真实好处,故不选。SDS 结构中直接存len字段,取长度无需遍历;C 字符串要一直扫到\0,是O(N)。 - B 二进制安全、可存含
\0的数据:确是 SDS 的真实好处,故不选。SDS 用len判断结尾,而不是靠\0,因此图片、序列化对象等含\0的字节流可以原样保存。 - C 自动扩容并预留空间:确是 SDS 的真实好处,故不选。扩容前先检查空间,杜绝缓冲区溢出;扩容采用空间预分配、缩容采用惰性释放,减少内存重分配次数。
- D 支持事务的 ACID 特性:正确(“不包括”的就是这一项)。 事务是 Redis 的命令组合机制,属于命令层特性;字符串的底层结构不参与事务语义,且 Redis 事务本身也不支持回滚,并不具备完整 ACID。
【知识点】 SDS(Simple Dynamic String,简单动态字符串)的定义:Redis 自己实现的字符串结构,用长度字段代替 \0 作为结尾判据。其结构大致为 len + alloc + flags + buf[],并按长度选择不同的头类型:
| 头类型 | 长度范围 | len / alloc 占用 |
|---|---|---|
sdshdr5 | ≤ 31 字节 | 不存 alloc(长度编码进 flags),不能原地扩容 |
sdshdr8 | ≤ 255 字节 | 各 1 字节 |
sdshdr16 | ≤ 65535 字节 | 各 2 字节 |
sdshdr32 | ≤ 4 GB | 各 4 字节 |
sdshdr64 | 更大 | 各 8 字节 |
按长度分档选头的目的是省内存 —— 短字符串不必背负 8 字节的长度字段。
SDS 与 C 字符串的逐项对比:
| 维度 | C 字符串 | SDS |
|---|---|---|
| 取长度 | O(N)(遍历到 \0) | O(1)(读 len) |
| 二进制安全 | ❌ 遇 \0 即截断 | ✅ 用 len 判界,可存任意字节 |
| 缓冲区溢出 | 易发生(strcat 不检查容量) | 扩容前检查 alloc,杜绝溢出 |
| 内存重分配 | 每次修改都可能 realloc | 空间预分配 + 惰性释放,减少次数 |
推导:C 字符串的一切缺陷都源于“只用 \0 表示结尾”。因为结尾信息与内容耦合 → 存不了 \0(非二进制安全);因为没有独立长度 → 取长度要遍历(O(N));因为不知道剩余空间 → 拼接可能越界(缓冲区溢出)。SDS 把“长度”从内容里抽出来单独存,三个问题一次性解决,这就是“长度与内容解耦”。
空间预分配策略:修改后 len < 1 MB 时,alloc = 2 × len(即再多分配一倍);len ≥ 1 MB 时,每次多分配 1 MB。惰性释放:缩短字符串时不立即 realloc,而是更新 len 并保留 alloc,等后续需要时再复用。
【记忆锚点】 「SDS 把长度抽出来单独存」 —— 一句话记住四项好处:O(1) 取长、二进制安全、不溢出、少重分配。
【易混对比】
- SDS vs C 字符串:核心差异是“结尾判据”—— C 靠
\0,SDS 靠len。 - 惰性释放 vs 内存泄漏:惰性释放是有意的缓存(保留空间以备复用),不是泄漏;空间仍由
alloc记录,后续写入可直接复用。 - SDS 与 listpack / ziplist 的关系:listpack 内部的字符串元素同样可用 SDS 或整型编码;三者都是 Redis 的基础存储单元,但层级不同(SDS 是字符串,listpack 是紧凑列表)。
- 换问法:若题干改成“下列哪项是 SDS 的好处”,答案就落在 A / B / C 任一项上;若问“SDS 有哪些头类型”,答案是
sdshdr5/8/16/32/64。
【自测】 一个 len = 10 字节的字符串(小于 1 MB),执行 APPEND key "abc" 后 len 变为 13,此时 SDS 的 alloc(可用空间)是多少?缩短字符串时为什么不一定立刻释放内存?
答:
alloc = 26(13 × 2—— 因为len < 1 MB时按“再多分配一倍”预分配)。缩短时不立刻释放,是因为惰性释放策略:保留已分配空间供后续APPEND直接复用,避免频繁realloc。与 R02、R04 连考;互联网面试高频。
【知识关联】
- 补题关联:补-28(底层编码,SDS 是字符串层基础)。
- 面试/工程:SDS 二进制安全 → 可存图片/序列化对象;预分配与惰性释放减少 realloc;
len字段使STRLENO(1)、杜绝 C 字符串截断。工程含义:不要把超大 JSON 整段塞进一个 string(应 Hash/分片);APPEND高频场景 SDS 扩容策略更友好。 - 面试追问:① SDS 如何兼容 C 字符串函数?(仍以
\0结尾,但二进制数据中间可含\0) ② 为什么要预分配?(摊还扩容,减少反复申请内存)
【拓展延伸】
- 变式问法:对比 C 字符串 vs SDS(长度获取、缓冲区溢出、二进制安全、内存重分配)。
- 参数/命令:
OBJECT ENCODING key(int/embstr/raw);STRLEN;APPEND;SETRANGE;大 value 用MEMORY USAGE评估;proto-max-bulk-len限制单值大小。
R04. 关于“Redis 为什么快”,下列原因不正确的是?
考点:Redis 的线程模型
A. 数据全部存放在内存中,读写无需磁盘 IO B. 单线程处理命令,避免了多线程的锁竞争与上下文切换开销 C. 使用 IO 多路复用(epoll)处理海量连接 D. Redis 的所有命令都是 O(1) 复杂度,不存在慢命令
答案:D
【考点】Redis 高性能的真实原因与“单线程”的准确含义。
【结论】 不正确的是 D —— “所有命令都是 O(1)”是明显错误的说法,Redis 存在 KEYS *、SORT 等 O(N) 甚至更慢的命令,正是生产中的阻塞风险点。
【逐项辨析】
- A 数据全在内存、读写无需磁盘 IO:说法成立,不选。这是 Redis 快的根本原因 —— 内存访问比磁盘快约 4~5 个数量级。
- B 单线程处理命令、避免锁竞争与上下文切换:说法成立,不选。但需注意“单线程”指的是命令执行单线程,不是“整个 Redis 只有一个线程”。
- C 使用 IO 多路复用(epoll)处理海量连接:说法成立,不选。epoll 让单线程能以极低开销管理大量连接,避免了“一连接一线程”的模型开销。
- D Redis 的所有命令都是
O(1)、不存在慢命令:正确(这是“不正确”的那一项)。 错在“所有”二字 ——KEYS *是O(N),SORT、HGETALL、SMEMBERS、ZRANGE在大数据量下都可能耗时数十毫秒甚至秒级,而阻塞单线程 = 阻塞全部请求。
【知识点】 “Redis 为什么快”可拆成四个原因,其中“单线程”最容易被误读:
| 原因 | 说明 | 易错点 |
|---|---|---|
| 纯内存操作 | 数据在内存,省去磁盘 IO | 根本原因,但持久化时仍有磁盘开销 |
| 单线程命令执行 | 无锁竞争、无上下文切换、无并发 bug | 不是“全进程单线程” |
| IO 多路复用(epoll) | 单线程高效管理海量连接 | 连接多 ≠ 线程多 |
| 高效的数据结构 | SDS、跳表、listpack 等定制结构 | 结构效率高,但不能改变命令复杂度 |
“单线程”的准确边界:
| 部分 | 线程模型 |
|---|---|
| 命令执行(读写数据结构) | 单线程(4.0 之前为全流程单线程) |
| 网络 IO | 6.0 起引入多线程 IO(读写 socket、解析协议) |
| 持久化(RDB / AOF) | 独立后台线程 / 子进程 |
异步删除(UNLINK、FLUSHALL ASYNC) | 后台线程 |
| 集群同步 | 独立线程 |
推导:为什么单线程反而快?① 纯内存操作使 CPU 不是瓶颈,瓶颈在网络 IO,多线程带来的并行收益有限;② 单线程省掉了锁竞争与上下文切换;③ 单线程天然保证命令的原子性,无需加锁就能实现 INCR、SETNX 这类复合语义。代价是:任何一个慢命令都会阻塞其后所有请求 —— 这就是“慢命令”问题的由来。
常见慢命令清单(生产必须规避):
| 命令 | 复杂度 | 替代方案 |
|---|---|---|
KEYS * | O(N) | SCAN 游标分批 |
HGETALL 大 Hash | O(N) | HSCAN,或按字段 HGET |
SMEMBERS 大 Set | O(N) | SSCAN |
ZRANGE 全量取 | O(N) | 限定范围或 ZSCAN |
SORT | O(N log N) | 尽量用 ZSet 维护有序 |
【记忆锚点】 「内存快、单线程省、epoll 多路复用、结构巧 —— 但慢命令一样能卡死全场」 —— 前三条是优点,第四条是必须警惕的代价。
【易混对比】
- “单线程”的两种理解:命令执行单线程 ✅ vs 整个 Redis 单线程 ❌(6.0 起网络 IO 已多线程,持久化 / 异步删除也由后台线程完成)。
- 单线程 vs 原子性:单线程是 Redis 命令级原子性的来源,但不提供事务级原子性(Redis 事务不支持回滚)。
- 慢命令 vs 大 key:二者互为因果 —— 大 key 上执行
O(N)命令就是典型的阻塞源;治理手段包括拆分 key、用SCAN系列替代全量命令、用UNLINK异步删除。 - 换问法:若题干改成“Redis 6.0 的线程模型变化”,答案是“网络 IO 引入多线程,命令执行仍是单线程”。
【自测】 生产环境误执行 KEYS user:*,Redis 出现数百毫秒卡顿,其他请求全部超时。请说明原因,并给出两种替代写法。
答:原因是
KEYS为O(N)全量遍历,且命令执行是单线程 —— 遍历期间其他命令全部排队等待。替代方案:① 用SCAN游标分批遍历(每次只返回少量 key,不阻塞);② 用 Set / Hash 维护索引,把“按前缀查 key”变成一次集合读取。与 R02、R26 连考;生产事故类面试高频。
【知识关联】
- 补题关联:补-28(底层编码与内存布局,命令复杂度与编码相关)、补-26(单线程下的限流实现)。
- 面试/工程:单线程避免锁与上下文切换,IO 多路复用(epoll)支撑 C10K;Redis 6.0 的多线程只加速网络读写,命令执行仍是单线程,所以 CPU 密集命令(大 SORT、大 SINTER)依然阻塞。理解这点才能解释“为什么加了 io-threads 还是慢”。
- 面试追问:① 哪些操作会阻塞主线程?(KEYS、大集合全量遍历、SAVE、大 Lua、慢 O(N) 命令) ② 多线程为何不解决 CPU 密集命令?(执行路径仍是单线程串行,数据结构也非线程安全)
【拓展延伸】
- 变式问法:问“Redis 6/7 是不是多线程”→ 网络 IO 多线程、命令执行单线程;问“为什么不用多核跑命令”→ 复杂度与数据结构安全,且单线程已能打满网卡/内存带宽。
- 参数/命令:
io-threads、io-threads-do-reads;压测redis-benchmark;慢日志SLOWLOG GET/LEN/RESET、CONFIG SET slowlog-log-slower-than;INFO commandstats看各命令调用耗时。
R05. 大量请求查询数据库中根本不存在的 key(如伪造的 id),导致请求全部打到数据库,这属于?
考点:缓存穿透
A. 缓存穿透 B. 缓存击穿 C. 缓存雪崩 D. 缓存预热
答案:A
【考点】缓存穿透的定义与三大缓存问题的区分。
【结论】 请求查的是数据库中根本不存在的 key,缓存永远不命中、每次都要打到库 —— 这叫缓存穿透,选 A。
【逐项辨析】
- A 缓存穿透:正确。 判定标志是“数据不存在” —— 缓存与数据库都没有该 key,因此每一次请求都穿透到数据库。
- B 缓存击穿:错。击穿是单个热点 key 过期瞬间的并发回源,该 key 在数据库里是存在的。
- C 缓存雪崩:错。雪崩是大批 key 同时失效(或 Redis 整体宕机),是“范围”问题,不是“数据不存在”问题。
- D 缓存预热:错。预热是系统上线前主动把热点数据加载进缓存,属优化手段,不是故障现象。
【知识点】 三大缓存问题只需一把尺子:“出问题的是什么数据”。
| 问题 | 触发对象 | 数据是否存在 | 典型成因 | 后果 |
|---|---|---|---|---|
| 穿透 | 不存在的 key | ❌ 缓存与库都没有 | 恶意攻击、参数错误 | 每次请求都打到库 |
| 击穿 | 单个热点 key | ✅ 存在,只是刚过期 | 热点 key 过期瞬间并发回源 | 瞬时大量请求打到库 |
| 雪崩 | 大批 key | ✅ 存在,只是集中失效 | TTL 对齐、Redis 宕机 | 数据库被整体压垮 |
穿透的本质是缓存对“不存在的 key”无法形成拦截:查询 miss → 回源数据库 → 数据库也为空 → “空”这个结果不会被写入缓存 → 下次同样的请求再次穿透。这是一个“负结果不被缓存”导致的漏洞。
推导:正常的缓存模式是“cache aside”—— 读时先查缓存,miss 则查库并回填。当数据存在时,回填动作会填补缓存,请求被挡住;当数据不存在时,没有东西可以回填,缓存永远是空的,于是每一次请求都走完整链路。因此穿透的解法必然围绕“让不存在的 key 也能被拦下”(缓存空值 / 布隆过滤器,见 R06)。
【记忆锚点】 「穿透查的是没有的东西,击穿是一个热点过期,雪崩是一片同时过期」 —— 先看“数据在不在”,再看“影响是一个点还是一片”。
【易混对比】
- 穿透 vs 击穿:穿透的数据根本不存在;击穿的数据存在,只是过期了。一字之差,解法完全不同(穿透用布隆过滤器 / 空值缓存;击穿用互斥锁 / 逻辑过期)。
- 击穿 vs 雪崩:击穿是一个点(单 key),雪崩是一大片(批量 key)。
- 穿透 vs 恶意攻击:穿透现象常由攻击引发(用不存在的 id 刷接口),但参数错误、脏数据同样会造成穿透 —— 不能一律归因于攻击。
- 换问法:若题干改成“大量请求查询已被删除的商品 id”,仍是穿透;若改成“首页大促 key 到期瞬间并发激增”,则是击穿。
【自测】 攻击者用 id = -1 这类必然不存在的参数高频刷接口,缓存和数据库都查不到,流量全部打到 MySQL。这属于三大缓存问题中的哪一种?最省内存的解法是什么?
答:缓存穿透;最省内存的解法是布隆过滤器(把存在的 key 预加载,判定“不存在”直接返回,内存开销极小),代价是有假阳性、不支持删除元素。与 R06、R07、R09 连考;大厂面试高频。
【知识关联】
- 补题关联:补-30(Cache Aside 与兜底补偿)、补-26(限流可挡住恶意扫描型穿透)。
- 面试/工程:穿透的典型形态是恶意扫不存在的 ID、爬虫打未命中接口,或删数据后缓存未清。防御分层:① 参数校验/网关黑名单(最便宜) ② 布隆过滤器拦“肯定不存在” ③ 空值缓存给“查库也无”的 key 短 TTL ④ 熔断限流保住 DB。只靠加机器扛穿透是错误方案。
- 面试追问:① 空值缓存 TTL 该设多长?(短,如 30s–2min,避免数据后来真的写入后长期读不到) ② 布隆过滤器误判方向是什么?(可能把不存在误判为存在 → 仍有少量漏到 DB;绝不会把存在误判为不存在)
【拓展延伸】
- 变式问法:对比穿透(key 本不存在)/击穿(热点 key 过期瞬间)/雪崩(大批 key 同时失效或 Redis 宕机);给出场景选类型。
- 参数/命令:空值:
SET key "" EX 60或专用前缀null:;布隆:RedisBloomBF.RESERVE key 0.01 1000000、BF.ADD/BF.EXISTS;也可用本地 Caffeine/Guava 布隆;网关 QPS 限流与 IP 黑名单。
R06. 解决缓存穿透最常用的两种方案是?
考点:缓存穿透的解决方案
A. 缓存空值(设置较短过期时间)+ 布隆过滤器 B. 延长缓存过期时间 C. 给热点 key 加互斥锁 D. 给过期时间加随机扰动
答案:A
【考点】缓存穿透的两种标准解法及其取舍。
【结论】 穿透的两种标准解法是缓存空值(设较短过期时间)与布隆过滤器 —— 选 A。
【逐项辨析】
- A 缓存空值(设置较短过期时间)+ 布隆过滤器:正确。 前者让“不存在的 key”也能被缓存拦住,后者在入口处直接判定“不存在”并返回,二者都针对“查的是不存在的数据”这一根因。
- B 延长缓存过期时间:错。错在方向反了 —— 穿透的问题是“数据不存在、根本没有东西可缓存”,延长 TTL 对不存在的 key 无效;延长 TTL 是缓解雪崩的思路之一。
- C 给热点 key 加互斥锁:错。互斥锁针对的是“热点 key 过期瞬间的并发回源”,那是击穿的解法,与“数据不存在”无关。
- D 给过期时间加随机扰动:错。随机扰动的目的是打散批量 key 的失效时刻,解决的是雪崩,对穿透无效。
【知识点】 两种方案的完整对照:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 缓存空值 | 查库为空时,也向 Redis 写入空值 / 特殊标记,并设较短 TTL(如 30~60 秒) | 实现简单、无需额外组件 | 占用内存(大量不同 key 会撑爆缓存);存在短暂不一致窗口(期间数据库新增了该数据也读不到) |
| 布隆过滤器 | 把存在的 key 预加载进位数组,请求先过过滤器,判定“不存在”则直接返回 | 内存极省(每元素约 10 bit 即可把误判率压到约 1%)、性能高 | 有假阳性(误判“存在”)、无假阴性;不支持删除元素 |
布隆过滤器的原理推导:用 k 个独立哈希函数把每个元素映射到位数组的 k 个位上,全部置 1。查询时若任一位为 0 → 元素一定不存在(无假阴性);若全部为 1 → 元素可能存在(可能是别人的位被撞上,即假阳性)。这就是“判不存在可信、判存在不可信”的由来。
为什么两种方案要配合使用:布隆过滤器负责挡住绝大多数不存在的请求(内存极省),缓存空值负责兜住过滤器漏过的假阳性请求(避免它们反复查库)。工程上还常加第三种补充手段 —— 接口层参数校验(如 id 必须为正整数、格式校验),把明显非法的请求挡在最外层。
【记忆锚点】 「穿透两招:空值缓存兜底、布隆过滤器拦截」 —— 一个用空间换简单,一个用极小空间换高拦截率。
【易混对比】
- 三大问题的解法一一对应:穿透 → 空值缓存 / 布隆过滤器;击穿 → 互斥锁 / 逻辑过期;雪崩 → TTL 随机扰动 / 多级缓存 / 熔断降级。三套解法不可混用。
- 缓存空值 vs 布隆过滤器:空值缓存占内存但精确;布隆过滤器省内存但有误判。二者常组合使用。
- 布隆过滤器 vs Counting Bloom Filter:后者把位换成计数器,支持删除,代价是内存成倍增加(约为普通布隆的 4 倍)。注意 Redis 的
RedisBloom模块里BF.*不提供删除单个元素的命令 ——BF.ADD/BF.EXISTS只能加与查,要清只能DEL整个过滤器;模块内能按元素删除的是 Cuckoo Filter 的CF.DEL(配CF.ADD/CF.EXISTS,空间通常比 Counting Bloom 更省)。 - 换问法:若题干问“布隆过滤器能否删除元素”,答案是不能(需 Counting Bloom Filter 或定期重建)。
【自测】 某商品查询接口用布隆过滤器拦截穿透,某商品下架后从数据库删除,但布隆过滤器仍判定“存在”,请求继续查库并穿透。这说明布隆过滤器的哪个特性?
答:说明布隆过滤器不支持删除元素 —— 元素一旦置位就无法撤销,删除数据后过滤器仍判“存在”(这也是假阳性的一种表现)。解法是使用 Counting Bloom Filter,或定期重建过滤器。与 R05、R07、R08 连考;大厂面试高频。
【知识关联】
- 补题关联:补-30(一致性与兜底)、补-26(令牌桶限流作最后防线)。
- 面试/工程:布隆过滤器适合“只判有无、可容忍极低误判”的海量集合(商品 ID、用户 ID);空值方案实现最简单,但要防“恶意大量不同 key”把空值缓存撑大(需上限与 LRU)。接口层校验 ID 合法性(位数、前缀、校验位)成本最低,应最先做。布隆删除困难(标准布隆不支持 delete,可用 Counting Bloom / Cuckoo Filter)。
- 面试追问:① 布隆过滤器能删除吗?(标准实现不能安全删;Counting Bloom 可,但内存更大) ② 空值会不会造成“数据永久看不到”?(若 TTL 过长且期间数据已写入 DB,会;故 TTL 要短,或写路径主动删空值)
【拓展延伸】
- 变式问法:题干强调“最常用两种”→ 布隆 + 空值;若选项有“缓存 null 并设短 TTL”也算空值方案;“全表扫校验”是错误项。
- 参数/命令:
BF.RESERVE key error capacity(error=误判率);误判率↓ → 内存↑;业务侧 UUID/雪花 ID 格式校验;布隆更新时机:新增商品时BF.ADD,可接受短暂漏放行。
R07. 某个热点 key 在过期的那一瞬间,大量并发请求同时打到数据库,这属于?
考点:缓存击穿
A. 缓存穿透 B. 缓存击穿 C. 缓存雪崩 D. 缓存一致性
答案:B
【考点】缓存击穿的判定标志:单个热点 key + 过期瞬间 + 高并发。
【结论】 “单个热点 key 在过期瞬间被大量并发请求同时回源” —— 判定标志齐全,属于缓存击穿,选 B。
【逐项辨析】
- A 缓存穿透:错。穿透查的是数据库中不存在的数据,而本题的热点 key 在数据库里是存在的,只是刚过期。
- B 缓存击穿:正确。 题干两个关键词 —— “某个”(单个 key)与“过期的那一瞬间”(TTL 到期)—— 正是击穿的定义特征。
- C 缓存雪崩:错。雪崩是一大批 key 同时失效,是“范围”层面的失效,而本题只涉及一个 key。
- D 缓存一致性:错。缓存一致性指缓存与数据库之间的数据同步问题(如更新时序导致的脏数据),与“并发回源”不是同一类问题。
【知识点】 击穿的本质是“单点热点 + 失效瞬间 + 高并发”三者叠加:
| 要素 | 含义 | 缺了会怎样 |
|---|---|---|
| 单点热点 | 该 key 承载极高 QPS | 非热点 key 过期,回源请求量小,数据库扛得住 |
| 失效瞬间 | TTL 到期,缓存出现“空洞” | key 永不过期则不存在空洞,天然不击穿 |
| 高并发 | 大量请求同时发现 miss | 并发低则只有零星请求回源 |
推导:击穿的伤害来自“同时性”。缓存失效的那一刻,N 个并发请求同时发现缓存 miss,由于没有互斥机制,它们会同时去查数据库 —— 数据库承受的不是 1 次查询,而是 N 次相同的查询。这就是“缓存本应起到的削峰作用,在失效瞬间完全失效”。
与雪崩的区别在范围:
| 维度 | 击穿 | 雪崩 |
|---|---|---|
| 失效对象 | 单个热点 key | 一大批 key(或整个 Redis) |
| 触发原因 | 热点 key 的 TTL 到期 | TTL 集中对齐 / Redis 宕机 |
| 数据库压力 | 一个 key 的 N 次重复查询 | 全量 key 的回源潮 |
注意一个常见误解:“热点 key 永不过期就天然不击穿” 是成立的,但这会带来两个新问题 —— 数据长期不更新(业务上可能读到陈旧值)与内存长期占用。因此工程上更多采用“逻辑过期”(key 不过期,value 里存逻辑过期时间)来兼顾二者,见 R08。
【记忆锚点】 「击穿是“一个点”炸了,雪崩是“一整片”塌了」 —— 判据是“失效的是单个热点 key,还是一批 key”。
【易混对比】
- 击穿 vs 穿透:击穿的数据存在(刚过期),穿透的数据不存在。击穿是“并发回源同一个 key”,穿透是“每次都查不存在的 key”。
- 击穿 vs 雪崩:一个点 vs 一大片(见上表)。
- 热点 key vs 大 key:热点 key 是访问频率高(击穿风险),大 key 是单个 value 体积大(网络与阻塞风险)—— 两个不同的治理维度,但都需专项处理。
- 换问法:若题干改成“缓存中大批 key 在同一时刻过期”,答案就变成雪崩;若改成“查询的 id 在数据库中不存在”,答案是穿透。
【自测】 某系统给所有商品缓存统一设置 TTL = 30 分钟,每天 10:00 批量预热后,10:30 左右数据库负载出现规律性尖峰。这属于三大缓存问题中的哪一种?为什么?
答:属于缓存雪崩 —— 因为所有 key 的 TTL 同时对齐,在 10:30 集体失效,请求潮涌向数据库,形成规律性尖峰。解法是给过期时间加随机扰动(如
TTL = 30 分钟 + 随机 0~5 分钟)打散失效时刻。与 R05、R06、R08 连考;大厂面试高频。
【知识关联】
- 补题关联:补-21(互斥锁/乐观思想直接用于重建)、补-30(一致性策略背景)。
- 面试/工程:商品详情、直播开播、微博热点在热点 key 过期瞬间最易击穿:并发线程同时 miss 并打穿 DB。与热 key 的区别:热 key 是访问频率过高(未过期也会打挂节点),击穿强调“过期瞬间的重建风暴”。与穿透的区别:key 是存在的,只是刚好失效。
- 面试追问:① 击穿与热 key 有什么区别?(击穿=失效瞬间重建风暴;热 key=访问热点导致单节点/单分片过热) ② 为什么击穿要互斥而不是都去查库?(把 N 次并行查库压成 1 次查库 + N-1 次等待/读旧值,保护 DB)
【拓展延伸】
- 变式问法:问“单个热点 key 失效”选击穿;问“大批 key 失效或缓存宕机”选雪崩;问“查不存在的数据”选穿透。
- 参数/命令:互斥:
SET lock:rebuild:hot 1 NX PX 3000;逻辑过期:value 内嵌expireAt,过期后先返回旧值、异步重建;热点可设更长 TTL 或本地缓存(Caffeine)二级。
R08. 解决缓存击穿(热点 key 过期)的常用方案是?
考点:缓存击穿的解决方案
A. 互斥锁(只让一个线程回源重建缓存)+ 热点 key 逻辑过期或永不过期 B. 缓存空值 C. 给所有 key 的过期时间加随机扰动 D. 部署布隆过滤器
答案:A
【考点】击穿的两种解法:串行化回源 vs 逻辑过期。
【结论】 击穿的两大解法是互斥锁(只让一个线程回源重建)+ 热点 key 逻辑过期或永不过期 —— 选 A。
【逐项辨析】
- A 互斥锁(只让一个线程回源重建缓存)+ 热点 key 逻辑过期或永不过期:正确。 前者把并发回源串行化(只查库一次),后者让热点 key 不产生失效空洞,两条路线正好覆盖击穿的两个成因。
- B 缓存空值:错。缓存空值解决的是“数据不存在”的穿透问题,而击穿的数据是存在的,缓存空值没有意义。
- C 给所有 key 的过期时间加随机扰动:错。随机扰动的目的是打散批量失效时刻,解决的是雪崩;击穿只有一个 key,打散无从谈起。
- D 部署布隆过滤器:错。布隆过滤器用于穿透拦截(判定 key 是否存在),与“热点 key 过期瞬间的并发回源”无关。
【知识点】 两种方案的完整对照:
| 维度 | 互斥锁方案 | 逻辑过期方案 |
|---|---|---|
| 做法 | 缓存 miss 时先抢分布式锁(SET key value NX EX),只有拿到锁的线程查库并回填,其余线程短暂等待后重试 | key 不设 Redis 过期时间(永不过期),value 内额外存 expireTime;读到时若逻辑已过期,则异步开线程重建,当前请求仍返回旧数据 |
| 优点 | 保证数据库只被查一次;数据强一致(返回最新值) | 完全不阻塞请求,可用性最高 |
| 缺点 | 其他线程需等待,吞吐下降;有锁超时 / 死锁风险 | 存在数据不一致窗口,返回旧值;需额外线程池 |
互斥锁方案的关键实现要点(以 Redis 分布式锁为例):SET lock_key unique_value NX EX 10。三个要点:① NX 保证“只有 key 不存在时才设置成功”,即只有一个人能拿到锁;② EX 10 给锁设过期时间,避免持锁者崩溃后死锁;③ unique_value(如 UUID)用于释放锁时校验归属 —— 必须用 Lua 脚本“先比对 value 再删除”,否则可能误删别人刚拿到的锁。
推导:击穿的成因是“失效瞬间的并发”,因此解法只有两条路 —— 要么让并发不进来(互斥锁,把 N 个并发回源压成 1 次),要么让失效不存在(逻辑过期 / 永不过期,热点 key 永远不产生空洞)。前者牺牲吞吐换一致性,后者牺牲一致性换可用性,是典型的“一致性与可用性的取舍”。
补充工程口径:热点 key 若确实选择“永不过期”,必须配套主动更新机制(数据变更时同步刷新缓存),否则会长期返回陈旧数据。
【记忆锚点】 「击穿两条路:抢锁串行回源,逻辑过期不阻塞」 —— 一个保数据新,一个保响应快。
【易混对比】
- 互斥锁 vs 逻辑过期:前者阻塞等待、数据新;后者不阻塞、数据旧。选型看业务能否容忍短暂脏读(如商品详情可容忍,库存不可容忍)。
- 互斥锁 vs 分布式锁:互斥锁是目的(只让一个线程回源),分布式锁是手段(
SET NX EX+ Lua 释放)。 - 三大问题的解法对照:穿透 → 空值缓存 / 布隆过滤器;击穿 → 互斥锁 / 逻辑过期;雪崩 → TTL 随机扰动 / 多级缓存 / 熔断降级。题干问哪种问题,就先想对应的那一组解法。
- 换问法:若题干问“击穿方案中哪个能保证请求不阻塞”,答案是逻辑过期;问“哪个能保证数据库只被查一次”,答案是互斥锁。
【自测】 某大促商品详情缓存采用“互斥锁”方案,大促开始瞬间 QPS 极高,大量线程在等锁,接口平均耗时飙升。换成哪种方案可以缓解?代价是什么?
答:换成逻辑过期方案(key 永不过期,value 存
expireTime,过期时异步重建、当前请求返回旧值)—— 因为没有任何线程需要等待锁,接口耗时稳定。代价是存在数据不一致窗口,短时间返回旧数据。与 R05、R06、R07 连考;大厂面试高频。
【知识关联】
- 补题关联:补-21(悲观互斥 vs 乐观无锁)、R21(Lua 保证「查空再占锁」的原子性);补-26 讲的是令牌桶限流脚本,别混、补-30(一致性取舍)。
- 面试/工程:互斥锁实现简单、强一致,但重建期间其他线程阻塞或重试,适合重建快、可等待的场景。逻辑过期不阻塞读(可立刻返回旧值),但会有秒级陈旧,适合容忍最终一致的展示类数据。两者可组合:先逻辑过期保可用,后台互斥重建防重复打库。失败线程通常短退避重试或直接读旧值,不建议长自旋占 CPU。
- 面试追问:① 互斥锁失败线程该自旋还是休眠?(短退避休眠/重试;长自旋浪费 CPU 且惊群) ② 逻辑过期如何避免击穿打到库?(只有一个线程能抢到重建锁,其余继续读旧值)
【拓展延伸】
- 变式问法:把“热点永不过期”也列为可选,但要指出一致性代价;把“所有线程都去查库”设为错误项。
- 参数/命令:
SET ... NX PX占锁;Lua 保证 GET-判断-DEL/占锁原子;信号量/令牌桶限制同时重建数;SET hot:data '{"v":1,"exp":...}'结构化逻辑过期字段。
R09. 大量 key 在同一时间集中过期,导致数据库压力骤增,这属于?
考点:缓存雪崩
A. 缓存穿透 B. 缓存击穿 C. 缓存雪崩 D. 缓存预热
答案:C
【考点】缓存雪崩的定义(含 Redis 整体宕机的情形)。
【结论】 大量 key 在同一时间集中过期,请求瞬间全部涌向数据库 —— 这叫缓存雪崩,选 C。判定词是“大批 key 同时失效”。
【逐项辨析】
- A 缓存穿透:错。关键字眼是“数据不存在” —— 穿透是缓存与数据库都查不到;本题的 key 存在,只是过期了。
- B 缓存击穿:错。关键字眼是“单个热点 key” —— 击穿的量级是“一个点”,而题干说的是“大量 key”。
- C 缓存雪崩:正确。 “同一时间集中过期”正是雪崩的判定标志:大批 key 同时失效,原本被缓存挡住的流量在极短时间内全落到数据库。
- D 缓存预热:错。预热是上线前主动把热点数据加载进缓存的优化手段,属于“制造缓存”,与题干描述的失效现象方向相反。
【知识点】 雪崩有两种触发形态,后果完全相同:
| 形态 | 成因 | 后果 |
|---|---|---|
| 大批 key 同时过期 | 启动时批量写入、统一设置相同 TTL | 同一秒大量 miss,请求潮涌向数据库 |
| Redis 整体不可用 | 宕机、网络分区、主从切换期间 | 所有请求直接落到数据库 |
推导:设缓存命中率为 h、QPS 为 Q,数据库实际承压约为 Q × (1 − h)。大批 key 失效时 h 从 0.95 瞬间跌到接近 0,数据库承压从 5% 陡增到接近 100% —— 数据库被瞬间打满,可能引发连锁故障(库挂 → 上层超时 → 重试放大 → 彻底雪崩)。所以雪崩防护必然是多层次的:既要在 TTL 上打散,也要在架构上做高可用与兜底(见 R10)。
【记忆锚点】 “穿透是查不存在的,击穿是一个热点过期,雪崩是一片同时过期” —— 先看数据在不在,再看影响是一个点还是一片。
【易混对比】
- 雪崩 vs 击穿:雪崩是一大片 key,击穿是一个热点 key。
- 雪崩 vs 穿透:雪崩的数据存在(只是失效),穿透的数据根本不存在。
- 换问法:改成“Redis 主从切换期间所有请求打到数据库”仍是雪崩;改成“爆款商品缓存到期的瞬间并发激增”则变成击穿。与 R05、R06、R10 连考。
【自测】 定时任务凌晨 2 点批量刷新 10 万个商品缓存,TTL 统一设为 3600 秒;3 点监控显示数据库 QPS 突增 20 倍。属于哪种缓存问题?
答:缓存雪崩。10 万个 key 的 TTL 完全对齐 → 一小时后集中失效。与 R05、R10 连考。
【知识关联】
- 补题关联:补-30(缓存与库一致性/兜底)、补-26(限流在雪崩时保命)。
- 面试/工程:上线大批写入且 TTL 设成同一固定值(如都 24h)是经典雪崩事故:某时刻集体过期,请求齐刷刷打穿 DB。Redis 宕机/主从切换导致全量缓存不可用,后果同级。治理要“防过期扎堆 + 防节点单点 + 保命限流”三层一起上,不能只加机器。
- 面试追问:① 为什么加随机 TTL 能防雪崩?(把过期时刻打散,避免同一毫秒集体失效) ② 雪崩与 Redis 宕机有何异同?(都表现为缓存层整体失效;雪崩还可因 TTL 扎堆,不必宕机)
【拓展延伸】
- 变式问法:问“缓存服务器重启导致全量失效”也归入雪崩类风险;把“单个 key 过期”排除为击穿。
- 参数/命令:
EXPIRE key (base + random(0,N));集群/多副本分担;多级缓存(本地+Redis);maxmemory-policy防止因内存满导致大量逐出;熔断组件与 DB 连接池上限。
R10. 缓解缓存雪崩的措施不包括?
考点:缓存雪崩的解决方案
A. 给缓存过期时间加随机扰动,避免集中失效 B. 搭建 Redis 高可用架构(哨兵 / Cluster),配合多级缓存与服务降级熔断 C. 对数据库做限流、并准备缓存预热与热点数据永不过期 D. 把所有 key 的过期时间统一设为同一时刻,便于集中管理
答案:D
【考点】雪崩的多层防护体系。
【结论】 把所有 key 的过期时间统一设为同一时刻恰恰是雪崩的成因,而不是缓解措施 —— 所以选 D。
【逐项辨析】
- A 加随机扰动避免集中失效:属有效缓解措施,不是本题答案。关键字眼是“随机” —— 基础 TTL 叠加随机值即可打散失效时刻。
- B 搭建 Redis 高可用(哨兵 / Cluster),配合多级缓存与降级熔断:属有效缓解措施。关键字眼是“高可用” —— 针对雪崩的第二种形态(Redis 整体不可用)。
- C 对数据库限流、并准备缓存预热与热点数据永不过期:属有效缓解措施。关键字眼是“兜底” —— 限流是最后的保险丝。
- D 把所有 key 的过期时间统一设为同一时刻,便于集中管理:正确(本题选它)。 统一过期时间 = 人为制造集中失效,等于亲手把“大批 key 同时过期”这个成因做出来,与 A 的“随机扰动”背道而驰。
【知识点】 雪崩防护是四层体系,每层挡的是不同的失败路径:
| 层次 | 手段 | 挡什么风险 | 关键配置 / 工具 |
|---|---|---|---|
| ① TTL 打散 | 基础过期时间 + 随机值 | 大批 key 同时过期 | 如 3600 + rand(0, 300) 秒 |
| ② 高可用 | 哨兵 / Cluster | Redis 自身单点故障 | 主从 + 自动故障转移 |
| ③ 多级缓存 | 本地缓存 + Redis | Redis 整体不可用 | Caffeine / Guava + Redis |
| ④ 服务治理 | 限流、熔断降级、预热、永不过期 | 前三层都失效后的兜底 | 数据库限流、返回兜底数据 |
推导:雪崩的根因是“缓存挡不住流量了”。要挡住,要么让缓存不集体失效(①),要么让 Redis 不挂(②),要么挂了还有别的缓存(③),要么挡不住时保护数据库不被打死(④)—— 四层分别对应四种“挡不住”的路径,所以这是典型的“不能只靠一招”的架构题。
【记忆锚点】 “打散 TTL、Redis 高可用、本地兜底、库前限流” —— 四层防线,少一层都会漏。
【易混对比】
- 雪崩 vs 击穿的解法:雪崩用“打散 + 高可用 + 多级缓存”(面上的措施);击穿用“互斥锁 / 逻辑过期”(点上的措施)。拿互斥锁防雪崩基本无效 —— 十万个 key 各锁各的,锁本身反成瓶颈。
- 换问法:若 D 改成“把热点数据的 TTL 设为永久”,则它是合理措施(配合预热使用)。与 R05、R09 连考。
【自测】 某团队把全部缓存 key 的 TTL 统一设为 24 小时、不设随机值,同时给数据库加了限流。这套方案能防住雪崩吗?
答:不能完全防住。TTL 完全对齐本身就是雪崩的典型成因,限流只能减轻后果。与 R09 连考。
【知识关联】
- 补题关联:补-30(删除失败补偿与最终一致)、补-26(限流)、补-21(互斥思想)。
- 面试/工程:互斥锁防击穿、随机 TTL 防雪崩、熔断限流保命、多级缓存降压,生产里是组合拳而不是单选。集群多副本(主从+读写分离)能降低“单节点宕机=全站缓存丢”的概率,但主从切换瞬间仍可能雪崩,需结合客户端重试与本地缓存。只做随机 TTL 不够:扛不住节点级故障。
- 面试追问:① 只做随机 TTL 够不够?(不够,防不了宕机与容量击穿) ② 集群多副本如何降低同时失效概率?(故障域分散;但仍要本地缓存与限流兜底)
【拓展延伸】
- 变式问法:题干换成“不包括”时,把“故意让所有 key 同时过期”“缓存宕机后不做限流直接打库”设为错误项。
- 参数/命令:过期通知
notify-keyspace-events Ex(仅辅助,不能当可靠补偿);监控INFO stats的expired_keys、evicted_keys;熔断限流(Sentinel/Hystrix 思想、网关令牌桶);本地缓存 Caffeine 设置短暂 TTL。
R11. 关于 RDB 持久化,下列说法正确的是?
考点:RDB 持久化
A. 记录写命令日志,文件体积小、恢复快 B. 是某一时刻的数据快照,二进制文件,体积小、恢复快,但可能丢失最后一次快照之后的数据 C. 实时持久化,完全不丢数据 D. 只能通过命令手动触发,无法配置自动触发
答案:B
【考点】RDB 的机制、优缺点与触发方式。
【结论】 RDB 是某一时刻的全量数据快照,二进制文件、体积小、恢复快,但两次快照之间的写入会丢失 —— 选 B。
【逐项辨析】
- A 记录写命令日志,文件体积小、恢复快:错。关键字眼是“写命令日志” —— 记录写命令的是 AOF;且 AOF 恰恰“文件大、恢复慢”。
- B 是某一时刻的数据快照,二进制文件,体积小、恢复快,但可能丢失最后一次快照之后的数据:正确。 快照、紧凑二进制、有丢失窗口,三个特征全中。
- C 实时持久化,完全不丢数据:错。关键字眼是“完全不丢” —— RDB 是周期性快照,快照点之间就是空档。
- D 只能通过命令手动触发,无法配置自动触发:错。关键字眼是“只能手动” —— 自动触发(
save规则、主从全量同步)才是常规方式。
【知识点】 RDB 的核心是“把内存中的全量数据在某一瞬间序列化成紧凑二进制”(默认文件 dump.rdb)。
| 维度 | RDB 的表现 | 原因 |
|---|---|---|
| 存储内容 | 某一时刻的全量数据快照 | 存数据本身,不存命令 |
| 文件体积 | 小 | 二进制紧凑编码,无命令历史冗余 |
| 恢复速度 | 快 | 直接加载进内存,无需重放命令 |
| 数据安全性 | 弱,两次快照间的写入会丢 | 快照周期性产生,之间有空档 |
触发方式分两类:
| 方式 | 命令 / 配置 | 特点 |
|---|---|---|
| 手动 | SAVE | 阻塞主进程,生产禁用 |
| 手动 | BGSAVE | fork 子进程后台执行 |
| 自动 | save 900 1 等规则 | N 秒内至少 M 次修改即触发 BGSAVE |
| 自动 | 主从全量同步 | 主节点生成 RDB 发给从节点 |
推导:RDB 存的是“某一时刻的整张内存快照”,因此天然“小、快”(无命令冗余),也天然带有“快照点之间有丢失窗口”的缺点 —— 优点与缺点来自同一个设计决策,这正是它常与 AOF 搭配使用的原因(见 R12)。
【记忆锚点】 “RDB 存数据、AOF 存命令” —— 快照小、恢复快,代价是快照之间会丢。
【易混对比】
- RDB vs AOF:RDB 存数据快照(小、快、可能丢);AOF 存写命令日志(大、慢、丢得少)。
SAVEvsBGSAVE:前者阻塞主进程,后者fork子进程后台执行 —— 生产只用BGSAVE。- 换问法:问“哪个更适合做冷备份与灾难恢复”,答案仍是 RDB(紧凑、易传输、加载快)。与 R12、R13 连考。
【自测】 实例只开 RDB、配置 save 900 1,10:00 触发一次 BGSAVE,10:12 宕机。10:00—10:12 之间的写入会怎样?
答:全部丢失。触发条件是“900 秒内至少 1 次修改”,10:00 之后 15 分钟内不会再触发快照。与 R12 连考。
【知识关联】
- 补题关联:无直接补题条目;可对照主库 redo/binlog 的持久化取舍(补-20 恢复思想迁移:都是“落盘节奏 vs 性能”)。
- 面试/工程:RDB 是某个时间点的全量快照,恢复快、适合备份与容灾,但两次快照之间的写会丢;AOF 追加写命令/RESP,丢数据窗口更小。生产常见 RDB+AOF 同时开:AOF 作主恢复源,RDB 作快速冷备。fork 依赖 COW,大内存实例 fork 时要警惕延迟尖刺。
- 面试追问:① RDB 为什么可能丢最近写入?(快照间隔内的写未落盘) ②
BGSAVE与SAVE区别?(BGSAVE 后台 fork,不阻塞;SAVE 阻塞主进程,生产禁用)
【拓展延伸】
- 变式问法:问“fork 后 COW 会不会导致内存翻倍”(不会立刻翻倍,只复制被修改页,但写入密集时内存峰值可能接近翻倍);问“什么业务适合 RDB”(可容忍分钟级丢失、要快速重启)。
- 参数/命令:
save 900 1/save 300 10等触发策略;dbfilename、dir;SHUTDOWN SAVE;BGSAVE;LASTSAVE;INFO persistence看 rdb_last_bgsave_status、aof_last_write_status。
R12. 关于 AOF 持久化,下列说法正确的是?
考点:AOF 持久化
A. 以快照形式保存全量数据 B. 默认开启且无法关闭 C. 追加写命令日志,可配置 appendfsync 为 always/everysec/no;文件更大、恢复较慢,但丢数据更少 D. 恢复速度比 RDB 更快
答案:C
【考点】AOF 的写入流程与 appendfsync 三种策略。
【结论】 AOF 追加写写命令日志,appendfsync 可配 always / everysec / no;文件比 RDB 大、恢复更慢,但丢数据更少 —— 选 C。
【逐项辨析】
- A 以快照形式保存全量数据:错。关键字眼是“快照” —— 那是 RDB;AOF 保存的是写命令,是“日志”而非“快照”。
- B 默认开启且无法关闭:错。关键字眼是“默认开启” —— AOF 默认是关闭的(
appendonly no),必须显式开启。 - C 追加写命令日志,可配置
appendfsync为always/everysec/no;文件更大、恢复较慢,但丢数据更少:正确。 机制、配置项、优缺点三处全对。 - D 恢复速度比 RDB 更快:错。关键字眼是“更快” —— 说反了。AOF 要逐条重放命令,远慢于 RDB 的直接加载快照。
【知识点】 AOF 的写入流程分三步,三档 appendfsync 的差别只在第三步的时机:
客户端写命令 → ① 写入 aof_buf 缓冲区 → ② write 到 AOF 文件(OS 页缓存)
→ ③ fsync 刷盘(真正落盘)appendfsync | 何时 fsync | 最多丢多少数据 | 性能 | 适用 |
|---|---|---|---|---|
always | 每条命令都刷 | 几乎不丢 | 最差 | 数据零容忍场景 |
everysec | 每秒刷一次(默认) | 最多 1 秒 | 好 | 生产主流选择 |
no | 由操作系统决定 | 不可控(可能几十秒) | 最好 | 不推荐 |
与 RDB 的三点对照:文件体积 RDB 小 / AOF 大(命令有历史冗余);恢复速度 RDB 快(直接加载)/ AOF 慢(逐条重放);默认状态 RDB 开启 / AOF 关闭。
推导:everysec 成为默认值,是性能与安全的平衡点 —— always 每条命令一次 fsync,磁盘 IO 成为吞吐瓶颈;no 把刷盘交给操作系统,丢失量无法预期。生产常见做法是两者同时开启:RDB 做备份与快速恢复,AOF 保证数据完整性(重启时优先用 AOF 恢复,因为它更完整)。
【记忆锚点】 “RDB 存数据、AOF 存命令;always 最安全、everysec 是默认、no 最不可控”。
【易混对比】
alwaysvseverysec:安全性差一档,性能差一大截;除非要求零丢失,一律everysec。- AOF 默认关闭 vs RDB 默认开启:最常被记反的一对,按“日志要额外开、快照默认开”来记。
- 换问法:问“重启后优先用哪个文件恢复”,答“优先 AOF”(更完整);AOF 关闭时才用 RDB。与 R11、R13 连考。
【自测】 实例同时开启 RDB 与 AOF(everysec),运行中 AOF 被误删、RDB 完好,重启后数据状态如何?
答:回到最近一次 RDB 快照的状态,快照之后的写入全部丢失。与 R11、R13 连考。
【知识关联】
- 补题关联:补-20(日志+检查点恢复思想迁移到 AOF rewrite/重放)。
- 面试/工程:always 最安全但性能差;everysec 丢约 1 秒,是默认推荐;no 依赖 OS,几乎不用。AOF 文件膨胀必须 rewrite:重放等价命令集、用临时文件+原子替换。重写期间新写要同时进旧 AOF 与 rewrite 缓冲,故重写时有额外内存与 CPU 压力,大实例要在低峰做。
- 面试追问:① everysec 到底丢多少?(通常 ≤1s 写入,取决于 fsync 调度与负载) ② AOF 膨胀不 rewrite 会怎样?(恢复变慢、磁盘打满、重放时间不可控)
【拓展延伸】
- 变式问法:对比 always/everysec/no;问“AOF 与 binlog 的异同”(都是追加逻辑日志,但 AOF 面向本机恢复,binlog 面向复制/PITR)。
- 参数/命令:
appendonly yes、appendfsync always|everysec|no、auto-aof-rewrite-percentage、auto-aof-rewrite-min-size、BGREWRITEAOF;INFO persistence。
R13. AOF 重写(rewrite)的目的是?
考点:AOF 重写
A. 压缩 AOF 文件体积——用“重建当前数据集所需的最少命令”替换历史全部命令,加快恢复速度 B. 备份数据到另一个文件 C. 提升读请求的性能 D. 实现主从同步
答案:A
【考点】AOF 重写机制与 auto-aof-rewrite-percentage 触发条件。
【结论】 AOF 重写的目的就是压缩 AOF 文件体积 —— 用“重建当前数据集所需的最少命令”替换历史全部命令,顺带加快重启恢复,选 A。
【逐项辨析】
- A 压缩 AOF 文件体积 —— 用“重建当前数据集所需的最少命令”替换历史全部命令,加快恢复速度:正确。 目的与手段都描述准确。
- B 备份数据到另一个文件:错。关键字眼是“备份” —— 重写产物是新的 AOF 文件,用于替换旧文件,目的不是留一份备份。
- C 提升读请求的性能:错。关键字眼是“读请求” —— 重写发生在持久化链路上,不改变数据结构也不建索引,对读性能无直接提升。
- D 实现主从同步:错。关键字眼是“主从同步” —— 全量同步走 RDB,增量同步走
repl_backlog;重写是本地文件整理。
【知识点】 AOF 是追加写,这是它需要“重写”的根本原因:
INCR counter × 10000 次 → AOF 里留下 10000 条 INCR 命令
而当前数据集只需 1 条 SET counter 10000即:AOF 记录的是“过程”,而恢复只需要“结果”。两者差距随时间单调增长,文件越来越大、恢复越来越慢 —— 重写就是把这个差距抹掉。流程是 fork + 双缓冲,不阻塞主进程:
| 阶段 | 主进程 | 子进程 |
|---|---|---|
| 期间 | 继续处理命令,同时写入 AOF 缓冲区与 AOF 重写缓冲区 | 用最少命令生成新 AOF 文件 |
| 完成 | 把重写缓冲区内容追加到新文件 | 退出 |
| 收尾 | 用新文件替换旧文件(原子 rename) | — |
触发条件(redis.conf)需同时满足:
| 配置项 | 含义 | 默认值 |
|---|---|---|
auto-aof-rewrite-percentage | 相对上次重写后体积的增长百分比 | 100(翻倍即触发) |
auto-aof-rewrite-min-size | 触发重写的文件最小体积门槛 | 64mb |
后者(min-size)防止小文件频繁重写(体积没到门槛就不触发),前者(percentage)保证大文件在持续增长后会重写;也可手动执行 BGREWRITEAOF。
【记忆锚点】 “AOF 记过程、恢复要结果;重写就是把过程压成结果” —— 一万条 INCR 压成一条 SET。
【易混对比】
BGSAVEvsBGREWRITEAOF:前者生成 RDB 快照(存数据),后者生成新 AOF(存最少命令);两者都用fork。- AOF 重写 vs AOF 刷盘(
appendfsync):重写解决“文件体积”,刷盘解决“落盘时机”,是两件事。 - 换问法:问“重写期间主进程新写入的命令会不会丢”,答“不会” —— 它们进入 AOF 重写缓冲区,完成后追加进新文件。与 R11、R12、R14 连考。
【自测】 某 key 被 INCR 了 10 万次。重写后这个 key 会变成几条命令?触发自动重写至少要满足什么条件?
答:1 条(写成
SET key 100000之类的最终值);触发需同时满足auto-aof-rewrite-percentage与auto-aof-rewrite-min-size。与 R12 连考。
【知识关联】
- 补题关联:补-20(恢复时间与重放成本)。
- 面试/工程:重写用“读当前内存数据状态 → 生成等价最小命令集”,而不是重放历史 AOF,因此文件会显著变小。重写期间父进程继续服务,子进程写临时文件;新写命令同时进 rewrite buffer,子进程结束后追加,再原子
rename替换。大实例重写高峰可能造成 CPU/内存尖刺,应低峰触发或调阈值。 - 面试追问:① 为什么重写后文件会变小?(历史反复 SET/DEL 被折叠成最终状态) ② 重写会不会丢数据?(不会:旧 AOF 继续写,结束后合并;异常则保留旧文件)
【拓展延伸】
- 变式问法:问“何时自动重写”→ 增长百分比+最小体积双条件;问“重写与 RDB 快照关系”→ 都是子进程+COW 思想,但输出物不同。
- 参数/命令:
BGREWRITEAOF、auto-aof-rewrite-percentage 100、auto-aof-rewrite-min-size 64mb、aof-use-rdb-preamble(混合持久化,4.0+);INFO persistence看 aof_current_size/rewrite。
R14. Redis 对已过期的键采用什么删除策略?
考点:过期键删除策略
A. 只使用惰性删除(访问时才判断过期) B. 只使用定时删除(为每个键设置定时器) C. 惰性删除 + 定期删除(定期任务随机抽查部分键删除) D. 不删除,等内存淘汰时统一处理
答案:C
【考点】惰性删除与定期删除的组合设计。
【结论】 Redis 采用惰性删除 + 定期删除的组合:访问时才判断过期,同时后台定期随机抽查部分键删除 —— 选 C。
【逐项辨析】
- A 只使用惰性删除:错。关键字眼是“只” —— 过期后长期不被访问的 key 会一直占着内存不被回收,最终撑满内存。
- B 只使用定时删除:错。关键字眼是“为每个键设置定时器” —— 定时删除虽最及时,但定时器数量巨大、CPU 开销过高,Redis 没有采用。
- C 惰性删除 + 定期删除:正确。 两种策略互为补充,正是 Redis 实际采用的方案。
- D 不删除,等内存淘汰时统一处理:错。关键字眼是“不删除” —— 过期键在访问时就会被惰性删除;内存淘汰(见 R15)是额外兜底,不是过期键的主要处理方式。
【知识点】 过期键的删除有三种候选策略,Redis 选了“折中组合”:
| 策略 | 做法 | 优点 | 缺点 | 采用 |
|---|---|---|---|---|
| 定时删除 | 为每个 key 建定时器,到点即删 | 最及时,内存友好 | 定时器多、CPU 开销大 | ✗ |
| 惰性删除 | 访问 key 时才检查是否过期 | 不浪费 CPU | 冷 key 长期占内存 | ✓ |
| 定期删除 | 后台任务随机抽查并删除 | 兼顾 CPU 与内存 | 抽查不保证全覆盖 | ✓ |
惰性删除(expireIfNeeded)的判定链:读命令遇到已过期的 key → 删除并返回空(视同不存在);写命令遇到已过期的 key → 先删除再正常执行。即过期键一旦被碰到就必然被清掉。
定期删除(activeExpireCycle)的四条规则:① 频率由 hz 控制,默认 hz = 10,即每秒 10 次;② 只从设置了过期时间的 key(expires 字典)中随机抽查,不是全量扫描;③ 若某次抽查中过期 key 占比超过 25%,则继续抽查;④ 单次执行有时间预算——每轮最多占用约 25% 的 CPU 时间片,按 hz=10 推算即 100ms × 25% ≈ 25ms,超时立即退出等下一轮,避免长时间阻塞单线程。注意这个 25ms 是推导值,Redis 并没有名为"单次上限 25ms"的配置项;改 hz 会同时改变每轮的时间预算。
推导:组合仍然不够 —— 惰性删除覆盖不到“冷数据”,定期删除是随机抽查、不保证某个具体 key 被抽到。两者叠加仍不能保证及时清理,必须再加内存淘汰策略兜底(见 R15)。这就是 Redis 的过期键处理是“三件套”而非两件套的原因。
【记忆锚点】 “惰性删除省 CPU,定期删除省内存;两者都不保及时,淘汰策略来兜底” —— 定期删除只查 expires 字典、只随机、只删已过期的。
【易混对比】
- 惰性删除 vs 定期删除:前者被动(访问触发)、不费 CPU 但漏内存;后者主动(后台任务),恰好相反。
- 过期键删除 vs 内存淘汰:前者针对“已过期的 key”(时间维度),后者针对“内存不足时的 key”(空间维度)。所有 key 都没过期,内存满了照样淘汰。
- 换问法:问“定期删除是否保证所有过期键都被删掉”,答“不保证” —— 随机抽查 + 25% 放大 + 25ms 上限。
【自测】 某实例 hz = 10。定期删除任务每秒执行几次?若一次抽查中发现过期 key 占比 60%,会怎样?
答:每秒 10 次;占比超过 25% → 继续抽查,直到占比降下来或达到单次 25ms 上限。与 R09、R15 连考。
【知识关联】
- 补题关联:无直接补题;与 MySQL 补-20 的“恢复后一致性”对照:过期键策略影响读到的数据是否“陈旧”。
- 面试/工程:惰性+定期是默认折中:不为每个 key 起定时器,又避免过期键长期占内存。内存紧时仅靠惰性可能堆积大量已逻辑过期 key,导致真需要内存时才暴发式访问磁盘/淘汰。TTL 过短会增加回源,过长则陈旧;业务常“基础 TTL + 随机抖动”。
- 面试追问:① 只有惰性删除会怎样?(过期键占内存直到再次被访问) ② 从库如何处理主库过期?(主库 DEL 后通过复制传播;从库也可按本地时钟惰性判过期,但以主库删除为准)
【拓展延伸】
- 变式问法:对比惰性删除 vs 定期删除 vs 内存淘汰(淘汰是“为腾内存主动删”,过期是“到期失效”)。
- 参数/命令:
EXPIRE/PEXPIRE/EXPIREAT、TTL/PTTL、PERSIST;active-expire-cycle相关行为(每秒采样);INFO stats的expired_keys;notify-keyspace-events Ex。
R15. 当 Redis 内存达到 maxmemory 时,默认采用哪种行为?
考点:内存淘汰策略
A. allkeys-random——随机淘汰 B. allkeys-lru——在所有 key 中淘汰最近最少使用的 C. volatile-lru——只在设置了过期时间的 key 中淘汰 D. noeviction——拒绝写入并返回错误
答案:D
【考点】八种内存淘汰策略的分类与默认值。
【结论】 Redis 的 maxmemory-policy 默认就是 noeviction:内存写满后读命令正常、写命令直接报错 —— 选 D。
【逐项辨析】
- A
allkeys-random:错。存在,同样不是默认值,且随机淘汰命中率差、生产极少使用。 - B
allkeys-lru:错。策略真实存在且是缓存场景的推荐值,但它不是默认值。 - C
volatile-lru:错。同样存在,但也不是默认值;且淘汰范围限定在带 TTL 的 key 上,这类 key 太少会导致淘汰失败。 - D
noeviction—— 拒绝写入并返回错误:正确。 这就是默认值,写命令会收到OOM command not allowed when used memory > 'maxmemory'。
注意:B、C、A 描述的策略本身都是真的,本题的陷阱只在“默认”二字。
【知识点】 八种策略由两个维度正交组合而成:淘汰范围(allkeys- 全部 key / volatile- 仅带过期时间的 key)× 淘汰算法(lru 最久没被访问、lfu 访问频率最低、random 随机、ttl 剩余寿命最短,其中 ttl 仅 volatile- 有)。故 allkeys- 3 种 + volatile- 4 种 + noeviction = 8 种:
| 策略 | 范围 | 算法 | 典型用途 |
|---|---|---|---|
noeviction | —(不淘汰) | — | 默认值,写满即报错 |
allkeys-lru | 全部 | LRU | 缓存场景首选 |
allkeys-lfu | 全部 | LFU | 有明显冷热分层的缓存 |
allkeys-random | 全部 | 随机 | 极少用 |
volatile-lru | 带 TTL | LRU | 缓存与常驻数据混布 |
volatile-lfu | 带 TTL | LFU | 同上 |
volatile-random | 带 TTL | 随机 | 极少用 |
volatile-ttl | 带 TTL | 剩余寿命最短 | 优先清快过期的 |
推导:noeviction 之所以是默认值,是因为 Redis 无法替用户判断“哪些数据可以丢” —— 默认保守、拒绝写入并报错,让问题立刻暴露,而不是静默删掉用户以为还在的数据。代价是不做配置时 Redis 写满就写不进去。选型:纯缓存用 allkeys-lru;若希望没设 TTL 的数据绝不被动,用 volatile-lru 并给所有缓存都设上过期时间。
【记忆锚点】 “默认 noeviction 只读不写、直接报错;纯缓存改 allkeys-lru、要保数据用 volatile-lru” —— 默认值是“什么都不淘汰”。
【易混对比】
- LRU vs LFU:LRU 看“多久没访问”,LFU 看“访问了多少次”。偶发的一次批量扫描会把 LRU 的热点挤掉,LFU 更抗干扰。
allkeys-*vsvolatile-*:前者在全部 key 里淘汰,后者只在带 TTL 的 key 里淘汰;后者在几乎没有带 TTL 的 key 时效果等同noeviction。- 换问法:问“缓存场景推荐哪个策略”,答案变成
allkeys-lru—— 注意区分“默认值”与“推荐值”,这是最常互换的考法。
【自测】 某实例设了 maxmemory 2gb 但没配 maxmemory-policy,且大量 key 都没有过期时间。内存涨到 2gb 后会发生什么?
答:写命令报
OOM command not allowed,读命令仍正常。默认noeviction不做淘汰;即使改成volatile-lru,因 key 都没设 TTL 也淘汰不出空间。与 R14、R16 连考。
【知识关联】
- 补题关联:无直接补题;容量规划与补-22(Buffer Pool 命中)思想类似:都是“有限内存下的保留策略”。
- 面试/工程:缓存场景多用
allkeys-lru或allkeys-lfu(8.0 后 LFU 更抗扫描型访问);有明确过期语义可用volatile-*。随机策略用于可重现实验。注意:淘汰与过期不同;设了 TTL 不等于会及时腾内存。noeviction在只读缓存+严格不过期时可选,但写入会 OOM 错误。 - 面试追问:① LRU 与 LFU 差在哪?(LRU 看最近使用时间,LFU 看访问频率;LFU 更不易被一次性全量扫描冲掉热点) ② 缓存击穿场景该选哪种淘汰?(与淘汰无直接关系,应靠互斥/逻辑过期;淘汰策略影响的是“谁先被挤出”)
【拓展延伸】
- 变式问法:给出业务(纯缓存 vs 计数持久)选拨;把“设置了过期就一定不会被内存淘汰”设为错误项。
- 参数/命令:
maxmemory、maxmemory-policy noeviction|allkeys-lru|allkeys-lfu|volatile-lru|volatile-lfu|allkeys-random|volatile-random|volatile-ttl;INFO memory的used_memory/evicted_keys;LFU 调参lfu-log-factor/lfu-decay-time。
R16. 关于 Redis 的内存与阻塞,下列做法不合理的是?
考点:内存碎片与内存优化
A. 用 MEMORY USAGE key 或 redis-cli --bigkeys 排查大 key B. 开启 activedefrag 或通过重启/主从切换缓解内存碎片 C. 用 INFO memory 观察 used_memory 与 mem_fragmentation_ratio D. 线上直接执行 KEYS * 扫描全部 key 来做容量统计
答案:D
【考点】Redis 内存排查工具与阻塞风险。
【结论】 KEYS * 会遍历整个 keyspace,在单线程模型下长时间阻塞 Redis —— 生产环境绝不能用它做容量统计,选 D。
【逐项辨析】
- A 用
MEMORY USAGE key或redis-cli --bigkeys排查大 key:合理。前者精确查单个 key 的内存占用,后者内部用SCAN安全统计,都不会阻塞。 - B 开启
activedefrag或通过重启 / 主从切换缓解内存碎片:合理。前者是主动碎片整理,后者通过重建内存让碎片自然消失。 - C 用
INFO memory观察used_memory与mem_fragmentation_ratio:合理。这是判断内存是否紧张、碎片是否严重的第一手指标。 - D 线上直接执行
KEYS *扫描全部 key 来做容量统计:不合理(本题选它)。 关键字眼是“线上”与“全部 key” —— Redis 命令处理是单线程的,KEYS *执行期间无法处理任何其他请求,key 越多阻塞越久。
【知识点】 为什么危险:Redis 的核心命令处理走单线程(6.0 起网络 IO 多线程,但命令执行仍是单线程)。因此任何复杂度为 O(N) 且 N 不可控的命令都会独占主线程,期间所有请求排队等待,客户端侧表现为超时、重连,极端情况下引发雪崩式故障。KEYS * 的复杂度正是 O(N),N 等于整个库的 key 数量。
| 目的 | 危险做法 | 安全替代 | 复杂度 |
|---|---|---|---|
| 遍历全部 key | KEYS * | SCAN cursor(游标增量遍历,非阻塞) | O(N) → 分批 O(1) |
| 统计大 key | KEYS * 后逐个 STRLEN | redis-cli --bigkeys(内部用 SCAN) | 安全 |
| 看单个 key 占用 | 无 | MEMORY USAGE key | O(1) |
| 看 key 总数 | KEYS * 再计数 | INFO keyspace / DBSIZE | O(1) |
内存碎片指标 mem_fragmentation_ratio = used_memory_rss / used_memory:大于 1.5 说明碎片较多、物理内存浪费明显,可开启 activedefrag 或重启 / 主从切换重建内存;约等于 1.0 正常;小于 1 说明部分内存被操作系统换出(swap),是危险信号。
推导:SCAN 之所以安全,是因为它采用游标(cursor)分批返回,每次只扫描一小部分并立即返回,把一次大阻塞拆成多次小阻塞,主线程在两次调用之间仍能处理其他命令。代价是它只保证“遍历期间一直存在的 key 一定被返回”,对遍历期间新增 / 删除的 key 不保证,且可能返回重复元素 —— 但用于容量统计完全够用。
【记忆锚点】 “KEYS 一次扫全库、单线程全阻塞;要遍历就用 SCAN、要统计就用 --bigkeys”。
【易混对比】
KEYS *vsSCAN:前者一次性返回全部(O(N)、阻塞),后者游标分批(非阻塞、可能重复、不保证实时一致)。SCANvsHSCAN/SSCAN/ZSCAN:同一套游标思想,分别遍历大 hash / set / zset 的内部元素 —— 同样要避免HGETALL。- 换问法:问“统计 key 总数最安全的方式”,答案是
INFO keyspace或DBSIZE;注意“内存碎片(> 1.5,有内存用不上)”与“内存不足(used_memory逼近maxmemory)”排查方向完全不同。
【自测】 运维同学要统计线上实例里所有 user:* 开头的 key 数量,写了 redis-cli KEYS "user:*"。这个命令安全吗?正确写法是什么?
答:不安全。
KEYS是O(N)全库遍历、单线程阻塞,加模式匹配也要先扫全部 key。正确写法是用SCAN游标迭代 + 客户端MATCH "user:*"过滤,或用INFO keyspace/DBSIZE估总量。与 R15、R09 连考。
【知识关联】
- 补题关联:补-28(编码与内存布局,大 key/大编码对象)、补-26(限流间接保护)。
- 面试/工程:
KEYS *、大SORT、大HGETALL、SAVE、同步大 RDB 传输都是阻塞或高开销命令,生产事故常来自“运维在高峰执行 KEYS”。正确姿势:SCAN 游标渐进遍历、大 Hash/Set 分片(hash tag 拆桶)、离线分析用 RDB 副本而非主库。SCAN 也不是绝对无阻塞:单次 COUNT 过大或实例 CPU 打满时仍有影响。 - 面试追问:① SCAN 就一定完全无阻塞吗?(相对 KEYS 好得多,但仍占 CPU;COUNT 与速率要控制) ② 大 key 如何发现与拆分?(
--bigkeys、MEMORY USAGE、RDB 离线分析;按业务维度拆 Hash 字段或改多 key)
【拓展延伸】
- 变式问法:题干给一组命令选“最容易阻塞”(KEYS/FLUSHALL/大 SORT/SAVE);或问“遍历千万 key 应该用什么”→ SCAN。
- 参数/命令:
SCAN cursor MATCH p* COUNT 100;HSCAN/SSCAN/ZSCAN;redis-cli --bigkeys/--hotkeys;MEMORY USAGE key;LATENCY DOCTOR、SLOWLOG GET;CONFIG SET调hz等需谨慎。
R17. 用 Redis 实现分布式锁,正确的加锁方式是?
考点:分布式锁的加锁
A. SET lock:key 1(不带 NX) B. 先 SETNX,成功后再 EXPIRE(分两条命令) C. SET lock:key <uuid> NX PX 30000(一条命令完成“不存在才设置”并带过期时间) D. INCR lock:key
答案:C
【考点】分布式锁的原子性加锁与唯一标识。
【结论】 正确写法是 SET lock:key <uuid> NX PX 30000 —— 一条命令同时完成“不存在才设置”与“设置过期时间”,并带上唯一标识,选 C。
【逐项辨析】
- A
SET lock:key 1(不带 NX):错。关键字眼是“不带 NX” —— 没有 NX 就是普通覆盖写,任何客户端都能改掉别人的锁值,互斥语义完全丧失。 - B 先
SETNX,成功后再EXPIRE(分两条命令):错。关键字眼是“分两条命令” —— 若客户端在SETNX成功之后、EXPIRE执行之前崩溃,这把锁就永远没有过期时间,形成死锁。 - C
SET lock:key <uuid> NX PX 30000:正确。 三个要素齐备:NX保证互斥、PX防死锁、唯一 value 便于校验归属。 - D
INCR lock:key:错。关键字眼是“INCR” —— 它不携带归属标识、也不带过期时间,无法表达“谁持有”,不是互斥锁语义。
【知识点】 一把合格的 Redis 分布式锁,加锁必须同时满足三个条件:
| 条件 | 手段 | 不满足会怎样 |
|---|---|---|
| 互斥 | NX(Not eXists) | 多个客户端同时持锁,锁形同虚设 |
| 防死锁 | PX / EX 设过期时间 | 持锁客户端崩溃 → 锁永不释放 |
| 可归属 | value 用唯一标识(UUID / 线程 ID) | 无法安全释放锁(见 R18) |
为什么必须“一条命令”?Redis 单线程执行命令,单条命令天然原子,两条命令之间则可能插入其他客户端的命令:
时刻 T1 客户端 A: SETNX lock:key 1 → 成功(锁归 A)
时刻 T2 客户端 A 崩溃(EXPIRE 尚未执行)
时刻 T3 锁永远存在,无人能再获取 → 死锁而 SET ... NX PX ... 把“判断 + 写入 + 设过期”压在一条命令里,不存在中间状态;PX(毫秒)与 EX(秒)二选一。过期时间太短则业务没跑完锁就自动释放,太长则崩溃后其他请求要等很久 —— 常见做法是先估算业务耗时取其数倍,再配合看门狗(watchdog)自动续期(如 Redisson 的续期机制)在业务未完成时延长锁的 TTL。
边界:单实例(或主从)下仍有局限 —— 主节点写入锁后尚未同步到从节点就宕机,从节点升主后锁丢失,可能出现两个客户端同时持锁。更强的方案是 Redlock(多数派加锁),但它在业界存在争议,不要盲目使用。
【记忆锚点】 “一条 SET 带 NX、带 PX、带 UUID” —— 互斥、防死锁、可归属,三要素缺一不可。
【易混对比】
NXvsXX:NX是“不存在才设置”(加锁),XX是“存在才设置”(常用于更新),方向相反。- 加锁 vs 释放:加锁的关键是“原子地抢占”,释放的关键是“原子地校验归属再删”(见 R18)—— 两题考点互为镜像,必须成对掌握。
- 换问法:问“为什么不能先
SETNX再EXPIRE”,答案就是“两条命令之间存在崩溃窗口,会导致锁永不过期”。
【自测】 客户端 A 执行 SETNX lock:key A 成功后进程被 kill -9,锁没有任何过期时间。后果是什么?正确写法应如何避免?
答:锁永久无法释放(死锁)。正确写法是
SET lock:key <uuid> NX PX 30000,把设过期与加锁放在同一条原子命令里。与 R18 连考。
【知识关联】
- 补题关联:补-21(悲观互斥的分布式实现就是 Redis 锁)、补-26(令牌桶取令牌要靠 Lua 保证「读-判断-扣」原子,与加锁是不同场景)。
- 面试/工程:正确加锁必须一次原子完成“设值+NX+过期”:
SET key uuid NX PX ttl。两步 SETNX+EXPIRE 在进程崩溃于两步之间时会留下永久死锁。value 用唯一标识,为的是解锁时校验持有者(见 R18)与问题排查。更强一致性考虑 etcd/ZK;Redis 锁要接受 failover 可能丢锁的边界。 - 面试追问:① 为什么 SETNX+EXPIRE 两步不行?(非原子,中间崩溃可导致锁无 TTL) ② 锁的 value 为什么要唯一?(解锁比对持有者,防止误删他人锁;便于日志审计)
【拓展延伸】
- 变式问法:正向选
SET lock:order:1 uuid NX PX 3000;错误项含“先 SETNX 再 EXPIRE”“只用 SETNX 不设过期”“value 用固定 1 无法校验持有者”。 - 参数/命令:
SET key value NX PX milliseconds;Lua 脚本原子加锁/解锁;RedissonRLock/tryLock(waitTime, leaseTime);SET lock uuid NX EX 3等价秒级写法。
R18. 释放 Redis 分布式锁时,为避免误删他人持有的锁,正确做法是?
考点:分布式锁的释放
A. 直接 DEL lock:key B. 用 Lua 脚本执行“校验 value 是否等于自己的标识,相等才 DEL”,保证判断与删除的原子性 C. 用 EXPIRE 把锁设为立即过期 D. 先 GET 校验,再 DEL(两条命令)
答案:B
【考点】释放锁的原子性要求与 Lua 脚本的使用。
【结论】 必须用 Lua 脚本把“校验 value 是否等于自己的标识”和 DEL 合并成一个原子操作,否则存在竞态窗口、可能误删他人的锁 —— 选 B。
【逐项辨析】
- A 直接
DEL lock:key:错。关键字眼是“直接” —— 完全不做归属校验,A 的锁到期自动释放后 B 拿到锁,A 若此时才执行DEL,删掉的正是 B 的锁。 - B 用 Lua 脚本执行“校验 value 是否等于自己的标识,相等才
DEL”,保证判断与删除的原子性:正确。 校验与删除必须不可分割,Lua 脚本在 Redis 中作为一个整体执行。 - C 用
EXPIRE把锁设为立即过期:错。关键字眼是“立即过期” —— 这不是标准释放做法,且同样不校验归属,并未解决“删错”的问题。 - D 先
GET校验,再DEL(两条命令):错。关键字眼是“两条命令” —— 方向对(有校验),但GET与DEL之间存在竞态窗口,正是本题要排除的写法。
【知识点】 竞态窗口是本题的核心,把它画出来:
时刻 T1 客户端 A 的锁 TTL 到期,Redis 自动删除 lock:key
时刻 T2 客户端 B 执行 SET lock:key <B> NX PX 30000 → 加锁成功
时刻 T3 客户端 A 的 GET 发生在 T1 之前,读到 <A>,校验通过
时刻 T4 客户端 A 执行 DEL lock:key → 删掉的是 B 的锁!关键点:A 的“校验通过”与“执行删除”之间存在时间差,只要锁在这段时间内易主,A 就会删掉别人的锁。把两步合成一步即可消除该窗口 —— Redis 保证单条命令与 Lua 脚本整体执行的原子性。标准写法(Redis 官方文档):
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endKEYS[1] 传锁的 key、ARGV[1] 传自己的唯一标识,用 == 比较、相等才删;返回 0 表示“锁已不属于自己”(可能已过期或被他人持有)。
| 写法 | 是否校验归属 | 是否原子 | 结论 |
|---|---|---|---|
DEL lock:key | ✗ | ✓ | 可能删掉他人的锁 |
GET + DEL | ✓ | ✗ | 存在竞态窗口,仍可能误删 |
| Lua 脚本 | ✓ | ✓ | 正确 |
边界:如果业务在锁过期后才执行完,说明锁的 TTL 设短了 —— 此时无论释放写得多正确,互斥都已被破坏(A、B 曾同时持锁)。因此续期(看门狗)比“更精巧的释放脚本”更重要:Lua 脚本解决“误删”,解决不了“锁提前过期”(见 R17)。
【记忆锚点】 “加锁一条 SET、释放一段 Lua” —— 判断与删除必须原子,中间不能有缝。
【易混对比】
- “误删他人锁” vs “锁提前过期”:前者是释放环节的原子性问题(本题,用 Lua 解决);后者是TTL 设置问题(用续期 / 加长 TTL 解决)。两者都表现为“互斥被破坏”,但根因不同。
- Lua 脚本 vs 事务(
MULTI/EXEC):MULTI只是把命令排队,无法在事务内根据上一条命令的结果做判断(没有条件分支),所以“校验后再删”只能用 Lua 实现。 - 换问法:问“
GET+DEL的问题是什么”,答案就是“两条命令之间存在竞态窗口,锁可能在窗口内易主”。与 R17 连考。
【自测】 客户端 A 持有 lock:key(value 为 A-uuid,TTL 30 秒),业务执行了 35 秒。A 执行释放脚本时会删掉谁的锁?根本问题出在哪?
答:不会删掉任何人的锁(脚本校验发现 value 已不是
A-uuid,返回 0)—— 但锁在 30 秒时已自动过期,期间 B 可成功加锁,互斥已被破坏。根本问题是 TTL 设短了,解法是加长 TTL 或用看门狗续期。与 R17 连考。
【知识关联】
- 补题关联:补-21(悲观锁/乐观锁与锁标识)、R21(解锁脚本同样要用 Lua 保证原子;补-26 是限流脚本,别混)。
- 面试/工程:误删他人锁是分布式锁最经典事故:线程 A 超时锁自动释放 → B 拿到锁 → A 恢复后直接
DEL删掉 B 的锁,临界区被并发破坏。因此解锁必须“值匹配才删”,且比较与删除必须在同一原子单元(Lua,或 WATCH+MULTI)。Redisson 的unlock()即封装该逻辑;value 推荐UUID:threadId便于审计。 - 面试追问:① 只用 DEL 行不行?(不行:无法判断锁是否仍归自己) ② 解锁脚本如何保证原子?(Lua 在 Redis 内整体执行,期间不插入其他客户端命令)
【拓展延伸】
- 变式问法:正向题选“Lua:GET 比较 value 再 DEL”;错误项含“直接 DEL”“先 GET 再 DEL(两步非原子)”“用 UNLOCK 原生命令(不存在)”。
- 参数/命令:典型解锁 Lua:
if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end;EVAL/EVALSHA执行;RedissonRLock.unlock();可选解锁前再校验持有线程标识。
R19. 关于 Redis 分布式锁的可靠性,下列说法正确的是?
考点:锁续期与 Redlock
A. Redlock 是绝对可靠的方案,不存在任何争议 B. 设置了过期时间的锁一定不会提前释放 C. Redlock 由 Antirez 提出,在多个独立 Redis 实例上多数派加锁;其正确性业界存在争议(如 Martin Kleppmann 的质疑),生产更常用 Redisson 的看门狗(watchdog)自动续期方案 D. Redlock 与 Redis 无关,是 ZooKeeper 的机制
答案:C
【考点】分布式锁的续期问题与 Redlock 争议。
【结论】 Redlock 由 Antirez 提出,在多个独立 Redis 实例上多数派加锁,其正确性在业界存在争议;生产更常用 Redisson 的看门狗(watchdog)自动续期方案 —— 选 C。
【逐项辨析】
- A Redlock 是绝对可靠的方案,不存在任何争议:错。错在“绝对”二字 —— Redlock 恰恰是分布式锁领域最有名的一场论战的主角,它依赖各实例的时钟,无法严格保证互斥。
- B 设置了过期时间的锁一定不会提前释放:错。错在“一定” —— 过期时间与业务执行时长无法预估匹配,业务跑得比 TTL 长,锁就会在业务未完成时提前释放。
- C Redlock 由 Antirez 提出,在多个独立 Redis 实例上多数派加锁;其正确性业界存在争议(如 Martin Kleppmann 的质疑),生产更常用 Redisson 的看门狗自动续期方案:正确。 提出者、加锁方式、争议事实、生产替代方案四个要点全部成立。
- D Redlock 与 Redis 无关,是 ZooKeeper 的机制:错。错在“与 Redis 无关” —— Redlock 是 Redis 作者提出的基于 Redis 的算法;ZooKeeper 用的是临时顺序节点 + Watch,是另一套机制。
【知识点】 分布式锁的可靠性,本质是“锁的持有时间必须覆盖业务的执行时间”,而这两者天然无法精确匹配。
| TTL 设置 | 后果 |
|---|---|
| 太短 | 业务未执行完锁就过期 → 互斥失效,出现并发写 |
| 太长 | 持锁者宕机后锁迟迟不释放 → 其他请求长时间阻塞 |
Redisson 看门狗 解决“太短”这一侧:加锁成功后启动后台定时任务,默认 lockWatchdogTimeout 为 30 秒,看门狗每 10 秒(TTL 的 1/3)把锁的过期时间重置回 30 秒,业务结束时再主动释放。因此只要持锁进程活着,锁就不会提前过期;进程崩溃后不再续期,锁最长约 30 秒后自动释放。
Redlock 的加锁流程(N 个相互独立的实例,通常 N = 5):
1. 记录起始时间 T1
2. 依次向 N 个实例用同一个 key、同一个随机 value 请求加锁,单实例超时远小于锁 TTL
3. 当且仅当在超过半数实例上加锁成功,且总耗时 < 锁 TTL,才判定加锁成功
4. 锁的真实有效时间 = 初始 TTL − 加锁总耗时
5. 加锁失败则向所有实例发送释放请求,避免残留争议的推导链条:Redlock 的互斥性依赖“锁在 TTL 内一定不会过期”这一假设,而 TTL 由各实例的本地时钟计时 —— 一旦发生时钟漂移、GC 停顿或网络延迟,某实例可能提前认为锁已过期并让其他客户端加锁成功,于是两个客户端同时持锁。Kleppmann 因此认为它不适合资金类等正确性要求高的场景,建议改用带 fencing token 的方案或 ZooKeeper / etcd。
【记忆锚点】 「看门狗续命,Redlock 靠时钟」 —— 生产用 Redisson 看门狗解决“提前释放”,Redlock 解决“单点故障”但把正确性押在了时钟上。
【易混对比】
- Redisson 看门狗 vs Redlock:前者自动续期,解决“锁提前释放”;后者跨多实例多数派加锁,解决“单点故障”,代价是依赖时钟。
- Redis 分布式锁 vs ZooKeeper 分布式锁:Redis 性能高,但正确性依赖 TTL 与续期;ZooKeeper 靠临时顺序节点 + 会话超时自动释放,一致性更强但性能较低。
- 换问法:若题干问“Redlock 的争议根源是什么”,答案就变成“它依赖各实例时钟,在时钟漂移 / GC 停顿下无法严格保证互斥”。
【自测】 Redisson 使用默认配置(TTL 30 秒、看门狗每 10 秒续期),业务实际执行了 5 分钟。锁会在第 30 秒被释放吗?
答:不会。看门狗每 10 秒把 TTL 重置回 30 秒,只要持锁进程存活,锁就一直被续期到业务主动释放;若进程中途崩溃,则锁最长约 30 秒后自动过期。与 R20、R22、R27 连考;大厂面试高频。
【知识关联】
- 补题关联:补-21(锁的正确性边界)、补-20(时钟/持久化与恢复对一致性的影响可对照)。
- 面试/工程:看门狗解决“TTL 太短业务没跑完锁没了”;Redlock 试图用多独立实例降低单点与 failover 造成双主锁的概率,但依赖本地时钟假设,争议大。资金类场景更推荐 etcd/ZooKeeper + fencing token,或业务层幂等/唯一约束兜底。工程上要先问:业务能否容忍短暂双锁?若不能,别只靠 Redis 锁。
- 面试追问:① 看门狗默认多久续期?(Redisson 默认 30s TTL,每 10s 续) ② Redlock 争议的核心是什么?(时钟漂移/GC/网络导致“锁已过期”的假设被打破,可能出现双持有)
【拓展延伸】
- 变式问法:问“TTL 设多少合适”→ 不应固定拍脑袋,应用看门狗动态续期;问“主从切换时锁会不会丢”→ 异步复制下可能丢,这是单实例/普通主从锁的弱点。
- 参数/命令:Redisson
lockWatchdogTimeout(默认 30000ms);SET lock val NX PX ttl;Redlock 客户端RLock多节点;CLUSTER/哨兵下锁 key 的落点;对比 etcd lease/TTL 与 ZooKeeper ephemeral node。
R20. Redis 事务(MULTI / EXEC)的特点是?
考点:Redis 事务
A. 支持完整的回滚(ACID 中的 A、I、D 都满足) B. 命令先入队、EXEC 时顺序执行且不被打断,但不支持回滚(运行期某条命令失败,其余命令仍会执行),也没有隔离级别的概念 C. 与关系型数据库事务完全等价 D. 是分布式事务的标准实现
答案:B
【考点】Redis 事务的能力边界(“原子性”的准确含义)。
【结论】 Redis 事务把命令先入队、EXEC 时顺序执行且不被打断,但不支持回滚(运行期某条命令失败,其余命令仍会执行),也没有隔离级别的概念 —— 选 B。
【逐项辨析】
- A 支持完整的回滚(ACID 中的 A、I、D 都满足):错。错在“完整的回滚” —— Redis 事务根本没有回滚能力,运行期出错后已执行的命令不会撤销。
- B 命令先入队、
EXEC时顺序执行且不被打断,但不支持回滚(运行期某条命令失败,其余命令仍会执行),也没有隔离级别的概念:正确。 这三句恰好划出了 Redis 事务能力的准确边界。 - C 与关系型数据库事务完全等价:错。错在“完全等价” —— 关系型数据库有 undo / redo 与隔离级别,Redis 事务两者都没有。
- D 是分布式事务的标准实现:错。错在“分布式” —— Redis 事务只在单个实例内生效、不跨实例,分布式事务需要 Seata、TCC 等方案。
【知识点】 Redis 事务的流程是:MULTI 开启事务 → 后续命令入队并返回 QUEUED → EXEC 按入队顺序执行,或用 DISCARD 放弃整个事务;配合 WATCH 可实现乐观锁。
关键是把两种错误分开看:
| 错误发生阶段 | 典型例子 | EXEC 的行为 | 是否回滚 |
|---|---|---|---|
| 入队阶段(语法错误 / 命令不存在) | SET 少参数、写了不存在的命令 | 整体拒绝执行,返回 EXECABORT | 全部不执行,无需回滚 |
| 执行阶段(运行期错误) | 对 String 执行 LPUSH | 其余命令照常执行 | 不回滚,数据可能处于中间状态 |
再看 ACID 四性的实际成色:
| 属性 | Redis 事务的表现 | 说明 |
|---|---|---|
| 原子性 A | 部分具备 | 只保证“不被穿插执行”,不保证“全部成功或全部失败” |
| 一致性 C | 弱 | 出错后可能落在中间状态,靠开发者自行保证 |
| 隔离性 I | 靠单线程天然获得 | 执行期间不处理其他客户端命令,等价于串行 |
| 持久性 D | 与事务无关 | 由 RDB / AOF 决定 |
为什么 Redis 不回滚:回滚需要额外的 undo 机制,而 Redis 认为运行期错误属于编程错误,应由开发者修正而非由引擎兜底 —— 这与它“保持简单、追求高性能”的设计取向一致。
WATCH 乐观锁:WATCH key 之后,若该 key 在 EXEC 之前被其他客户端修改,EXEC 返回 nil 且整个事务不执行,客户端需重试。
【记忆锚点】 「Redis 事务只打包,不兜底」 —— 能保证“一口气执行完、中间不被插队”,不能保证“出错全撤销”。
【易混对比】
- Redis 事务 vs Lua 脚本:两者都“执行期不被打断”;区别是 Lua 脚本内可读中间结果、写
if判断,事务做不到。 - Redis 事务 vs Pipeline:Pipeline 只是批量传输,服务端仍会穿插执行其他客户端的命令;事务保证执行期不被穿插。
- 入队错误 vs 运行期错误:前者让整个事务不执行,后者只让出错那条失败、其余照常执行且不回滚。
- 换问法:若题干问“对 String 执行
LPUSH后事务会怎样”,答案仍是“该条失败、其余命令照常执行、不回滚”。
【自测】 客户端依次执行 MULTI、SET k1 v1、LPUSH k1 v2、EXEC,其中 k1 此前是 String 类型的 key。最终结果如何?
答:
SET成功执行,LPUSH因类型不匹配报错,事务不回滚,k1的最终值仍是v1。与 R21、R25 连考;大厂面试高频。
【知识关联】
- 补题关联:补-17(ACID:Redis 事务无回滚、持久性也不完整)、补-21(需要条件判断时 Lua/乐观更合适)。
- 面试/工程:MULTI/EXEC 只是“命令排队后连续执行”,不提供回滚与隔离级别;语法错误整队拒绝,运行期错误默认继续执行后面命令。需要“判断再执行”“失败回滚”“复杂逻辑”时用 Lua。Pipeline 是性能优化(省 RTT),与事务正交,可组合但概念不要混。
- 面试追问:① EXEC 中某条命令运行期错误会怎样?(默认不回滚,已执行命令效果保留;Redis 不支持类似 SQL 的自动回滚) ② WATCH 属于什么锁思想?(乐观锁/CAS:执行前检查 key 是否被改,被改则 EXEC 失败)
【拓展延伸】
- 变式问法:三方对比题:MULTI/EXEC vs Pipeline vs Lua(原子性 / 隔离 / 回滚 / 适用场景);或问“Redis 事务是否满足 ACID”→ 不满足完整 A/C/I/D 口径。
- 参数/命令:
MULTI/EXEC/DISCARD/WATCH key/UNWATCH;EXEC返回数组逐条结果;需要原子+条件 →EVALLua;客户端 SpringSessionCallback可把 MULTI 包进 pipeline。
R21. Redis 执行 Lua 脚本时,关于其原子性,下列说法正确的是?
考点:Lua 脚本的原子性
A. 整个脚本作为一个整体执行,期间不会插入其他客户端的命令(具备原子性),但脚本执行过久会阻塞整个 Redis B. 不保证原子性,脚本中的命令可能与其他客户端的命令交错 C. 脚本中若某条命令失败,整个脚本会回滚 D. Lua 脚本由多线程并行执行
答案:A
【考点】Lua 脚本的原子性与阻塞风险。
【结论】 整个 Lua 脚本被当作一个命令执行,执行期间不会插入其他客户端的命令,因此具备原子性;但脚本执行过久会阻塞整个 Redis —— 选 A。
【逐项辨析】
- A 整个脚本作为一个整体执行,期间不会插入其他客户端的命令(具备原子性),但脚本执行过久会阻塞整个 Redis:正确。 原子性来自“当作一个命令执行”,代价则是主线程被长时间独占。
- B 不保证原子性,脚本中的命令可能与其他客户端的命令交错:错。错在“交错” —— 脚本执行期间 Redis 不处理其他客户端的命令。
- C 脚本中若某条命令失败,整个脚本会回滚:错。错在“回滚” —— Lua 脚本没有回滚机制,已执行的命令不会撤销(与 R20 同理)。
- D Lua 脚本由多线程并行执行:错。错在“多线程并行” —— Redis 处理命令的主线程是单线程的,脚本串行执行。
【知识点】 理解本题的关键是把“原子性”拆成两层含义:
| 含义 | Lua 脚本是否具备 | 说明 |
|---|---|---|
| 不被穿插(隔离性) | 具备 | 脚本整体作为一条命令提交给主线程,主线程串行处理 |
| 全部成功或全部失败(回滚) | 不具备 | 中途报错,已执行的命令不撤销 |
阻塞风险的推导:Redis 主线程单线程 → 脚本执行时间 = 主线程被独占的时间 → 脚本里若有遍历大集合、循环调用 redis.call 等重操作,所有其他客户端的请求全部排队等待。因此脚本必须短小。
两个调用函数的区别:redis.call 出错时中断脚本并把错误抛回客户端;redis.pcall 把错误作为返回值交给脚本、脚本可继续执行。但即使 redis.call 中断脚本,此前已执行成功的命令依然不回滚。
典型用途:分布式锁的安全释放(GET 比对 value 再 DEL,把“判断 + 删除”合成一个原子步骤)、限流(INCR + EXPIRE 合并)、库存扣减。演进:Redis 7.0 起提供 Function(FUNCTION LOAD),可持久化、可复用,是脚本管理的演进方案。
【记忆锚点】 「脚本是一整条命令:原子、不回滚、还怕长」 —— 只写短小脚本,重逻辑不要塞进 Lua。
【易混对比】
- Lua 脚本 vs
MULTI/EXEC:两者都保证“执行期不被穿插”;区别是脚本内可读中间结果并做if判断,事务不能。 - Lua 脚本 vs Pipeline:Pipeline 不保证原子性,服务端仍会穿插执行其他客户端的命令(见 R25)。
- 原子性 vs 不回滚:Redis 语境下“原子性”通常只指“不被穿插”,不要理解成“可回滚”。
- 换问法:若题干问“Lua 脚本执行时间过长的后果”,答案是“阻塞整个 Redis,其他客户端请求全部排队等待”。
【自测】 用 Lua 脚本实现分布式锁的安全释放:脚本先 redis.call('GET', key),若取到的值与客户端持有的随机值不相等,应该怎么做?
答:直接
return 0返回,不执行DEL—— 判断与删除在同一脚本内完成,中间不会被其他客户端插入,从而避免“删掉了别人持有的锁”。与 R19、R20 连考。
【知识关联】
- 补题关联:补-26(令牌桶用 Lua 保证取令牌原子)、补-21(比 WATCH/MULTI 更适合条件复杂逻辑)。
- 面试/工程:库存扣减、限流、防重复提交、解锁校验都是 Lua 典型场景。脚本要短:长脚本阻塞整个实例。先用
SCRIPT LOAD+EVALSHA减少网络传输;注意集群下所有 key 要同槽,否则报错。Redis 7+ 有 Function,便于集中管理脚本。 - 面试追问:① Lua 里能调用任意 Redis 命令吗?(有调用规范与副作用限制,随机性命令慎用;集群要求 key 同槽) ② 脚本执行中能被其他命令打断吗?(默认原子执行;超时后可能被 SCRIPT KILL,若已写数据则只能 SHUTDOWN NOSAVE 级处理——故脚本要短)
【拓展延伸】
- 变式问法:问“如何原子实现‘不存在则写入并设置过期’”→ Lua 或 SET NX EX;问“Pipeline 和 Lua 谁更原子”→ Lua。
- 参数/命令:
EVAL script numkeys key... arg...、SCRIPT LOAD、EVALSHA、SCRIPT EXISTS/FLUSH/KILL;redis.replicate_commands()(旧版);集群hash tag保证同槽;lua-time-limit。
R22. Redis 主从复制的默认同步方式是?
考点:主从复制的同步方式
A. 同步复制,主库等待从库确认后才返回成功 B. 异步复制,主库写完后立即返回,从库异步追赶,存在数据丢失窗口 C. 半同步复制(等待至少一个从库确认) D. 主从之间没有数据复制
答案:B
【考点】异步复制的含义及其带来的数据丢失风险。
【结论】 Redis 主从复制默认是异步复制 —— 主库写完立即向客户端返回成功,从库异步追赶,因此存在数据丢失窗口 —— 选 B。
【逐项辨析】
- A 同步复制,主库等待从库确认后才返回成功:错。错在“等待从库确认” —— Redis 默认不等从库确认就返回。
- B 异步复制,主库写完后立即返回,从库异步追赶,存在数据丢失窗口:正确。 “立即返回”带来低延迟,“不等确认”带来丢失窗口,一体两面。
- C 半同步复制(等待至少一个从库确认):错。错在“半同步” —— 这是 MySQL 的概念(如
rpl_semi_sync_master),Redis 原生没有这种复制模式。 - D 主从之间没有数据复制:错。错在“没有复制” —— 主从复制正是 Redis 高可用的基础。
【知识点】 主从复制分三个阶段:
| 阶段 | 做什么 | 关键点 |
|---|---|---|
| 建立连接 | 从库发 PSYNC,主库返回 FULLRESYNC | 协商 run id 与复制偏移量 |
| 全量同步 | 主库 BGSAVE 生成 RDB 发给从库 | 期间的新写命令暂存到复制缓冲区 |
| 增量同步 | 主库把写命令持续传播给从库 | 断线重连后用 PSYNC + offset 续传 |
三种同步模式的对照:
| 模式 | 主库返回时机 | 数据安全性 | Redis 是否默认 |
|---|---|---|---|
| 异步复制 | 本地执行完立即返回 | 有丢失窗口 | 是 |
| 同步复制 | 等从库确认后返回 | 不丢 | 否 |
| 半同步复制 | 等至少一个从库确认 | 较安全 | 否(MySQL 概念) |
丢失窗口的推导:主库执行写命令 → 立即向客户端返回成功 → 写命令进入复制缓冲区 → 经网络传播到从库。若在传播完成前主库宕机,且从库尚未收到该命令,则主从切换后这部分数据永久丢失。这既解释了“Redis 主从切换可能丢数据”,也是 R19 中 Redlock 争议的同一根源。
WAIT 的正确理解:WAIT numreplicas timeout 让当前客户端阻塞等待,直到指定数量的从库确认收到之前的写命令。它不改变复制模式本身,只是一次性同步等待,超时后即使未达数量也返回已确认的副本数 —— 所以不能说 Redis “支持同步复制”。
生产缓解:配置 min-replicas-to-write N 与 min-replicas-max-lag S,要求至少有 N 个从库的延迟不超过 S 秒,否则主库拒绝写入 —— 用可用性换取更小的丢失窗口。
【记忆锚点】 「异步复制:快在前,丢在后」 —— 不等从库所以快,也因为不等所以可能丢。
【易混对比】
- 主从复制 vs Sentinel vs Cluster:复制解决“数据有备份”,Sentinel 解决“故障自动切换”,Cluster 解决“容量与写入扩展”(见 R23、R24)。
- 复制模式 vs
WAIT:WAIT只是“这一次等一等”,不是把模式改成同步复制;Redis 原生也没有半同步复制。 - 换问法:若题干问“Redis 主从切换为什么会丢数据”,答案仍是“异步复制下主库返回成功时,从库可能还没收到写命令”。
【自测】 主库执行 SET k v 后立即宕机,此时从库尚未收到该命令。哨兵把从库提升为新主库后,k 还存在吗?
答:不存在(数据已丢失)。这正是异步复制的丢失窗口;若业务不允许丢数据,需配合
min-replicas-to-write限制写入,或改用强一致的存储。与 R19、R23 连考。
【知识关联】
- 补题关联:补-29(集群视角下的节点状态变化)、补-20(日志位点与恢复)。
- 面试/工程:全量同步成本高(RDB 生成+传输+从库清空加载),应尽量走部分重同步:保证
repl-backlog-size足够覆盖主库峰值写入 × 断线时长。无盘复制降低主库磁盘峰值但占网络。复制仍可能丢:异步复制下主库 ACK 前宕机。要 RPO≈0 需半同步/组提交+确认策略。 - 面试追问:① 复制积压缓冲区作用?(环形缓冲保存最近写命令流,断线后从 offset 续传) ② 何时全量何时部分?(runid 匹配且 offset 在 backlog 内 → 部分;否则全量 PSYNC/FULLRESYNC)
【拓展延伸】
- 变式问法:问“从库重启后走全量还是部分同步”——取决于 backlog 与 runid/offset 是否仍匹配;问“为什么从库只读还可能延迟大”→ 网络、大 RDB、单线程重放、硬件。
- 参数/命令:
replicaof/REPLICAOF、masterauth、repl-backlog-size、repl-diskless-sync、repl-diskless-sync-delay、min-replicas-to-write;INFO replication看 master_repl_offset/slave_repl_offset。
R23. Redis Sentinel(哨兵)的核心作用是?
考点:Sentinel 哨兵
A. 对数据进行分片存储 B. 实现请求限流 C. 负责持久化 D. 监控主从节点、自动进行故障转移(选主)与通知客户端,解决 Redis 的高可用问题
答案:D
【考点】哨兵的三项职责与它“不做什么”。
【结论】 哨兵负责监控主从节点、自动进行故障转移(选主)与通知客户端,解决 Redis 的高可用问题 —— 选 D。
【逐项辨析】
- A 对数据进行分片存储:错。错在“分片” —— 分片是 Redis Cluster 的职责(见 R24),哨兵不做数据分片。
- B 实现请求限流:错。错在“限流” —— 限流属于网关或业务侧职责,哨兵不参与。
- C 负责持久化:错。错在“持久化” —— 持久化由 RDB / AOF 完成,与哨兵无关。
- D 监控主从节点、自动进行故障转移(选主)与通知客户端,解决 Redis 的高可用问题:正确。 监控、通知、故障转移三项职责齐备,定位也准确。
【知识点】 哨兵是独立进程,职责固定为三项:
| 职责 | 具体动作 |
|---|---|
| 监控 | 周期性向主库、从库及其他哨兵发送 PING(心跳探测) |
| 通知 | 哨兵之间用 __sentinel__:hello 频道互相发现;故障判定与切换结果走 +switch-master 等频道发布,客户端订阅后更新拓扑(两类频道别混) |
| 自动故障转移 | 选主 → 让其他从库复制新主库 → 通知客户端新地址 |
主观下线与客观下线(最容易考错的一对概念):
| 概念 | 判定条件 | 作用 |
|---|---|---|
| 主观下线 SDOWN | 单个哨兵在 down-after-milliseconds 内收不到 PING 回复 | 初步判断,可能误判 |
| 客观下线 ODOWN | 认为主观下线的哨兵数达到 quorum(法定票数) | 确认主库真的下线,避免单点误判 |
选主规则(优先级从高到低):replica-priority 数值最小 → 复制偏移量最大(数据最新)→ run id 最小。随后由哨兵 Leader(Raft 式投票选出,需半数以上哨兵同意)执行切换。
部署建议:哨兵通常部署 3 个及以上奇数个,分散在不同机器或机架,避免脑裂与单点故障。
【记忆锚点】 「哨兵只做监控、通知、切换三件事,不管分片、不管持久化」 —— 定位高可用,不解决容量。
【易混对比】
- Sentinel vs Cluster:Sentinel 只解决主从的高可用切换,数据仍在一台主库上,容量与写入无法扩展;Cluster 既做分片又自带故障转移。
- 主观下线 vs 客观下线:前者是单个哨兵的判断,后者是达到 quorum 后的集体结论,只有客观下线才触发切换。
- 换问法:若题干问“哨兵部署几个合适”,答案是“3 个及以上奇数个,且分散部署”。
【自测】 3 个哨兵监控 1 主 2 从,quorum 配置为 2。某一时刻只有 1 个哨兵收不到主库的 PING 回复,会触发故障转移吗?
答:不会。只达到主观下线,认为主库下线的哨兵数(1)未达到
quorum(2),不构成客观下线。与 R22、R24 连考;大厂面试高频。
【知识关联】
- 补题关联:补-29(集群拓扑变化与重定向;Sentinel 是高可用方案,Cluster 是分片+高可用方案)、补-20(故障恢复思想)。
- 面试/工程:Sentinel 适合“单主多从 + 自动 failover + 客户端发现主节点”;Cluster 适合“数据分片”。客户端必须支持 Sentinel 协议(订阅
+switch-master或查询当前主)。脑裂:旧主网络隔离仍可写,恢复后数据被新主覆盖——可用min-replicas-to-write限制写。哨兵部署要奇数个、跨机房/机架。 - 面试追问:① quorum 与 majority 区别?(quorum=判定客观下线所需的同意数;majority=选举领导者/授权 failover 的多数派) ② 脑裂如何缓解?(多数派哨兵、
min-replicas-to-write/min-replicas-max-lag、客户端 fencing)
【拓展延伸】
- 变式问法:问“主观下线/客观下线/领导者选举”三阶段;或问“哨兵挂了会怎样”→ 仍需多数派存活才能 failover。
- 参数/命令:
sentinel monitor mymaster ip port quorum;sentinel down-after-milliseconds;sentinel failover-timeout;sentinel parallel-syncs;min-replicas-to-write/min-replicas-max-lag;SENTINEL get-master-addr-by-name。
R24. Redis Cluster 的数据分片机制是?
考点:Cluster 分片机制
A. 一致性哈希 B. 随机分配到任意节点 C. 按 key 长度取模分片 D. 哈希槽(共 16384 个 slot)——key 经 CRC16 取模映射到槽,槽再分配给各节点
答案:D
【考点】哈希槽机制及其相对一致性哈希的优势。
【结论】 Redis Cluster 把键空间划分为 16384 个哈希槽,key 经 CRC16 取模映射到槽,槽再分配给各节点 —— 选 D。
【逐项辨析】
- A 一致性哈希:错。错在“一致性哈希” —— 那主要是客户端分片(如 Memcached 客户端)的做法,Redis Cluster 用的是哈希槽。
- B 随机分配到任意节点:错。错在“随机” —— 同一个 key 每次必须落到同一个槽,否则读写会找不到数据。
- C 按 key 长度取模分片:错。错在“按 key 长度” —— 分片依据是 key 的哈希值,按长度取模会让数据分布严重倾斜。
- D 哈希槽(共 16384 个 slot)—— key 经
CRC16取模映射到槽,槽再分配给各节点:正确。 公式是CRC16(key) % 16384,槽是逻辑层、节点是物理层,二者解耦。
【知识点】 哈希槽的本质是两级映射:
key --CRC16(key) % 16384--> slot(0 ~ 16383) --槽分配表--> node为什么用哈希槽而不是一致性哈希:一致性哈希需维护哈希环与虚拟节点,扩缩容时要重算大量 key 的归属;哈希槽把“key → 槽”(稳定不变)与“槽 → 节点”(可调整)解耦,扩缩容时只需迁移槽,无需重算每个 key。3 个主节点时大致各负责 5461 个槽(16384 ÷ 3 ≈ 5461)。
常用命令:CLUSTER KEYSLOT key 查 key 属于哪个槽;CLUSTER NODES 查看节点与槽的分配;CLUSTER SETSLOT <slot> NODE <id> 把槽指派给指定节点。
两种重定向(必须分清):
| 重定向 | 含义 | 客户端动作 |
|---|---|---|
MOVED | 该槽已永久归属别的节点 | 更新本地槽映射表后重试 |
ASK | 该槽正在迁移,该 key 已迁到目标节点 | 只对本次请求先发 ASKING 再转向,不更新映射表 |
跨槽限制:不支持跨槽的多 key 操作(跨槽 MGET、SUNION 会报 CROSSSLOT 错误)。需要多 key 同槽时使用 hash tag:{user:1}:name 与 {user:1}:age 只对 {} 内的 user:1 计算 CRC16,因此落到同一个槽。
【记忆锚点】 「先算槽,再看谁管这个槽」 —— CRC16(key) % 16384 决定槽(不变),槽到节点的映射才是可变的。
【易混对比】
- 哈希槽 vs 一致性哈希:前者是“固定 16384 个逻辑桶 + 槽迁移”,后者是“哈希环 + 虚拟节点 + 环上重算”。
MOVEDvsASK:MOVED是永久重定向(要更新映射表),ASK是临时重定向(不更新映射表)。- Cluster vs Sentinel:Cluster 解决分片与容量扩展(自带故障转移),Sentinel 只解决主从的高可用切换、不分片。
- 换问法:若题干问“
{user:1}:name与{user:1}:age会不会同槽”,答案是“会”。
【自测】 3 主 3 从的 Redis Cluster 中,CRC16("user:1") % 16384 的结果是槽 8000,而槽 8000 当前由节点 B 负责。客户端误向节点 A 发送 GET user:1,会得到什么响应?
答:返回
MOVED 8000 <节点 B 的地址>,客户端据此更新本地槽映射表,再转向节点 B 重试。与 R23 连考;大厂面试高频。
【知识关联】
- 补题关联:补-29(MOVED/ASK 重定向语义必须与槽迁移一起记)。
- 面试/工程:16384 的取舍:槽太少迁移粒度粗、心跳包与槽位图更大;官方认为 16384 在常见规模下够用且心跳图小。客户端应缓存
slot→node并处理 MOVED/ASK;跨槽多 key 要用 hash tag 保证同槽,否则CROSSSLOT。扩缩容是“迁槽 + 迁数据”,不是改 key 哈希。 - 面试追问:① 为何不用更多槽?(槽位图/心跳与元数据变大,收益有限) ② 迁移中客户端读到什么?(可能 ASK 重定向;目标节点上若 key 尚未迁完,源节点仍可服务,直到槽标记迁移完成)
【拓展延伸】
- 变式问法:对比一致性哈希(环+虚拟节点,扩缩容重算归属)与哈希槽(key→槽稳定,槽→节点可变);问 hash tag 如何解决 CROSSSLOT。
- 参数/命令:
CLUSTER KEYSLOT key、CLUSTER NODES、CLUSTER ADDSLOTS、CLUSTER GETKEYSINSLOT、CLUSTER SETSLOT <slot> MIGRATING/IMPORTING/NODE;cluster-require-full-coverage;客户端MOVED后刷新映射表。
R25. Redis Pipeline(管道)的作用是?
考点:Pipeline 管道
A. 保证多条命令的原子性 B. 将多条命令一次性发送、减少网络往返(RTT),提升批量操作吞吐;但不保证原子性 C. 实现事务回滚 D. 用于持久化
答案:B
【考点】Pipeline 的优化原理与与事务的区别。
【结论】 Pipeline 把多条命令一次性发送、减少网络往返(RTT)以提升批量吞吐,但不保证原子性 —— 选 B。
【逐项辨析】
- A 保证多条命令的原子性:错。错在“原子性” —— Pipeline 只是打包传输,服务端仍会穿插执行其他客户端的命令。
- B 将多条命令一次性发送、减少网络往返(RTT),提升批量操作吞吐;但不保证原子性:正确。 收益在于“少跑几趟网络”,而不在于“执行不被打断”。
- C 实现事务回滚:错。错在“回滚” —— Pipeline 与事务、回滚都无关(Redis 事务本身也不回滚,见 R20)。
- D 用于持久化:错。错在“持久化” —— 持久化由 RDB / AOF 负责,与 Pipeline 无关。
【知识点】 一条命令的端到端耗时可拆成三部分:
| 组成 | 含义 | Pipeline 是否优化 |
|---|---|---|
| 网络往返 RTT | 客户端与服务端之间一次往返的耗时 | 是,把 N 次压缩为 1 次 |
| 服务端执行时间 | 命令本身的处理耗时 | 否,完全不变 |
| 客户端处理时间 | 解析结果、调用开销 | 部分优化 |
推导:单条命令耗时 ≈ RTT + 执行时间。当命令本身很短(如 SET、INCR)时 RTT 占主导,逐条发送 N 条命令的总耗时 ≈ N × RTT;用 Pipeline 后 ≈ 1 × RTT + N × 执行时间,吞吐量可提升数倍到数十倍。反之,若单条命令本身就很慢,提升非常有限 —— 它省的是路费,不是执行时间。
三种批量方案的对照:
| 方案 | 减少 RTT | 执行期不被打断 | 脚本内可做逻辑判断 |
|---|---|---|---|
| Pipeline | 是 | 否 | 否 |
MULTI / EXEC | 是 | 是 | 否 |
| Lua 脚本 | 是 | 是 | 是 |
使用建议:单次 Pipeline 的命令数量要适度(如 500 至 1000 条),过多会占用大量内存、拉长阻塞时间;服务端是读一批执行一批,期间仍会穿插处理其他客户端的请求,因此 Pipeline 内的命令并非“整体不可分割”。
【记忆锚点】 「Pipeline 省的是路费,不是加锁」 —— 减少网络往返,但不提供原子性保证。
【易混对比】
- Pipeline vs 事务:Pipeline 不保证不被穿插;
MULTI/EXEC保证执行期不被打断,但两者都不回滚。 - Pipeline vs Lua 脚本:只是“批量写”用 Pipeline 即可;需要“读中间结果再做判断”必须用 Lua。
- Pipeline vs 批量命令(
MSET、MGET):批量命令减少的是命令条数,Pipeline 减少的是网络往返次数,两者可叠加使用。 - 换问法:若题干问“Pipeline 能提升吞吐的根本原因”,答案是“把 N 次 RTT 压缩为 1 次”。
【自测】 客户端需要连续执行 1000 次 SET key_i value_i。使用 Pipeline 与不使用,服务端执行这些命令的总时间会变化吗?
答:基本不变。服务端执行每条
SET的时间相同,变的是网络往返次数:从约 1000 次 RTT 降为约 1 次,因此端到端吞吐大幅提升。与 R20、R21 连考;大厂面试高频。
【知识关联】
- 补题关联:无直接补题;与补-21(事务边界)、补-26(令牌桶限流的 Lua 脚本;Pipeline 只批量不原子,原子性见 R21 的 EVAL)对比记忆:Pipeline 是性能手段,不是正确性手段。
- 面试/工程:批量写入/导出用 Pipeline 把 N 次 RTT 压成 1 次,吞吐提升一个数量级常见;但单次 pipeline 过大(万级命令/数百 MB)会造成延迟尖刺与客户端缓冲膨胀,应分批(几百到几千条)。需要原子则 Pipeline 包 MULTI,或直接 Lua。
- 面试追问:① Pipeline 能否与 MULTI 一起用?(可以,客户端把整段 MULTI…EXEC 打包进 pipeline) ② 为什么 Pipeline 不保证原子?(只是打包发命令,中间仍可被其他客户端命令插入执行)
【拓展延伸】
- 变式问法:对比“减少 RTT”与“保证原子”;把“Pipeline=事务”设为错误项。
- 参数/命令:Jedis
Pipeline、LettuceFlux/dispatch、Spring Data RedisexecutePipelined;建议分批大小数百条并压测;监控INFO stats的instantaneous_ops_per_sec与慢日志。
R26. 在 Redis 单线程执行命令的模型下,下列哪组操作最容易导致阻塞?
考点:阻塞风险命令
A. GET / SET 单个 key B. KEYS *、FLUSHALL、删除大 key(DEL)、执行耗时 Lua 脚本 C. MGET 少量 key D. INCR 计数
答案:B
【考点】生产环境的“阻塞命令黑名单”。
【结论】 KEYS *、FLUSHALL、删除大 key(DEL)、耗时 Lua 脚本的共同点是时间复杂度随数据规模增长、且在单线程中同步执行,最容易阻塞 Redis —— 选 B。
【逐项辨析】
- A
GET/SET单个 key:错。错在“单个 key” —— 二者都是O(1)操作,执行时间极短,几乎不构成阻塞。 - B
KEYS *、FLUSHALL、删除大 key(DEL)、执行耗时 Lua 脚本:正确。 四者耗时都与数据规模正相关,且都要在主线程里同步跑完。 - C
MGET少量 key:错。错在“少量” ——MGET是O(N)但 N 很小,开销可忽略。 - D
INCR计数:错。错在“计数” ——INCR是O(1)的原子自增,几乎不耗时。
【知识点】 判断阻塞风险的公式是:时间复杂度随数据规模增长 × 在单线程中同步执行,两个条件同时成立才危险。
| 危险命令 | 复杂度 | 替代方案 |
|---|---|---|
KEYS * | O(N),N 为全库 key 数 | SCAN(游标分批,单次开销小) |
FLUSHALL / FLUSHDB | O(N) | 低峰期执行;4.0 起可用 FLUSHALL ASYNC |
DEL 大 key | O(N),N 为该 key 的元素数 | UNLINK(后台线程异步释放内存) |
| 耗时 Lua 脚本 | 取决于脚本内容 | 拆分脚本、限制脚本长度 |
HGETALL / SMEMBERS 大集合 | O(N) | HSCAN / SSCAN 分批遍历 |
SORT | O(N log N) | 移到业务侧排序;必要时加 LIMIT |
SAVE | O(N),阻塞式快照 | BGSAVE(fork 子进程) |
SCAN 的代价:用游标分批遍历,每次只返回少量元素、不阻塞主线程;但同一 key 可能被返回多次(客户端需去重),且不保证看到一致的快照。生产上禁止使用 KEYS,一律用 SCAN。
排查与兜底:SLOWLOG GET 查慢命令日志(阈值由 slowlog-log-slower-than 设定);LATENCY DOCTOR / LATENCY HISTORY 查延迟事件;redis-cli --bigkeys 定位大 key;INFO commandstats 查看各命令调用统计。此外可在 redis.conf 中用 rename-command 把危险命令重命名或禁用。
【记忆锚点】 「单线程怕的不是命令多,而是一条命令太慢」 —— 凡是 O(N) 且 N 不可控的命令,都要进黑名单。
【易混对比】
DELvsUNLINK:功能相同,前者同步释放内存(阻塞),后者把内存回收交给后台线程(不阻塞)。SAVEvsBGSAVE:前者阻塞主线程,后者 fork 子进程做快照、主线程继续服务。KEYSvsSCAN:前者一次返回全部(阻塞),后者游标分批(不阻塞、但不保证快照一致)。- 换问法:若题干问“删除一个含百万字段的 Hash 应该用什么命令”,答案是
UNLINK。
【自测】 某个 key 是一个含 200 万字段的 Hash,直接 DEL 会阻塞 Redis 主线程。改用哪个命令可以避免阻塞?为什么?
答:
UNLINK。它只把 key 从键空间中摘除并立即返回,真正的内存回收交给后台线程异步完成,因此不会卡住主线程。与 R25、R28 连考;生产运维高频。
【知识关联】
- 补题关联:补-28(大对象编码带来的额外开销)、补-26(限流从客户端保护 Redis)。
- 面试/工程:线上禁用
KEYS/FLUSHALL/FLUSHDB(rename-command);大 Hash/List/Set 要拆分;分析用 SCAN 或离线 RDB。HGETALL大 Hash 会一次返回巨量数据,阻塞主线程并打满网卡。MONITOR在生产几乎必挂。用busy-reply-threshold/lua-time-limit给脚本设超时。 - 面试追问:① HGETALL 大 Hash 为什么危险?(O(N) 且一次返回全部字段,阻塞+带宽双杀) ② 如何替代 KEYS?(SCAN 游标 + MATCH;或业务侧维护索引结构)
【拓展延伸】
- 变式问法:给出命令列表选最危险:KEYS、SAVE、大 SORT、大 LRANGE、大 HGETALL。
- 参数/命令:
rename-command KEYS "";lua-time-limit;busy-reply-threshold(6.0+);CLIENT KILL;LATENCY HISTORY;客户端读超时与熔断。
R27. 保证缓存与数据库数据一致性,生产中最常用的策略是?
考点:缓存与数据库一致性
A. Cache Aside:先更新数据库,再删除缓存(配合延迟双删、失败重试/MQ 补偿处理并发与失败) B. 先删除缓存再更新数据库,且不做任何补偿 C. 只更新缓存,不更新数据库 D. 两边都写,不关心顺序与失败
答案:A
【考点】Cache Aside 模式的正确顺序与兜底手段。
【结论】 Cache Aside 的标准做法是“读时回填、写时删除” —— 先更新数据库、再删除缓存,并用延迟双删、失败重试 / MQ 补偿处理并发与失败 —— 选 A。
【逐项辨析】
- A Cache Aside:先更新数据库,再删除缓存(配合延迟双删、失败重试 / MQ 补偿处理并发与失败):正确。 顺序对(先库后缓存)、动作对(删除而非更新)、兜底也对。
- B 先删除缓存再更新数据库,且不做任何补偿:错。错在“先删除缓存”与“不做任何补偿” —— 删完到更新完之间若有读请求,会把旧值回填进缓存;没有兜底则会长期脏读。
- C 只更新缓存,不更新数据库:错。错在“不更新数据库” —— 数据库是权威数据源,不写库则数据随时可能丢失。
- D 两边都写,不关心顺序与失败:错。错在“不关心顺序” —— 并发写场景下两边写入顺序错乱必然留下脏数据。
【知识点】 四种缓存更新策略的定位:
| 策略 | 读操作 | 写操作 | 一致性 | 适用场景 |
|---|---|---|---|---|
| Cache Aside | 读缓存,miss 则查库并回填 | 更新库 + 删除缓存 | 最终一致 | 生产最常用 |
| Read Through | 由缓存层代理回源 | 由缓存层代理写库 | 最终一致 | 依赖缓存组件支持 |
| Write Through | 同上 | 写缓存同时同步写库 | 较强 | 写多、对一致性要求较高 |
| Write Behind | 同上 | 只写缓存,异步批量刷库 | 弱 | 写极多、可容忍丢数据 |
为什么是“删除”而不是“更新”:更新缓存需要把新值写入,并发场景下两个写请求可能以错误顺序落到缓存,留下旧值;删除则让“下次读自然回填最新值”,把并发问题收敛到“回填”这一步。
为什么是“先库后缓存” —— 推导两种顺序的脏窗口:
先删缓存、后更新库:
删缓存 → [窗口] → 更新库
窗口内读请求:缓存 miss → 从库里查到旧值 → 回填旧值
→ 更新库完成,但缓存里长期留着旧值(不自愈)
先更新库、后删缓存:
更新库 → [窗口] → 删缓存
窗口内读请求:读到的是缓存里的旧值(稍后即被删除)
窗口结束后:缓存为空 → 下次读回填新值(可自愈)剩余风险与兜底:① 并发回填窗口 —— 读请求 A 在“更新库”之前查到旧值,但回填动作发生在“删缓存”之后,旧值被写回缓存;用延迟双删(更新库后延迟一段时间再删一次,延迟时间应大于一次读操作加回填的耗时)收敛。② 删除缓存失败 —— 用本地消息表 / 定时任务重试、MQ 异步重试,或订阅 binlog(如 Canal)异步删除,保证“库改了,缓存最终也会被删”。③ 极端一致性要求 —— 可考虑 binlog + MQ 驱动的最终一致方案。
一致性等级的提醒:强一致无法只靠缓存实现,工程上必须先明确业务能接受的一致性等级(最终一致 / 准实时一致),再选方案。
【记忆锚点】 「读时回填、写时删除;先库后缓存,失败要补偿」 —— 顺序、动作、兜底三件事缺一不可。
【易混对比】
- 删除缓存 vs 更新缓存:删除更安全(避免并发写顺序错乱),代价是下一次读多一次 miss 回填。
- 先删缓存 vs 先更新库:先删缓存会把旧值回填进缓存(即 B 的问题);先更新库残留的脏窗口更短且能自愈。
- 延迟双删 vs binlog 订阅:前者是应用侧定时补偿,简单但有延迟上限;后者由数据库侧驱动、更可靠,适合对一致性要求更高的场景。
- 换问法:若题干问“为什么写操作是删除缓存而不是更新缓存”,答案是“避免并发写入顺序错乱导致缓存长期留存旧值”。
【自测】 写请求更新数据库成功,但删除缓存时因网络抖动失败了。缓存里会长期是旧值吗?怎么兜底?
答:只要有兜底就不会长期脏读。可用本地消息表或 MQ 重试删除,或订阅 binlog(Canal)异步删除,再配合延迟双删缩短脏窗口。与 R19、R28 连考;大厂面试高频。
【知识关联】
- 补题关联:补-30(先更库再删缓存,删除失败要补偿)、补-17(一致性要求决定策略)。
- 面试/工程:Cache Aside(先更 DB 再删缓存)最常用:读未命中回源并回填;写只删不更,避免并发写覆盖。若删缓存失败,可靠补偿是重试队列/MQ + 订阅 binlog(Canal/Debezium)异步对账删;再叠加 TTL 兜底。延迟双删可缓解“先删后更”的旧值回填窗口,延迟时长难精确,只能缓解。
- 面试追问:① 为什么不是“先删缓存再更库”?(删后、更前若有读回源会把旧值填回去,不一致窗口更难控) ② 延迟双删延迟多久合适?(经验值如几百 ms–1s,本质是覆盖主从延迟+回源耗时,无法理论精确)
【拓展延伸】
- 变式问法:对比 Cache Aside / Read-Through / Write-Through / Write-Behind;问“删除失败怎么办”→ MQ 重试 + binlog 订阅 + TTL 兜底。
- 参数/命令:Canal/Debezium 订阅 binlog → 消费删除;重试表/MQ 幂等消费;缓存 TTL 作最终一致;监控删除失败率与 binlog 延迟。
R28. 线上发现某个 key 的 QPS 极高(热 key),下列优化思路无助于缓解热 key 的是?
考点:热 key 治理
A. 增加本地缓存(多级缓存),让读请求在应用层就被挡住 B. 把热 key 复制成多个副本,分散到不同 Redis 节点,读取时随机选一个 C. 对读请求做限流、降级与熔断,保护后端 D. 给该 key 设置更长的过期时间,让它更长时间命中缓存
答案:D
【考点】热 key 的成因与治理手段。
【结论】 热 key 的瓶颈是单个节点的处理能力上限,延长过期时间与这个瓶颈无关,反而延长了热点的持续时间 —— 因此 D 不合理,选 D。
【逐项辨析】
- A 增加本地缓存(多级缓存),让读请求在应用层就被挡住:合理。这是“减少请求量”,把大部分读请求在应用进程内消化掉。
- B 把热 key 复制成多个副本,分散到不同 Redis 节点,读取时随机选一个:合理。这是“分散压力”,用随机后缀把单 key 的 QPS 摊到多个节点上。
- C 对读请求做限流、降级与熔断,保护后端:合理。这是“服务治理”,在压力超过承载能力时保护 Redis 与数据库不被压垮。
- D 给该 key 设置更长的过期时间,让它更长时间命中缓存:对缓解热 key 无帮助(本题选它)。 热 key 的瓶颈是单个节点的处理能力上限,过期时间长短与这个上限无关;延长 TTL 只会让热点在同一个节点上停留更久,还可能因长期不回源而读到更旧的数据。(注意区分:延长 TTL/永不过期是治「缓存击穿」与减少回源的常规手段,但不是热 key 的解法。)
【知识点】 热 key 指单位时间内访问次数远高于其他 key(如单 key QPS 占实例总量很大比例)的 key。典型成因:秒杀商品、明星八卦、突发热点新闻、大促爆款、被爬虫集中访问的 key。
热 key 的两层瓶颈:
| 瓶颈层 | 表现 | 说明 |
|---|---|---|
| 单节点 / 单线程 | 该节点 CPU 打满、延迟升高 | Redis 主线程是单线程,单实例 QPS 有天花板 |
| 网络带宽 | 单机网卡打满 | 单 key 返回值越大越先撞上带宽瓶颈(大 value 热 key 更危险) |
治理思路分三层:
| 层次 | 手段 | 代价 |
|---|---|---|
| 减少请求量 | 本地缓存(如 Caffeine)+ 多级缓存 | 本地缓存有一致性与容量上限问题 |
| 分散压力 | 热 key 加随机后缀拆成 N 个副本(hotkey:1 至 hotkey:N),随机读取 | 需维护 N 个副本的一致性 |
| 服务治理 | 读限流、降级返回兜底数据、熔断 | 会牺牲部分功能或返回降级数据 |
副本方案的关键细节:写操作必须同步更新所有副本(或借助 MQ、定时任务对齐),否则各副本取值不一致;副本数 N 按“预估 QPS ÷ 单节点承载能力”估算,N 越大一致性维护成本越高。
排查手段:redis-cli --hotkeys 基于 LFU 统计(要求 maxmemory-policy 为 LFU 系列,只统计内存中的 key、有采样误差);MONITOR 实时打印所有命令,但本身开销极大、只能短时使用;生产上更常用客户端埋点 / 代理层统计或监控平台的热 key 探测。
【记忆锚点】 「热 key 要“分流”,不要“续命”」 —— 延长过期时间只让热点更持久,正确做法是本地缓存分流、副本分散压力、限流兜底。
【易混对比】
- 热 key vs 大 key:热 key 是访问频率高(QPS 问题),大 key 是单 key 体积大(内存与网络问题);两者叠加时最危险。治理手段不同:热 key 靠分流,大 key 靠拆分(如 Hash 分桶)。
- 热 key vs 缓存击穿:击穿是热点 key 过期瞬间的并发回源(一次性事件,解法是互斥锁 / 逻辑过期);热 key 是持续高频访问(长期状态,解法是分流与本地缓存)。
- 本地缓存 vs Redis 缓存:本地缓存无网络开销、性能最好,但多实例之间不一致、容量受 JVM 堆限制。
- 换问法:若题干问“热 key 加随机后缀副本的代价是什么”,答案是“需要维护多个副本的一致性,写入时要写全部副本”。
【自测】 秒杀场景中商品详情 key 的 QPS 达到 10 万,而 Redis 单节点约能承受 8 万。除了加本地缓存,还可以怎么把压力摊到多个节点?
答:把该 key 拆成 N 个副本(如
item:1001:1至item:1001:10),读取时随机取一个副本,把 10 万 QPS 摊到 10 个 key 上;写入时需同步更新全部副本。与 R27 连考;大厂面试高频。
【知识关联】
- 补题关联:补-26(限流)、补-27(数据结构与访问模式匹配)、补-28(编码/大对象)。
- 面试/工程:热 key 与大 key 双高最危险:单分片 CPU/带宽打满。治理组合:发现(
--hotkeys、客户端采样、网关统计)→ 本地缓存(Caffeine)挡读 → 多副本/拆 key 分散 → 限流保命。写路径要注意多副本同步与热点重建风暴(可与 R08 互斥重建组合)。延长 TTL 只能缓解过期风暴,治不了访问热点本身。 - 面试追问:① 如何发现热 key?(LFU 下
--hotkeys、Proxy/网关采样、MONITOR抽样慎用、业务监控) ② 副本方案的写路径要注意什么?(多副本一致性/失效广播,避免读到长期旧副本)
【拓展延伸】
- 变式问法:题干换成“不合理的是”时,把“延长 TTL/让热点更久”“只靠加机器”设为错误项;对比热 key vs 大 key vs 两者叠加。
- 参数/命令:
redis-cli --hotkeys(需maxmemory-policy allkeys-lfu或 volatile-lfu);MEMORY USAGE;客户端本地缓存随机 TTL;hot:{id}:r1/r2多后缀副本;网关按 key 限流。