Redis 高频面试题系统梳理:从数据结构、持久化到集群与分布式锁

Redis 高频面试题系统梳理:从数据结构、持久化到集群与分布式锁

本文面向中高级 Java 开发面试,不把 Redis 当作一份需要死记硬背的题目清单,而是沿着性能、数据模型、内存、数据安全、高可用、工程治理六条主线,由内到外分析:为什么低延迟、数据结构如何取舍、持久化和复制会留下什么故障窗口、Sentinel 与 Redis Cluster 如何改变可用性边界,以及缓存一致性、分布式锁和线上排查怎样形成工程闭环。阅读时始终追问三件事:它保证什么、依靠什么机制、在哪些边界会失效。

一、知识地图:从六条主线建立全局认识

mindmap root((Redis 面试知识地图)) 性能 内存访问 事件循环与 IO 多路复用 命令串行执行 RESP 与 Pipeline 阻塞边界 数据模型 String List Hash Set ZSet Bitmap HLL GEO Stream 内存 SDS listpack quicklist intset hash table 与渐进式 rehash skiplist 加字典 大 Key 与碎片 数据安全 RDB AOF 复制与故障窗口 高可用 主从 Sentinel Redis Cluster 分片与故障转移 工程治理 缓存一致性 穿透击穿雪崩 分布式锁 监控容量与热 Key

读图方法是:先回答“它保证什么”,再说明“用什么机制实现”,最后补上“在哪些边界会失效”。这比从“Redis 是单线程所以快”开始更接近中高级面试的期待。

二、为什么快与线程模型:不是某一个原因,而是一条短路径

核心结论

Redis 的低延迟来自一组相互配合的设计:热数据主要在内存中访问;数据结构按规模选择紧凑编码;基于事件循环和 IO 多路复用管理大量连接;核心命令通常由主线程顺序执行,减少共享数据上的锁竞争和线程切换;RESP 协议解析路径相对直接,Pipeline 又能摊薄网络往返。它“通常很快”,不代表任何命令、任何数据规模下都不会阻塞。

原理拆解

  1. 内存访问是前提,不是全部答案。 读写主要避开磁盘随机 IO,但仍可能受到 CPU、内存带宽、NUMA、Swap、网络和持久化 fork 等因素影响。
  2. 紧凑结构同时节省空间和访问成本。 小 Hash、ZSet 等可采用 listpack,小整数 Set 可采用 intset;连续内存减少对象、指针和分配器开销,也通常具有更好的 CPU Cache 局部性。超过阈值后会转换为更适合大规模查询的通用编码。
  3. 事件循环避免“一连接一线程”。 IO 多路复用让少量线程监听许多 socket 的就绪事件,只有就绪连接才进入实际读写处理,避免大量阻塞线程带来的调度成本。
  4. 核心命令串行执行简化并发控制。 常规命令的数据访问主要由主线程按序执行,单条命令天然不会与另一条普通命令并行改写同一份数据。代价是:慢命令会占住执行通道,让其他客户端排队。
  5. 协议和批量化缩短请求路径。 客户端将命令编码为 RESP,经 TCP 发送;服务端读取、解析、查找命令、操作数据、编码响应。一次请求的成本还包含客户端序列化、网络 RTT、系统调用和响应拷贝。Pipeline 可一次发送多条命令、稍后批量读取响应,主要减少 RTT 与读写系统调用次数,但不会降低每条命令本身的计算复杂度,也不等同于事务。

Redis 不是“只有一个线程”

准确表述应是:Redis 进程会使用多个线程或后台任务,但常规命令对核心数据的执行模型主要是单线程串行。 较早版本已经会将部分磁盘 IO、异步释放等工作交给后台线程;Redis 6 又引入可配置的网络 IO 多线程,但相关配置必须拆开理解。以 Redis 6.2.14 的官方配置说明为例:io-threads NN > 1)启用 IO 线程后,默认先把响应缓冲区写入 socket 的工作分摊出去;只有再配置 io-threads-do-reads yes,才会让 IO 线程也参与 socket 读取与 RESP 协议解析。无论是否开启线程化读取,解析完成后的命令分派和核心数据操作仍回到主线程串行执行。因此 Redis 6 的改进重点是网络 IO 吞吐,并没有把普通命令改成可并行修改数据的模型。

flowchart LR C[多个客户端<br/>RESP 请求] --> S[socket 就绪事件] S --> M[IO 多路复用<br/>主事件循环协调] M --> R{io-threads > 1 且<br/>io-threads-do-reads yes?} R -->|是| IR[IO 线程并行<br/>socket 读取与协议解析] R -->|否| MR[主线程完成<br/>socket 读取与协议解析] IR --> P[读取阶段汇合] MR --> P P --> E[主线程顺序分派并执行<br/>核心命令与数据操作] E --> B[主线程生成响应缓冲区] B --> W{io-threads > 1?} W -->|是| IW[IO 线程并行<br/>把响应写入 socket] W -->|否| MW[主线程写入 socket]

图中最关键的边界是 E:IO 线程可以减轻网络收发压力,却不能替一个高复杂度命令“解除”主线程阻塞。

哪些操作会击穿单线程的低延迟假设

  • 大 Key: 一次传输、遍历、复制或删除大量元素,会占用 CPU、内存带宽和网络缓冲区;DEL 删除集合类对象还可能随元素数量增长。可拆分 Key,批量小步处理,删除优先评估 UNLINK 或 lazy-free 配置。
  • 复杂命令: KEYS *、大范围 HGETALL/SMEMBERS/ZRANGE、超大集合的交并差等,会随扫描或结果规模增长;生产环境不能使用 KEYS * 做常规遍历。生产遍历优先 SCAN 家族,但完整一轮依然是 O(N),并且不是某一时刻的快照:完整迭代可能重复返回元素;从迭代开始到结束始终存在的元素保证会被返回;期间新增、删除或短暂存在的元素是否返回则没有定义。调用方应能去重或保证重复处理安全。
  • 长 Lua/Function: 为保证原子执行,脚本执行期间会阻塞其他服务活动;原子不等于可以承载任意时长的业务逻辑。
  • 同步释放: 删除或过期一个包含海量元素的对象,内存回收本身可能很重。异步释放把回收工作移到后台,但命令语义、内存峰值和回收速度仍需监控。
  • 其他边界: AOF fsync 策略、RDB/AOF 重写时的 fork 与写时复制、Swap、慢客户端和过大的响应,也可能形成延迟尖峰。

一个可落地的诊断顺序是:先看 SLOWLOGLATENCY DOCTOR,再查命令复杂度、Key 大小、网络与客户端,最后结合持久化、内存和操作系统指标定位。使用 LATENCY DOCTOR 的前提是事先把 latency-monitor-threshold 配置为非零值,并且采样窗口覆盖故障时段;默认值为 0 时监控关闭,事后临时开启无法还原此前的延迟事件。

三、数据类型:从业务语义反推结构

选型总表

下表复杂度是典型量级,具体以命令文档和返回元素数量为准;“常见实现”描述主流 Redis Open Source 版本,内部编码可能随版本、配置和数据规模转换。

类型 业务场景 典型命令 常见内部实现 常见复杂度 使用边界
String 缓存对象、计数器、分布式锁令牌 SETGETINCRMGET Redis Object 的 int/embstr/raw 编码,字符串主体基于 SDS GET/SET/INCR 通常 O(1);范围返回与数据长度相关 单值最大容量仍受限制;大 JSON 更新一个字段也要整体序列化;锁必须校验令牌并设置过期时间
List 队列、栈、时间线两端操作 LPUSHRPOPLRANGEBLPOP quicklist,节点内使用 listpack(旧版本可能涉及 ziplist) 两端增删通常 O(1);索引/大范围读取 O(N) 不适合频繁随机访问;可靠消息、消费组与回溯需求优先考虑 Stream
Hash 用户对象、购物车、对象字段更新 HSETHGETHMGETHSCAN 小对象常用 listpack,变大后转 hash table 单字段访问平均 O(1);HGETALL O(N) 字段过多仍是大 Key;是否支持字段级过期要结合目标 Redis 版本,不能默认所有版本都有
Set 去重、标签、共同关注、抽样 SADDSISMEMBERSINTERSRANDMEMBER 纯小整数集合可用 intset;新版本小集合可用 listpack;通用形式为 hash table 增删查平均 O(1);集合运算与参与集合规模相关 大集合交并差可能阻塞;只需近似基数时 HLL 更省内存
ZSet 排行榜、延时任务、滑动窗口 ZADDZRANGEZRANKZPOPMIN 小集合常用 listpack;通用形式为 skiplist + dictionary ZADD/排名查询常见 O(log N),范围读取 O(log N+M) score 是双精度浮点数;同分按 member 字典序;大范围返回和大规模删除仍昂贵
Bitmap 签到、活跃状态、布尔标签统计 SETBITGETBITBITCOUNTBITOP 不是独立顶层类型,本质是在 String 上做位运算 单 bit 读写 O(1);计数/位运算与字符串长度相关 适合稠密、可映射为整数偏移的 ID;最大偏移会拉长字符串,稀疏超大 ID 可能浪费内存
HyperLogLog UV、去重规模估算 PFADDPFCOUNTPFMERGE 逻辑上是概率结构,在 Redis 中编码为 String PFADD、单 Key PFCOUNT 为 O(1);PFMERGE 为 O(N),N 是输入 HLL 数量 只返回近似基数,Redis 官方给出的标准误差为 0.81%、最坏内存约 12 KB;不能列举成员、判断单个成员是否存在
GEO 附近的人/店、范围检索 GEOADDGEOSEARCHGEODIST 基于 ZSet,将经纬度编码后的值用于有序索引 写入通常 O(log N);查询与候选和结果规模相关 地球模型、经纬度范围和精度有边界;member 唯一,复杂地理多边形检索不适合直接承担
Stream 事件流、消费组、待确认消息 XADDXREADGROUPXACKXPENDINGXTRIM 以 Redis 7.2.4 为例,radix tree(基数树)组织节点,节点内采用 listpack,并维护消费组元数据 XADD 通常 O(1);范围、消费与返回条目数相关 不是 Kafka 的透明替代品;需治理长度、PEL、重试、幂等、毒消息和消费者恢复

