面试复习资料 列表全部文章
面试复习资料

面试复习:Redis 与缓存设计(数据结构、持久化、集群、一致性、分布式锁,初中高级三层)

按初级/中级/高级三层整理 Redis 与缓存面试:底层编码、RDB/AOF、过期淘汰、IO 多线程、主从哨兵 Cluster、穿透击穿雪崩、缓存一致性、大 key 热 key、Redisson 分布式锁,以及 Redis 8 的新变化与核对结论。

更多

有个规律我总结了八年:只要简历里写了「使用 Redis 提升接口性能」,面试官八成会先问一句“Redis 为什么快”,然后顺着这句话把你从 IO 多路复用问到集群脑裂,再问到“缓存和数据库不一致怎么办”,最后来一句“你们线上出过缓存事故吗”。所以 Redis 这一章的正确姿势不是背 50 道题,而是把“快”“稳”“准”三个字各讲透一遍:快是数据结构与线程模型,稳是持久化与高可用,准是缓存一致性与锁。

本文根据公开面经与官方文档整理改编,不是我本人某场面试的记录。按难度分三层,方便对号入座:🌱 初级(0–3 年)讲是什么、怎么用;🌿 中级(3–5 年)讲底层为什么、怎么选、怎么排查;🌳 高级(5 年+)讲架构取舍、线上治理和量化复盘。每层都给「标准回答要点 / 追问链 / 加分项」。

版本提示:Redis 这两年变化不小(7.x 的 listpack 与多部 AOF,8.0 的 IO 线程重写与内置模块),文中凡涉及版本差异我都标了出来;Redis 8 的新特性我专门查了官方博客和文档做核对,结论放在最后一章。

一、知识地图

先看全局:一次缓存读写要经过“客户端 → 网络 IO → 单线程执行命令 → 内存数据结构”,写入时再经过“持久化 / 复制”。围绕这条链路,面试题分成五大块。

快:数据结构与线程模型 String / List / Hash / Set / ZSet listpack、skiplist、intset 编码 IO 多路复用 + 单线程执行 6.0 IO 线程 / 8.0 重写 过期删除与内存淘汰 稳:持久化与高可用 RDB:fork + 快照 AOF:appendfsync + 重写 主从:PSYNC / backlog 哨兵:故障发现与切换 Cluster:16384 槽 + 分片 准:缓存一致性 Cache Aside 读写流程 先删还是后删、并发竞态 延迟双删 / 订阅 binlog 过期时间兜底 强一致的代价 保护数据库:三大缓存问题 + 大 key 热 key 穿透:查不存在的 → 布隆过滤器 / 空值缓存 击穿:热点 key 过期 → 互斥重建 / 逻辑过期 雪崩:批量过期或宕机 → TTL 打散 / 多级缓存 大 key:拆分 + UNLINK;热 key:本地缓存 + 打散 兜底:限流、熔断、降级 分布式锁与 Redis 8 变化 SET NX PX + Lua 释放 + 随机 value Redisson:可重入、看门狗默认 30s Redlock 争议与 fencing token Redis 8:内置 JSON/向量集等、IO 线程重写 AGPLv3 授权选项、HGETEX 等新命令

二、数据结构与底层编码:Redis 为什么快

🌱 初级:五大类型与典型场景

标准回答要点:

类型典型场景常用命令
String缓存对象(JSON)、计数器、分布式锁、SessionSET/GET/INCR/SET NX
List简易消息队列、时间线LPUSH/RPOP/BRPOP
Hash对象的字段级存取(购物车、用户信息)HSET/HGET/HINCRBY
Set去重、共同关注、抽奖SADD/SISMEMBER/SINTER
ZSet排行榜、延迟队列、按分数范围查询ZADD/ZRANGE/ZRANGEBYSCORE

官方文档(redis.io 数据类型总览)里还列出了 Bitmap、Bitfield、HyperLogLog、Geo、Stream,以及 Redis 8 内置的 JSON、时间序列、布隆/布谷鸟过滤器等概率结构和向量集(vector set)。面试里要能各说一个场景:Bitmap 做签到与活跃统计,HyperLogLog 做 UV 估算(误差约 0.81%,单个键最多 12KB),Geo 做附近的人,Stream 做带消费组的轻量消息流。

Redis 为什么快(标准答法):①数据在内存里,操作是内存读写;②主要命令执行是单线程,没有线程切换与锁竞争的开销;③基于 epoll 的 IO 多路复用,一个线程可以同时监听大量连接;④高效的数据结构(SDS、跳表、listpack、哈希表渐进式 rehash);⑤简单的协议 RESP。

追问链:“单线程会不会浪费多核?”——命令执行是单线程,但网络读写可以用 IO 线程(6.0 起)、持久化用子进程、惰性删除等用后台线程,所以多核不是完全闲置;要横向扩展就用 Cluster 或多实例。“String 最大多大?”——512MB,但线上绝不能真放这么大。

🌿 中级:底层编码与阈值

Redis 为每种类型准备了多种内部编码,在小数据量时用紧凑结构省内存、大了再切换,这是“Redis 省内存又快”的关键:

类型小数据编码大数据编码默认切换阈值
Stringint(可转 long 的整数)、embstr(≤44 字节,对象头与数据一次分配)raw(SDS 独立分配)44 字节
Listlistpack(7.2 起小列表可用)quicklist(双向链表的每个节点是 listpack)由 list-max-listpack-size 控制(默认 -2,即每节点约 8KB)
Hashlistpack(7.0 之前叫 ziplist)hashtablehash-max-listpack-entries 128、hash-max-listpack-value 64 字节
Setintset(全是整数)、7.2 起有 listpackhashtableset-max-intset-entries 512
ZSetlistpackskiplist + hashtablezset-max-listpack-entries 128、zset-max-listpack-value 64

