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 | 文档、搜索、指标、布隆过滤器、向量检索 |
如果面试官问“九大数据结构”,常见口径是:五个基础结构 String、Hash、List、Set、Sorted Set,再加上 Bitmap、HyperLogLog、GEO、Stream 这类高阶能力。有些资料也会把 Bloom Filter 算进“九大”,但严格说 Bloom Filter 在 Redis 历史上更多来自 RedisBloom / Redis Stack 模块,Redis 8 开始才把概率结构作为扩展能力统一到官方产品线里,所以答题时最好说明版本和模块边界。
底层原理:
Redis 的“数据类型”有两个层次。第一层是对外命令语义,比如 HGET、LPUSH、ZADD;第二层是内部编码,比如 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 常见编码包括 int、embstr、raw。如果值能表示为整数,可能使用 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/RPOP 或 RPUSH/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:一个事件循环同时管理很多连接,底层可用 epoll、kqueue、select 等多路复用机制发现哪些 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 已经过期”不是一回事。常见策略包括 noeviction、allkeys-lru、volatile-lru、allkeys-lfu、volatile-lfu、allkeys-random、volatile-random、volatile-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 与热 Key、Redis 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 有很多分片,一个明星商品详情、全站配置、热门排行榜也可能集中到一个槽位和一个主线程。
排查路径建议这样回答:
- 先看现象:p99、CPU、网络、连接池、慢命令、输出缓冲区。
- 查命令:
SLOWLOG、INFO commandstats、延迟事件。 - 查 Key:
MEMORY USAGE、SCAN抽样、redis-cli --bigkeys、热点采样工具或 Redis 8.6+HOTKEYS。 - 定治理:拆分 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 如何保证数据一致。
面试短答:
缓存与数据库很难做到严格强一致,因为 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,会阻塞服务,生产慎用 |
BGSAVE | fork 子进程生成 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 与 backlog、PSYNC:决定全量还是增量、全量同步过程、复制延迟与读副本旧数据。
面试短答:
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 Cluster | Redis Cluster 槽位表 + 客户端缓存 | 迁移槽位,重新分布 16384 个槽 | Cluster 内部投票提升副本 |
| 代理分片 | Proxy | 代理层调整路由和迁移 | 代理和后端 Redis 都要 HA |
客户端分片的核心是“路由在客户端”。比如客户端维护一组 Redis 节点,对 Key 做哈希取模,把不同 Key 写到不同实例。优点是服务端实例彼此独立,理解和部署都简单;缺点也来自客户端路由:扩容缩容时取模基数变化,Key 要重新分布;每个客户端都要感知节点列表;连接池要面对多个分片;某个分片故障时,客户端要自己处理失败、降级或切换。
槽定位:
slot = CRC16(key) % 16384
slot -> node
如果 Key 中包含 Hash Tag,比如 order:{1001}:items 和 pay:{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 并发复用,内部有连接池。生产中要关注 Context、DialTimeout、ReadTimeout、WriteTimeout、PoolTimeout、重试、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 同槽约束和客户端重试幂等。能把这些问题连成一条链,基本就不是只会背八股了。