三组容易混淆的选择

List 还是 Stream? 只需要简单先进先出、阻塞弹出时 List 足够;需要多消费组、消息 ID、确认与待处理列表时选 Stream。Stream 提供的是消息处理基础设施,业务仍要自行保证幂等和失败补偿。

Set 还是 ZSet? 只关心唯一性、成员判断和集合运算用 Set;需要按分数排序、范围查找或弹出最小值用 ZSet。不要为了“以后可能排序”无条件选择 ZSet,它付出了额外的索引与内存成本。

精确去重还是近似统计? Set 能保留成员并精确判断,内存随成员数增长;HyperLogLog 以无法列举成员为代价,使用近似且有误差的基数统计。Bitmap 则适合 ID 域可控、相对稠密的布尔集合。

四、底层编码:数据类型不等于唯一数据结构

下面涉及源码结构的描述统一以 Redis 7.2.4 为例,用于解释设计思路,而不是承诺所有版本都采用完全相同的字段或编码。对外语义以命令文档为准,部署时应结合目标版本的 OBJECT ENCODING、配置与对应 tag 源码确认。

SDS:Redis 为什么不用普通 C 字符串承载所有语义

SDS(Simple Dynamic String)在字符缓冲区之外保存长度、容量等元数据。其价值可分为三层:第一,已记录长度,取长度通常是 O(1),不必扫描到 \0;第二,API 使用显式长度,因此可以保存包含零字节的二进制数据;第三,容量管理可以减少连续追加时的重复扩容,并在写入前检查空间,降低缓冲区越界风险。SDS 末尾仍保留 \0,便于复用部分 C 字符串接口,但不能由此误判它“不支持二进制”。

String 的 intembstrraw 是对象编码层面的选择,SDS 是字符串实现层面的基础,两者不是同一级概念。短字符串可能让对象头和 SDS 连续分配以减少分配次数;发生修改或长度变化后,具体编码可能转换。

紧凑结构:用少量 CPU 换更低内存

listpack 用连续字节序列紧凑存放小元素,省去大量指针和独立对象开销;小 Hash、ZSet,以及部分新版本的小 Set/List 都可能使用它。数据增大或元素超过配置阈值后,Redis 会转为通用编码。紧凑编码节省内存且局部性好,但中间插入、查找可能需要扫描或搬移,所以阈值不是越大越好。官方编码文档区分了版本:Redis 6.2 及以前的小型聚合结构可能使用 ziplist,Redis 7.0 起相关编码转向 listpack,Redis 7.2 起小 Set 也可使用 listpack;具体阈值由配置决定。

Redis 7.2.4 quicklist.h 为例,quicklist 是 List 的折中:用链式节点避免一整块超大连续内存,每个节点内部再用 listpack 保存多个元素。这样兼顾两端操作、压缩率和局部性。旧文章常说 List 是“双向链表 + ziplist”,面试时应主动补充版本背景。

intset 适合元素都是整数且数量较小的 Set,以有序紧凑数组保存;加入无法继续按该编码表示的成员或超过阈值后,会转换为其他编码。转换通常不可逆,不应假设删除元素后自动降级回来。

hash table 与渐进式 rehash

通用 Hash、Set 以及顶层键空间都依赖字典/哈希表。平均 O(1) 查询建立在散列分布合理、装载因子受控的基础上,并非最坏情况 O(1)。以 Redis 7.2.4 dict.c 为例,扩缩容时字典同时维护旧表和新表,用 rehashidx 标记迁移进度:常见查询或更新操作会顺带迁移少量桶,周期性维护也可按时间预算推进,直到旧表清空。这样避免在一个步骤中搬完全部桶造成长停顿。

rehash 期间,查询可能要检查两张表;新增通常落入新表;删除或更新需要正确处理元素所在表。它把一次长停顿摊成多次短工作,但不会让迁移成本消失,也不代表每一次请求耗时完全相同。

ZSet 为什么是跳表 + 字典

Redis 7.2.4 t_zset.c 为例,通用编码下,跳表按 (score, member) 维持顺序,适合 O(log N) 级别插入、删除、排名和范围定位;字典维护 member -> score,用于平均 O(1) 的成员查分与存在性判断。两份索引服务不同访问路径,写入时需要同步维护,因此 ZSet 比 Set 更耗内存。数据较小时可使用 listpack,以线性操作换取更低内存;达到配置阈值后再转换为通用编码。

常见误区

  • “一个类型永远对应一个结构”——错误。类型是对外语义,编码是内部实现,并会随版本、配置、元素数量和元素大小变化,可用 OBJECT ENCODING key 观察当前实例。
  • “渐进式 rehash 在独立线程完成”——不准确。核心思想是分步迁移,迁移可由字典操作及事件循环推进,不要把它等同于任意后台并行任务。
  • “跳表一定比平衡树快”——过度绝对。Redis 选择跳表与实现复杂度、范围遍历、维护成本等综合权衡有关;面试应解释需求匹配,而非宣称所有场景性能更优。

高频追问:可直接复述的分层回答

1. SDS 相比 C 字符串解决了什么?

一句话: SDS 给字节数组增加长度和容量元数据,让长度获取、二进制安全和扩容管理更适合数据库场景。

展开: 长度读取通常 O(1);显式长度允许内容包含 \0;写入前检查容量并按策略扩容,减少越界和频繁分配。末尾兼容 \0 不影响二进制安全。

边界: 不要把 SDS 与 embstr/raw/int 混为一谈,后者是 Redis Object 的编码策略;具体结构字段也会随版本演进。

2. 渐进式 rehash 如何保证查询正确?

一句话: 扩缩容期间同时保留新旧两张表,小步迁移桶,读操作按状态查询相关表,写操作配合迁移规则处理。

展开: 通过迁移游标记录进度;普通字典操作和事件循环逐步搬迁,新增通常进入新表,直到旧表清空后完成切换。

边界: 平均查询仍可保持常数级,但 rehash 会增加常数成本;“渐进式”只是削平长停顿,不是零成本。

3. ZSet 为什么同时需要跳表和字典?

一句话: 跳表解决排序、排名和范围查询,字典解决按 member 快速定位 score,两者服务不同访问路径。

展开: ZADD 同步维护两套索引;跳表按 score、同分再按 member 排序,字典提供平均 O(1) 成员查找。

边界: 小 ZSet 可能是 listpack,并非一创建就有跳表;双索引也意味着比普通 Set 更高的内存成本。

4. Redis 6 引入多线程后,命令会并行执行吗?

一句话: 常规核心命令仍主要由主线程串行执行,Redis 6 的 IO 线程主要分担网络 socket 读写。

展开: io-threads NN > 1)先启用线程化写出;io-threads-do-reads yes 才额外启用线程化读取与协议解析。读取阶段汇合后,命令分派和核心数据操作仍由主线程串行完成,因此网络成为瓶颈时吞吐可能提升,同时保留核心数据访问模型的简单性。

边界: 不能用一个“多线程开关”概括读写两条路径;io-threads-do-reads yesio-threads 未实际启用多线程时也不会凭空产生并行度。网络线程无法加速本身很慢的 O(N) 命令或长 Lua。

5. 单线程模型为什么还会阻塞?

一句话: 正因为核心命令串行,一个命令占用主线程过久,后续客户端就只能等待。

展开: 常见来源包括大 Key、全量遍历、集合运算、超大响应、长 Lua、同步释放,以及持久化和操作系统导致的延迟尖峰。

治理:SLOWLOG、延迟监控和 Key 采样定位;拆分数据,限制批量大小,以 SCAN 替代 KEYS,按场景使用 UNLINK,并为脚本设置明确的计算上限。

6. 哪些命令复杂度危险?能否只背 O(N)?

一句话: 危险不只看大 O,还要看 N 的含义、结果大小、调用频率和是否处在主线程路径。

展开: KEYS 扫全库;HGETALLSMEMBERS、大范围 ZRANGE 返回整个集合;SINTER 等复杂度受多个集合影响;DEL 删除集合对象的释放成本与元素数相关;Lua 还会把多条命令的总成本包在一个原子阻塞窗口里。

治理: 上线前查官方命令复杂度,设置返回上限,分页或游标迭代,避免在请求链路做无界计算。SCAN 单次通常较轻,但完整遍历仍是 O(N),不能被包装成“零成本”。

7. listpack、quicklist、intset 应如何区分?

一句话: listpack 是紧凑顺序容器,quicklist 是 List 的分段链式组织,intset 是纯整数小 Set 的专用编码。

展开: listpack 强调连续存储和低指针开销;quicklist 在节点内放紧凑块,兼顾两端操作与内存;intset 按整数宽度紧凑保存并保持有序。

边界: 它们都是内部编码,不是业务数据类型;实际采用哪一种要看 Redis 版本、阈值和当前数据内容。

8. 面试中怎样回答“Redis 为什么快”才完整?

建议用四层框架:先给结论——内存数据结构服务器,常见操作路径短;再讲机制——紧凑编码、事件循环、IO 多路复用、核心命令串行、RESP/Pipeline;然后澄清版本——Redis 6 可用网络 IO 多线程,但不等于命令并行;最后主动说边界——大 Key、O(N) 命令、长脚本、同步释放、网络与持久化都可能造成尾延迟。这样既回答“为什么快”,也证明你知道“什么时候不快”。

五、过期与淘汰:一个管生命周期,一个管内存上限

核心结论

过期删除由 TTL 驱动:键到了业务设定的过期时间就应失效。内存淘汰由 maxmemory 驱动:内存达到上限时,为给新数据腾空间而按 maxmemory-policy 选键删除。一个键即使没有 TTL,也可能被 allkeys-* 淘汰;一个已经逻辑过期的键,也可能尚未被后台周期立即清理,但访问时不会再作为有效值返回。

Redis 对过期键采用惰性删除(被访问时检查)+ 定期删除(周期抽样检查)。只靠惰性删除会让永不再访问的过期键占内存;若每次都全量扫描,又会阻塞主线程,因此定期任务采用分批、抽样和时间预算控制。

原理拆解

淘汰策略先看候选集合,再看集合中的排序规则:

策略 候选键 选择方式 适用边界
allkeys-lru 所有键 近似淘汰最近最少使用键 通用缓存,访问具有冷热分层
volatile-lru 仅设置 TTL 的键 近似 LRU 同实例混放缓存和不可淘汰数据;更建议条件允许时拆实例
allkeys-lfu 所有键 近似淘汰访问频率低的键 长期热点明显,偶发扫描不应污染热点
volatile-lfu 仅设置 TTL 的键 近似 LFU 仅希望在可过期数据内保护长期热点
allkeys-random 所有键 随机 访问概率接近,维护冷热价值不大
volatile-random 仅设置 TTL 的键 随机 过期键价值接近,策略成本优先
volatile-ttl 仅设置 TTL 的键 优先剩余 TTL 较短的键 应用能用 TTL 表达数据价值
noeviction 不删除键 可能增大数据集的写命令报错,读仍可服务 数据不能因缓存策略丢失,容量不足应显式暴露

allkeysvolatile 的关键区别不是“是否会过期”,而是淘汰候选集是否要求键带 TTL。当没有任何带 TTL 的键时,volatile-* 实际会退化为无法淘汰并拒绝相关写入。

Redis 的 LRU 不是维护一条全局精确双向链表,而是随机采样少量候选,结合候选池挑出更久未访问的键;可用 maxmemory-samples 在准确度和 CPU 开销间权衡。LFU 同样是近似算法,以小空间概率计数器估算频率,并随时间衰减,使历史热点不会永久霸占内存。近似算法的意义是:用少量元数据与有限 CPU,换取足够接近真实 LRU/LFU 的命中率。

示例:一次读取与一次写入分别发生什么

GET session:42
  -> 检查 TTL
  -> 已过期:惰性删除并返回 nil
  -> 未过期:返回值,并更新淘汰算法需要的访问信息

SET cache:item:9 value
  -> 执行写入使内存超过 maxmemory
  -> 根据 maxmemory-policy 选择候选键
  -> 删除候选,直至满足内存约束或无法继续
  -> noeviction / volatile 无候选时:相关写入返回错误

选型口诀不是“一律 LRU”:短期最近访问能预测未来时用 LRU;长期访问频率更可靠时用 LFU;业务已用 TTL 表达价值时考虑 volatile-ttl;纯缓存通常优先 allkeys-*;缓存与关键数据混放只是折中方案,不要把 volatile-* 当作真正的资源隔离。

常见误区

  • “键一到期就被后台线程瞬间删掉。”错。逻辑上已失效,物理内存回收可能由惰性或定期流程稍后完成。
  • “配置 TTL 就不会触发淘汰。”错。过期删除看时间,淘汰看内存压力,两条路径可以同时存在。
  • “Redis 用的是精确 LRU。”错。精确全局排序的内存与维护成本过高,Redis 使用近似采样。
  • volatile-lru 总能释放空间。”错。没有带 TTL 的候选键时,它无法淘汰。

高频追问(分层回答)

  1. 过期键为什么还可能占内存?
    • 基础层:Redis 不会为每个 TTL 都在到点时执行一次删除,而是惰性检查加定期抽样。
    • 深入层:这是主线程延迟、CPU 消耗和内存回收及时性的折中;观察时要区分“命令不可见”和“内存已回收”。
  2. LRU 为什么是近似的?调大采样一定好吗?
    • 基础层:精确 LRU 要持续维护全局顺序,元数据和操作成本高。
    • 深入层:增加采样通常更接近理论 LRU,但增加 CPU;应以命中率、延迟、evicted_keys 验证,而非盲目调大。
  3. LRU 和 LFU 怎么选?
    • 基础层:最近访问更重要选 LRU,长期热度更重要选 LFU。
    • 深入层:突发扫描可能把冷数据短暂“刷热”,LFU 对此通常更稳;热点快速变化时则要关注 LFU 衰减参数是否跟得上。
  4. 缓存和关键键混在一起,选 volatile-lru 就安全了吗?
    • 基础层:它能把无 TTL 键排除在候选集外。
    • 深入层:它不能解决容量争抢、故障域和误设 TTL;若过期候选不足仍会拒绝写,生产上优先拆分实例与容量配额。

回答框架

先说“两套触发条件”,再说“惰性+定期”,随后用“候选范围(allkeys/volatile)× 选择规则(LRU/LFU/random/TTL)”解释策略,最后落到业务命中率、可丢失性和容量隔离,通常就是一段完整的中高级回答。

六、RDB 与 AOF:快照和写日志如何取舍

核心结论

RDB 把某一时点的内存数据生成紧凑快照,恢复快、适合备份,但故障时可能丢失上次快照后的写入。AOF 追加记录写命令并在启动时重放,通常能把丢失窗口压得更小,但文件、IO 和恢复成本往往更高。二者都不是“开启即绝不丢数据”:数据安全取决于快照间隔、appendfsync、磁盘与备份、复制,以及故障类型。

RDB 原理拆解:fork、页表与 COW

BGSAVE 的核心不是把整个父进程内存复制一份再写盘,而是 fork 子进程后利用操作系统的 Copy-on-Write:

flowchart LR A[触发 BGSAVE] --> B[主进程 fork] B --> C[父子进程初始共享物理页<br/>页表映射被复制] C --> D[子进程遍历快照视图] D --> E[写临时 RDB 文件] E --> F[完成后原子替换旧 RDB] C --> G[父进程继续处理请求] G --> H{父进程修改共享页?} H -- 是 --> I[复制对应内存页<br/>父进程写新页] H -- 否 --> J[继续共享物理页]

fork 时主要复制页表等进程元数据,物理数据页起初共享,所以不是瞬间额外占用一份完整数据集。但数据集越大,页表越大,fork 本身越可能让主进程出现可观测停顿;CPU 紧张、虚拟化环境或内存压力大时更明显。

COW 的真实风险取决于快照期间的写入量与脏页比例。父进程改到共享页时,操作系统才复制相应页。若大对象频繁更新、写流量很高或子进程写盘很慢,快照持续更久、被修改的页更多,额外内存可能快速增长,甚至触发 swap 或 OOM。不能简单说“RDB 会占双倍内存”,更准确的表达是:理论风险可接近再复制大量数据页,实际增量由被写脏的共享页决定,还叠加页表、缓冲和碎片开销。

RDB 恢复时直接加载快照,恢复点就是该文件代表的时点。假设每 5 分钟生成一次快照,宕机可能丢失最近不足 5 分钟的数据;若上一次生成失败,窗口还会更大,所以还要监控 rdb_last_bgsave_status、快照时间与备份可恢复性。

AOF 原理拆解:追加、fsync 与重写

写命令先追加到 AOF 相关缓冲/文件路径,appendfsync 决定何时要求操作系统把数据同步到持久介质:

策略 同步语义 性能与丢失窗口
always 每批追加后执行 fsync,回复前等待 最保守但最慢;仍不能把硬件、文件系统或运维事故风险说成零
everysec 通常每秒 fsync 常用折中;灾难故障可能丢约 1 秒写入
no Redis 不主动 fsync,交给操作系统 吞吐较好但窗口由内核刷新策略决定,不应承诺固定秒数

AOF 重写不是“整理旧文件中的命令”,而是依据当前内存状态生成能重建同一数据集的最小命令集合。重写期间主进程仍接收写入,因此必须把“重写基准”之后的新写保留下来:Redis 7.0 及以后采用多段 AOF,子进程生成新的 base AOF,父进程打开新的 incremental AOF 持续记录增量,最后通过 manifest 原子切换;旧版本的经典实现则会同时写旧 AOF,并把增量放入重写缓冲,待子进程完成后追加到新文件。面试时最好先声明版本,避免把两套流程混为一谈。

“混合持久化”通常指 AOF 重写后的基准部分采用 RDB 格式,后面接 AOF 增量:兼顾 RDB 的紧凑/加载速度与 AOF 的较小数据丢失窗口。它不等于同时配置 RDB 和 AOF;后者是两个持久化机制并存,重启时 Redis 会优先使用信息更完整的 AOF 恢复。

RDB 与 AOF 对照

维度 RDB AOF
记录内容 某一时点的数据快照 可重放的写操作;新版本为 base + incremental + manifest
生成成本 fork、COW、顺序写快照 日常追加与 fsync;重写同样涉及 fork/COW
故障丢失窗口 上次成功快照之后 appendfsync 等决定,everysec 常见约 1 秒风险
文件与备份 紧凑单文件,适合归档 通常更大;Redis 7+ 需按 manifest 管理多段文件
恢复 大数据集通常更快 需加载 base 并重放增量,通常更慢
典型用途 备份、容许分钟级损失、快速恢复 更看重写入耐久性

常见误区

  • BGSAVE 完全不阻塞。”错。子进程写盘是后台的,但 fork 发生在主进程路径,可能造成停顿。
  • “COW 一定立刻占用两倍内存。”错。初始共享,写到哪些页才复制哪些页;高写入和慢磁盘会放大风险。
  • “AOF 一条命令都不会丢。”错。everysec 有窗口,no 更大;always 也不能覆盖介质损坏、错误操作、坏备份等所有风险。
  • “AOF 重写会阻塞客户端直到完成。”错。重写主体由子进程完成,但 fork、COW、磁盘竞争和最终切换仍可能影响延迟。

高频追问(分层回答)

  1. 为什么 fork 会卡顿?
    • 基础层:主进程要创建子进程并复制页表,期间不能正常推进命令处理。
    • 深入层:停顿与数据集/页表规模、CPU、内核、虚拟化环境有关;应监控 fork 耗时、慢日志/延迟,并避免超大单实例。
  2. COW 内存怎么估?
    • 基础层:估算持久化期间被修改的数据页,而不是直接按数据集大小翻倍。
    • 深入层:关注写速率、对象更新模式、脏页比例和快照时长;预留内存,避免 swap,治理大 Key,并缩短子进程落盘时间。
  3. AOF 重写期间的新写会丢吗?
    • 基础层:不会因为“新基准文件尚未完成”就直接丢弃,新写仍由父进程记录。
    • 深入层:Redis 7+ 写入新 incremental AOF,完成后与新 base 一起写入临时 manifest 并原子切换;旧版依赖旧 AOF 加重写缓冲。
  4. 线上选 RDB、AOF 还是两者都开?
    • 基础层:缓存可只做 RDB甚至不持久化;重要数据常用 AOF everysec,并保留 RDB 备份。
    • 深入层:选择必须从 RPO、RTO、峰值延迟、磁盘能力和演练结果出发;持久化不是远程备份,恢复文件也必须定期验证。
  5. 为什么不能说 AOF 绝不丢数据?
    • 基础层:不同 fsync 策略本就有不同窗口。
    • 深入层:即便 always,仍要考虑操作系统、存储设备、文件损坏、误操作和主从切换窗口,严格耐久性必须端到端设计。