用 OBJECT ENCODING key 可以看当前编码。要点:

  • SDS(简单动态字符串):记录了 len 和 alloc,获取长度 O(1)、二进制安全、不会缓冲区溢出、有空间预分配与惰性释放,比 C 字符串强。
  • ZSet 为什么用跳表 + 哈希表:跳表支持按分数范围查询和按排名查询,平均 O(log N),实现比平衡树简单、范围遍历方便;哈希表让 ZSCORE 是 O(1)。不用红黑树是因为范围操作与实现复杂度。
  • 哈希表渐进式 rehash:字典有两张表,扩容时不是一次性搬完,而是每次增删改查顺带迁移一个桶,并由定时任务辅助,避免一次 rehash 卡住主线程。
  • listpack 取代 ziplist:ziplist 的每个节点记录“前一个节点长度”,会引发级联更新;listpack 每个节点只记录自身长度,避免了连锁更新(7.0 起 hash / zset 已切换为 listpack)。

追问链:“为什么建议 Hash 存对象而不是把对象序列化成 String?”——字段级读写、小对象时 listpack 编码省内存;但整体读取频繁时序列化成一个 String 更省网络往返。“key 很多时内存占用大,怎么优化?”——缩短 key 名与 value(用二进制序列化如 Protobuf 代替冗长 JSON)、小对象聚合成 Hash 让其走 listpack、设置合理 TTL、压缩大 value。

🌳 高级:内存治理与容量规划

  • 内存碎片与 jemalloc:看 INFO memory 的 mem_fragmentation_ratio(used_memory_rss / used_memory)。大于 1.5 说明碎片较多,可开 activedefrag yes 主动整理(有 CPU 开销),或在低峰滚动重启。小于 1 说明用了 swap,要立刻处理,Redis 用 swap 延迟会崩溃式变差。
  • 容量规划:maxmemory 不能设成机器内存的 100%。因为 RDB/AOF 重写要 fork,写时复制在写入频繁时最坏会多占接近一倍内存;复制缓冲、客户端输出缓冲也在 maxmemory 之外(官方淘汰文档也提示了这类缓冲不计入淘汰判断)。经验上留出 30%–50% 的余量,具体看写入比例,用压测得数而不是拍脑袋。
  • fork 的代价:fork 要复制页表,实例越大越慢,官方持久化文档提到数据集很大时 fork 可能让 Redis 停止服务几毫秒乃至一秒。这就是为什么推荐单实例内存别太大(常见经验 10–20GB 内),大数据量上 Cluster 分片。监控 latest_fork_usec。
  • 量化举例:“1000 万用户的会话,每个 Hash 6 个字段,走 listpack 编码,平均约 300 字节 → 约 3GB;如果序列化成 JSON String 平均 600 字节 → 约 6GB。”这类估算面试官很爱,过程比数字重要。

加分项:Redis 7.4 引入的 Hash 字段级过期(HEXPIRE 等),Redis 8 又加了 HGETEX / HSETEX / HGETDEL(官方 Redis 8 GA 博客),对“一个 Hash 里不同字段不同 TTL”的场景(如会话里的临时令牌)很有用,此前只能拆成多个 key。

三、持久化:RDB 与 AOF

🌱 初级:两种持久化是什么

标准回答要点(对照官方 persistence 文档):

  • RDB:按配置的时间点做快照(save 60 1000 表示 60 秒内至少 1000 次修改就触发),fork 子进程把内存数据写到 dump.rdb。特点:文件紧凑、恢复快、适合备份与灾备;缺点是会丢最后一次快照之后的数据(可能是几分钟)。
  • AOF:把每条写命令追加到日志,重启时回放。appendfsync 三档:always(每次写都 fsync,最安全最慢)、everysec(默认,每秒 fsync,最多丢约 1 秒)、no(交给操作系统,Linux 通常约 30 秒刷一次)。文件通常比 RDB 大,恢复慢一些。
  • 官方建议:想要接近 PostgreSQL 的数据安全,两个都开;能接受丢几分钟,可以只用 RDB;不推荐只用 AOF,因为 RDB 更适合做备份和快速重启。如果两者都开启,重启时用 AOF 恢复,因为它保证最完整。

🌿 中级:fork、写时复制、AOF 重写与混合持久化

  • fork + 写时复制(COW):子进程共享父进程内存页,只有父进程修改某页时才复制该页,所以快照期间对外服务基本不受影响;但如果这段时间写入很多,内存会明显膨胀。
  • AOF 重写:日志越来越大,比如一个计数器 incr 了 100 次,AOF 里有 100 条,其实一条 SET 足够。重写就是按当前内存数据生成最少的命令集。重写不是读旧 AOF,而是遍历内存。触发条件由 auto-aof-rewrite-percentage(默认 100)与 auto-aof-rewrite-min-size(默认 64MB)控制。
  • 7.0 之后的 Multi Part AOF(官方文档):AOF 被拆成一个 base 文件(可以是 RDB 格式或 AOF 格式)和若干 incr 增量文件,放在 appenddirname 目录,由 manifest 文件管理。重写时父进程新开一个 incr 文件继续写,子进程生成新 base,最后原子替换 manifest。旧版本(<7.0)重写期间新写入要先缓存在内存缓冲里,还要双写,内存和 IO 压力更大。
  • 混合持久化(aof-use-rdb-preamble yes,4.0 引入,现在默认开):AOF 重写时 base 部分用 RDB 格式,加载更快,之后再接增量命令。
  • AOF 文件损坏:末尾被截断,Redis 默认能忽略最后一条不完整命令继续加载(aof-load-truncated yes);中间损坏则启动失败,用 redis-check-aof --fix 修复,但修复会丢弃损坏点之后的内容。

