返回文章列表

Redis 第 21 章:高频面试题合并版

围绕截图与视频清单中的 Redis 高频面试题,按关联关系合并为 20 个题组,并按基础、进阶、高阶、架构难度递进回答底层原理。

第 21 章:Redis 高频面试题合并版

这一章把重复、相近、互为追问的问题合并成 20 个题组。顺序不是截图顺序,而是面试复盘顺序:

定位和数据结构
-> 底层编码和性能模型
-> 缓存架构和一致性
-> 原子性、事务、Lua、分布式锁
-> 持久化、复制、Sentinel、Cluster
-> 客户端、源码设计和综合表达

每个题组都包含三件事:先给面试短答,再讲底层原理,最后给出可以跳回前面章节对应小节的链接。


21.2 Redis 定位、缓存价值与 Memcached 对比

合并来源:Redis 通常应用于哪些场景、为什么使用缓存、分布式系统中常用的缓存方案、Redis 和 Memcached 有哪些区别。

对应主讲章节:[Redis 适合与不适合的业务](/blog/tech/Redis/01.Redis 定位、架构与快速入门#33-redis-适合与不适合的业务)、[Redis 与关系数据库、Memcached、本地缓存的区别](/blog/tech/Redis/01.Redis 定位、架构与快速入门#32-redis-与关系数据库memcached本地缓存的区别)、四种常见缓存模式

面试短答:

Redis 最常见的定位是高性能缓存和数据结构服务器,也可以在明确边界的情况下承担轻量队列、排行榜、分布式锁、限流、会话、去重、延迟任务等能力。使用缓存的核心原因不是“内存快”这么简单,而是把高频读、重复计算和热点访问从数据库主链路移走,用更低成本吸收读流量、保护下游,并换取更低的尾延迟。

Redis 和 Memcached 的区别可以这样答:Memcached 更像简单的分布式 KV 缓存,模型轻、只关注字符串 value;Redis 提供更丰富的数据结构、持久化、复制、高可用、Lua、Stream、Cluster 等能力。代价是 Redis 的功能更多,也更容易被滥用成“半个数据库”。

底层原理:

缓存本质是派生数据。数据库保存权威事实,缓存保存为了读性能和系统保护而复制出来的近似实时视图。这个视图一定会面对三个问题:

数据从哪里来
-> 什么时候失效
-> 失效失败或更新并发时如何兜底

因此缓存方案通常不是只有 Redis 一层,而是从用户侧到数据侧逐层拦截重复访问:

方案位置典型用途风险
浏览器 / App / H5 本地缓存用户端静态资源、配置、页面片段版本失效难、用户端不可控
CDN / 边缘缓存用户近端图片、视频、JS、CSS、公开接口结果失效链路长、个性化能力弱
Nginx / 网关缓存接入层热门接口响应、静态化页面、限流前置结果权限和个性化处理复杂
进程内缓存应用实例内极热点配置、字典、短 TTL 结果多实例不一致、内存膨胀、重启丢失
Redis / Memcached 集中缓存独立缓存层通用读缓存、计数、排行榜、限流、锁网络成本、热点 Key、故障回源
ORM / 框架缓存应用和数据库之间MyBatis、Hibernate 等二级缓存脏读、失效规则难统一
数据库自身缓存DB Buffer Pool / Query Cache数据页复用、执行结果复用难承担业务级限流和热点隔离
OS Page Cache / Buffer操作系统文件页、磁盘 I/O 缓冲对业务语义无感

越靠近用户,越适合缓存静态、公开、可版本化的数据;越靠近数据库,越适合缓存结构化、业务相关、需要精细失效的数据。面试时可以顺手补一句:多级缓存能降低单层压力,但也会增加一致性链路,必须设计统一的版本号、TTL、主动失效和兜底回源策略。

面试里要主动说明:Redis 不适合直接替代关系数据库。它的单命令原子性、持久化和复制能力不能等价为关系型事务、约束、二级索引、复杂查询、强一致复制和备份恢复体系。

追问边界:

  • 读多写少、可容忍短暂旧数据、热点明显的场景适合缓存。
  • 强约束、复杂查询、多表事务、审计和账务流水不应只放 Redis。
  • Redis 故障时要定义失败开放还是失败关闭,不能默认全部回源数据库。

21.3 常见数据类型与业务场景

合并来源:常见数据类型、九大数据结构、Redis 的数据结构及使用场景。

对应主讲章节:[核心数据结构](/blog/tech/Redis/01.Redis 定位、架构与快速入门#61-核心数据结构)、[String 场景](/blog/tech/Redis/04.String 的使用场景与底层实现#32-string-的典型使用场景)、[Hash 与 List](/blog/tech/Redis/05.Hash 与 List#3-核心概念)、[Set、Sorted Set 与 GEO](/blog/tech/Redis/06.Set、Sorted Set 与 GEO#3-核心概念)、[Bitmap、Bitfield 与 HyperLogLog](/blog/tech/Redis/07.Bitmap、Bitfield 与 HyperLogLog#3-核心概念)、[Redis 8 扩展数据能力](/blog/tech/Redis/10.Redis 8 的扩展数据能力#31-redis-8-与历史-redis-stack)。

面试短答:

Redis 常见核心类型可以按问题来记:

类型解决的问题典型场景
String一个 Key 对应一段字节序列缓存、Session、Token、计数器
Hash一个 Key 下保存多个字段用户资料、购物车、对象局部更新
List双端有序序列栈、FIFO 队列、简单阻塞队列
Set无序唯一集合去重、共同好友、标签集合
Sorted Set唯一成员 + score 排序排行榜、延迟任务、权重队列
Bitmap用 bit 表达大量布尔状态签到、活跃标记、权限位
Bitfield把 bit 数组解释成小整数密集计数、小状态位
HyperLogLog近似唯一计数大规模 UV、去重估算
GEO基于坐标的附近查询附近门店、附近司机
Stream可保留、可回放的消息日志简单消息流、消费组、重试
Redis 8 扩展JSON、Search、Time Series、概率结构、Vector Set文档、搜索、指标、布隆过滤器、向量检索

如果面试官问“九大数据结构”,常见口径是:五个基础结构 StringHashListSetSorted Set,再加上 BitmapHyperLogLogGEOStream 这类高阶能力。有些资料也会把 Bloom Filter 算进“九大”,但严格说 Bloom Filter 在 Redis 历史上更多来自 RedisBloom / Redis Stack 模块,Redis 8 开始才把概率结构作为扩展能力统一到官方产品线里,所以答题时最好说明版本和模块边界。

底层原理:

Redis 的“数据类型”有两个层次。第一层是对外命令语义,比如 HGETLPUSHZADD;第二层是内部编码,比如 listpack、hashtable、quicklist、skiplist。面试中最容易扣分的是把这两层混为一谈。

同一个逻辑类型可能有多种内部编码。小对象优先使用连续紧凑结构,节省指针和分配器开销;对象变大后切换到哈希表、跳表或链式结构,换取更稳定的查询和更新复杂度。

追问边界:

  • 不要把 Redis 类型当成万能抽象。比如 List 能做队列,但不等于可靠消息队列。
  • 不要只背命令,要能说出每个类型的内存、复杂度和高可用风险。
  • Redis 8 的扩展能力提升了建模能力,但索引、搜索、向量和概率结构都会带来额外写入成本和内存成本。

21.4 String、SDS、512 MB 与 embstr 44 字节

合并来源:String 底层实现、SDS、字符串最大值大小、embstr 阈值为何为 44。

对应主讲章节:[String 不是文本字符串,而是字节序列](/blog/tech/Redis/04.String 的使用场景与底层实现#31-string-不是文本字符串而是字节序列)、[SDS 为什么比 C 字符串更适合 Redis](/blog/tech/Redis/04.String 的使用场景与底层实现#62-sds-为什么比-c-字符串更适合-redis)、[embstr 编码](/blog/tech/Redis/04.String 的使用场景与底层实现#65-embstr-编码)、[raw 编码](/blog/tech/Redis/04.String 的使用场景与底层实现#66-raw-编码)。

面试短答:

Redis String 本质是一段二进制安全的字节序列,不只是文本字符串。它可以保存 JSON、MessagePack、图片片段、整数计数等内容。单个 String value 的官方上限是 512 MB,但生产中不应该把这个上限当成设计目标,大 value 会拖慢网络、复制、AOF、fork COW 和客户端反序列化。

底层上,String 常见编码包括 intembstrraw。如果值能表示为整数,可能使用 int;短字符串可能使用 embstr,对象头和 SDS 一次分配;较长或需要修改的字符串使用 raw,对象和 SDS 通常分开管理。

底层原理:

SDS 解决了 C 字符串在 Redis 场景下的几个问题:

len   : 当前已使用长度,O(1) 获取字符串长度
alloc : 分配容量,支持预分配和减少扩容
flags : SDS 头部类型,短字符串用更小头部
buf   : 实际字节数组,末尾仍保留 \0 兼容部分 C API

SDS 显式记录长度,所以 value 中可以包含 \0,这就是二进制安全。它保留末尾 \0,不是为了依赖 \0 判断结束,而是为了在某些 C API 或调试场景中保持便利。

embstr 的 44 字节阈值要讲成“版本相关的内存布局结果”,不要机械背数字。经典解释是:Redis 对象头、SDS 头、内容和结尾 \0 会尽量适配内存分配器的大小等级。旧版 SDS 头部更大时,阈值常见为 39;后来 SDS 头部分级后,短字符串可以把上限提高到 44。当前系列前文以 Redis 8.8 为基线,仍把 44 字节作为嵌入式字符串的重要阈值。

追问边界:

  • sdshdr8 是 SDS 头部类型,embstr 是 Redis 对象编码,它们不是同一层概念。
  • GET 的哈希查找接近 O(1),但返回大 value 至少要付出 O(value bytes) 的网络和客户端处理成本。
  • 缓存对象不一定都用 JSON String。局部更新多、字段访问多时,Hash 或 Redis JSON 可能更合适。

21.5 Hash、List、队列、栈、quicklist 与 listpack

合并来源:Redis 的 hash 是什么、List 常见命令、如何实现队列和栈、ziplist、quicklist、listpack。

对应主讲章节:[Hash:一个 Key 下的字段集合](/blog/tech/Redis/05.Hash 与 List#31-hash一个-key-下的字段集合)、[Hash 与多个 String Key 的核心差异](/blog/tech/Redis/05.Hash 与 List#32-hash-与多个-string-key-的核心差异)、[List:双端有序序列](/blog/tech/Redis/05.Hash 与 List#34-list双端有序序列)、[Hash:listpack 与 hashtable](/blog/tech/Redis/05.Hash 与 List#61-hashlistpack-与-hashtable)、[List、quicklist 与 listpack](/blog/tech/Redis/05.Hash 与 List#62-listquicklist-与-listpack)。

面试短答:

Redis Hash 是一个 Key 下的字段集合,适合保存字段数量不大、需要局部读写的对象。它和“多个 String Key 保存对象字段”的区别在于:Hash 能减少 Key 元数据和网络往返,但如果单个 Hash 过大,也会变成 BigKey 或 HotKey。

List 是双端有序序列,可以用 LPUSH/LPOP 实现栈,用 LPUSH/RPOPRPUSH/LPOP 实现 FIFO 队列。阻塞命令如 BLPOP 能做简单工作队列,但消费者崩溃时可能丢任务;要提高可靠性,需要 processing 列表、BLMOVE 或直接考虑 Streams / MQ。

底层原理:

Hash 小时使用 listpack 这类连续紧凑结构,减少指针、哈希桶和内存碎片。字段变多或元素变大后,会转换为 hashtable,换取更稳定的字段查找和更新成本。

List 早期经历过 ziplist、linkedlist,后来演进到 quicklist。quicklist 可以理解为:

双向链表节点
-> 每个节点里放一个 listpack
-> 端点操作保留链表优势
-> 中小元素在节点内连续存储,降低内存开销

Listpack 是 ziplist 的后继紧凑编码,目标是继续获得连续内存的空间收益,同时改善 ziplist 级联更新等问题。面试表达可以说:Redis 在“小集合省内存”和“大集合稳定复杂度”之间不断做工程折中。

追问边界:

  • HGETALL 大 Hash 会阻塞主线程并打满网络,线上更适合 HSCAN 分批处理。
  • List 能做队列,但不天然提供 ACK、重试、死信和消费组。
  • 在 Cluster 中,跨 Key 的队列搬移、事务、Lua 都要考虑同槽约束。

21.6 Set、ZSet、跳表、排行榜与 GEO

合并来源:跳表实现原理、ZSet 实现原理、为什么不用红黑树或 B+ 树、排行榜、GEO。

对应主讲章节:[Sorted Set:唯一成员加可排序 score](/blog/tech/Redis/06.Set、Sorted Set 与 GEO#34-sorted-set唯一成员加可排序-score)、[Sorted Set 的四类核心场景](/blog/tech/Redis/06.Set、Sorted Set 与 GEO#35-sorted-set-的四类核心场景)、[Sorted Set 的紧凑编码与双索引](/blog/tech/Redis/06.Set、Sorted Set 与 GEO#63-sorted-set-的紧凑编码与双索引)、[为什么 Redis 使用跳表而不是红黑树](/blog/tech/Redis/06.Set、Sorted Set 与 GEO#107-为什么-redis-使用跳表而不是红黑树)、[GEO 的底层是什么](/blog/tech/Redis/06.Set、Sorted Set 与 GEO#1012-redis-geo-的底层是什么)。

面试短答:

ZSet 是“成员唯一 + score 排序”的集合。它常用于排行榜、延迟任务、时间窗口、权重队列。较大 ZSet 的核心是 dict + skiplist 双结构:dict 负责按 member O(1) 查 score,skiplist 负责按 score 有序范围查询和排名。

排行榜可以用 ZADD 写入分数,用 ZREVRANGE WITHSCORES 查询 Top N,用 ZREVRANK 查询名次。GEO 底层也是 Sorted Set,把经纬度编码成 geohash,再借助 ZSet 的 score 排序和范围查询做附近搜索。

底层原理:

跳表是多层有序链表。底层包含所有节点,上层用随机晋升形成快速通道。查找时从最高层向右走,超过目标再向下走,平均复杂度接近 O(log N)。

Redis 使用跳表而不是红黑树,常见原因是:

  • 范围查询实现自然,找到起点后沿底层链表顺序遍历即可;
  • 实现比红黑树旋转更简单,插入删除只需要调整多层前后指针;
  • 随机层高在工程上足够稳定,平均性能好;
  • Redis 的 ZSet 还需要按 member 快速定位,所以本来也会搭配 dict。

B+ 树更适合磁盘页和块设备友好的范围扫描。Redis 主要在内存中工作,跳表指针结构不需要围绕磁盘页大小设计。

追问边界:

  • score 是 double,时间戳、金额、精确排序要注意精度边界。
  • 热门排行榜可能是 HotKey,需要分片榜、分桶榜或本地缓存。
  • ZSet 延迟任务只能提供“可领取”,不能天然保证恰好一次执行。

21.7 Bitmap、HyperLogLog、Bloom Filter 与 UV

合并来源:布隆过滤器、统计大量用户唯一访问量、Bitmap/HLL 类统计。

对应主讲章节:[Bitmap 的本质](/blog/tech/Redis/07.Bitmap、Bitfield 与 HyperLogLog#31-bitmap-的本质)、[HyperLogLog:只回答“有多少个”](/blog/tech/Redis/07.Bitmap、Bitfield 与 HyperLogLog#36-hyperloglog只回答有多少个)、[HyperLogLog 的直觉原理](/blog/tech/Redis/07.Bitmap、Bitfield 与 HyperLogLog#37-hyperloglog-的直觉原理)、Bloom Filter 算法、[Redis 8 概率数据结构](/blog/tech/Redis/10.Redis 8 的扩展数据能力#35-五种概率数据结构)。

面试短答:

Bitmap 适合表达大量布尔状态,比如签到、是否活跃、权限位。HyperLogLog 适合大规模 UV 估算,优点是内存固定且很小,缺点是有误差、不能删除单个元素、不能作为精确计费依据。Bloom Filter 适合判断“某个元素一定不存在或可能存在”,常用于缓存穿透防护。

三者可以按“问题类型”区分:Bitmap 回答“这个位置是不是 1”;HyperLogLog 回答“不同元素大约有多少个”;Bloom Filter 回答“这个元素是否可能存在”。如果需要精确列出成员,用 Set;如果只需要低成本拦截不存在的 ID,用 Bloom Filter;如果只需要 UV 数量级,用 HyperLogLog。

底层原理:

Bitmap 本质仍是 String,只是把字节当成 bit 数组来解释。偏移量越大,底层 String 越长,所以用稀疏大 ID 直接做 bit offset 会造成内存突增。

HyperLogLog 的直觉是:如果一个哈希值前面出现了很多连续的 0,说明你很可能看过很多不同元素。它维护多个寄存器,记录不同桶里观察到的最大前导零长度,再通过调和平均估算基数。

Bloom Filter 使用多个哈希函数,把一个元素映射到多个 bit。查询时如果任何一个 bit 为 0,元素一定不存在;如果全为 1,只能说明可能存在,因为这些 bit 可能被其他元素共同置位。

它的优点是内存占用极低,插入和查询复杂度约为 O(k),其中 k 是哈希函数个数;它不保存元素本身,只保存 bit 状态,所以可以用很小空间表达海量候选集合。多个 Bloom Filter 如果使用相同大小和相同哈希函数,还可以做按位合并来表达集合并集。

它的缺点同样要讲清楚:会有假阳性,也就是“不存在的数据可能被判断为存在”;普通 Bloom Filter 不能删除单个元素,因为清掉某个 bit 可能影响其他元素;也不能返回元素内容。需要删除时通常考虑 Counting Bloom Filter、定期重建,或者用别的数据结构兜底。误判率取决于 bit 数组大小、哈希函数个数和元素数量,装得越满误判率越高。

追问边界:

  • Bloom Filter 有误判率,不适合回答“一定存在”。
  • Bloom Filter 普通形态不支持安全删除,删除需求要用计数型或重建。
  • HLL 的 PFADD 返回 1 不等于真实新增了一个唯一用户。
  • 精确集合统计用 Set,低内存近似 UV 用 HyperLogLog,大规模存在性拦截用 Bloom Filter。

21.8 Redis 为什么快、为什么单线程、Redis 6 为什么引入 I/O Threads

合并来源:Redis 为什么快、为什么设计为单线程、Redis 6.0 为什么引入多线程、线程模型。

对应主讲章节:[Redis 快,不只是因为内存](/blog/tech/Redis/12.Redis 高性能原理#31-redis-快不只是因为内存)、[事件循环](/blog/tech/Redis/12.Redis 高性能原理#33-事件循环)、[I/O 多路复用](/blog/tech/Redis/12.Redis 高性能原理#34-io-多路复用)、[Redis 不能简单描述成完全单线程](/blog/tech/Redis/12.Redis 高性能原理#36-redis-不能简单描述成完全单线程)、[I/O Threads 的作用与限制](/blog/tech/Redis/12.Redis 高性能原理#39-io-threads-的作用与限制)。

面试短答:

Redis 快不是单一原因,而是内存访问、紧凑数据结构、单线程命令执行、I/O 多路复用、短命令路径、长连接、Pipeline 和避免锁竞争共同作用。Redis 的“单线程”主要指核心命令执行在主线程串行完成,不代表整个 Redis 进程只有一个线程。

Redis 6 引入 I/O Threads,是因为在大连接数、大请求/响应、网络读写和协议处理成本变高时,主线程可能被 socket I/O 拖住。I/O Threads 主要分担网络读写和部分协议处理,命令执行仍主要由主线程串行完成,所以它不能解决慢命令、大 Key、Lua 阻塞这类问题。

底层原理:

Redis 请求路径可以简化为:

客户端连接
-> I/O 多路复用监听 socket 可读/可写事件
-> 文件事件分派器把事件交给连接、读、写处理器
-> 读取请求并解析 RESP
-> 查命令表并执行命令
-> 访问对象和数据结构
-> 生成响应并写回客户端

这个模型本质上接近 Reactor:一个事件循环同时管理很多连接,底层可用 epollkqueueselect 等多路复用机制发现哪些 socket 就绪,再把不同事件交给对应 handler 处理。因为 Redis 命令大多是短路径内存操作,主线程不用为每个连接创建一个线程,也不用在对象访问上频繁加锁。

单线程命令执行带来的收益是:

  • 不需要为每个对象操作加锁,减少上下文切换和锁竞争;
  • 命令天然串行,单命令原子性更容易保证;
  • 数据访问路径更可预测,局部性更好;
  • 实现简单,故障面更小。

这里的前提是每条命令要足够短。如果命令本身变成大集合遍历、大 value 传输、长 Lua、同步磁盘 I/O 或阻塞式模块调用,单线程优势就会反过来变成队头阻塞。Redis 也并不是所有事情都放在主线程里做:RDB/AOF rewrite 会用子进程,异步释放、lazy free、AOF fsync、网络 I/O 线程等也会把部分工作挪到后台,但核心命令执行仍然要保持串行语义。

但它也带来边界:一个慢命令、一个大 Key 删除、一个复杂 Lua 脚本都可能阻塞后续请求。Redis 的性能优化不是“开多线程就好”,而是控制单命令工作量、控制 value 大小、使用有界 Pipeline、避免热点集中,并用监控定位 CPU、网络、内存、磁盘和客户端排队。

追问边界:

  • I/O Threads 不会把同一个命令的业务逻辑并行化。
  • Redis 性能问题不能只看 QPS,要看 p99、p999、慢命令、输出缓冲区、fork、AOF fsync 和客户端连接池。
  • 多实例分片通常比指望单实例无限扩容更现实。

21.9 Pipeline、MGET/MSET 与批处理边界

合并来源:Pipeline 功能、MSET/MGET 与 Pipeline 的区别。

对应主讲章节:[Pipeline:优化网络,不优化命令复杂度](/blog/tech/Redis/09.Pipeline、事务、WATCH、Lua 与 Functions#32-pipeline优化网络不优化命令复杂度)、[Pipeline 为什么能提高吞吐量](/blog/tech/Redis/12.Redis 高性能原理#问题-7pipeline-为什么能提高吞吐量)、[MGET、Pipeline 和 Lua 如何选择](/blog/tech/Redis/12.Redis 高性能原理#问题-8mgetpipeline-和-lua-应该如何选择)。

面试短答:

Pipeline 是客户端把多条命令连续写到连接里,减少多次 RTT,服务端仍按顺序逐条执行。它提升的是网络吞吐,不会降低单条命令复杂度,也不是事务。

MGET / MSET 是 Redis 原生命令,一条命令处理多个 Key;Pipeline 是客户端批量发送多条独立命令。能用原生命令表达的批量读写,通常优先考虑原生命令;需要不同命令组合时用 Pipeline;需要“判断 + 更新”原子化时用 Lua 或事务。

底层原理:

没有 Pipeline 时:

命令 1 -> 等响应 1
命令 2 -> 等响应 2
命令 3 -> 等响应 3

有 Pipeline 时:

一次写入:命令 1、命令 2、命令 3
服务端顺序执行
一次或连续读取多个响应

它减少了请求-响应往返,但也会把一批命令和响应暂存在客户端、服务端输入缓冲区、输出缓冲区里。批次太大时,可能导致内存峰值、连接不公平和尾延迟上升。

追问边界:

  • Pipeline 不是事务,中间命令失败不会自动回滚。
  • Pipeline 批次要有上限,按响应字节数和 p99 调整。
  • Cluster 跨槽 Pipeline 可以发,但多 Key 原生命令、事务和 Lua 需要同槽。

21.10 TTL、过期删除、淘汰策略、内存碎片与旧 VM

合并来源:数据过期后的删除策略、缓存过期策略、内存淘汰策略、常见淘汰算法、内存碎片化、虚拟内存 VM。

对应主讲章节:[TTL 命令体系](/blog/tech/Redis/03.Key 设计、TTL 与生命周期管理#33-ttl-命令体系)、[惰性过期与主动过期](/blog/tech/Redis/03.Key 设计、TTL 与生命周期管理#36-惰性过期与主动过期)、淘汰策略Redis 为什么使用近似 LRU内存指标与碎片

面试短答:

Redis 过期删除主要是被动过期 + 主动过期。被动过期是在访问 Key 时发现过期并删除;主动过期是后台周期性抽样检查带 TTL 的 Key,删除已过期数据。Redis 不会每毫秒扫描全量 Key,因为那会把 CPU 都花在维护过期上。

设置过期时间常见命令有几类:

命令含义
EXPIRE key seconds / PEXPIRE key milliseconds给已有 Key 设置相对过期时间
EXPIREAT key timestamp / PEXPIREAT key ms-timestamp给已有 Key 设置绝对过期时间点
SET key value EX seconds / SET key value PX milliseconds写入 value 时同时设置 TTL
SETEX key seconds value旧式写法,等价于设置值并设置秒级过期
TTL key / PTTL key查看剩余过期时间
PERSIST key移除过期时间,让 Key 变成永久 Key

缓存过期从策略上可以分成三种:

策略做法优缺点
定时删除每个 Key 到期时由定时器立刻删除内存释放及时,但定时器数量大时 CPU 和调度成本高
惰性删除访问 Key 时才判断是否过期CPU 友好,但长期不访问的过期 Key 会占内存
定期删除后台周期性抽样检查并删除过期 Key折中 CPU 和内存,是 Redis 主体思路之一

Redis 实际采用惰性删除 + 定期抽样删除,而不是给每个 Key 都挂一个精确定时器,也不是定期全量扫描。业务自己实现本地缓存时,有时还会用时间轮、时间桶等方式把大量过期任务分组管理,本质也是为了避免“每个 Key 一个定时器”的成本。

内存淘汰是在 maxmemory 达到限制时触发,和“Key 已经过期”不是一回事。常见策略包括 noevictionallkeys-lruvolatile-lruallkeys-lfuvolatile-lfuallkeys-randomvolatile-randomvolatile-ttl

常见淘汰算法可以这样解释:

算法依据适合记忆方式
FIFO谁最早进入缓存,谁先淘汰只看进入时间,不看后续访问
LRU谁最久没有被访问,谁先淘汰看最近一次访问时间
LFU谁访问频率最低,谁先淘汰看访问次数或频率衰减

Redis 的 lru / lfu 策略是采样近似,不是维护全局精确链表或全局精确频率表。答题时要把“算法思想”和“Redis 工程实现”分开讲。

底层原理:

过期删除看的是 TTL 元数据,淘汰看的是内存压力。二者关系如下:

TTL 到期
-> 被动访问删除,或主动抽样删除

maxmemory 达到阈值
-> 根据 maxmemory-policy 从候选 Key 中淘汰

Redis 的 LRU / LFU 都是近似算法,不是全局精确排序。原因很现实:如果维护全局精确 LRU 链表或每次都全量比较,会引入额外锁、指针修改、CPU 和内存开销。Redis 用采样方式在成本和效果之间折中。

业务缓存里的“过期策略”和 Redis 的 maxmemory-policy 也不是同一层概念。业务上可以设置统一 TTL、随机 TTL、冷热分级 TTL、主动删除缓存、延迟双删、消息通知失效等;Redis 内部的淘汰策略只在实例内存达到上限时才介入,目标是让实例活下去,而不是保证业务数据新鲜。

内存碎片是 used_memory_rss 明显高于 used_memory 的常见原因之一。它可能来自 jemalloc 分配粒度、对象大小变化、大量删除后的空洞、fork/COW、活跃碎片整理未及时回收等。优化方向包括控制 value 大小、分批删除、开启或调优 active defrag、合理预留内存、避免频繁创建删除巨大对象。

旧版 Redis 曾经有过 VM(虚拟内存)设计思路,把冷数据换出到磁盘。但这个机制已经不是现代 Redis 的主流能力。现在更应该关注操作系统虚拟内存和 Swap:Redis 对 Swap 非常敏感,页面换入换出会带来不可控尾延迟,生产上通常要避免 Redis 进入 Swap。

追问边界:

  • volatile-* 只从设置了 TTL 的 Key 中淘汰,没 TTL 的 Key 可能导致 OOM。
  • volatile-ttl 也不是全局精确删除 TTL 最短的 Key,而是采样近似。
  • 大量 Key 同一时刻过期会造成 CPU 抖动,应给 TTL 加随机抖动。

21.11 BigKey、HotKey 与性能瓶颈排查

合并来源:Big Key、热点 Key、性能瓶颈如何处理。

对应主讲章节:[BigKey 与 HotKey](/blog/tech/Redis/03.Key 设计、TTL 与生命周期管理#39-bigkey-与-hotkey)、[大 Key 与大集合运算](/blog/tech/Redis/12.Redis 高性能原理#311-大-key-与大集合运算)、大 Key 与热 KeyRedis CPU 高时如何排查

面试短答:

BigKey 是单个 Key 的 value 很大、元素很多或返回数据很多;HotKey 是访问频率过高,集中打到单个 Key、单个分片或单个实例。BigKey 主要拖慢命令执行、网络传输、删除释放、复制和持久化;HotKey 主要造成单点 CPU、带宽、连接和队列瓶颈。

底层原理:

BigKey 的问题不是 Key 名长,而是 value 或集合元素规模大。比如:

一个 20 MB String
一个 100 万 field 的 Hash
一个包含 500 万 member 的 ZSet
一次 HGETALL / LRANGE 0 -1 返回大量数据

HotKey 的问题是流量分布极不均匀。即使 Cluster 有很多分片,一个明星商品详情、全站配置、热门排行榜也可能集中到一个槽位和一个主线程。

排查路径建议这样回答:

  1. 先看现象:p99、CPU、网络、连接池、慢命令、输出缓冲区。
  2. 查命令:SLOWLOGINFO commandstats、延迟事件。
  3. 查 Key:MEMORY USAGESCAN 抽样、redis-cli --bigkeys、热点采样工具或 Redis 8.6+ HOTKEYS
  4. 定治理:拆分 Key、分页读取、异步删除、局部缓存、读副本、Key 分片、请求合并、降级。

追问边界:

  • BigKey 不能只靠加机器解决,单 Key 仍可能落在单分片。
  • 热 Key 分片会增加一致性和聚合复杂度。
  • 删除 BigKey 优先考虑 UNLINK、分批删除集合元素、低峰处理。

21.12 缓存穿透、击穿、雪崩与常用缓存方案

合并来源:缓存穿透、缓存击穿、缓存雪崩、如何避免、为什么使用缓存、常用缓存方案。

对应主讲章节:缓存穿透缓存击穿缓存雪崩本地缓存与 Redis 两级缓存Bloom Filter 算法

面试短答:

这三个问题都发生在“先查 Redis,未命中再查数据库,查到后写回 Redis”的缓存读路径上。区别在于未命中的原因和影响面不同。

缓存穿透是查询的数据本来就不存在,Redis 没有,数据库也没有,但请求还是不断打到数据库。典型例子是数据库用户 ID 从正数递增,攻击者不断查 user_id = -1 或随机不存在的 ID。常见解法是参数合法性校验、访问控制、空值缓存、Bloom Filter 和限流。

缓存击穿是数据库中有数据,但某个热点 Key 在 Redis 中不存在或刚好过期,大量并发同时回源数据库。它强调“单个热点 Key”。常见解法是请求合并、互斥重建、逻辑过期、热点 Key 预热,必要时让热点数据不过物理期并由后台刷新。

缓存雪崩是大量 Key 在同一时间失效,或者 Redis 缓存层整体不可用,导致大量不同 Key 的请求同时回源数据库。它强调“影响面大”。常见解法是 TTL 随机化、分批预热、多级缓存、限流降级、熔断、回源保护、Redis 高可用和容量隔离。

底层原理:

三者的共同背景是缓存承担了数据库前面的保护层:

请求
-> 查 Redis
-> 命中则直接返回
-> 未命中则查数据库
-> 数据库查到后写回 Redis
-> 返回结果

如果 Redis 未命中后所有请求都能无约束地进入数据库,数据库就会重新暴露在高并发流量下。面试时可以先用这条链路说明“为什么会出问题”,再区分三类问题:

问题触发点影响面常用治理
穿透数据本来不存在,缓存和数据库都没有大量非法 Key 或随机 Key参数校验、访问控制、空值缓存、Bloom Filter、限流
击穿数据库有数据,单个热点 Key 未命中或过期单个热点对象singleflight、互斥重建、逻辑过期、热点预热
雪崩大量 Key 同时过期,或缓存层故障全局、多 Key、大流量TTL 抖动、分批预热、多级缓存、熔断降级、Redis 高可用

缓存穿透的治理重点是“不要让明显不存在的数据走到数据库”。

第一层是参数校验和业务规则拦截。比如用户 ID 必须是正数、手机号必须符合格式、租户 ID 必须属于当前登录人,这些请求在应用层就可以拒绝,不必查 Redis,更不必查数据库。

第二层是空值缓存。数据库确认不存在后,仍然把这个 Key 写入 Redis,Value 可以是空对象或特殊标记,并设置较短 TTL。这样同一个不存在 Key 被重复请求时,Redis 可以直接返回“没有”。但空值缓存不能设置太久,否则会占用大量内存,也可能把后来新建的数据短暂挡住;同时它主要防重复请求,对“每次换一个随机 Key”的攻击效果有限。

第三层是 Bloom Filter。把数据库中合法 ID 或业务 Key 加入 Bloom Filter,请求进入 Redis 前先判断一次:如果 Bloom Filter 判断“不存在”,就直接拒绝;如果判断“可能存在”,再继续查 Redis 或数据库。Bloom Filter 的优势是内存低、判断快,适合挡住大量不存在 Key;边界是存在误判,判断“可能存在”不等于一定存在,后面仍要以数据库为准。

缓存击穿的治理重点是“热点 Key 失效时只允许少量请求回源”。

最常见做法是互斥重建:发现热点 Key 未命中后,先尝试拿锁,拿到锁的请求查数据库并写回缓存,没拿到锁的请求等待、短暂重试、返回旧值或降级。单机内可以用 singleflight 合并同 Key 请求,多实例场景需要分布式锁或 Redis 原子占位。无论哪种锁,都要设置租期、超时和二次检查:拿到锁后先再查一次缓存,避免多个请求排队后重复查库。

另一种做法是逻辑过期。缓存 Key 不设置物理过期,Value 里放一个 expire_at 字段。读请求发现逻辑过期时,仍然可以先返回旧值,然后异步触发后台线程重建缓存。这个方案牺牲短时间新鲜度,换来热点场景下的稳定性,适合商品详情、配置、榜单等“允许旧一点,但不能打爆数据库”的读链路。

热点 Key 也可以提前预热,或者在大促、直播、热点事件前写入缓存。对极核心热点,可以设置较长 TTL 或不过物理期,但要配合后台刷新、版本号或主动失效机制,避免数据长期陈旧。

缓存雪崩的治理重点是“不要让大量 Key 或整个缓存层同时失效”。

第一类雪崩来自 TTL 设计。程序如果把一批 Key 都设置成相同过期时间,比如统一 expire 300s,高并发下这批 Key 会在同一时刻集中回源。通常做法是在基础 TTL 上增加随机抖动,例如 300s + random(0, 60s),或者按业务分片、分批预热,让失效时间分散。

第二类雪崩来自冷启动。服务刚上线、缓存刚清空、Redis 扩容迁移后,缓存里可能没有数据,此时所有请求都会直接查数据库。解决方式是缓存预热:上线前或流量切入前把核心 Key 从数据库加载到 Redis,或者先用小流量灰度逐步填充缓存。

第三类雪崩来自 Redis 故障或容量不足。此时不能简单让所有流量回源数据库,要同时做 Redis 主从、Sentinel、Cluster 或云服务高可用,多级缓存,本地热点缓存,数据库连接池限流,服务熔断降级,以及“失败开放还是失败关闭”的业务策略。比如商品详情可以返回旧缓存或降级内容,扣费、下单这类强一致链路则宁愿失败也不能读错。

注意 singleflight 只能合并单进程内同 Key 请求,不能自动覆盖多实例。分布式互斥重建也要设置租期、超时、降级和二次检查,否则锁本身会成为新的故障点。

追问边界:

  • 穿透是“缓存没有、数据库也没有”,击穿是“缓存没有、数据库有”,雪崩不关心单条数据是否存在,而是大量请求同时回源。
  • 空值缓存要设置短 TTL,避免把无效 Key 堆满 Redis,也避免把临时不存在固化太久。
  • 普通 Bloom Filter 可以判断“一定不存在”和“可能存在”,不能证明“一定存在”;普通 Bloom Filter 也不适合直接删除元素,需要重建、计数 Bloom 或其他过滤器方案。
  • 互斥重建不要把所有请求都阻塞死,要有等待超时、旧值返回或降级兜底。
  • 逻辑过期适合“宁愿旧一点,也不要打爆数据库”的热点读场景,不适合强一致读。
  • TTL 随机化只能缓解同一批 Key 同时过期,不能替代 Redis 高可用、限流、熔断和数据库容量保护。

21.13 缓存与数据库一致性

合并来源:保证缓存与数据库一致性、Redis 和 MySQL 如何保证数据一致。

对应主讲章节:Cache Aside 的标准读写路径缓存与数据库一致性数据提交后的失效失败是部分成功

面试短答:

缓存与数据库很难做到严格强一致,因为 MySQL 事务和 Redis 命令属于两个中间件,普通业务代码无法把它们放进同一个本地 ACID 事务。工程上通常把数据库当成权威事实,把缓存当成派生视图,目标是最终一致、短时间旧读可控、失败可补偿。

最常用模式是 Cache Aside:读时先读缓存,未命中再读数据库并写回缓存;写时先更新数据库,再删除缓存。面试里不要只背“先删缓存还是先写库”,要把几种方案的失败窗口讲清楚:先更新数据库再更新缓存可能更新缓存失败或乱序覆盖;先删除缓存再更新数据库可能被并发读把旧值写回缓存;先更新数据库再删除缓存窗口最小,但删除失败仍要重试、补偿和监控。

底层原理:

先看标准读路径:

读请求
-> 查 Redis
-> 命中则返回
-> 未命中则查数据库
-> 把数据库结果写回 Redis
-> 返回

问题出在写路径。数据库里的商品价格、库存、用户状态才是真实数据,Redis 只是为了保护数据库和降低延迟保存的一份副本。比如商品价格从 1000 改成 500,如果数据库已经变成 500,但缓存里还是 1000,用户读到的就是旧价格;反过来,如果缓存先变了而数据库没有变,后续对账和业务判断更危险。

常见方案可以这样比较:

方案做法主要问题适用性
先更新数据库,再更新缓存DB 提交后直接写 Redis 新值Redis 更新失败会旧读;并发写可能旧值后到覆盖新值;复杂缓存重算成本高简单、单值、低并发场景可用,最好带版本
先删除缓存,再更新数据库写开始先删 Redis,再提交 DB写库期间并发读可能查到旧 DB,再把旧值回填到缓存一般不作为首选,可配延迟双删缓解
先更新数据库,再删除缓存DB 成功后删除 Redis,下一次读回源重建删除缓存失败会继续读旧缓存;删除前的极短窗口仍可能读旧值Cache Aside 常用首选
串行化读写同 Key 的写和缓存未命中读进入有序队列吞吐下降、队列积压、超时和实现复杂强一致要求更高且热点可控时才考虑

推荐“先更新数据库,再删除缓存”的原因是:数据库是权威事实,缓存是派生视图。写入成功后删除缓存,让下一次读重新从数据库加载最新值,而不是在写路径里重新计算并写入一份可能已经过期的复杂缓存。

但它仍有失败窗口:

更新数据库成功
-> 删除缓存超时或失败
-> 旧缓存继续被读取

因此生产系统通常还要叠加:

  • 短 TTL 作为最终兜底;
  • 删除失败重试或补偿任务;
  • Binlog/CDC 异步失效;
  • 版本号或更新时间防止旧值回填;
  • 热点 Key 使用逻辑过期和后台刷新;
  • 对强一致读走数据库或使用读写路径约束。

延迟双删是对“先删除缓存,再更新数据库”的补救:先删缓存,更新数据库,等待一段时间后再删一次缓存。第二次删除是为了清掉写库期间被并发读回填的旧缓存。这里的等待时间至少要覆盖一次读请求“查库 + 写缓存”的耗时;如果系统做了数据库读写分离,还要把主从复制延迟考虑进去。它能降低旧值回填概率,但依赖时间估计,等待太短不稳,等待太长又拖慢写请求吞吐,所以不能当成严格正确性证明。

如果业务确实要求更强的一致性,可以按 Key 串行化:写操作先删除缓存,然后把数据库更新放入同 Key 的有序队列;缓存未命中的读请求也进入同一个队列,等前面的写完成后再读数据库并回填缓存。这样可以减少并发交错,但代价是读写排队、吞吐下降、超时和热点积压。实际工程里通常会按业务 Key 分片成多个队列提升并行度,并配合超时、降级和监控。

更稳的通用增强是版本化缓存:缓存值带版本号或更新时间,写缓存时只允许新版本覆盖旧版本,旧版本不能覆盖新版本。再配合删除失败重试、消息队列或 Binlog/CDC 补偿、短 TTL 兜底,才能把“偶发不一致”控制在可观测、可修复范围内。

追问边界:

  • Redis 和 MySQL 不能跨系统开启一个普通 ACID 事务。
  • 如果是库存、余额、订单状态,最终一致之外还要有数据库约束、幂等键、状态机和对账。
  • 缓存删除失败是“部分成功”,必须进入监控、重试和补偿链路。
  • 延迟双删不是银弹,读写分离、复制延迟和慢查询都会让 sleep 时间变得难估。
  • 更新缓存不一定比删除缓存好,复杂对象可能来自多表聚合,删除后按需加载更轻、更少乱序覆盖。

21.14 Pub/Sub、List 队列与 Streams

合并来源:订阅发布功能、List 队列和栈。

对应主讲章节:[三种投递语义](/blog/tech/Redis/08.PubSub、List 队列与 Streams#31-三种投递语义)、[Pub/Sub:瞬时广播模型](/blog/tech/Redis/08.PubSub、List 队列与 Streams#32-pubsub瞬时广播模型)、[List:阻塞工作队列](/blog/tech/Redis/08.PubSub、List 队列与 Streams#33-list阻塞工作队列)、[Streams:可保留、可回放的日志](/blog/tech/Redis/08.PubSub、List 队列与 Streams#34-streams可保留可回放的日志)、[PEL、ACK 与 Claim](/blog/tech/Redis/08.PubSub、List 队列与 Streams#35-pelack-与-claim)。

面试短答:

Redis Pub/Sub 是瞬时广播。发布者把消息发到 Channel,在线订阅者能收到;不在线、断线或消费慢的订阅者没有可靠持久化保障,所以它适合通知、配置广播、轻量事件,不适合核心业务可靠消息。

List 可以做简单队列,Streams 更接近可保留、可回放、有消费组和 ACK 的消息日志。需要可靠投递、重试、消费位点和失败恢复时,应优先考虑 Streams 或专业 MQ。

底层原理:

三种模型的差异:

模型是否持久化消费进度适合场景
Pub/Sub在线通知、广播
List 队列List 保存任务应用自己设计简单任务队列
Streams消息日志保存消费组、PEL、ACK可重试消息流

Streams 的关键是 PEL(Pending Entries List)。消费者读到消息但未 ACK 时,消息会留在 PEL 中。消费者崩溃后,其他消费者可以通过 XCLAIM / XAUTOCLAIM 接管超时未确认消息,从而实现至少一次投递。

追问边界:

  • 至少一次投递意味着业务必须幂等。
  • 先 ACK 再更新数据库可能丢业务,先更新数据库再 ACK 可能重复消费。
  • Streams 可以做轻量消息流,但不能简单替代 Kafka/RabbitMQ/RocketMQ 的完整生态和运维能力。

21.15 事务、WATCH、Lua 与关系型数据库事务差异

合并来源:Redis 支持事务吗、如何实现、Lua 脚本、Redis 事务与关系型数据库事务区别。

对应主讲章节:[MULTI、EXEC 与 DISCARD](/blog/tech/Redis/09.Pipeline、事务、WATCH、Lua 与 Functions#33-multiexec-与-discard)、[Redis 事务为什么不等同于关系数据库事务](/blog/tech/Redis/09.Pipeline、事务、WATCH、Lua 与 Functions#34-redis-事务为什么不等同于关系数据库事务)、[WATCH:基于乐观锁的 CAS](/blog/tech/Redis/09.Pipeline、事务、WATCH、Lua 与 Functions#35-watch基于乐观锁的-cas)、[Lua:把判断和更新放到 Redis 内部](/blog/tech/Redis/09.Pipeline、事务、WATCH、Lua 与 Functions#36-lua把判断和更新放到-redis-内部)、Lua 的原子性与成本

面试短答:

Redis 支持事务,但它不是关系型数据库那种 ACID 事务。Redis 事务通过 MULTI 开启、命令入队、EXEC 顺序执行。执行期间不会被其他客户端命令插入,所以能保证事务队列的串行执行,但它不提供复杂隔离级别、自动回滚、约束检查和跨 Key 强一致事务语义。

WATCH 是乐观锁:执行事务前监控 Key,如果 Key 被别人修改,EXEC 返回失败,客户端重试。Lua 是把“读取、判断、更新”放到 Redis 内部一次执行,适合库存扣减、限流、解锁等需要原子判断的短逻辑。

底层原理:

Redis 事务的原子性更接近“队列中的命令连续执行,不被插队”。它不做关系型数据库那样的 undo 日志回滚。如果事务里某条命令在执行期失败,前面已经执行的命令不会自动回滚。

完整流程可以这样背:

可选 WATCH key
-> MULTI 开启事务
-> 后续命令先入队,客户端通常收到 QUEUED
-> EXEC 顺序执行队列
-> 或 DISCARD 丢弃队列
-> UNWATCH 取消监控

错误也要分阶段回答。命令入队阶段如果出现语法错误、命令不存在、参数数量不对,Redis 能在 EXEC 前发现,事务会整体失败;但如果是执行阶段才出现的类型错误,比如对 String 执行 List 命令,Redis 不会回滚之前已经执行成功的命令,后续命令也可能继续执行。WATCH 监控的 Key 如果在 EXEC 前被其他客户端修改,EXEC 会失败返回空结果,客户端需要重新读取、重新计算、重新提交。

Lua 原子,是因为脚本执行期间 Redis 主线程不会执行其他命令。但这也意味着脚本必须短小、确定、有界。长 Lua 会阻塞整个实例;在 Cluster 中,Lua 涉及的 Key 还必须满足同槽约束。

追问边界:

  • Pipeline 只减少网络 RTT,不是事务。
  • EXEC 或 Lua 超时后,客户端不能盲目重试非幂等操作,因为命令可能已经执行成功。
  • Lua 原子不等于持久化强一致;故障切换时仍要考虑复制和 AOF 窗口。

21.16 分布式锁、Redlock、Redisson 与 watch dog

合并来源:分布式锁实现、未完成逻辑前过期怎么办、Red Lock、分布式锁问题、Redisson、watch dog。

对应主讲章节:Redis 锁本质上是带租期的所有权声明锁值为什么必须唯一解锁必须是比较并删除的原子操作锁超时与过期所有者自动续期不是无条件安全Redlock 的基本思路与争议

面试短答:

Redis 分布式锁的基本实现是:

SET lock_key unique_value NX PX lease_ms

释放锁不能直接 DEL,必须用 Lua 比较 value 后删除,避免 A 删除了 B 刚获得的锁。value 必须唯一,TTL 必须存在,业务执行时间必须小于租期,超时后的结果必须按“不确定”处理。

早期常见写法是先 SETNX lock_key value,再 EXPIRE lock_key ttl。这个写法有致命窗口:如果客户端在 SETNX 成功后、EXPIRE 之前宕机,锁就可能永不过期,形成死锁。所以现代写法要用一条原子命令 SET key value NX PX ttl 同时完成“只在不存在时写入”和“设置租期”。

如果业务未完成锁先过期,有三种思路:缩短临界区、设置合理租期、自动续期。但自动续期不是万能解,进程 STW、网络分区、调度暂停、下游超时都可能让“旧持有者”继续操作。因此关键资源还要用 Fencing Token,让下游拒绝过期持有者。

底层原理:

Redis 锁本质不是“绝对互斥”,而是一个带过期时间的所有权声明。它能减少并发冲突,但不能证明在所有故障模型下只有一个业务执行者。

Redlock 的思路是在多个独立 Redis 主节点上尝试加锁,获得多数节点成功并且总耗时小于租期才认为获得锁。它试图降低单 Redis 主从切换导致双持有者的概率,但也引入时钟、网络延迟、暂停时间和多数派判断等复杂性。面试里比较稳的表达是:Redlock 可以作为提高锁可用性的方案,但对强正确性资源仍建议使用数据库约束、状态机、Fencing Token 或共识系统。

Redisson watch dog 会在未显式指定 leaseTime 时,为仍然存活的持锁线程续期,避免业务还没执行完锁就过期。但它不能解决所有问题:如果业务线程卡住却仍续期,锁可能被长期占用;如果进程暂停、网络异常或续期失败,锁仍会过期。

可重入锁还要额外保存“持有者是谁”和“重入次数”。单纯的 SET NX PX 只能表达某个 token 持有锁,不能表达同一线程重复进入临界区。Redisson 这类客户端通常会用 Hash 记录 owner 和 count,并配合 Lua 保证加锁、重入、释放、计数递减这些动作的原子性。面试里可以补一句:可重入提升易用性,但状态更多,续期和释放边界也更复杂。

追问边界:

  • 锁的安全解锁必须比较 token。
  • Redis 主从切换可能产生两个持有者。
  • 用锁保护外部系统时,下游最好校验 Fencing Token 或业务版本。

21.17 RDB、AOF、fork、COW 与持久化

合并来源:持久化机制、RDB 和 AOF、生成 RDB 文件时如何处理请求。

对应主讲章节:[四种持久化模式](/blog/tech/Redis/14.RDB、AOF 与数据恢复#31-四种持久化模式)、[RDB 快照](/blog/tech/Redis/14.RDB、AOF 与数据恢复#33-rdb时间点快照)、[fork 与 Copy-on-Write](/blog/tech/Redis/14.RDB、AOF 与数据恢复#34-fork-与-copy-on-write)、[AOF 的三种 fsync 策略](/blog/tech/Redis/14.RDB、AOF 与数据恢复#问题-5三种-appendfsync-策略如何选择)、[fork 延迟与业务延迟](/blog/tech/Redis/14.RDB、AOF 与数据恢复#66-fork-延迟与业务延迟)。

面试短答:

Redis 持久化主要有 RDB、AOF、RDB + AOF、混合 AOF。RDB 是某一时刻的数据快照,恢复快、文件紧凑,但两次快照之间的数据可能丢失。AOF 是追加写命令日志,数据丢失窗口取决于 appendfsync 策略,恢复通常比 RDB 慢,但可保留更细粒度的写入历史。

生成 RDB 时,线上通常使用 BGSAVE。Redis 主进程 fork 子进程,子进程负责把快照写到磁盘,主进程继续处理请求。依靠操作系统 Copy-on-Write,fork 时父子进程共享内存页;如果主进程继续写入某些页,系统会复制被修改的页,这会带来额外内存峰值。

RDB 触发方式可以补充为:

触发方式说明
SAVE主线程同步生成 RDB,会阻塞服务,生产慎用
BGSAVEfork 子进程生成 RDB,主进程继续处理请求
save m n 配置m 秒内至少 n 次修改时触发后台快照
主从全量同步主节点可能生成 RDB 发给副本
关闭或管理命令正常关闭、FLUSHALL 等场景可能影响 RDB 内容,要结合配置看

底层原理:

RDB 期间请求处理大致是:

主进程 fork 子进程
-> 子进程遍历内存生成 RDB
-> 主进程继续处理读写
-> 写入触发 COW,修改页被复制

因此 BGSAVE 不会像 SAVE 那样长时间阻塞所有命令,但 fork 本身、页表复制、磁盘 I/O、COW 内存放大仍然可能造成延迟尖峰和 OOM 风险。

AOF 的关键是 fsync 策略:

策略含义取舍
always每次写入都 fsync最安全,最慢
everysec通常每秒 fsync常用折中,可能丢约 1 秒
no交给 OS 决定刷盘性能好,丢失窗口更大

AOF 持久化链路可以拆成三步:命令执行成功后追加到 AOF buffer,再按策略刷到磁盘,文件变大后通过 AOF rewrite 生成更紧凑的新基线。rewrite 不是把旧 AOF 一行行压缩,而是根据当前内存状态重新生成能恢复同样数据集的写命令,因此可以把很多历史中间状态合并掉。

恢复时如果同时开启 RDB 和 AOF,通常优先加载 AOF,因为它一般包含更新的数据;但 Redis 7 之后 multi-part AOF 和 manifest 机制让文件组织更复杂,答题时不要把旧版本细节说死。误执行 FLUSHALL 这类命令后,如果它已经写入 AOF 但还没有 rewrite,理论上可以停机、备份 AOF、移除错误命令再尝试恢复;但这属于事故应急,不是常规恢复方案,操作前要先保全现场文件。

追问边界:

  • 持久化不是备份,高可用也不是备份。
  • AOF rewrite 不是读取旧 AOF 逐条压缩,而是基于当前内存状态生成新基线。
  • RDB/AOF 同时存在时,加载优先级和 manifest 机制要按 Redis 版本确认。

21.18 主从复制、复制拓扑与复制延迟

合并来源:主从复制原理、主从拓扑、主从同步机制、复制延迟原因。

对应主讲章节:异步复制模型replication ID、offset 与 backlogPSYNC:决定全量还是增量全量同步过程复制延迟与读副本旧数据

面试短答:

Redis 主从复制默认是异步复制。主节点处理写命令后,把写命令传播给副本;副本重放命令,最终追上主节点。断线重连时,副本会带上 replication ID 和 offset 请求 PSYNC;如果主节点 backlog 中还保留缺失数据,就做部分重同步,否则做全量同步。

常见拓扑包括一主一从、一主多从、级联复制、哨兵管理的主从、Cluster 中每个主分片带副本。复制延迟常见原因包括主节点写入量大、网络慢、副本机器慢、AOF/fsync 或磁盘慢、大 Key 传播、全量同步、backlog 太小导致频繁全量同步、读副本承担读流量过重。

配置上可以通过 replicaof <master-ip> <port>(旧命令是 SLAVEOF)让一个 Redis 实例成为副本。副本不是直接“复制内存指针”,而是建立 TCP 连接,完成认证、能力协商、同步判断、加载快照和持续接收命令这条链路。

底层原理:

复制链路可以这样记:

副本连接主节点
-> PING / AUTH / REPLCONF 握手与能力协商
-> 发送 PSYNC replication_id offset
-> 主节点判断部分重同步还是全量同步
-> 全量同步:生成 RDB,发送给副本,期间增量写入进入缓冲
-> 副本清空旧数据并加载 RDB
-> 主节点继续发送增量命令,副本重放命令

replication offset 是复制进度,replication backlog 是主节点保留的一段复制历史。backlog 太小,副本稍微落后就无法补齐,只能全量同步。全量同步会产生 RDB、网络传输、加载和 COW 成本,可能拖慢主节点。

部分重同步依赖两个条件:副本带来的 replication ID 能和主节点当前或历史 ID 对上;副本缺失的 offset 还在主节点 backlog 里。backlog 可以理解成主节点上的环形复制缓冲区,写流量不断向前推进,太旧的数据会被覆盖。只要 ID 对不上、offset 太旧、主节点重启导致复制 ID 变化,或者副本落后太多,就会退化为全量同步。

全量同步期间还有一个容易漏讲的缓冲窗口:主节点 fork 子进程生成 RDB 时,主线程仍在接收新的写命令,这些写命令要先缓存在复制缓冲区里;等副本加载完 RDB 后,再把这段增量命令发过去。这个阶段如果写入量过大、网络慢或副本加载慢,主节点输出缓冲区和内存压力都会上升。

追问边界:

  • 主从复制不是强一致。客户端收到写成功后,如果主节点还没把写传播给副本就故障,切换后可能丢写。
  • WAIT 可以等待副本确认,但不能把 Redis 变成严格强一致系统。
  • 读写分离可能读到旧数据,关键读要走主节点或做一致性保护。

21.19 Sentinel、高可用与脑裂

合并来源:哨兵机制、高可用方案、集群会出现脑裂问题吗。

对应主讲章节:[Sentinel 部署模型](/blog/tech/Redis/16.Sentinel 高可用#31-sentinel-部署模型)、[Sentinel 的四项核心职责](/blog/tech/Redis/16.Sentinel 高可用#32-sentinel-的四项核心职责)、[SDOWN:主观下线](/blog/tech/Redis/16.Sentinel 高可用#33-sdown主观下线)、[ODOWN:客观下线](/blog/tech/Redis/16.Sentinel 高可用#34-odown客观下线)、[完整故障转移过程](/blog/tech/Redis/16.Sentinel 高可用#37-完整故障转移过程)、[网络分区与脑裂](/blog/tech/Redis/16.Sentinel 高可用#39-网络分区与脑裂)。

面试短答:

Redis 高可用方案可以按能力层次来答:单机最简单,但有单点故障、单机吞吐瓶颈和单机内存瓶颈;主从复制可以把读流量分摊到副本,但主节点故障需要人工切换;Sentinel 在主从复制之上增加监控、通知、服务发现和自动故障转移,是单主多从架构的高可用控制面;如果还要解决容量和整体吞吐扩展,就要考虑客户端分片、Redis Cluster 或代理分片。

可以用这张表做面试总览:

方案解决的问题主要不足
单机 Redis部署简单、延迟低单点故障、容量和吞吐受单机限制
主从复制读扩展、副本冗余默认异步复制,无自动故障转移
Sentinel主从架构自动切换、服务发现不做分片,不能突破单主容量和写吞吐
Redis Cluster分片扩容、分片级故障转移跨槽限制、运维和客户端路由更复杂
客户端分片 / 代理分片自定义路由或屏蔽路由复杂度迁移、故障转移、代理高可用要自己设计

Sentinel 的核心职责是监控主从节点、故障告警、自动故障转移和配置发现。多个 Sentinel 共同判断主节点是否下线,选举一个 Leader 执行故障转移,把合适的副本提升为新主,并通知客户端更新主节点地址。客户端通常连接 Sentinel 获取当前主节点,而不是把某个 Redis 主节点 IP 写死。

Redis 会出现脑裂风险,尤其在网络分区下,旧主可能仍能被部分客户端访问并接受写入,而 Sentinel 在另一侧提升了新主。后续旧主恢复为副本时,旧主未同步到新主的数据可能丢失。

底层原理:

主从复制解决的是“读扩展和副本冗余”,但不等于自动高可用。一个副本执行 SLAVEOF / REPLICAOF <master-ip> <port> 后,会从主节点同步数据;读请求可以分担到副本,主节点故障时也有候选副本。但如果没有 Sentinel 或其他控制面,主挂了以后谁升级为新主、客户端连接哪个地址、旧主恢复后如何重新加入,都需要人工处理。

Sentinel 把这套人工切换流程自动化。它本身也应该多实例部署,通常至少 3 个 Sentinel,分布在不同故障域,避免单个 Sentinel 误判或自身故障影响切换。

Sentinel 判断下线分两层:

SDOWN: 单个 Sentinel 主观认为主节点不可达
ODOWN: 多个 Sentinel 达到 quorum,客观认为主节点下线

SDOWN 是主观下线,只代表某个 Sentinel 自己认为主节点不可达,可能是网络抖动或 Sentinel 自身问题。ODOWN 是客观下线,需要达到配置的 quorum。进入客观下线后,Sentinel 之间还要选举 Leader,由 Leader 选择一个合适的副本提升为新主,让其他副本改为复制新主,再通过发布订阅和服务发现把新主地址通知客户端。

这里要区分两个概念:quorum 用来判断 ODOWN,多数派用来决定是否能执行故障转移。只有两个 Sentinel 很危险,因为网络分区时很难安全获得多数派,也容易出现切换不可用或误判风险。

Sentinel 能提高可用性,但不保证零数据丢失。Redis 主从复制默认异步,主节点写成功后,如果还没同步给副本就故障,新主提升后这部分写可能丢失。Sentinel 解决的是“尽快恢复服务”,不是把 Redis 变成强一致存储。

缓解脑裂的常见手段:

  • 至少三个 Sentinel,分布在不同故障域;
  • 设置合理的 down-after-milliseconds,避免抖动导致误判;
  • 使用 min-replicas-to-write / min-replicas-max-lag 限制孤立主继续写;
  • 客户端通过 Sentinel 服务发现,不硬编码旧主;
  • 关键写入使用幂等、版本、补偿和对账。

追问边界:

  • Sentinel 提高可用性,不保证零数据丢失。
  • RTO 取决于检测、选举、提升、重配置和客户端重连。
  • RPO 取决于异步复制延迟和故障窗口。
  • 主从复制本身偏读扩展和冗余,不负责自动故障转移。
  • Sentinel 适合单主多从自动切换,不解决单实例内存容量上限和单主写吞吐上限。
  • 高可用方案需要压测和故障演练,确认 Sentinel 数量、故障域、超时参数、客户端重连和业务降级是否符合目标。

21.20 Redis Cluster、槽定位、集群策略与 Sentinel 区别

合并来源:Redis 集群实现原理、集群策略、Cluster 与 Sentinel 区别、如何根据键定位节点、高可用方案。

对应主讲章节:[Redis Cluster 的目标与拓扑](/blog/tech/Redis/17.Redis Cluster 分片与高可用#31-redis-cluster-的目标与拓扑)、[16384 个 Hash Slot](/blog/tech/Redis/17.Redis Cluster 分片与高可用#32-16384-个-hash-slot)、[为什么不使用经典一致性哈希](/blog/tech/Redis/17.Redis Cluster 分片与高可用#33-为什么不使用经典一致性哈希)、[Hash Tag](/blog/tech/Redis/17.Redis Cluster 分片与高可用#34-hash-tag)、[MOVED 与 ASK](/blog/tech/Redis/17.Redis Cluster 分片与高可用#35-moved-与-ask)、[多 Key、事务与 Lua 的同槽限制](/blog/tech/Redis/17.Redis Cluster 分片与高可用#310-多-key事务与-lua-的同槽限制)。

面试短答:

分片方案解决的是单机容量和整体吞吐瓶颈。早期常见做法是客户端分片:客户端按哈希或一致性哈希决定 Key 落在哪个 Redis 实例,每个 Redis 实例彼此独立。它实现简单、扩展直观,但节点增删会导致大量 Key 迁移,客户端配置和连接管理复杂,也不自带故障转移。

Redis Cluster 是 Redis 官方的服务端分片方案,通过 16384 个 Hash Slot 做分片。Key 先计算 CRC16,再对 16384 取模得到槽位,槽位归属于某个主节点。客户端可以缓存槽位表,直接把命令发到目标节点;如果发错,服务端返回 MOVED 或迁移中的 ASK 重定向。

Sentinel 解决的是“单主复制架构下主节点故障自动切换”;Cluster 解决的是“数据分片 + 每个分片副本高可用”。Sentinel 不做水平分片,Cluster 不提供跨槽事务强一致。代理分片则把路由逻辑放到代理层,客户端更无感,但代理本身会引入额外跳转、运维和高可用问题。

底层原理:

几种集群策略的差异可以这样落地:

策略路由在哪里扩容方式故障转移
主从 + Sentinel客户端通过 Sentinel 发现主节点不分片,主要靠升配或拆业务Sentinel 自动提升副本
客户端分片客户端 SDK / 业务代码调整哈希规则并迁移数据客户端自己处理或接入外部 HA
Redis ClusterRedis Cluster 槽位表 + 客户端缓存迁移槽位,重新分布 16384 个槽Cluster 内部投票提升副本
代理分片Proxy代理层调整路由和迁移代理和后端 Redis 都要 HA

客户端分片的核心是“路由在客户端”。比如客户端维护一组 Redis 节点,对 Key 做哈希取模,把不同 Key 写到不同实例。优点是服务端实例彼此独立,理解和部署都简单;缺点也来自客户端路由:扩容缩容时取模基数变化,Key 要重新分布;每个客户端都要感知节点列表;连接池要面对多个分片;某个分片故障时,客户端要自己处理失败、降级或切换。

槽定位:

slot = CRC16(key) % 16384
slot -> node

如果 Key 中包含 Hash Tag,比如 order:{1001}:itemspay:{1001}:status,只对 {1001} 计算槽位,这样多个 Key 可以被放到同一个槽,支持同槽事务、Lua 或多 Key 命令。但 Hash Tag 过粗会制造热点槽。

Cluster 不使用经典一致性哈希,主要是为了让槽位迁移、拓扑管理、位图传播和客户端路由更简单可控。固定槽位表便于标记每个槽属于哪个节点,也便于迁移时处理 MOVED / ASK

Cluster 的每个主分片通常配置一个或多个副本。主节点故障后,Cluster 节点通过集群总线和投票机制选择副本提升为新主,所以 Redis Cluster 自身就包含分片级故障转移能力,通常不再用 Sentinel 来管理 Cluster 内部主从切换。

Cluster 节点之间会通过 cluster bus 交换拓扑、心跳、故障状态和槽位信息,默认 bus 端口通常是客户端端口加 10000。客户端第一次可能连到任意节点,但最终要学习并缓存 slot -> node 的映射。MOVED 表示槽位已经归属到别的节点,客户端应更新本地槽位表;ASK 常出现在槽迁移过程中,表示这次请求临时去目标节点执行,但不一定代表槽位归属已经永久变化。

扩容缩容的核心动作不是直接“移动 Key”,而是先迁移槽位,再把槽位中的 Key 逐步迁过去。迁移期间会出现源节点 MIGRATING、目标节点 IMPORTING 的中间状态,客户端和服务端靠 ASKING / ASK 协议完成过渡。

代理分片的思路是把分布式路由放到代理层:客户端只连代理,代理决定 Key 去哪个 Redis 分片。这样客户端改造小,服务端升级或迁移对业务更透明;代价是代理层要做高可用、容量规划和故障隔离,而且多一跳网络转发。

追问边界:

  • Cluster 不是强一致系统,主从复制仍是异步。
  • 跨槽多 Key 命令会报 CROSSSLOT,需要 Hash Tag 或业务拆分。
  • 分片可以解决容量和整体吞吐,不会自动解决单个 HotKey。
  • Cluster 默认只使用 0 号数据库,不支持像单机 Redis 那样切换多个逻辑 DB。
  • 槽迁移、扩容缩容和客户端重定向都要验证,不能只看“能自动分片”。
  • 客户端分片、Cluster、代理分片都要回答路由、扩容、故障转移和数据迁移四件事。

21.21 客户端选型、源码巧思与面试收束

合并来源:项目中使用的 Redis 客户端、Redis 源码中有哪些巧妙设计。

对应主讲章节:[四种常用客户端](/blog/tech/Redis/02.使用 Go 操作 Redis#32-四种常用客户端)、[连接池不是越大越好](/blog/tech/Redis/02.使用 Go 操作 Redis#34-连接池不是越大越好)、go-redis:连接、超时、重试与可观测性、[Redis 核心对象与底层数据结构](/blog/tech/Redis/11.Redis 核心对象与底层数据结构#6-底层实现)。

面试短答:

如果项目用 Go,常见回答是 go-redis/v9。它的 Client 可以被多个 goroutine 并发复用,内部有连接池。生产中要关注 ContextDialTimeoutReadTimeoutWriteTimeoutPoolTimeout、重试、TLS、ACL、客户端名称、OpenTelemetry 和连接池指标。不要每个请求创建一个 Redis Client。

源码巧思可以举这些例子:SDS 的显式长度和分级头部、对象编码把逻辑类型和内存表示解耦、dict 渐进式 rehash、quicklist 折中链表和连续内存、ZSet dict + skiplist 双索引、过期和淘汰使用采样近似算法、事件循环和 I/O 多路复用、fork + COW 生成快照。

底层原理:

客户端侧常见事故不是 Redis 本身慢,而是应用把连接池用错:

  • 每个 HTTP 请求创建新 Client,导致连接风暴;
  • PoolSize 过小,出现 PoolTimeout
  • PoolSize 过大,把 Redis 打到排队和上下文切换;
  • 对非幂等命令自动重试,造成重复扣减或重复计数;
  • Context 超时后误以为命令一定没执行。

源码设计的主线可以总结为:Redis 一直在用工程折中换取稳定延迟。小对象用紧凑编码节省内存,大对象切换结构保证复杂度;命令主线程串行减少锁竞争,I/O Threads 只分担网络瓶颈;LRU/LFU 采用近似采样,避免维护全局精确状态的巨大成本。

一分钟收束回答:

Redis 面试不要把知识点背成散点。更好的主线是:Redis 先用内存、事件循环、单线程命令执行和紧凑数据结构获得低延迟;再用丰富数据类型解决缓存、计数、排行榜、队列、统计、限流和锁;当进入生产后,真正的难点变成 Key 设计、TTL、BigKey、HotKey、缓存一致性、持久化、复制延迟、故障切换、Cluster 同槽约束和客户端重试幂等。能把这些问题连成一条链,基本就不是只会背八股了。