回答框架

先用 RPO/RTO 给结论,再画 fork → 共享页 → 写时复制 → 子进程落盘;讲 AOF 时分开 writefsync,再声明 Redis 7+ 多段 AOF;最后说明“持久化、复制、异地备份、恢复演练”是不同层次,不能相互替代。

七、主从复制:能否增量取决于复制历史是否还对得上

核心结论

Redis 主从复制默认是异步的。主库处理写命令后把复制流发送给从库,并不等所有从库落地后才向客户端确认,因此从库可能延迟、读到旧数据,主库突发故障也可能丢失尚未复制的已确认写入。

断线重连不一定全量复制。是否能部分重同步取决于从库携带的 replication ID 与 offset 是否仍属于主库认识的复制历史,以及缺失的那段数据是否还保留在 replication backlog 中。

原理拆解

  • replication ID:标识一段数据集历史。实例从头成为主库,或从库晋升为主库时会产生新 ID;晋升节点还会保留旧历史相关信息,以提高故障切换后部分重同步的机会。
  • offset:复制流的字节位置,可理解为同一复制历史内的逻辑时间。相同 ID 下,offset 越小表示缺的复制流越多。
  • replication backlog:主库内存中的环形积压缓冲区,保存最近一段复制流。环形意味着旧数据会被覆盖;断线时间越长、写流量越大、backlog 越小,缺口越容易超出覆盖范围。
  • PSYNC:从库带上旧 ID 和已处理 offset 请求同步。历史匹配且缺口仍在 backlog,主库只补增量;否则进行全量同步。
flowchart TD A[从库连接或重连] --> B[发送 PSYNC<br/>replication ID + offset] B --> C{主库认识这段历史?} C -- 否 --> F[全量同步] C -- 是 --> D{缺失复制流仍在 backlog?} D -- 是 --> E[部分重同步<br/>发送缺失增量] D -- 否 --> F F --> G[主库生成/传输 RDB<br/>并缓冲期间新写] G --> H[从库加载 RDB] H --> I[继续发送缓冲命令流] E --> J[恢复在线命令流复制] I --> J

全量同步时,主库通常创建 RDB(也可使用无盘复制直接通过网络发送),同时缓存生成和传输期间的新写;从库接收并加载快照后,再应用缓冲的命令流。这个过程会消耗 CPU、内存、磁盘和网络,因此“复制风暴”往往比单次断连更危险:多个从库反复全量同步可能同时放大 fork、COW 和带宽压力。

异步复制决定了读写分离的边界:写刚在主库成功,马上从从库读,可能读到旧值或不存在。业务若要求 read-your-writes,可让关键读取回主库、在客户端短时间粘主,或基于 offset/确认机制做额外约束;不能只靠“从库很多”推导出强一致。

示例:为什么短断线也可能全量

主库当前 offset = 9 GB
从库断线位置      = 8 GB
backlog 可覆盖     = 最近 512 MB

缺口 1 GB > backlog 覆盖范围
=> 即使 replication ID 匹配,也无法补齐,必须全量同步。

反过来,长断线在低写入场景也可能仍能增量。决定因素是“缺失复制流字节量能否被 backlog 覆盖”,不是只看断线秒数。

常见误区

  • “从库重连一定全量。”错,优先尝试 PSYNC 部分重同步。
  • “ID 一样就一定增量。”错,缺失片段还必须存在于 backlog。
  • “写成功说明至少一个从库已收到。”错,默认异步复制不提供这个承诺。
  • “读从库天然线性扩展且结果一致。”错,从库可能延迟,且热点、网络、全量加载都会影响读服务。

高频追问(分层回答)

  1. 断线后到底何时全量、何时增量?
    • 基础层:ID/历史匹配且 offset 缺口仍在 backlog 就增量,否则全量。
    • 深入层:故障切换后新主可借助主/次复制 ID 延续旧历史;但 backlog 被覆盖、未知历史或特定重启恢复条件不满足时仍会全量。
  2. backlog 越大越好吗?
    • 基础层:更大能覆盖更长的“写入字节窗口”,减少全量同步。
    • 深入层:它占主库内存;应根据峰值写带宽 × 目标断线容忍时长估算,并结合网络抖动和容量余量设置。
  3. 为什么从库会读到旧数据?
    • 基础层:复制流传输和执行需要时间,主库不会默认等它追上再回复。
    • 深入层:延迟来自网络、从库事件循环阻塞、加载 RDB、慢命令和资源争用;一致性敏感读应走主库或建立业务级读屏障。
  4. 复制能替代持久化吗?
    • 基础层:不能,错误删除和错误写也会复制到从库。
    • 深入层:无持久化主库重启为空时存在把空数据传播给从库的风险;复制解决副本可用性,持久化与备份解决不同故障模型。

回答框架

用“ID 定历史、offset 定位置、backlog 定能否补齐”回答同步;再讲全量流程与资源成本;最后主动指出异步复制带来的延迟读和数据丢失窗口,答案就不只停留在命令层面。

八、Sentinel:给非 Redis Cluster 主从架构补上故障发现与自动切换

核心结论

Sentinel 不负责数据分片,它为一组主从 Redis 提供监控、通知、自动故障转移和客户端服务发现。故障转移不是“一个 Sentinel 觉得主库挂了就切”:先有单节点的主观下线(SDOWN),再由达到 quorum 的 Sentinel 形成客观下线(ODOWN);真正执行切换还需要某个 Sentinel 获得多数 Sentinel 授权,成为本轮 failover Leader。

quorum 与“多数派授权”不是同一个数字概念:前者决定多少 Sentinel 的故障判断足以触发 ODOWN,后者约束谁有权执行故障转移。5 个 Sentinel、quorum=2 时,2 个可触发 ODOWN,但执行者仍至少需要 3 个 Sentinel 的授权。

原理拆解

flowchart TD A[Sentinel 持续 PING 主库] --> B{单个 Sentinel<br/>超时未获有效响应?} B -- 是 --> C[该 Sentinel 标记 SDOWN] C --> D[向其他 Sentinel 询问判断] D --> E{同意数达到 quorum?} E -- 否 --> A E -- 是 --> F[主库进入 ODOWN] F --> G[候选 Sentinel 请求投票] G --> H{获得所需多数派授权?} H -- 否 --> G H -- 是 --> I[Leader 选择合适从库] I --> J[向该从库发送<br/>REPLICAOF NO ONE] J --> K[其余从库改为复制新主] K --> L[传播更高配置纪元] L --> M[客户端向 Sentinel 查询新主地址]

SDOWN 是本地判断;ODOWN 只用于主库的自动故障转移判断。Leader 选出新主时先排除长期断线等不可靠从库,再按 replica-priority(数值越小越优先,0 表示永不晋升)、已处理 replication offset(越新越优先)、run ID 等规则确定顺序。切换后,其他从库执行 REPLICAOF 指向新主;旧主恢复也会被重配置为新主的从库。

客户端不能把主库 IP 写死。支持 Sentinel 的客户端应配置多个 Sentinel 地址,通过服务名查询当前主库;断线后重新发现并重连。Sentinel 给出的是配置发现能力,不会替应用自动保证请求重试的幂等性,切换窗口中的超时/未知结果仍需业务处理。

部署与故障域

健壮部署至少 3 个 Sentinel,并放在独立故障域,例如不同物理机或可用区。把 3 个进程放在同一台机器只能满足“进程数”,不能抵抗宿主机或机房故障;使用 2 个 Sentinel 时,一旦一边故障,剩余一方无法获得多数授权。多数派是控制面安全的基础,故障域决定这个多数派在真实故障中是否还活着。

Sentinel 仍基于异步主从复制,所以切换可能丢失尚未到达新主的已确认写。min-replicas-to-writemin-replicas-max-lag 可让主库在健康从库数量不足或延迟过大时拒绝写,从而缩小旧主在隔离区持续接收写的风险;代价是牺牲可用性,而且异步确认粒度决定它只能降低风险,不能提供强一致或零丢失保证

常见误区

  • quorum=2 就代表只需 2 票完成所有步骤。”错,ODOWN 判断和 failover 授权是两个阶段。
  • “Sentinel 越多越好,放哪都一样。”错。数量增加还会改变多数派要求;共故障域部署没有真正冗余。
  • “Sentinel 代理所有客户端流量。”错。客户端通常直连 Redis,Sentinel 提供发现与通知。
  • “切换成功说明旧主上的所有写都在新主。”错。底层仍是异步复制。

高频追问(分层回答)

  1. 为什么生产至少 3 个 Sentinel?
    • 基础层:多数派系统需要在容忍 1 个故障时仍有 2 个节点形成多数。
    • 深入层:还要跨独立故障域;3 个 Sentinel 同宿主机、2 个 Sentinel 分两侧,都可能在真实分区下失去可用或安全的授权条件。
  2. SDOWN 和 ODOWN 有什么区别?
    • 基础层:SDOWN 是单个 Sentinel 的本地判断,ODOWN 是达到 quorum 后的共同判断。
    • 深入层:ODOWN 触发 failover 流程,但 Leader 还必须取得多数派授权,借助配置纪元保证不同切换有序传播。
  3. 新主怎么选?
    • 基础层:排除不可靠从库,依次比较优先级、复制进度和稳定的最终排序条件。
    • 深入层:replica-priority=0 永不晋升;相同优先级优先 offset 更大者,减少潜在数据缺口,最后用 run ID 使结果确定。
  4. Sentinel 切换时客户端会怎样?
    • 基础层:旧连接失败,客户端向 Sentinel 查询新主并重连。
    • 深入层:请求可能超时但服务端已执行,业务需幂等、限次重试和连接池刷新;不要把发现新地址误认为请求级 exactly-once。
  5. min-replicas-* 能防止数据丢失吗?
    • 基础层:只能降低概率,在副本不足/延迟过大时让主库拒写。
    • 深入层:检测与 ACK 是有时间粒度的,复制仍异步;配置越严越安全但越容易因网络抖动牺牲可用性。