追问链:

  1. everysec 真的最多丢 1 秒吗?——官方说“通常最多 1 秒”,但如果后台 fsync 被磁盘拖住,主线程写入会尽量避开,极端情况下可能不止 1 秒。
  2. 为什么 fork 会卡?——要复制页表,数据集越大页表越大;开启 THP(透明大页)会让 COW 复制粒度变成 2MB,写入延迟飙升,所以官方与运维实践都建议关闭 THP。
  3. 主从复制是不是也用 RDB?——是,全量同步时主库 BGSAVE 生成 RDB 发给从库,期间的写命令缓存在复制缓冲区,之后增量发送。8.0 起改为并行两条流(RDB 传输和增量流同时进行),官方博客称主库峰值复制缓冲减少约 35%。

🌳 高级:持久化策略选型与备份演练

  • 先分清 Redis 的角色:纯缓存(丢了能从数据库回填)可以关持久化或只留 RDB(用于快速预热);带状态的数据(分布式 Session、库存预扣、排行榜、幂等 key)应当 RDB + AOF everysec,并且必须配副本 + 哨兵/Cluster,同时接受“主从异步复制下切换仍可能丢最近几条写”。要绝对不丢就不要把 Redis 当唯一数据源。
  • “Redis 当数据库”的风险话术:切换时丢写、内存成本高、没有 SQL 的查询能力、备份恢复时间与数据量成正比。面试时说“我会评估丢写窗口是否能被业务接受,并且有从 MySQL / 日志重建的后路”,比说“Redis 不能当数据库”更成熟。
  • 备份:官方建议定时拷贝 RDB(小时级 + 每天级,保留策略,异地传输),拷贝 RDB 本身在运行时是安全的(它生成时用临时文件,完成后原子重命名)。AOF 备份要小心与重写并发,官方给出的做法是先关闭自动重写、确认没有重写在进行,再拷贝目录,或用硬链接缩短窗口。官方文档还提到较新版本提供 BACKUP 命令族(START / SEAL / CLEANUP 等)来做在线备份,标注为 8.10 起,我没有在生产用过,用之前请以对应版本文档为准。
  • 恢复演练:定期用备份拉起一个实例,核对 key 数量、抽样数据、启动耗时。“备份能恢复”只有演练过才算事实。

四、过期删除与内存淘汰

🌱 初级 + 🌿 中级:两个概念别混

过期删除是“TTL 到了怎么删”,内存淘汰是“内存满了删谁”。

  • 过期删除 = 惰性删除 + 定期删除:访问时发现过期才删(惰性);同时每秒执行 hz(默认 10)次的定期任务,从设置了过期时间的 key 里随机抽样一批(每次 20 个)检查并删除,如果过期比例超过 25% 会继续抽样,同时有时间上限避免占用主线程太久。所以过期 key 不会在到期瞬间被删除,但读取时一定不会返回过期数据。
  • 主从场景:从库不会自己主动过期,等主库发来 DEL(从库读到已过期 key 会返回不存在,但不删)。
  • 内存淘汰策略(maxmemory-policy,官方 eviction 文档):noeviction(默认,写入报错,读正常)、allkeys-lru / lfu / random、volatile-lru / lfu / random / ttl。官方建议:不知道选什么时用 allkeys-lru(访问符合二八分布);访问频率相近可用 random;能给键设置合理 TTL 时用 volatile-ttl。volatile-* 在没有设置过期的 key 时表现同 noeviction。
  • LRU 是近似的:每次随机采样 maxmemory-samples(默认 5)个 key,淘汰其中最久未访问的,3.0 起还维护了候选池,采样数调到 10 更接近真 LRU,但更耗 CPU。LFU(4.0 起)用一个几 bit 的对数计数器(Morris 计数器)记录访问频率并随时间衰减,参数 lfu-log-factor(默认 10)和 lfu-decay-time(默认 1 分钟)。LFU 更能抵抗“一次性大扫描”把热点冲掉的问题。
  • 官方文档 latest 版本里还出现了 LRM(Least Recently Modified)策略,只有写入才更新时间戳,标注为 8.6 起可用;如果你的线上版本低于此,不要在面试里当成通用知识点,提一句“较新版本有,我了解但没用过”即可。

追问链:“为什么不实现真正的 LRU?”——需要额外的链表维护开销和内存;近似算法在幂律访问下几乎无差别。“主库设置了 maxmemory,复制缓冲区算不算?”——不计入淘汰判断,所以要给缓冲区预留内存,官方文档也提醒了这一点,可用 INFO 的 mem_not_counted_for_evict 估算。

🌳 高级:淘汰引发的线上问题

  • 缓存命中率下降的排查:用 INFO stats 的 keyspace_hits / (keyspace_hits + keyspace_misses) 算命中率;evicted_keys 高说明淘汰过多(内存不足或策略不合适),expired_keys 高说明 TTL 太短或集中过期。
  • 大批量 key 同时过期会导致定期删除占用主线程、延迟抖动;TTL 加随机抖动(如基础 30 分钟 + 0–5 分钟随机)。
  • 删除大 key 卡主线程:用 UNLINK 代替 DEL,并开启 lazyfree-lazy-eviction / lazyfree-lazy-expire / lazyfree-lazy-server-del,把释放内存交给后台线程。

五、线程模型:单线程与 IO 多线程

🌿 中级:到底哪部分是单线程

