有个规律我总结了八年:只要简历里写了「使用 Redis 提升接口性能」,面试官八成会先问一句“Redis 为什么快”,然后顺着这句话把你从 IO 多路复用问到集群脑裂,再问到“缓存和数据库不一致怎么办”,最后来一句“你们线上出过缓存事故吗”。所以 Redis 这一章的正确姿势不是背 50 道题,而是把“快”“稳”“准”三个字各讲透一遍:快是数据结构与线程模型,稳是持久化与高可用,准是缓存一致性与锁。
本文根据公开面经与官方文档整理改编,不是我本人某场面试的记录。按难度分三层,方便对号入座:🌱 初级(0–3 年)讲是什么、怎么用;🌿 中级(3–5 年)讲底层为什么、怎么选、怎么排查;🌳 高级(5 年+)讲架构取舍、线上治理和量化复盘。每层都给「标准回答要点 / 追问链 / 加分项」。
版本提示:Redis 这两年变化不小(7.x 的 listpack 与多部 AOF,8.0 的 IO 线程重写与内置模块),文中凡涉及版本差异我都标了出来;Redis 8 的新特性我专门查了官方博客和文档做核对,结论放在最后一章。
一、知识地图
先看全局:一次缓存读写要经过“客户端 → 网络 IO → 单线程执行命令 → 内存数据结构”,写入时再经过“持久化 / 复制”。围绕这条链路,面试题分成五大块。
二、数据结构与底层编码:Redis 为什么快
🌱 初级:五大类型与典型场景
标准回答要点:
| 类型 | 典型场景 | 常用命令 |
|---|---|---|
| String | 缓存对象(JSON)、计数器、分布式锁、Session | SET/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 省内存又快”的关键:
| 类型 | 小数据编码 | 大数据编码 | 默认切换阈值 |
|---|---|---|---|
| String | int(可转 long 的整数)、embstr(≤44 字节,对象头与数据一次分配) | raw(SDS 独立分配) | 44 字节 |
| List | listpack(7.2 起小列表可用) | quicklist(双向链表的每个节点是 listpack) | 由 list-max-listpack-size 控制(默认 -2,即每节点约 8KB) |
| Hash | listpack(7.0 之前叫 ziplist) | hashtable | hash-max-listpack-entries 128、hash-max-listpack-value 64 字节 |
| Set | intset(全是整数)、7.2 起有 listpack | hashtable | set-max-intset-entries 512 |
| ZSet | listpack | skiplist + hashtable | zset-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修复,但修复会丢弃损坏点之后的内容。
追问链:
everysec真的最多丢 1 秒吗?——官方说“通常最多 1 秒”,但如果后台 fsync 被磁盘拖住,主线程写入会尽量避开,极端情况下可能不止 1 秒。- 为什么 fork 会卡?——要复制页表,数据集越大页表越大;开启 THP(透明大页)会让 COW 复制粒度变成 2MB,写入延迟飙升,所以官方与运维实践都建议关闭 THP。
- 主从复制是不是也用 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 不保证强一致,因为主从之间是异步复制,主库确认了写入后崩溃,来不及同步到从库就切换,这条写入就丢了;网络分区时,被少数派隔离的主库在超时前仍会接受写入,之后这些写入会丢弃。
追问链:
- 哨兵模式脑裂是什么?——网络分区后旧主还在接收写入,哨兵在多数派一侧选了新主,分区恢复旧主降级为从库并同步新主数据,分区期间写入旧主的数据丢失。缓解:
min-replicas-to-write与min-replicas-max-lag,让主库在没有足够多、足够新的从库时拒绝写入,把丢失窗口收窄。 - Cluster 扩缩容怎么做?——
redis-cli --cluster reshard迁移槽位,逐 keyMIGRATE;迁移期间用 ASK 重定向;要注意迁移大 key 会阻塞。 - 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 整体宕机,流量压垮 DB | TTL 加随机值;多级缓存;高可用集群;限流降级;缓存预热 |
🌿 中级:每种方案的细节与坑
- 布隆过滤器:位数组 + 多个哈希函数,判断“一定不存在”或“可能存在”,有误判没有漏判,不支持删除(计数布隆或布谷鸟过滤器可以)。用途是拦截明显不存在的 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 再写缓存。写——先更新数据库,再删除缓存。为什么删除而不是更新缓存?①更新缓存代价可能很大(值要经过复杂计算);②多个写并发时,更新缓存的顺序错乱会写入脏数据;③删除是“懒加载”,下次读再重建,更简单可靠。
🌿 中级:先删缓存还是后删缓存,竞态分析
标准回答要点:
- 先删缓存再更新 DB:如上图 A,读线程可能在两步之间把旧值写回缓存,脏数据持续到 TTL。补救:延迟双删(更新 DB 后 sleep 一小段再删一次),但 sleep 多久很难拿准,且降低吞吐。
- 先更新 DB 再删缓存(推荐):只在一种极端交错下出问题——读线程读缓存未命中、读到旧 DB 值,写线程更新 DB 并删缓存,然后读线程才把旧值写入缓存。这要求“读 DB”比“写 DB + 删缓存”还慢,概率很低。另一个真正的风险是删除缓存失败(网络抖动、Redis 短暂不可用),会留下长期脏数据。
- 删除失败的补救:①重试(失败后丢消息队列异步重试,要幂等);②订阅 binlog(Canal / Debezium)由独立服务解析变更再删缓存,与业务代码解耦,且天然保证“DB 变了才删”;③设置合理的 TTL 作为最终兜底。
- 需要更强一致时:读写加同一把分布式读写锁(读读不互斥、读写互斥),或读多写少的场景用版本号,写缓存时用 Lua 比较版本,旧版本不覆盖新版本。但强一致会牺牲性能,缓存的定位本来就是最终一致。
追问链:
- 主从延迟会影响缓存一致性吗?——会。写主库后删缓存,读线程从从库读到旧值并回写缓存,就把旧数据缓存住了。解决:缓存重建读主库,或延迟删除时间大于主从延迟,或 binlog 订阅方从主库读。
- 为什么不用“先更新缓存”?——并发写下两个线程的“更新 DB 顺序”和“更新缓存顺序”可能相反,缓存最终值与 DB 不同,且读多写少时更新缓存是浪费。
- 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 条件更新。
追问链:
- 锁续期失败会怎样?——看门狗续期依赖 Redis 可达,网络分区时续期失败,锁到期释放,而业务线程可能仍在执行;所以业务要设计成幂等 + 可被 fencing 保护。
- Redisson 的
tryLock(wait, lease)与lock()区别?——lock()无限等待且启用看门狗;tryLock可设等待上限。生产更推荐tryLock避免线程无限挂起。 - 读写锁、联锁(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 + hashtable | OBJECT ENCODING 查看 |
| String 编码 | int / embstr(≤44 字节)/ raw | SDS 的优势 |
| RDB 与 AOF 区别 | 快照紧凑恢复快但可能丢几分钟;AOF 追加更持久但更大 | 官方建议两者并用 |
| AOF 三种 fsync | always / everysec(默认)/ no | everysec 最多丢约 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),请以你线上实际使用的版本文档为准,别在面试里把“最新文档写了”当成“我用过”。
参考资料
- Redis 官方博客:Redis 8 is now GA:核对 Redis 8 的内置数据结构、向量集 beta、IO 线程性能数据、新复制机制、新哈希命令与 AGPLv3 授权选项。
- Redis 官方文档:Data types:官方数据类型清单(含 JSON、时间序列、概率结构与向量集)。
- Redis 官方文档:Persistence:RDB 与 AOF 的原理、fsync 三档、Multi Part AOF(7.0 起)、备份建议。
- Redis 官方文档:Key eviction:maxmemory、淘汰策略、近似 LRU 与 LFU 参数,以及缓冲区不计入淘汰判断的说明。
- Redis 官方文档:Scale with Redis Cluster:Cluster 的分片、可用性与一致性特点(含异步复制导致的丢写窗口)。
- JavaGuide:分布式锁实现方案详解:Redis 锁的常见坑、Redisson 看门狗默认 30 秒续期、Redlock 争议与 fencing token 的整理。
一起聊聊