回答框架

按“SDOWN → quorum 达成 ODOWN → 多数派授权 Leader → 选择从库 → 重配拓扑 → 客户端重新发现”回答,然后补一句“异步复制、故障域和 min-replicas-* 的边界”,即可覆盖 Sentinel 的控制面与数据面。

九、Redis Cluster:用 Slot 分片,用 Gossip 维护拓扑

核心结论

Redis Cluster 把键空间划分为 16384 个 Hash Slot,每个主节点负责一部分 Slot。普通键的槽位计算是 CRC16(key) mod 16384;Hash Tag 允许取键名中满足规则的 {...} 部分参与计算,让多个键进入同一 Slot,例如 order:{42}:headerorder:{42}:items

Redis Cluster 同时解决水平分片与分片内高可用,但它不是强一致系统。主从仍采用异步复制,已向客户端确认但尚未复制的写可能在主节点故障或网络分区时丢失。min-replicas-to-write / min-replicas-max-lag 只能通过拒写缩小风险窗口,不能把 Redis Cluster 变成同步复制或共识数据库。

原理拆解:路由、重定向与迁移

Redis Cluster-aware 客户端通常缓存 slot → node 映射并直连目标节点。节点不代理普通请求;客户端找错节点时收到重定向:

  • MOVED slot endpoint:该 Slot 的稳定归属已经是另一个节点。客户端应把本次请求发到新节点,并更新槽位缓存;实际客户端常重新拉取完整拓扑。
  • ASK slot endpoint:Slot 正在迁移,本次请求临时去目标节点;客户端先发送 ASKING,再发送原命令,不要永久更新 Slot 映射。

迁移 Slot 时,源节点设为 MIGRATING,目标节点设为 IMPORTING。源节点继续服务仍存在于本地的键;若键已不在源节点,则返回 ASK,引导本次到目标节点。工具逐批使用 MIGRATE 搬键,全部完成后再把 Slot 所有权稳定切给目标节点,此后客户端会收到 MOVED。

稳定期:客户端 -> 节点 A(Slot 8 的所有者)

迁移期:
  键仍在 A:A 直接处理
  键已到 B:A 返回 ASK -> 客户端向 B 发送 ASKING + 原命令

完成后:
  A 返回 MOVED -> 客户端更新 Slot 8 -> B 的路由

因此 MOVED 是“以后都去那里”的拓扑更新信号,ASK 是“只有这一次先去那里”的迁移过渡信号。

多 Key、Hash Tag 与热点边界

原生跨 Key 操作、事务和 Lua 脚本通常要求涉及的键位于同一 Slot,否则会得到 CROSSSLOT。Hash Tag 能把相关键放入同槽:

cart:{user42}:items
cart:{user42}:coupon
lock:{user42}

这三个键以 {user42} 计算槽位,可以执行同槽多 Key 操作。但 Hash Tag 不是越多越好:如果大量键都用同一标签,会把流量和内存压到单个 Slot/单个主节点,破坏分片均衡。设计时应以“需要原子操作的最小聚合边界”选标签。

Gossip、故障检测与主从切换

Redis Cluster 节点通过独立的 Redis Cluster Bus 形成全连接,并用 Gossip 在心跳消息中传播随机节点的状态、槽位与配置纪元等信息。单节点先把超时不可达标为 PFAIL;当收集到多数主节点在有效时间窗口内的失败报告后升级为 FAIL。若故障主节点有从库,符合条件的从库发起选举,获得多数主节点授权后晋升,接管原主负责的 Slot。没有可晋升从库时,相关槽位不可服务,集群可能进入错误状态并拒绝请求。

这套机制追求可扩展和可用,但不提供强一致:

  1. 主库可能先回复客户端,再把写送达从库;此时主库故障会丢已确认写。
  2. 网络分区少数侧在 NODE_TIMEOUT 窗口内可能继续接收写;多数侧完成切换后,少数侧旧主数据最终被新拓扑覆盖。
  3. 少数主节点一侧超过超时时间会停止接受写,限制风险窗口,但这是可用性与写安全的折中,不是零窗口。

常见误区

  • “16384 是节点上限,所以应建上万节点。”错。它首先是固定槽位数量;官方规格建议的节点规模远小于槽数。
  • “ASK 和 MOVED 都要刷新永久路由。”错。ASK 只影响下一次请求,MOVED 表示稳定归属变化。
  • “Hash Tag 能支持任意跨节点事务。”错。它只是主动让相关键落在同一个 Slot。
  • “有从库和多数派选举就是强一致。”错。故障检测有多数参与,不等于每次数据写入经多数同步确认。

高频追问(分层回答)

  1. 为什么是 16384 个 Slot?
    • 基础层:Redis Cluster 用固定槽位做键到分片的中间层,迁移的是 Slot 中的键,而不是重算所有键到节点。
    • 深入层:槽位位图还会随集群消息传播,数量是在分片粒度、元数据和网络开销间的工程权衡;不要把 Slot 数等同于推荐节点数。
  2. MOVED 和 ASK 到底怎么处理?
    • 基础层:MOVED 重试并更新路由;ASK 先发 ASKING,只重试本次,不更新永久映射。
    • 深入层:ASK 对应 MIGRATING/IMPORTING 过渡状态,错误地缓存会让后续仍在源节点的键来回重定向。
  3. 跨 Slot 多 Key 怎么办?
    • 基础层:用 Hash Tag 把需要一起操作的键放同槽,或在应用层拆分操作。
    • 深入层:应用层拆分会失去跨槽原子性;Hash Tag 需防热点。若业务天然需要大范围跨分片事务,应重新评估数据模型或产品选型。
  4. Redis Cluster 是强一致吗?为什么?
    • 基础层:不是。主从异步复制存在确认写尚未到从库就发生故障的窗口。
    • 深入层:Gossip 多数派用于故障判定和主从晋升,不是每条写的共识提交;分区下采用最终胜出的主库数据,少数侧写可能丢失。
  5. min-replicas-* 是否能让 Redis Cluster 强一致?
    • 基础层:不能,只能在副本数量或延迟不满足时拒写。
    • 深入层:它按副本在线/ACK 新鲜度做风险控制,不是同步 quorum commit;阈值更严会提高拒写概率,仍需按 RPO 与可用性权衡。
  6. Redis Cluster 扩容为什么可能抖动?
    • 基础层:扩容需要搬迁 Slot 内的键,消耗网络和 CPU,大 Key 迁移还会产生延迟。
    • 深入层:应分批限速、避开高峰、先治理大 Key,并确保客户端正确处理 ASK/MOVED;迁移完成后再确认 Slot 归属收敛。

回答框架

按“key → CRC16/Hash Tag → Slot → 主节点”讲路由;用一次 Slot 迁移区分 ASK/MOVED;再讲 Gossip、PFAIL/FAIL 与从库选举;最后明确“故障判断的多数派不等于写入的强一致”,这是 Redis Cluster 面试题的得分点。

十、架构选型:主从、Sentinel 与 Redis Cluster 怎么选

维度 主从复制 Sentinel Redis Cluster
核心目标 数据副本、读扩展 非分片主从的监控、发现、自动切换 数据分片 + 分片内高可用
数据分片 不支持 不支持 支持,16384 Slot
自动故障转移 单纯主从不负责 Sentinel Leader 编排 Redis Cluster 节点内建检测与选举
客户端能力 知道主从地址/读写策略 支持 Sentinel 服务发现 支持 Slot 路由、MOVED、ASK
一致性 异步复制,可读旧/丢窗口写 底层仍异步复制 异步复制,非强一致
多 Key 限制 单实例内正常使用 单实例内正常使用 通常要求同 Slot,可用 Hash Tag
典型场景 只读副本、手工或外部编排 数据量单机可承载但要求自动高可用 容量/吞吐需水平扩展
主要风险 延迟读、全量同步压力、手工切换 控制面故障域、切换窗口、客户端重连 跨槽限制、迁移抖动、热点 Slot、分区写丢失

选型顺序应是:先问单机容量和吞吐是否够;够但要自动切换,考虑 Sentinel;不够且数据模型能接受 Slot 约束,考虑 Redis Cluster。无论哪一种,只要依赖异步复制,就不能把“有副本”直接表述成“强一致、不丢数据”。

十一、Cache Aside:一致性不是一个删除动作,而是一套补偿链路

核心结论

Cache Aside 的常见读路径是“先查缓存,未命中再查数据库并回填”;写路径通常选择“数据库提交成功后删除缓存”。原因不是后者绝对一致,而是它的主要异常窗口更小,也避免数据库更新失败时缓存已经被提前删除。

数据库与 Redis 通常不在同一个本地事务中,因此不能承诺瞬时强一致。工程目标应表述为:缩短不一致窗口,通过可靠重试、短期 TTL、binlog/事务消息补偿、版本控制和监控告警实现可验证的最终一致性。

读:请求 → 查 Redis → 命中则返回
                  └→ 未命中 → 查数据库 → 回填 Redis → 返回

写:请求 → 开启数据库事务
              ├→ 更新业务数据
              └→ 写缓存失效 outbox
          → 两者一起提交
          → afterCommit 直接删除 Redis(仅快速路径)→ 返回

独立补偿:消费者读取已提交 outbox → 幂等删除 Redis → 标记事件完成
                                      └→ 删除失败:不确认事件,退避重试

两种写顺序怎么比较

写顺序 主要并发窗口 失败边界 结论
先删缓存,再更新数据库 删除后、数据库提交前,另一个读请求可能读取旧数据库并把旧值回填 数据库更新失败会造成额外缓存未命中;若旧值已回填,可能持续到 TTL 或下一次失效 一般不作为首选
先更新数据库,再删缓存 极端情况下,一个更早开始的缓存未命中读先读到旧值,却在写请求删除缓存之后才回填 删除失败会保留旧缓存;进程在提交后、删除前崩溃也会留下旧值 通常首选,但必须补偿