标准回答要点:

  • 6.0 之前:网络读写、协议解析、命令执行都在一个主线程里(事件循环 + epoll)。
  • 6.0 起:引入 IO 线程,只负责 socket 读写与协议解析,命令执行仍然由主线程串行执行,所以不需要为数据结构加锁,原子性天然成立。默认关闭,通过 io-threads 开启(默认 1,即不开)。
  • 还有后台线程(bio):关闭文件、AOF fsync、lazy free 释放内存;子进程:RDB / AOF 重写。
  • Redis 8:官方 GA 博客称引入了“全新的 IO 线程实现”,设置 io-threads 为 8 时在多核 Intel CPU 上吞吐最高提升约 112%,具体取决于命令类型;默认值仍是 1。也就是说“Redis 是单线程”这句话现在应该说成“命令执行是单线程的,网络 IO 可以多线程”。

追问链:“为什么命令执行不多线程?”——瓶颈通常在网络 IO 和内存,而不是 CPU;多线程执行命令需要对数据结构加锁,复杂度和上下文切换成本可能抵消收益;单线程还让每条命令原子,事务与 Lua 脚本的语义简单。“哪些操作会阻塞主线程?”——大 key 的 DEL/HGETALL/SMEMBERS、KEYS *、大范围 ZRANGE、Lua 死循环、fork(持久化)、AOF always 下 fsync 慢、swap 等。

🌳 高级:延迟排查

线上“Redis 偶发慢”的排查路径:①SLOWLOG GET(slowlog-log-slower-than 默认 10000 微秒)找慢命令;②LATENCY DOCTOR、redis-cli --latency 看延迟分布,区分是命令慢还是网络慢(客户端与服务端各自测);③看 latest_fork_usec 是否因持久化 fork 抖动;④看是否内存不足触发 swap 或大量淘汰;⑤看是否有大 key、集中过期;⑥看客户端侧:连接池耗尽、Nagle、序列化耗时,很多“Redis 慢”其实是应用线程池排队。

六、高可用:主从、哨兵与 Cluster

🌱 初级:三种模式

  • 主从复制:一主多从,读写分离,数据冗余。从库用 REPLICAOF(旧称 SLAVEOF)指向主库。
  • 哨兵(Sentinel):监控主从,主库挂了自动选新主并通知客户端;解决高可用,不解决容量。
  • Cluster:数据分片到多个主节点,每个主可带从库;同时解决容量与高可用。

🌿 中级:机制细节

  • PSYNC 与增量同步:从库带着主库的 replid 与自己的 offset 请求同步;如果 offset 还在主库的复制积压缓冲区(repl-backlog)范围内,就只补差量,否则全量同步(生成 RDB)。所以主从断线时间长、写入量大时,要调大 repl-backlog-size,避免反复全量同步拖垮主库。
  • 哨兵的故障判定:单个哨兵认为主观下线(SDOWN,超过 down-after-milliseconds 没响应);达到 quorum 个哨兵同意则客观下线(ODOWN);然后哨兵们选举出一个 leader 来执行故障转移,从从库里按优先级、复制偏移量、runid 选新主。哨兵自己也要奇数个(至少 3 个)部署在不同机器。
  • Cluster:16384 个哈希槽,slot = CRC16(key) mod 16384,每个主节点负责一段槽位;客户端请求错节点时返回 MOVED(槽位已永久迁移,客户端应更新路由表)或 ASK(槽位迁移中的临时重定向);节点间用 Gossip 协议交换状态;多数主节点认为某主下线后,其从库发起选举升主。
  • Hash Tag:key 里 {} 中的部分决定槽位,如 {order:1001}:info 与 {order:1001}:items 落在同一槽,可以用于多 key 操作与事务。跨槽的 MGET、事务、Lua 会报 CROSSSLOT 错误。
  • 为什么是 16384 个槽?——作者的解释是槽位信息随心跳包携带,16384 个槽用 bitmap 表示正好 2KB,心跳包较小;而集群主节点数量通常也不会超过 1000,够用。
  • Cluster 的一致性:官方文档明确说,Redis Cluster 不保证强一致,因为主从之间是异步复制,主库确认了写入后崩溃,来不及同步到从库就切换,这条写入就丢了;网络分区时,被少数派隔离的主库在超时前仍会接受写入,之后这些写入会丢弃。

追问链:

  1. 哨兵模式脑裂是什么?——网络分区后旧主还在接收写入,哨兵在多数派一侧选了新主,分区恢复旧主降级为从库并同步新主数据,分区期间写入旧主的数据丢失。缓解:min-replicas-to-write 与 min-replicas-max-lag,让主库在没有足够多、足够新的从库时拒绝写入,把丢失窗口收窄。
  2. Cluster 扩缩容怎么做?——redis-cli --cluster reshard 迁移槽位,逐 key MIGRATE;迁移期间用 ASK 重定向;要注意迁移大 key 会阻塞。
  3. Cluster 不支持哪些?——多库(只有 db0)、跨槽多 key 命令、跨槽事务。

🌳 高级:选型与治理

  • 什么时候需要 Cluster:单实例内存过大(fork 慢、恢复慢)、单实例 QPS 不够(几十万级)、想要在线扩容。不需要时优先哨兵 + 应用侧分片/拆实例,因为 Cluster 的多 key 限制会污染业务代码。
  • 客户端拓扑刷新:Lettuce / Jedis Cluster 需要处理 MOVED 与故障转移后的拓扑更新,配置定时刷新与自适应刷新,否则主从切换后一段时间内请求报错;同时设置合理的超时与重试,防止重试风暴。
  • 跨机房与多活:Redis 原生没有强一致的多活,常见做法是各机房独立缓存 + 数据库为准 + 失效消息广播;不要试图让缓存做跨地域强一致。
  • 升级与迁移:滚动升级先从库后主库(先 failover 再升级旧主);跨大版本前在预发用真实 RDB 演练;注意授权变化(见最后一章 Redis 8 的授权说明)。

七、缓存三大问题:穿透、击穿、雪崩

🌱 初级:概念与基本解法

问题是什么基本解法
缓存穿透查询根本不存在的数据,缓存与数据库都没有,请求每次落到 DB参数校验;缓存空值(短 TTL,如 30–60 秒);布隆过滤器提前拦截
缓存击穿某个热点 key 过期瞬间,大量并发同时打到 DB热点 key 不过期 / 逻辑过期;互斥锁只让一个线程重建
缓存雪崩大量 key 同时过期,或 Redis 整体宕机,流量压垮 DBTTL 加随机值;多级缓存;高可用集群;限流降级;缓存预热

🌿 中级:每种方案的细节与坑

  • 布隆过滤器:位数组 + 多个哈希函数,判断“一定不存在”或“可能存在”,有误判没有漏判,不支持删除(计数布隆或布谷鸟过滤器可以)。用途是拦截明显不存在的 id。参数:预期元素数 n、误判率 p(如 1%),位数 m ≈ -n·ln p / (ln2)²,1% 误判率时每个元素约需 9.6 bit。Redisson 有 RBloomFilter;Redis 8 起概率数据结构内置在官方发行版(官方博客),可以直接使用 BF.ADD / BF.EXISTS。数据新增时要同步加入过滤器,否则新数据会被误拦。
  • 缓存空值的坑:攻击者用海量不同的不存在 id,空值缓存会撑爆内存,所以要短 TTL + 限制数量,同时叠加限流与布隆过滤器。
  • 互斥重建(击穿):抢到锁的线程查 DB 并回写,没抢到的短暂 sleep 后重试读缓存,不要直接查 DB。要有双重检查:拿到锁后再查一次缓存。
  • 逻辑过期:value 里带一个过期时间字段,物理上 key 不过期;读到发现逻辑过期后,由一个线程异步重建,其它线程先返回旧值。优点是不阻塞、可用性高,缺点是有短暂不一致,且占内存。适合允许短暂旧数据的热点。
  • 雪崩:Redis 挂了怎么办?——本地缓存(Caffeine)兜底、限流保护 DB(Sentinel / 令牌桶)、熔断降级返回默认值、DB 连接池与慢 SQL 保护,预案要演练。
// 互斥重建 + 双重检查(示意,省略异常处理)
public Product getProduct(long id) {
    String key = "product:" + id;
    String json = redis.get(key);
    if (json != null) return parse(json);                 // 命中(含空值标记)

    String lockKey = "lock:" + key;
    if (redis.setIfAbsent(lockKey, uuid, 10, TimeUnit.SECONDS)) {   // 抢重建权
        try {
            json = redis.get(key);                        // 双重检查,可能别人刚重建完
            if (json != null) return parse(json);
            Product p = productMapper.selectById(id);     // 只有一个线程查库
            int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);  // TTL 抖动
            redis.set(key, p == null ? "" : toJson(p), ttl, TimeUnit.SECONDS);
            return p;
        } finally {
            unlockIfOwner(lockKey, uuid);                 // Lua 校验 value 再删
        }
    }
    sleepQuietly(50);                                     // 没抢到:短睡后重读缓存
    return getProduct(id);                                // 生产要限制重试次数,避免栈溢出
}

🌳 高级:多级防护与容量保护

  • 分层防御:入口层(网关限流、参数合法性)→ 本地缓存(Caffeine,毫秒级 TTL,吸收热点)→ Redis → DB(连接池上限、慢 SQL 熔断)。每一层都要能独立降级。
  • 缓存预热与发布:大促前预热热点;发布/重启时不要一次性清空缓存;Redis 主从切换或扩容期间预留容量。
  • 数据库能扛多少:要有数字。比如“DB 只读能力约 3000 QPS,缓存命中率 99% 时,Redis 承担 30 万 QPS 只有 3000 落库,刚好;命中率跌到 90% 就是 3 万 QPS 落库,DB 必挂”。所以命中率的下降斜率比绝对值更值得告警。
  • 复盘话术:“我们遇到过热点商品 key 过期导致 DB 连接打满,止血是临时把 TTL 拉长并限流,根治是热点 key 改逻辑过期 + 后台预刷新,同时给重建加了分布式互斥;之后压测确认重建期间 DB QPS 只增加一个线程的量。”

八、缓存与数据库一致性

🌱 初级:Cache Aside

最常用的旁路缓存模式:读——先读缓存,命中返回;未命中读 DB 再写缓存。写——先更新数据库,再删除缓存。为什么删除而不是更新缓存?①更新缓存代价可能很大(值要经过复杂计算);②多个写并发时,更新缓存的顺序错乱会写入脏数据;③删除是“懒加载”,下次读再重建,更简单可靠。

🌿 中级:先删缓存还是后删缓存,竞态分析

方案 A:先删缓存,再更新 DB(易脏) 写线程 读线程 ① 删除缓存 ② 读缓存未命中,读 DB 旧值 ③ 把旧值写回缓存 ④ 更新 DB 为新值 结果:缓存旧值、DB 新值 不一致会一直持续到 TTL 到期 概率不低:读线程只要卡在写线程的两步之间 方案 B:先更新 DB,再删缓存(推荐) 写线程 读线程 ① 更新 DB 为新值 ② 读缓存命中旧值(短暂) ③ 删除缓存 ④ 之后读:未命中读新值 正常路径:只有很短的旧值窗口 极端竞态:读线程在更新 DB 前读了旧值, 在删缓存之后才回写 → 仍会脏(概率极低)