“先删缓存再更新数据库”的典型错误时序:

写请求 A:DEL cache
读请求 B:GET cache,未命中
读请求 B:SELECT db,读到旧值 V1
读请求 B:SET cache = V1
写请求 A:UPDATE db = V2 并提交
结果:数据库是 V2,缓存长期为 V1

“先更新数据库再删缓存”仍有一个更窄但不能忽略的时序:

读请求 B:GET cache,未命中;SELECT db 读到 V1 后暂停
写请求 A:UPDATE db = V2 并提交;DEL cache
读请求 B:恢复执行;SET cache = V1
结果:旧值在删除之后被重新写入

所以面试中不要回答“更新数据库后删缓存就绝对一致”。更准确的说法是:它降低了常见竞争概率,剩余窗口交给 TTL、第二次删除、可靠变更事件,以及带独立最新版本水位的原子回填校验共同治理。

删除失败、延迟双删、binlog 与版本号

手段 解决什么 关键边界
删除失败重试 Redis 瞬时超时、网络异常、进程内删除失败 重试任务必须持久化、删除天然幂等,并设置退避、上限、死信和告警;不能只在内存里重试
延迟双删 尝试覆盖“旧值在第一次删除后回填”的竞争窗口 延迟时间难以准确覆盖慢查询、GC 和网络抖动;进程崩溃仍会漏掉第二删,不能单独承担一致性保证
binlog 订阅 数据库提交后,以变更事件异步删除或刷新缓存 存在消费延迟、重复、乱序、积压和恢复问题;消费者要幂等,并监控位点与延迟
数据版本号 识别乱序事件,在具备版本基准时阻止旧版本覆盖 删除后缓存 Key 为空,单独比较缓存对象版本没有基准;须保留独立最新已提交版本水位或带版本 tombstone,Lua 仅拒绝待写版本 < 水位,并同时拒绝待写版本 < 现有缓存版本
TTL 给残留旧值设置最长生存边界 TTL 只是安全网;过长会放大不一致,过短会增加回源压力和击穿风险

更稳妥的组合是:在更新业务数据的同一个数据库事务内,无条件写入缓存失效 outbox,或者由 binlog CDC 捕获已提交变更;提交后的直接删除只是降低延迟的快速路径;独立消费者读取 outbox/CDC 事件并幂等删除,缓存再设置业务可接受的 TTL。这样即使进程在数据库提交后、直接删除前崩溃,持久化事件仍可恢复消费。

版本号也不是单独使用就能关闭竞争窗口。假设 V2 更新后缓存被删除,一个早先读到 V1 的请求随后回填:此时数据 Key 为空,若 Lua 只比较“缓存现值版本”,仍会接受 V1。要拒绝它,必须另存独立的最新已提交版本水位,例如 product:version:1001=2,或保留带版本的 tombstone。回填脚本要在一个原子操作中执行两项判断:若待写版本 < 最新水位则拒绝;若已有缓存且待写版本 < 现有缓存版本,也拒绝;其余情况才写入。这样 V1 会被水位 V2 拒绝,而合法的 V2 满足 >= 水位,可以在缓存为空或 tombstone 为 V2 时重建;并发出现更高版本缓存时,第二项判断又能阻止较旧数据覆盖新值。水位本身也要由 outbox/CDC 可靠推进,并处理 TTL、丢失和乱序,否则版本号只是附加字段,不是并发保护。

所谓“最终一致”,还必须给出可度量的 SLO,例如“99.99% 的变更在 5 秒内完成缓存失效”,并监控 outbox 待处理量、最老事件年龄、消费失败和 CDC 位点延迟。

Spring Data Redis 核心示例

下面只展示 Cache Aside 的关键骨架。业务更新与 outbox 写入位于同一个数据库事务中:二者同时提交或同时回滚。afterCommit 中的直接删除仅是快速路径;它抛出的异常不能回滚已经提交的数据库事务,也不能消除“提交后、回调前进程崩溃”的窗口。可靠性来自独立消费者持续读取已提交 outbox 并幂等删除,而不是来自回调本身。

建议文件:ProductCacheAsideService.java

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.time.Duration;

/**
 * 商品旁路缓存服务:负责缓存查询、数据库回源和事务提交后的缓存失效。
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class ProductCacheAsideService {

    private static final Duration CACHE_TTL = Duration.ofMinutes(10);

    private final RedisTemplate<String, ProductView> redisTemplate;
    private final ProductRepository productRepository;
    private final CacheInvalidationOutboxRepository outboxRepository;

    /**
     * 按商品 ID 查询商品;缓存未命中时回源数据库并写入有限 TTL 的缓存。
     */
    public ProductView queryProduct(long productId) {
        String cacheKey = "product:" + productId;
        log.info("查询商品缓存,输入商品ID={},缓存Key={}", productId, cacheKey);

        ProductView cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            log.info("商品查询完成,输出来源=缓存,商品ID={}", productId);
            return cached;
        }

        ProductView loaded = productRepository.findViewById(productId);
        if (loaded != null) {
            // 基础示例:这里只展示普通 Cache Aside 回填,不包含版本水位保护。
            // 若启用版本水位方案,必须用 Lua 原子校验“最新水位 + 现有缓存版本”并写入,替换此 SET。
            redisTemplate.opsForValue().set(cacheKey, loaded, CACHE_TTL);
        }
        log.info("商品查询完成,输出来源=数据库,商品ID={},是否存在={}", productId, loaded != null);
        return loaded;
    }

    /**
     * 在同一数据库事务中更新商品并写入缓存失效 outbox,提交后尝试快速删除缓存。
     */
    @Transactional
    public void updateProduct(long productId, ProductUpdateCommand command) {
        log.info("开始更新商品,输入商品ID={},变更字段数量={}", productId, command.changedFieldCount());
        productRepository.update(productId, command);
        String cacheKey = "product:" + productId;

        // 与商品更新共享数据库事务:即使提交后的快速删除未执行,事件仍可恢复消费。
        outboxRepository.save(CacheInvalidationEvent.pending(productId, cacheKey));

        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                try {
                    Boolean deleted = redisTemplate.delete(cacheKey);
                    log.info("商品缓存快速删除完成,输出商品ID={},是否删除成功={}", productId, deleted);
                } catch (RuntimeException ex) {
                    // 数据库已经提交,此处异常无法回滚;独立 outbox 消费者仍会继续删除。
                    log.warn("商品缓存快速删除失败,输出商品ID={},等待Outbox消费者补偿", productId, ex);
                }
            }
        });
    }
}

上面的普通 SET 只适用于未启用版本水位保护的基础方案。若采用前文的版本水位或 tombstone 方案,必须把该行替换为 Lua 原子回填:同时拒绝待写版本 < 最新已提交水位,以及待写版本 < 现有缓存版本,不能在 Lua 校验之外再执行普通 SET

建议文件:CacheInvalidationOutboxConsumer.java

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

/**
 * 缓存失效 outbox 消费者:独立读取已提交事件,幂等删除缓存,成功后确认事件。
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class CacheInvalidationOutboxConsumer {

    private final RedisTemplate<String, ProductView> redisTemplate;
    private final CacheInvalidationOutboxRepository outboxRepository;

    /**
     * 处理一条已提交的缓存失效事件;重复投递时再次删除同一 Key 仍然安全。
     */
    public void consume(CacheInvalidationEvent event) {
        redisTemplate.delete(event.cacheKey());
        outboxRepository.markCompleted(event.eventId());
        log.info("缓存失效事件处理完成,输出事件ID={},商品ID={}", event.eventId(), event.productId());
    }
}

实际实现还要保证 outbox 领取、失败重试和成功确认不会漏事件,例如使用状态机、租约和定时扫描;markCompleted 只能发生在 Redis 删除成功之后。删除不存在的 Key 仍可安全重放。若进程在删除成功后、确认事件前退出,下次重复删除即可;若 Redis 删除抛错,则不确认事件并由调度器退避重试。

日志中的“输入/输出”采用中文业务语义,但生产环境必须对手机号、Token、地址等敏感字段脱敏;高并发读路径应采样或降级为指标,不能为了排查反过来制造日志 IO 压力。示例省略了空值缓存、请求合并和序列化配置,它们不改变一致性主线。

常见误区与回答框架

  • 误区:把删除缓存放在数据库事务提交前。事务若回滚,会造成无意义回源;更重要的是无法表达“提交后才失效”的因果关系。
  • 误区:在 afterCommit 失败分支里临时发布消息,就称为“可靠 outbox”。真正的 outbox 必须与业务更新在同一个数据库事务内持久化。
  • 误区:延迟双删等于强一致。它只是概率性覆盖竞争窗口。
  • 回答框架:先说“业务事务内写 outbox,提交后删缓存走快速路径”;再画两个并发时序;随后补充幂等消费、TTL、binlog,以及独立版本水位;最后用不一致时长、outbox 积压量与消费延迟说明如何验收。

十二、缓存穿透、击穿与雪崩:一张表说清

问题 核心特征 典型根因 影响范围 常见治理
缓存穿透 缓存没有,数据库也没有 非法参数、查询不存在的数据、恶意请求 大量无效请求持续访问数据库 参数校验、缓存空值、布隆过滤器、限流
缓存击穿 单个热点 Key 失效后大量请求同时回源 热点过期、重建较慢 单个数据源或分片瞬时过载 请求合并/互斥重建、逻辑过期、热点预热、限流
缓存雪崩 大量 Key 同时失效或缓存整体不可用 TTL 集中、实例故障、流量突增 大面积请求回源,可能级联故障 TTL 随机化、多级缓存、限流降级、高可用、预热

面试时先用“访问对象是否存在、失效范围是单 Key 还是大量 Key”区分三者即可;完整治理代码不在本章重复展开。

十三、分布式锁:锁住只是开始,过期后的旧持有者才是难点

核心结论与正确释放

单 Redis 实例上的基本加锁命令应把“仅不存在时写入”和“租约过期”放在同一个原子命令中:

SET lock:order:1001 <unique-value> NX PX 30000