标准回答要点:

  • 先删缓存再更新 DB:如上图 A,读线程可能在两步之间把旧值写回缓存,脏数据持续到 TTL。补救:延迟双删(更新 DB 后 sleep 一小段再删一次),但 sleep 多久很难拿准,且降低吞吐。
  • 先更新 DB 再删缓存(推荐):只在一种极端交错下出问题——读线程读缓存未命中、读到旧 DB 值,写线程更新 DB 并删缓存,然后读线程才把旧值写入缓存。这要求“读 DB”比“写 DB + 删缓存”还慢,概率很低。另一个真正的风险是删除缓存失败(网络抖动、Redis 短暂不可用),会留下长期脏数据。
  • 删除失败的补救:①重试(失败后丢消息队列异步重试,要幂等);②订阅 binlog(Canal / Debezium)由独立服务解析变更再删缓存,与业务代码解耦,且天然保证“DB 变了才删”;③设置合理的 TTL 作为最终兜底。
  • 需要更强一致时:读写加同一把分布式读写锁(读读不互斥、读写互斥),或读多写少的场景用版本号,写缓存时用 Lua 比较版本,旧版本不覆盖新版本。但强一致会牺牲性能,缓存的定位本来就是最终一致。

追问链:

  1. 主从延迟会影响缓存一致性吗?——会。写主库后删缓存,读线程从从库读到旧值并回写缓存,就把旧数据缓存住了。解决:缓存重建读主库,或延迟删除时间大于主从延迟,或 binlog 订阅方从主库读。
  2. 为什么不用“先更新缓存”?——并发写下两个线程的“更新 DB 顺序”和“更新缓存顺序”可能相反,缓存最终值与 DB 不同,且读多写少时更新缓存是浪费。
  3. Write Through / Write Behind 是什么?——Write Through 是写缓存同时同步写 DB;Write Behind 是只写缓存,异步批量落库,性能高但丢数据风险大,业务里少用。

🌳 高级:一致性方案的工程化

  • 先明确“一致”的等级:金额、库存扣减不能靠缓存做判断,以 DB 为准,缓存只做展示;商品详情、用户资料这类允许秒级不一致,用 Cache Aside + TTL + binlog 订阅。等级不同,方案成本差一个数量级。
  • binlog 订阅方案的工程要点:消费要保证顺序与幂等;缓存 key 的映射规则集中管理(表 + 主键 → 缓存 key 列表);对于“一个变更影响多个缓存 key(列表、聚合)”的场景要有反向索引;订阅链路延迟要监控(通常百毫秒到秒级);服务挂了要能从位点恢复。
  • 多级缓存的一致性:本地缓存(Caffeine)+ Redis,DB 变更后用 Redis Pub/Sub 或 MQ 广播失效本地缓存;本地缓存 TTL 设短(如 5–30 秒)承认最终一致;Redis 6.0 的客户端缓存(server-assisted client-side caching,RESP3 tracking)是另一个方向,但要评估连接数和失效风暴。
  • 可观测:做一致性巡检——抽样比对缓存与 DB,统计不一致率并告警,比争论“方案是否完美”更有价值。

九、大 key 与热 key

🌿 中级:判定、危害与处理

  • 大 key 判定(经验值,非官方标准):String 超过 10KB;Hash/List/Set/ZSet 元素个数超过 5000(或总大小超过 1MB 左右)。各公司阈值不同,关键是理解危害。
  • 危害:读写耗时长阻塞主线程;DEL 同步释放内存卡顿;集群下数据倾斜、单节点内存与流量不均;网络带宽被打满;主从复制与迁移变慢;AOF/RDB 更大更慢。
  • 发现:redis-cli --bigkeys(按类型找最大的,内部用 SCAN,生产建议低峰、必要时加 -i 0.1 限速);redis-cli --memkeys;MEMORY USAGE key;离线分析 RDB(如 rdb-tools);云厂商的大 key 诊断。千万别在生产用 KEYS *。
  • 处理:拆分(大 Hash 按 hash(field) % N 拆成 N 个小 Hash;大 List 按时间/数量分段);压缩/精简(去掉无用字段、二进制序列化);换存储(超大集合放 ES 或 DB);删除用 UNLINK 或对集合类型分批 HSCAN + HDEL、SSCAN + SREM、LTRIM;预防靠开发规范与上线前压测。

热 key:短时间内被海量请求集中访问(明星商品、秒杀、热点新闻),单节点 CPU/带宽打满,Cluster 下无法靠加节点分摊。

  • 发现:redis-cli --hotkeys(需要 LFU 淘汰策略);客户端埋点统计;代理层统计;云厂商热 key 分析;MONITOR 只能短时采样(有性能开销,慎用)。
  • 处理:本地缓存(Caffeine,热 key 命中本地,秒级 TTL);key 打散(hotkey:{id}:{0..N-1} 写多份,读随机一份);读副本分流;热点数据提前推 CDN;提前预热并对该 key 单独限流。

🌳 高级:自动化治理与案例

  • 规范化:key 命名规范(业务:模块:id)、value 大小上限(如 10KB)由 SDK 层强校验;禁止 KEYS、FLUSHALL(用 ACL 或 rename-command 禁用,Redis 6 起 ACL 更推荐);批量操作(MGET、Pipeline)单批不超过几百条。
  • 发现自动化:定时低峰扫描 + 采集 MEMORY USAGE 抽样;对大 key 生成工单推给 owner。热 key 用客户端 SDK 的滑动窗口计数,超阈值自动升级为本地缓存(“热点探测 + 自动本地化”,类似京东 hotkey 一类方案的思路)。
  • 案例话术:“某个粉丝列表用一个 Set 存,头部用户有 300 万粉丝,SMEMBERS 一次要 1 秒以上,同时 DEL 触发了 800ms 的阻塞。方案:按用户 id 与粉丝 id 哈希拆成 256 个分片 Set,读走 SSCAN 分页,删除走 UNLINK;改造后该实例 P99 由 400ms 降到 5ms。”