unique-value 必须标识这一次锁请求,可由足够随机的请求 ID 构成,不能只用线程 ID 或固定服务名。TTL 防止客户端崩溃后形成永久死锁,但它同时声明:客户端只在租约有效窗口内拥有锁。

旧客户端暂停超过 TTL 后,锁可能已被新客户端获取。因此释放时不能直接 DEL,而要原子地“比较 value,相等才删除”。兼容较多 Redis 版本的 Lua 写法如下:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

故障边界

风险 发生过程 工程处理
租约过短 业务尚未完成,锁先过期,第二个客户端进入 基于业务 P99/P999 设计 TTL;必要时续期,但必须比较 value,且续期次数有界
GC 停顿或进程挂起 客户端在租约内“消失”,恢复时误以为仍持锁 客户端检查锁状态只能尽早中止,检查后仍可能过期;严格场景由下游在写入时原子校验 fencing token
误删他人锁 A 的锁过期,B 获取后,A 直接 DEL 唯一 value + Lua 比较后删除
客户端异常退出 未执行 finally 释放 TTL 自动释放;业务操作必须幂等,可安全重试
续期线程异常 守护线程停止或网络分区导致续期失败 业务立即进入失败/中止策略,不能假定“进程活着锁就一直在”
主从切换窗口 锁写入尚未复制,主节点故障,新主节点上没有该锁 明确单实例/复制方案的互斥边界;高正确性场景不要只依赖普通异步主从锁

锁只能减少并发进入,不能替代业务正确性。客户端在关键写入前重新查询锁是否存在,最多只能尽早发现失效:检查通过到真正写入之间仍有 TOCTOU 窗口,锁可能恰好过期并被他人取得。严格场景应把单调递增的 fencing token 随写请求传给下游,并让下游在同一个原子存储操作中比较该 token 与“已接受最大 token”,只接受更大的值并更新水位。下单、扣库存、发券仍应有数据库唯一约束、状态机版本、幂等键或去重表兜底。

Redlock:目标、假设与争议

Redlock 的目标是在多个相互独立的 Redis 主节点上获得多数锁,避免把单实例故障直接等同于锁失效。典型算法会向多个独立主节点请求同一个 key/value,只有在租约时间内取得多数成功才认为加锁成功,并从有效期中扣除获取锁消耗的时间。

它依赖的前提包括:节点故障尽量独立、客户端能快速超时、各机器本地时钟前进速率的偏差相对租约足够小、工作在有效租约内完成,并正确处理崩溃恢复和部分加锁释放。官方文档本身也列出了 Redlock 的一致性分析与反方观点,并提醒墙上时钟跳变可能破坏互斥。因此它不是“节点越多就自动强一致”的万能答案。

如果重复执行只是偶发、可补偿的成本问题,可以评估单实例锁或 Redlock;如果错误执行会破坏账务、库存所有权或外部不可逆资源,应该引入 fencing token:协调服务为每次成功持锁发放单调递增令牌,下游必须在实际写入的同一个原子操作中,只接受大于已见最大值的 token,并同步更新最大值。若“比较 token”和“业务写入”分成两步,仍然存在 TOCTOU。Redis 锁仍可用于削峰,但最终正确性由下游原子令牌校验、数据库约束和业务幂等共同保证。

常见误区与回答框架

  • 误区:SETNX 后再单独 EXPIRE。客户端可能在两条命令之间退出,留下无 TTL 的锁。
  • 误区:有看门狗续期就没有超时问题。GC、网络分区和调度停顿都会使续期失败。
  • 误区:锁释放失败就直接删除。必须先比较持有者标识。
  • 回答框架:从 SET NX PX 和唯一 value 开始;说明 Lua 安全释放;主动讲租约、GC 与续期失败;再区分普通互斥与严格正确性,落到 fencing token、幂等和数据库约束。

十四、事务、WATCH、Lua 与 Pipeline 怎么选

核心结论

Redis 事务解决“一组已排队命令在 EXEC 时连续执行,不被其他客户端插入”;WATCH 为事务增加乐观并发判断;Lua 把读、计算、写放到服务端原子执行;Pipeline 只批量发送命令、压缩网络往返。Pipeline 不是事务,Redis 事务也不提供关系型数据库式自动回滚。

机制 RTT 特征 原子性/隔离性 是否自动回滚 失败处理 典型场景
MULTI/EXEC 逐条发送时可有多个 RTT;客户端可结合批量发送优化 EXEC 后队列命令连续执行,不被其他客户端命令插入 入队错误会使事务被拒绝;执行期某条命令报错时,其他已排队命令仍会执行,客户端检查逐条结果 已知的一组写命令批量原子执行
WATCH + MULTI/EXEC 至少包含监视、读取、提交等交互 被监视 Key 在 EXEC 前变化时,整个事务放弃执行 不涉及已执行回滚 客户端识别空结果,限次重读、重算、重试 CAS、低冲突乐观并发
Lua 通常一次脚本请求/响应,读写在服务端完成 执行期间不会被其他命令插入 否;运行时错误不会撤销错误前已经完成的写入 把校验放在写入前,返回明确错误码;异常后按可能部分写入处理 原子扣减、比较并删除、读改写
Pipeline 将多次往返压缩为一次或少量批次 无整批原子性,可能与其他客户端命令交错 按顺序检查每条响应;失败只代表相应命令失败 批量独立读写、数据导入、减少 RTT

原理与边界

MULTI
SET account:1 100     # QUEUED
LPOP account:1        # QUEUED,执行时可能 WRONGTYPE
EXEC                  # SET 成功,LPOP 失败;SET 不会自动撤销

WATCH 不是悲观锁。它让 EXEC 变成条件提交:监视的 Key 若在提交前被修改,事务终止,客户端重新读取和计算。高冲突下大量重试会放大负载,应考虑把逻辑改为单条原子命令、Lua 或调整数据模型。

Lua 适合“后续写依赖前一次读结果”的逻辑,因为计算留在服务端;代价是脚本执行期间其他命令要等待。这里的“原子”表示脚本执行过程中不会插入其他客户端命令,不表示关系型数据库式回滚:如果脚本先完成写入,随后遇到类型错误等运行时异常,异常前的写入不会被自动撤销。应先完成参数、类型和业务条件校验,再执行写操作,并把异常结果按“可能已有部分写入”处理。脚本还应短小、复杂度可控、避免遍历未知规模集合;长 Lua 不仅拉高自身延迟,也会制造实例级排队。

Pipeline 适合相互独立的命令。客户端连续发送大量请求时,服务端需要暂存响应,因此应按合理批次读取结果,不能一次塞入无限命令。若业务要求“要么都执行,要么都不执行”,Pipeline 本身不满足要求。

高频追问与回答框架

  • DISCARD 是执行前清空队列,不是执行后回滚。
  • WATCH 冲突后要重试,但必须限次、退避并统计冲突率。
  • Pipeline 优化的是通信与系统调用成本,不会降低命令自身复杂度。
  • 回答框架:先按“是否依赖前序结果、是否要求原子、是否主要优化 RTT”三问分类;再说明失败语义和阻塞/内存边界。

十五、大 Key 与热 Key:定义来自业务容量模型

核心结论与影响

大 Key 没有脱离业务的固定字节阈值。一个 Key 的内存、成员数、单次响应大小、删除耗时或迁移耗时,只要超过本系统延迟和容量预算,就应视为大 Key。热 Key 同样没有统一 QPS 阈值:只要单个 Key 的请求占比造成节点 CPU、带宽、连接或下游分片明显倾斜,就是热 Key。

类型 主要影响 典型现象
大 Key 内存不均、单次网络包大、序列化慢、命令阻塞、过期/删除抖动、持久化和复制开销、Redis Cluster 迁移困难 单次请求慢、延迟尖刺、节点内存倾斜、迁槽耗时
热 Key 单节点 CPU/带宽/连接倾斜,同一数据被高频访问,故障时流量集中回源 总 CPU 可能不高但某节点或网卡已饱和,热点接口 P99 升高

一个 Key 可以既大又热,但两者治理方向不同:大 Key 先缩小数据单元和单次操作量;热 Key 先分散读流量、合并请求并保护后端。

发现方法与生产安全

INFO
INFO commandstats
INFO clients
INFO memory
INFO persistence
INFO replication
SLOWLOG GET 128
LATENCY DOCTOR
MEMORY USAGE product:1001 SAMPLES 5
SCAN 0 MATCH product:* COUNT 200

redis-cli --bigkeys
  • INFO 先看整体:连接数、命中率、内存、逐出、持久化、复制和命令统计;Redis Cluster 要逐节点比较,不能只看聚合平均值。
  • SLOWLOG GET 记录的是命令在服务端执行阶段的耗时,不包含客户端连接池等待、网络往返和客户端反序列化。因此“慢日志没有记录”不等于链路不慢。
  • LATENCY DOCTOR 基于延迟监控数据给出事件分析;若未配置延迟监控或采样窗口不覆盖故障时段,报告可能缺少证据。
  • MEMORY USAGE 估算单 Key 及其值的内存占用;聚合类型的采样参数会影响精度,线上不要对海量未知 Key 无节制执行。
  • SCAN 是增量遍历,但完整扫描总体仍是 O(N),COUNT 只是工作量提示。应在从库、隔离诊断节点或低峰限速执行,并允许重复元素和弱一致视图。
  • redis-cli --bigkeys 适合初筛各类型较大的 Key,但扫描仍会增加 CPU、网络和命令量;生产执行前评估实例规模、选择低峰并限速,不能把结果当作完整内存排行榜。

严禁把 KEYS * 当成生产常规排查方案;它只能出现在“不要在线上全量阻塞扫描”的警告语境。对未知规模集合也不要直接执行 SMEMBERSHGETALL 或一次性全量删除。

治理方法

大 Key 治理:

  1. String 大对象改为分片对象或对象存储,只在 Redis 保存索引和必要字段。
  2. 大 Hash/Set/ZSet 按业务维度分桶,例如 user:tag:{bucket},读写端维护路由。
  3. 集合删除先用 HSCAN/SSCAN/ZSCAN 配合小批量 HDEL/SREM/ZREM;整 Key 可评估 UNLINK,让实际内存回收异步完成。
  4. 控制单批大小并观察延迟、lazyfree 队列、内存释放速度;分批删除不是越快越好。