十、分布式锁与 Redisson

🌱 初级:Redis 怎么实现分布式锁

标准回答要点:加锁用一条原子命令:SET lock_key unique_value NX PX 30000(NX 表示不存在才设置,PX 是过期毫秒)。释放锁必须先校验 value 是自己的再删除,且两步要原子,用 Lua 脚本:

-- 释放锁:只有持有者才能删除
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

为什么要三件事:①NX + 过期时间要放在同一条命令里(老写法 SETNX 再 EXPIRE 不是原子的,中间崩溃就永久死锁);②value 是随机唯一值,防止误删别人的锁(A 超时锁被自动释放,B 拿到锁,A 执行完把 B 的锁删了);③释放用 Lua 保证“判断 + 删除”原子。

// Redisson 用法
RLock lock = redissonClient.getLock("order:pay:" + orderId);
boolean ok = lock.tryLock(3, TimeUnit.SECONDS);   // 最多等 3 秒;未指定 leaseTime,启用看门狗
if (!ok) throw new BizException("操作频繁,请稍后再试");
try {
    doBusiness();
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();   // 只有持有者才能解锁
}

🌿 中级:Redisson 原理与常见坑

  • 可重入:Redisson 的锁在 Redis 中是一个 Hash:key 是锁名,field 是 客户端UUID:线程ID,value 是重入次数;同一线程再次加锁计数加 1,解锁减 1,减到 0 才删除 key。加锁与解锁都是 Lua 脚本。
  • 看门狗(watchdog):不指定 leaseTime 时,默认锁的过期时间是 lockWatchdogTimeout = 30 秒,每隔 1/3(10 秒)检查持有线程是否仍在,还在就把过期时间续到 30 秒;进程宕机则续期停止,锁最多 30 秒后自动释放。一旦你显式指定了 leaseTime,就不会启动看门狗,到期即释放——这是最常见的“锁被提前释放”的原因。
  • 等待机制:未获取到锁的线程订阅一个 Pub/Sub 频道,持有者释放时发布消息唤醒,比自旋更省资源,超过 waitTime 则放弃。
  • 主从异步复制丢锁:线程 A 在主库拿到锁,主库崩溃且锁还没同步到从库,从库被选为新主,线程 B 又拿到了同一把锁,出现两个持有者。这是单主 Redis 锁在故障转移下的固有缺陷。
  • Redlock:作者提出在 N(通常 5)个相互独立的 Redis 主节点上依次加锁,超过半数成功且总耗时小于锁有效期才算成功。Martin Kleppmann 提出批评:进程暂停(GC、页错误)、网络延迟、时钟漂移都可能让持有者在锁过期后仍然继续操作,Redlock 依赖时钟与有界延迟假设,因此不能保证安全性;antirez 则做了反驳。面试里知道有争议、并能说出“锁只能保证效率,正确性要靠 fencing token”就够了。
  • fencing token:锁服务每次授予锁时返回单调递增的令牌,业务写入资源时带上令牌,资源端拒绝比已见过的最大令牌更小的写请求。这样即使旧持有者因 GC 暂停后醒来,也无法破坏数据。需要资源端(如 DB 的 version 列)配合,Redis 本身不提供。
  • 常见坑清单:锁粒度太粗(全局锁)导致吞吐掉;业务执行时间超过 leaseTime;在 finally 里直接 unlock 而没检查持有者,抛 IllegalMonitorStateException;事务还没提交就释放了锁(锁在事务方法内部、事务提交在方法之后,别人拿到锁时读不到你的更新——锁要包住事务,而不是在事务里面);把锁用在秒杀扣库存上,应该用 Lua 原子扣减或 DB 条件更新。

追问链:

  1. 锁续期失败会怎样?——看门狗续期依赖 Redis 可达,网络分区时续期失败,锁到期释放,而业务线程可能仍在执行;所以业务要设计成幂等 + 可被 fencing 保护。
  2. Redisson 的 tryLock(wait, lease) 与 lock() 区别?——lock() 无限等待且启用看门狗;tryLock 可设等待上限。生产更推荐 tryLock 避免线程无限挂起。
  3. 读写锁、联锁(MultiLock)、公平锁?——Redisson 提供 RReadWriteLock、RLock.multiLock、getFairLock,按需了解,但要知道公平锁性能更低。

🌳 高级:锁的选型与替代方案

  • 先问:真的需要分布式锁吗?很多场景可以用数据库唯一索引 / 乐观锁 / 条件更新代替(update ... where status=0);幂等场景用唯一键去重;串行化场景用按业务 key 路由到同一个消费者/队列(MQ 分区顺序)取代锁。
  • 效率锁 vs 正确性锁:防止重复执行定时任务、防止缓存击穿重建——偶尔锁失效只会浪费一点资源,用 Redis 锁足够;涉及资金、库存最终正确性的,用数据库事务 + 唯一约束 + 版本号兜底,或选择基于共识的 ZooKeeper / etcd 锁并配合 fencing token(业内共识:正确性要求高时不要单靠 Redis 锁)。
  • 故障演练:模拟 Redis 主从切换、Full GC 暂停超过 lease 时间,验证业务是否仍然正确(幂等/回滚),而不是只测“锁能加能放”。
  • 可观测:记录锁等待时间与持有时间分位数,等待时间分位数升高是锁竞争过大的早期信号;对持有超过阈值的锁告警,怀疑业务卡死。

十一、Redis 8 有哪些新变化(已核对)

面试里常被问“你了解 Redis 新版本吗”,我把 Redis 8 的说法去官方博客与文档里逐条核对了一遍,结论如下(依据 2025-05-01 发布的 “Redis 8 is now GA” 官方博客,该文 2026-06 有更新,以及 redis.io 的数据类型与淘汰、持久化文档):