热 Key 治理:

  1. 对只读且允许短暂陈旧的数据增加进程内本地缓存,并设置短 TTL、容量上限和失效策略。
  2. 对同一时刻的回源使用请求合并,让一个请求加载,其余等待同一结果。
  3. 在网关和服务内限流、降级,防止热点失效后流量击穿数据库。
  4. 对可复制的只读热点建立多个逻辑副本并随机路由;写场景必须处理副本一致性,不应盲目复制。
  5. 从数据模型上分桶,降低单 Key 的访问集中度,但接受聚合查询、路由和一致性复杂度上升。

十六、完整线上排查链路

从现象到证据

确认现象与影响范围
→ 对齐应用、客户端、代理、Redis 的时间线
→ 分解连接池等待 / 网络 RTT / Redis 执行 / 反序列化耗时
→ 查看 INFO、SLOWLOG、LATENCY 和节点级系统指标
→ 定位慢命令、大 Key、热 Key、持久化、复制与集群倾斜
→ 检查超时、重试、连接池、GC、DNS/TLS 和下游业务
→ 小流量修复 → 对比 P50/P95/P99、错误率与资源水位 → 复盘

第一步先问清:是所有接口还是单接口,是平均延迟还是 P99 尖刺,是单机房、单实例、单分片还是全局;同时保留请求 ID、精确时间、命令类型和 Key 前缀,避免只看故障后的平均值。

第二步分段计时。应用侧至少拆成:获取连接、写请求、网络等待、Redis 响应、反序列化、业务后处理。服务端用 SLOWLOG GETLATENCY DOCTOR 对齐同一时间段,再查 INFO clients/memory/persistence/replication/commandstats/stats。系统层检查网卡丢包与重传、带宽、文件描述符、CPU steal、内存换页和磁盘延迟。

第三步按证据定位:

  • 慢日志有高复杂度命令:确认命令复杂度、输入规模和 Key 大小,限流后改数据模型或拆批。
  • 延迟监控出现 fork、AOF 或过期事件:核对持久化配置、磁盘抖动、大 Key 过期/删除时间线。
  • 单节点流量显著高:排查热 Key、槽位倾斜和客户端路由。
  • 连接数或阻塞客户端异常:检查连接泄漏、池大小、等待队列和超时配置。
  • 复制延迟或集群切换:核对客户端拓扑刷新、重定向、读写目标和重试风暴。

修复时先止损:限流、降级、隔离热点、缩小批次或暂停危险任务;再做数据拆分、客户端配置和架构调整。验证不能只看“CPU 降了”,而要对比接口分位延迟、连接池等待、Redis RTT、错误率、慢日志条数和业务正确性指标。

专题:Redis CPU 不高,接口为什么仍然慢

CPU 不高只说明“当前采样下 CPU 不是明显瓶颈”,不能证明 Redis 链路健康。按下面顺序排查:

  1. 连接池等待:连接池过小、泄漏或连接创建慢,线程在客户端排队,Redis 根本没收到命令。
  2. 网络问题:跨机房访问、丢包重传、带宽打满、DNS/TLS/代理延迟会抬高 RTT,但不一定抬高 Redis CPU。
  3. 客户端暂停:应用 Full GC、线程池拥塞、事件循环阻塞,响应已到客户端却不能及时处理。
  4. 序列化成本:大对象编码、压缩、拷贝和反序列化发生在应用端;服务端慢日志可能完全正常。
  5. 排队和阻塞:长 Lua、大 Key 命令或阻塞命令让其他请求等待;采样平均 CPU 可能掩盖短促尖刺,应看 P99、事件时间线和延迟监控。
  6. 持久化/系统抖动:fork、AOF fsync、磁盘或虚拟化抖动可形成延迟尖峰,而 CPU 均值仍低。
  7. 超时重试放大:超时太短触发并发重试,调用方看到的是多轮等待;必须标记重试次数和总耗时。
  8. 下游逻辑慢:接口中数据库、RPC 或锁等待慢,却被笼统归因于 Redis;必须做分段埋点。

回答这道题的关键不是列举可能性,而是给出可证伪的方法:比较应用端总耗时、连接池等待、客户端命令 RTT 与 Redis 慢日志执行时间。若应用端 200 ms、Redis 执行 1 ms,问题大概率在命令进入 Redis 之前或响应离开 Redis 之后。

十七、高频复习与检查表

一分钟总回答模板

Redis 的数据治理分三层。单机内,TTL 由惰性加定期删除负责,达到 maxmemory 才进入淘汰;淘汰先区分 allkeys/volatile 候选集,再用近似 LRU、LFU、random 或 TTL 选键。持久化上,RDB 用 fork 子进程做时点快照,COW 的内存增量取决于期间脏页;AOF 记录写命令,耐久性由 appendfsync 决定,Redis 7+ 重写采用 base、incremental 与 manifest。高可用上,主从通过 replication ID、offset、backlog 判断增量还是全量,但复制是异步的;Sentinel 用 SDOWN、ODOWN、Leader 选举完成非分片主从切换;Redis Cluster 用 16384 Slot 分片,用 Gossip 和多数主节点参与故障判断,客户端处理 MOVED/ASK。它们都不能仅凭副本机制承诺强一致,min-replicas-* 只能用可用性换取更小风险窗口。

工程高频追问:索引式分层速答

前文已经给出完整时序和故障边界,这里只保留“先给结论,再补边界/实践”的面试速答索引,避免重复展开。

高频追问 一句话结论 深一层边界与实践 回看
1. 为什么通常更新数据库后删缓存? 常见竞争窗口更小,但不是强一致 业务事务内写 outbox;提交后删除是快速路径,独立消费者兜底 第十一章
2. 数据库成功、缓存删除失败怎么办? 把删除变成持久、幂等、可重放的事件 outbox 与业务数据同事务,或用 binlog CDC;监控积压、失败和最老事件年龄 第十一章
3. 延迟双删能保证一致吗? 不能,只能概率性覆盖旧值回填 延迟难覆盖慢 SQL、GC 和崩溃,仍需可靠事件与 TTL 第十一章
4. 单独给缓存对象加版本号够吗? 不够,Key 被删后没有可比较基准 Lua 拒绝版本 < 最新水位或 < 现有缓存版本,允许相同版本重建 第十一章
5. binlog/CDC 还有什么风险? 它提供可恢复的异步最终一致,不是实时强一致 消费者处理重复、乱序、位点和积压;必要时按业务键有序或比较版本水位 第十一章
6. 锁 value 为什么必须唯一? 它是本次锁请求的所有权凭证 用请求级随机 ID,释放时 Lua 比较后删除,避免旧客户端误删新锁 第十三章
7. 锁过期但业务未完成怎么办? 旧持有者不能再假设拥有排他权 状态检查仍有 TOCTOU;严格写入由下游原子比较单调 fencing token 第十三章
8. Redlock 适合所有场景吗? 不适合,应先判断失败后果与系统假设 可补偿场景按成本评估;严格场景仍需 fencing、幂等和数据库约束 第十三章
9. Redis 事务失败会自动回滚吗? 不会 入队错误可能拒绝 EXEC;执行期错误不撤销其他命令,客户端检查逐条响应 第十四章
10. Lua 运行时错误会回滚吗? 不会撤销错误前已经完成的写入 原子只指不会被其他命令插入;先校验后写,并按可能部分写入处理异常 第十四章
11. WATCH 与 Lua 怎么选? 低冲突客户端计算用 WATCH,紧凑读改写用 Lua WATCH 高冲突会放大重试;长 Lua 会阻塞实例 第十四章
12. Pipeline 为什么不是事务? 它优化 RTT,不保证整批原子性 其他客户端命令可穿插;批次过大会积压响应内存 第十四章
13. 如何安全删除大 Key? 先识别类型和规模,再选 UNLINK 或分批删除 异步回收仍耗资源;低峰、小批、限速并观察延迟和内存 第十五章
14. 大 Key 和热 Key 是一回事吗? 不是,分别描述体量和访问集中度 大 Key 拆数据,热 Key 分散流量;阈值必须来自业务预算 第十五章
15. Redis CPU 不高但接口慢先看什么? 先做端到端分段计时 对比连接池等待、网络 RTT、服务端执行、序列化与业务耗时 第十六章
16. 如何证明缓存最终一致? 必须有时间上限、补偿闭环和可观测证据 定义 SLO,监控 outbox/CDC 延迟;版本防旧回填须依赖独立水位或 tombstone 第十一、十六章

全文复习检查表

十八、总结

Redis 的核心能力可以归结为三层:单机内用内存数据结构、紧凑编码与事件循环缩短执行路径;数据安全层用 RDB、AOF 和异步复制在性能、恢复时间与丢失窗口之间取舍;分布式层再由 Sentinel 或 Redis Cluster 完成故障发现、拓扑切换和水平分片。任何一层都不能脱离边界谈保证:近似淘汰不是精确排序,后台持久化仍有 fork 与 COW 成本,副本不等于强一致,多数派故障判断也不等于每条写都经过共识提交。

工程实践同样要从失败路径出发。Cache Aside 要从并发时序讲到事务内 outbox、CDC、TTL 与版本水位;分布式锁要从唯一令牌和 Lua 释放讲到租约失效、fencing token、数据库约束与幂等;事务、Lua 和 Pipeline 要区分原子执行、回滚语义与 RTT;大 Key、热 Key 和线上延迟则要用指标与时间线验证。稳定的面试回答顺序是:先给结论,再解释机制,主动说明失败边界,最后落到项目中的兜底、监控和验收。

十九、官方资料

以下资料均来自 Redis 官方文档或 Redis 官方 GitHub 仓库,并按主题去重整理。

性能、数据类型与内部实现

过期、持久化、复制与高可用

事务、锁与线上诊断

版本敏感结论应以目标版本的命令文档、配置示例和对应源码 tag 为准,尤其注意 Redis 7.0 前后的 AOF 重写机制与不同版本的内部编码变化。

posted @ 2026-08-20 15:37  松鼠航  阅读(27)  评论(0)    收藏  举报