传闻/说法核对结果
Redis 8 把 Stack 的模块并进来了属实。Redis Stack 与社区版合并为“Redis Open Source”,JSON、时间序列、Bloom / Cuckoo / Count-min / Top-k / t-digest 等作为内置数据结构提供(此前多为独立模块);官方博客称新增 8 种数据结构
新增向量集(vector set)属实但为 beta。官方明确说 API 可能变更甚至被破坏,且受 Salvatore Sanfilippo 设计,扩展自 sorted set 的思想,用于向量相似度检索;生产请谨慎
IO 多线程性能大幅提升属实但有前提。全新的 IO 线程实现,io-threads 默认仍为 1;官方称设为 8 时在多核 Intel CPU 上吞吐最高提升约 112%,具体取决于命令;并宣称超过 30 项性能改进、部分命令延迟最多降低 87%(是对比 7.2.5 的基准测试结论,不是普适保证)
新的复制机制属实。全量同步时并行使用两条流,不再等 RDB 传输完成再发增量;官方测试峰值复制缓冲区降低约 35%、复制耗时减少约 18%
新增哈希命令属实:HGETDEL、HGETEX、HSETEX,建立在 7.4 引入的 Hash 字段过期之上
授权变化属实。在此前的 RSALv2 / SSPLv1 双授权基础上,Redis 8 新增 AGPLv3 作为可选授权(OSI 认可的开源协议)。企业内部使用与二次分发的合规要求不同,请法务确认,我不给法律结论
新增淘汰策略与 BACKUP 命令官方 latest 文档里列有 allkeys-lrm / volatile-lrm(标注 8.6 起)和 BACKUP 命令族(标注 8.10 起)。这两项版本号来自文档原文,我没有在实际环境验证过,选型时以你使用的具体版本文档为准

面试建议:被问到时按“先说事实(合并模块、IO 线程重写、复制优化、新命令、授权)→ 再说你的评估(升级收益、兼容性、授权合规、beta 特性不进生产)”的结构答,比单纯罗列特性更像做过架构决策的人。另外业界还有从 Redis 分叉出来的 Valkey 等兼容项目,选型时也会被拿来比较,这部分我没有在本文核对,就不展开了。

十二、高频追问速查表

问题一句话答案出处 / 延伸
Redis 为什么快内存 + 命令单线程无锁 + epoll 多路复用 + 高效结构命令执行单线程,IO 可多线程
ZSet 底层小数据 listpack,大数据 skiplist + hashtableOBJECT ENCODING 查看
String 编码int / embstr(≤44 字节)/ rawSDS 的优势
RDB 与 AOF 区别快照紧凑恢复快但可能丢几分钟;AOF 追加更持久但更大官方建议两者并用
AOF 三种 fsyncalways / everysec(默认)/ noeverysec 最多丢约 1 秒
AOF 重写怎么做fork 子进程按内存生成最小命令集,7.0 起 base + incr + manifest混合持久化
过期怎么删惰性删除 + 定期抽样删除从库不主动过期
默认淘汰策略noeviction,写入报错推荐 allkeys-lru / lfu
哨兵如何判故障SDOWN → 达 quorum 变 ODOWN → 选 leader 执行切换脑裂用 min-replicas-to-write 缓解
Cluster 槽数与路由16384 槽,CRC16(key) mod 16384,MOVED/ASK 重定向Hash Tag 与 CROSSSLOT
穿透/击穿/雪崩不存在的 / 热点过期 / 批量过期或宕机布隆+空值 / 互斥+逻辑过期 / TTL 抖动+多级
先删缓存还是先更新库先更新 DB 再删缓存;失败靠重试或 binlog 订阅;TTL 兜底延迟双删、主从延迟
大 key 怎么处理拆分、压缩、UNLINK 或分批删除--bigkeys / MEMORY USAGE
热 key 怎么处理本地缓存、key 打散多份、副本读--hotkeys 需要 LFU
锁怎么实现SET NX PX + 唯一 value + Lua 释放不要 SETNX + EXPIRE 分两步
Redisson 看门狗未指定 leaseTime 时默认 30 秒,每 10 秒续期指定 leaseTime 则不续期
Redlock 靠谱吗有争议,依赖时钟与延迟假设;正确性要靠 fencing token高一致场景用 ZK/etcd 或 DB 约束兜底
Redis 8 新东西内置 JSON/时序/概率结构、向量集 beta、IO 线程重写、新复制、AGPLv3 选项官方 GA 博客

一句话备忘:快靠“内存 + 单线程执行 + 多路复用 + 紧凑编码”,稳靠“RDB+AOF、主从哨兵 Cluster 与异步复制的丢写窗口”,准靠“先更新库再删缓存 + 重试/binlog + TTL 兜底”,锁靠“SET NX PX + Lua + 看门狗,正确性再加 fencing”。

十三、收尾:一个自我提醒

写完这一章我最大的感受是:Redis 的面试题几乎都在考同一件事——你知不知道“快”的代价。单线程快,所以一个大 key 就能拖垮全局;异步复制快,所以切换会丢写;缓存快,所以要面对一致性;锁便宜,所以正确性要自己兜底。每一次你答“它很快”之后,主动补一句“代价是……我们的对策是……”,追问链基本就自己断了,因为你把面试官下一个问题提前答完了。

最后是个小提醒:本文里带具体版本号的地方(7.0 的 listpack 与 Multi Part AOF、7.4 的 Hash 字段过期、8.0 的各项变化,以及文档里标注的 8.6、8.10),请以你线上实际使用的版本文档为准,别在面试里把“最新文档写了”当成“我用过”。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人