Redis 面试题


1. Redis 缓存穿透、击穿、雪崩

题目: 请分别说明缓存穿透、缓存击穿、缓存雪崩的概念、产生原因以及各自的解决方案。

💡 答案:

这三个问题名字相似但场景完全不同。

缓存穿透是指查询一个数据库中根本不存在的 key,缓存没有命中,请求直接打到数据库。因为缓存和数据库中都没有,每次查询都会穿透到数据库,恶意攻击者可以用大量不存在的 key 把数据库打死。解决方案有两种:

  • 一是缓存空值——对查询结果为空的数据也做缓存,过期时间设短一些
  • 二是使用布隆过滤器,把所有可能的 key 预先加载到布隆过滤器中,查询前先判断 key 是否存在

缓存击穿是指某个热点 key 在缓存过期的瞬间,大量并发请求同时打到数据库,因为缓存刚好在这一刻失效。解决方案:

  • 一是互斥锁——让一个线程去查数据库并回写缓存,其他线程等待
  • 二是”永不过期”——物理上不设过期时间,用逻辑过期来异步更新

缓存雪崩是指大量缓存 key 在同一时间段集中过期,或者 Redis 集群宕机,导致所有请求瞬间涌向数据库。解决方案:

  • 一是给过期时间加上随机值,避免集中过期
  • 二是使用主从集群加哨兵或 Cluster 模式保证高可用
  • 三是服务降级和限流,在缓存不可用时保护数据库

追问1: 布隆过滤器解决缓存穿透时,如果布隆过滤器判断”存在”就真的一定存在吗?那删除缓存数据时怎么办?

布隆过滤器有一个关键特性:判断”不存在”则一定不存在,判断”存在”则可能存在(有误判率)。这是因为布隆过滤器通过多个哈希函数把元素映射到位数组中,不同元素可能映射到相同的 bit 位产生碰撞。所以即使布隆过滤器说”存在”,也可能是一个不存在的 key 恰好命中了其他 key 的哈希位置,此时还是需要查缓存和数据库确认。删除更棘手——布隆过滤器的位数组不支持直接删除,因为一个 bit 可能被多个 key 共用,删了会影响其他 key。解决方案是使用计数布隆过滤器(Counting Bloom Filter),把 bit 位扩展为计数器,插入加一、删除减一,但代价是内存占用成倍增长。实际工程中如果数据变化不频繁,更常见的做法是定期重建布隆过滤器,而不是实时删除。

追问2: 热点 key 的缓存击穿如果用互斥锁解决,锁的粒度如何设计?会不会变成性能瓶颈?

互斥锁解决缓存击穿的关键在于锁的粒度。如果对整个查询方法加锁,所有请求都会串行化,性能极差。正确的做法是以”缓存 key”为粒度加锁——不同 key 的请求可以并发执行,只有同一个 key 的请求需要排队。实现上可以用 Redis 的 SETNX 命令:设置一个锁 key(比如 lock:product:1001),设置成功的线程执行数据库查询和回写,其他线程自旋等待一段时间后重新查缓存。这里还有一个细节:如果加锁成功但回写缓存失败,锁没有释放,后续请求就永远拿不到锁。所以锁必须设置过期时间,过期时间不宜太短(否则锁提前释放导致多个线程同时查库),也不宜太长(否则故障恢复慢),一般设个 5-10 秒比较合理。另外可以用 Redisson 这类分布式锁框架,它提供了自动续期机制,能避免业务执行时间超过锁过期时间的问题。

📌 易错点 / 加分项:

  • 三个概念很多人搞混,核心区别:穿透是查”不存在的数据”、击穿是”单个热点 key 过期”、雪崩是”大量 key 同时过期或集群挂”
  • 布隆过滤器不能删除是它的核心缺陷,能用这个话题引出计数布隆过滤器会很加分
  • 能提到逻辑过期 + 异步更新的方案(类似 Caffeine 的 refreshAfterWrite)

2. Redis 过期删除与内存淘汰策略

题目: Redis 过期 key 的删除策略是什么?为什么不用定时全部扫描删除?

💡 答案:

Redis 采用”惰性删除 + 定期删除”两种策略组合。惰性删除是指客户端访问 key 时,Redis 先检查 key 是否过期,如果过期了就删除并返回空,不主动触碰。定期删除是指 Redis 每秒执行 10 次(由 hz 参数控制)周期性任务,每次从设置了过期时间的 key 中随机抽取若干个检查并删除过期的 key,每次任务有最大执行时间限制(25ms),如果到时间还没删完就等下一次。仅靠惰性删除不够——有些 key 过期后再也没有被访问过,如果没被别的机制清理就会一直占用内存;仅靠定期删除也不够——定时全量扫描过期字典性能代价太大(可能有几十万个过期 key,一次全扫描会导致 Redis 的单线程被长时间阻塞)。两者组合加上内存淘汰策略形成了一个分层的过期处理机制。

追问1: Redis 的内存淘汰策略有哪些?LRU 和 LFU 的核心区别是什么?

Redis 提供了八种内存淘汰策略,核心分为三大类:

  • 不淘汰(默认的 noeviction,内存满后写操作直接报错)
  • 全量淘汰(allkeys-lruallkeys-lfuallkeys-random,对所有 key 按对应算法淘汰)
  • 带过期时间的淘汰(volatile-lruvolatile-lfuvolatile-randomvolatile-ttl,只淘汰设了过期时间的 key)。LRU(Least Recently Used)的核心思想:最近被访问过的 key 更有可能再次被访问,淘汰最近最少使用的 key。Redis 实现的不是严格 LRU,而是近似 LRU——随机采样 N 个 key(由 maxmemory-samples 控制,默认 5)淘汰其中最久未被访问的那个。LFU(Least Frequently Used)引入了”访问频率”维度,不仅看最近访问还要看访问次数,通过一个 8bit 的计数器统计频率,这个计数器会有衰减机制——随时间流逝频率值自动降低。LFU 解决了 LRU 的一个盲点:那些偶尔被大量访问的冷数据在 LRU 下可能比真正热数据更”新”而逃过淘汰,LFU 能更好地区分”真正的热数据”和”偶发性访问的冷数据”。

追问2: 一个 key 设置了过期时间但到时没有立即删除,什么情况下客户端还读到它?这算 bug 吗?

会读到,这不算是 bug,而是过期删除机制的必然结果。惰性删除只在访问时触发,定期删除是随机采样而非全量扫描——一个过期的 key 可能恰好没有被采样到,也没有被访问,就一直留在内存中。当客户端访问它时,惰性删除机制先发现它过期、删除、返回空——这个过程对客户端来说是正常的。但如果 key 已经逻辑过期但物理上还在内存中,主库里的 key 过期后会写入一条 del 命令到 AOF 和同步到从库保证主从一致。在 Redis 4.0 之前从库没有惰性删除——即使 key 已过期,从库必须等待主库同步 del 命令,如果有客户端读从库可能读到过期数据。Redis 4.0 后从库也加入了惰性删除检查机制。

📌 易错点 / 加分项:

  • Redis 不用定时全量扫描的核心原因是单线程——不能因为清理操作阻塞命令执行
  • maxmemory 设为 0 在 64 位系统表示”不限制内存”,32 位系统隐式为 3GB
  • LFU 的 8bit 计数器如何在有限精度下平衡新旧数据是一个很好的算法设计题

3. Redis 持久化:RDB vs AOF

题目: 请详细对比 Redis 的 RDB 和 AOF 两种持久化方式,各自适用于什么场景?

💡 答案:

RDB 是快照持久化,将某个时间点 Redis 的内存数据完整写入磁盘文件。触发方式可以是 SAVE(阻塞主线程立刻执行)或 BGSAVE(fork 子进程后台写入)。优势是文件紧凑、恢复速度快,适合做灾备和冷备。劣势是 fork 子进程时如果内存大的话可能造成毫秒级阻塞,而且两次快照之间的数据如果宕机会全部丢失。

AOF 是追加写日志,将每一条修改 Redis 数据的写命令追加记录到文件中。优势是数据安全性高(可配置每命令 fsync、每秒 fsync),丢失数据最多一秒;劣势是文件体积大、恢复速度慢。

生产环境典型配置是两者同时开启:用 RDB 做冷备保证恢复速度,用 AOF 保证数据安全性。

追问1: AOF 的重写机制是如何工作的?重写过程中新来的写请求会丢失吗?

AOF 重写的核心思想不是”读原 AOF 文件再压缩写入”,而是”读取当前数据库的快照状态,反向生成写入命令”。Redis fork 出一个子进程,子进程读取当前内存中的数据状态,生成一串可以重建这些数据的最小化命令集合写入新的 AOF 文件。在子进程重写期间,主进程继续接收写请求,这些新命令会被写入”AOF 重写缓冲区”。子进程写完新文件后,主进程将重写缓冲区中积累的增量命令追加到新文件末尾,然后原子地把新文件替换旧文件(rename)。因为重写过程中主进程把新增命令写入了重写缓冲区,所以不会丢失。这个过程中 fork 子进程的那一刻内存数据和当前内存数据是独立的(copy-on-write),父进程和子进程互不干扰。

追问2: Redis 7.0 中 AOF 做了哪些改进?为什么去掉了 AOF 重写的缓冲区?

Redis 7.0 最大的改进是引入了 Multi-Part AOF。之前 AOF 是单文件,重写时同时存在旧文件和新文件 + 重写缓冲区。7.0 将 AOF 设计为 base + incr 的多文件架构:base 文件是某一时刻的完整快照,incr 文件记录后续的增量变化。文件数量由 aof-use-rio 参数控制。去掉了重写缓冲区的主要原因是:在旧方案中重写缓冲区本质上是内存中的缓冲(重写期间积累的命令),如果增量命令很多会消耗大量内存。7.0 的 Multi-Part AOF 方案中,重写时直接将增量写入新的 incr AOF 文件,不需要在内存中缓冲。还有一个改进是 manifest 文件——类似”目录”来管理哪些 base 和 incr 文件组成当前 AOF,管理更清晰,恢复时也更快。

📌 易错点 / 加分项:

  • AOF 重写是由 auto-aof-rewrite-percentageauto-aof-rewrite-min-size 两个参数触发的,不是定时触发
  • 不能只用 RDB——万一宕机丢数据很严重,至少配合每秒 fsync 的 AOF
  • 混合持久化(RDB preamble in AOF)是 Redis 4.0 引入的,将 RDB 数据部分作为 AOF 文件的开头

4. Redis 主从复制与哨兵机制

题目: Redis 主从复制的工作流程是怎样的?复制过程中主库宕机了怎么办?

💡 答案:

Redis 主从复制分为全量复制和增量复制两个阶段。从库首次连接主库,或者主从断开时间过长导致复制积压缓冲区被覆写时,执行全量复制:主库 fork 子进程生成 RDB 快照发给从库,从库清空旧数据加载 RDB,在此期间主库新增的命令写入 client-output-buffer 缓存,RDB 加载完后主库把缓存的增量命令发给从库执行,从库追上主库状态。之后进入增量复制阶段:主库每执行一条写命令,将命令写入自己的复制积压缓冲区并通过网络发给所有从库,从库接收并执行。主库宕机时,需要哨兵或手动将从库提升为新主库,这个过程涉及选举、配置更新和数据同步。

追问1: 哨兵(Sentinel)模式是如何判断主库下线的?主观下线和客观下线有什么区别?

哨兵判断主库下线分为两步:主观下线(SDOWN)和客观下线(ODOWN)。主观下线是单个哨兵通过定期 PING 主库判断——如果在 down-after-milliseconds 时间内主库无响应,这个哨兵认为主库”主观下线”。但这只是一个哨兵的看法,可能是网络抖动误判。客观下线是这个哨兵向其他哨兵发起询问(SENTINEL is-master-down-by-addr),如果达到法定人数 quorum 认为主库也下线了,则判定为”客观下线”,开始执行故障转移。故障转移流程:哨兵 leader 选举(Raft 算法选出一个哨兵来执行),选出一个最优的从库提升为新主库(优先级最高、复制偏移量最大、runid 最小),通知其他从库改认新主库。

追问2: Redis Cluster 和哨兵方案各自适用于什么规模?Cluster 的数据分片机制是怎样的?

哨兵方案适用于”一个主库 + 多个从库”的架构,数据全部在主库上,从库做备份和读分流。当数据量超过单机内存极限时哨兵就不好使了。Redis Cluster 是去中心化的分布式方案,通过哈希槽(Hash Slot,一共 16384 个槽)将数据分片到多个主节点,每个主节点负责一部分槽。客户端连接任意节点,通过 MOVED 重定向和 ASK 转向最终路由到正确的节点。Cluster 方案的扩展方式是增加节点,槽和数据自动迁移。生产选型上:数据量小于单机内存上限时用哨兵方案足够,简单运维成本低;数据量超过单机上限、或者业务需要水平扩展时上 Cluster。

📌 易错点 / 加分项:

  • client-output-buffer 溢出会导致全量复制失败并重试,这是主从复制一个常见的坑
  • 哨兵不是三个就够——quorum 设 2 哨兵数设 5 的配置在生产中错误案例很多
  • Cluster 模式下多 key 操作(mget、事务)要求所有 key 在同一个 slot,跨 slot 不能做

5. Redis 分布式锁的正确实现

题目: 用 Redis 实现一个分布式锁需要考虑哪些问题?SET lock_key value NX EX 10 够吗?

💡 答案:

SET lock_key value NX EX 10 实现了最基本的功能——NX 保证互斥,EX 10 防止锁不释放造成死锁。但在生产环境中,至少有四个问题需要解决:

  • 第一是”误删锁”:线程 A 的锁过期后被线程 B 获取,A 恢复后执行 unlock 会误删 B 持有的锁。解决方案是 value 设唯一标识(UUID 或线程 ID),删除时用 Lua 脚本原子地判断 if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1])
  • 第二是”锁过期但业务没执行完”:设置固定的 10 秒过期,但业务逻辑可能因为 GC 停顿或网络延迟超出 10 秒,锁被自动释放后其他线程获取了锁导致并发安全问题。
  • 第三是”可重入”:同一个线程多次获取同一把锁需要支持。
  • 第四是”获取锁失败时的等待策略”:自旋、阻塞带超时等。

追问1: Redisson 的看门狗(WatchDog)机制解决了什么问题?其原理是什么?

看门狗就是解决”锁过期但业务没执行完”问题的。Redisson 获取锁后,如果未指定锁的过期时间,看门狗会以锁过期时间的 1/3 为间隔(默认锁过期 30 秒,每 10 秒检查一次),检查当前线程是否还持有这把锁,如果还持有着就自动续期(将过期时间重新设为 30 秒)。续期操作通过 Netty 的定时任务 + Redis 的 Lua 脚本完成,只有锁的持有者才有权续期。如果业务执行完手动 unlock,看门狗自动停止续期。如果进程崩溃,定时任务停了,30 秒后锁自然过期,不会死锁。这本质上是一个”心跳续约”模式,保证了锁只有在持有者存活时才有效。

追问2: Redis Cluster 环境下分布式锁有什么额外风险?RedLock 算法是如何尝试解决的?

Redis Cluster 的异步复制带来了安全风险:主节点获取锁成功但这条数据还没来得及同步到从节点,主节点宕机,从节点提升为新主——锁在新主上丢失,另一个线程可能再次获取同一把锁。RedLock 算法是 Redis 创始人提出的方案:在 N 个独立的主节点(注意是独立主节点、不是主从)上依次获取锁,如果能在大多数节点(≥ N/2 + 1)上获取成功且总耗时不长于锁的有效期,则认为获取成功。实施时需要所有节点都用相同配置、物理独立(非同一集群),且获取锁的启动时间需要时钟协调。但 RedLock 在实际中争议很大——分布式系统领域普遍认为它依赖时钟同步,而时钟是不可靠的。Martin Kleppmann 曾发文质疑 RedLock 的安全性,这也是面试中的一个加分谈资。生产实践中,对一致性要求极高的场景建议使用 ZooKeeper 的临时顺序节点或 etcd 的 lease 机制实现分布式锁。

📌 易错点 / 加分项:

  • 误删锁用 UUID + Lua 脚本解决,这个回答几乎是面试标配了
  • 能说清楚 RedLock 的局限性和争议点说明有自己判断力,而不是人云亦云
  • Zookeeper 锁的临时节点方案利用了 ZK 的 session 管理——断开连接自动删锁

6. Redis 五种数据类型及底层编码

题目: Redis 的五种基本数据类型各自的底层编码(内部数据结构)是什么?它们之间是如何自动切换的?

💡 答案:

每种 Redis 类型在不同数据规模下使用不同的内部编码,Redis 根据数据大小自动切换。

  • String:三种编码。int(值是整数且可以用 long 表示时,直接存数字而非字符串)、embstr(短字符串,≤ 44 字节,RedisObject 和 SDS 连续分配在一段内存中,减少内存碎片和两次 Malloc)、raw(长字符串,RedisObject 和 SDS 分开分配)。embstr 是只读的——如果修改 embstr 编码的字符串,它会自动转为 raw。
  • List:两种编码。ziplist(压缩列表,元素少且每个元素小时使用,连续内存存储,节省内存)和 quicklist(Redis 3.2 后默认,本质是 ziplist + 双向链表的混合——每个 list node 中放一个 ziplist)。quicklist 兼顾了 ziplist 的内存效率和链表的插入灵活性。
  • Hashziplist(字段少且每个字段值都短时)和 hashtable(标准哈希表)。
  • Setintset(所有元素都是整数且元素数量少时,有序整数数组)和 hashtable
  • ZSetziplist(元素少且每个元素短时)和 skiplist + hashtable(跳表保证按 score 排序,哈希表保证按 member 查找 O(1))。

编码转换的条件由 redis.conf 中的阈值控制:hash-max-ziplist-entries 512(Hash 的 ziplist 最多条目数)、hash-max-ziplist-value 64(Hash 的 ziplist 每个值的最大字节)。同理 set 有 set-max-intset-entries 512,zset 有 zset-max-ziplist-entries 128zset-max-ziplist-value 64。一旦超出阈值,触发编码升级——升级是单向的,ziplist 升级为 hashtable 后不会自动降回来(即使元素被删得很少)。

追问1: skiplist(跳表)为什么比红黑树更适合 Redis 的 ZSet?

Redis 选择跳表而非红黑树基于三个考量。

  • 实现复杂度:跳表的插入、删除、范围查询逻辑比红黑树的旋转和染色更直观、代码量更少、bug 更少,这在追求稳定性的基础设施中很重要。
  • 范围查询:跳表天然支持顺序遍历(最低层就是有序链表),ZRANGE 操作就是沿着最低层链表走,效率 O(log N + M)。红黑树的范围查询需要中序遍历,不那么直接。
  • 并发友好:跳表的局部性更好,插入/删除时只影响局部节点的前后指针,而红黑树的旋转可能影响树的大部分路径。

📌 易错点 / 加分项:

  • embstr 的 44 字节限制是怎么来的——RedisObject(16B) + SDS header(3B) + 字符串内容(≤ 44B) ≤ 64B(cache line 大小)
  • ziplist 是连续内存结构,插入和删除需要 memmove,元素太多时 O(n) 操作会很慢——这也是设阈值的原因
  • OBJECT ENCODING key 命令可以查看某个 key 当前使用的内部编码

7. Redis 管道(Pipeline)与批量操作

题目: Redis 的管道(Pipeline)是如何提升性能的?它和事务(MULTI/EXEC)有什么区别?批量操作(如 MGET/MSET)和管道各自适合什么场景?

💡 答案:

Redis 的单个命令执行时间通常是微秒级的,但一次命令的往返网络延迟(RTT)可能是毫秒级的。如果做 100 次 SET,不做优化的情况下是 100 次 RTT,总耗时 = 100 × RTT。管道的核心思想是”打包发送”——客户端把一批命令连续发送到 Redis 而不等待单条响应,Redis 按序执行后一次性把所有结果返回。100 次 SET 通过管道只需要 1 次 RTT,总耗时接近 1 RTT + 100 次命令执行时间。

管道和事务的核心区别在于原子性。MULTI/EXEC 事务保证中间的所有命令在 Redis 中作为一个原子单元执行——事务中的命令不会被其他客户端的命令插入打断。管道只是”批量发送”,没有任何原子性保证——管道中的命令在 Redis 端是和其他客户端的命令交替执行的。另外事务在 EXEC 之前不会返回任何结果,管道可以逐步读取响应(但需要维护顺序)。

MGET/MSET 这类原生批量命令比管道更适合”一次操作多个 key”的场景。因为 MGET 在 Redis 服务端一次解析、一次查找、一次返回,效率最高。管道中的多个 GET 虽然也是一次网络往返,但在服务端是逐个解析、逐个执行的。所以能使用 MGET/MSET 的场景优先使用,不能用(key 不固定、跨不同数据类型)时用管道。管道特别适合”大量相同类型的操作”——比如批量写入 1 万条数据做初始化。

追问1: 管道中如果某条命令执行失败(如对 String 执行 LPUSH),会影响后续命令吗?

不会。管道没有原子性,也没有事务的回滚能力。每条命令独立执行,某条失败不影响其他命令。但有个容易踩的坑——管道中的命令执行顺序是保证的(FIFO),所以响应顺序和命令顺序严格对应。客户端需要通过响应顺序来区分哪条命令的结果对应哪个请求。如果一条命令失败(返回错误),错误信息会占据本该是正常结果的响应位置,客户端需要能处理这种错位。

📌 易错点 / 加分项:

  • 管道不是 Redis 服务端的功能——它是客户端的行为模式,服务端无感知
  • 管道的批量大小需要控制——一次发送 10 万条命令会导致 Redis 瞬间占用大量内存承载请求/响应缓冲区
  • Jedis、Lettuce 等客户端都有管道的封装——Lettuce 的异步 API 天然支持”自然管道”

8. Redis 事务与 Lua 脚本

题目: Redis 的事务(MULTI/EXEC)能保证 ACID 中的哪些?它的原子性和数据库事务的原子性有什么不同?Lua 脚本相比事务有什么优势?

💡 答案:

Redis 事务只能保证部分 ACID 特性。

  • 原子性:Redis 事务保证”命令要么全部执行、要么全部不执行”,但这和数据库的原子性有细微区别。Redis 事务中如果某条命令语法错误(如不存在的命令),EXEC 时整个事务不执行(全部不执行);但如果某条命令在语法上正确但操作层面失败了(如对 String 执行 LPUSH),这条命令会执行并报错,但事务中的其他命令继续执行、不会回滚。所以 Redis 事务的原子性不是”要么全成功要么全失败”——它是”命令不被打断地连续执行”(隔离性)。
  • 一致性:Redis 事务不主动保证,应用层自己负责。
  • 隔离性:Redis 单线程执行事务中的命令,天然隔离。
  • 持久性:取决于持久化配置(RDB/AOF)。

Lua 脚本相比事务有三个重要优势。真正的原子性 + 条件判断:Lua 脚本在 Redis 中执行期间,其他命令全部阻塞。脚本内部可以做条件判断——比如 if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]),这是事务做不到的(事务只能顺序执行固定的命令,不能在中间做 if-else 判断)。减少网络往返:Lua 脚本在服务端执行,不需要来回传输中间结果。复杂的逻辑(比如分布式锁的安全解锁)用一条 Lua 脚本一次请求完成。可复用:脚本可以通过 SCRIPT LOAD 预加载到 Redis 的脚本缓存中,后续用 EVALSHA 调用脚本 SHA1 值,减少网络传输。

追问1: Lua 脚本在 Redis 中执行有什么限制?写了一个耗时 5 秒的脚本会发生什么?

最大的限制是”阻塞单线程”——Lua 脚本执行期间 Redis 不能再处理任何其他命令,包括过期 key 的惰性删除和定期删除。如果脚本执行了 5 秒,Redis 在这 5 秒内完全不可用,所有客户端都会感知到卡顿。所以脚本必须短小精悍、避免循环大量数据。Redis 通过 lua-time-limit 参数(默认 5000ms)来限制脚本执行时间——超过后 Redis 不会自动终止脚本,但会接受 SCRIPT KILL 命令来强制停止。如果脚本已经执行了写操作(redis.call("SET", ...)),SCRIPT KILL 无法终止(防止数据不一致),只能等它执行完或 SHUTDOWN NOSAVE。最佳实践:脚本中避免遍历大量 key;避免在脚本中产生无限循环;用 SCAN 系列命令替代 KEYS。

📌 易错点 / 加分项:

  • 事务中的命令在 MULTI 后就排队了,WATCH 监控的 key 在 EXEC 时做乐观锁检查——如果 key 被修改则 EXEC 返回 nil
  • Lua 脚本中不能用 redis.call("MULTI") 嵌入事务——Lua 脚本本身就是原子的
  • 脚本中所有 key 必须通过 KEYS 数组传参(不能用字符串拼接 key 名),这是 Redis Cluster 正确路由的前提

9. Redis Stream 消息队列

题目: Redis Stream 是什么?它和 Redis 的 Pub/Sub 有什么本质区别?它适合替代 Kafka 吗?

💡 答案:

Redis Stream 是 Redis 5.0 引入的持久化消息队列数据结构,它的设计理念和 Kafka 的 Topic 非常类似。核心概念:消息以追加方式写入 Stream,每条消息有一个唯一的 ID(格式为 毫秒时间戳-序号)。消费者组——多个消费者可以属于同一个消费者组,组内的消费者分摊消费消息(类似 Kafka 的 Consumer Group),每个消费者维护自己的消费位置。Stream 支持 ACK 确认——消费者读完消息后需要 XACK 确认,未被确认的消息会在 PEL(Pending Entries List)中保留,超过一定时间可以被其他消费者重新消费(Claim 机制)。

和 Pub/Sub 的本质区别:Pub/Sub 是”即发即忘”——消息发布时如果没有订阅者在线,消息直接丢失。Pub/Sub 不持久化消息、不支持历史消息回放、不支持消费者组。Stream 是持久化的——消息存储在内存+磁盘(取决于配置),消费者下线后重连可以从上次位置继续消费,不会丢消息。但 Stream 的持久化受限于 Redis 的持久化策略——如果 Redis 没有配置 AOF/RDB,宕机后 Stream 数据仍可能丢失。

Stream 不适合替代 Kafka 的场景:数据量极大(每天 TB 级)——Stream 受限于 Redis 实例的内存和磁盘,不像 Kafka 是纯磁盘存储可以无限扩容。多消费者组独立消费——Stream 的消费者组管理和偏移量维护都在 Redis 单实例中,无法像 Kafka 那样水平扩展。Stream 适合”数据量不大但需要轻量级消息队列”的场景——比如负责几个微服务间的异步任务通知,不想引入 Kafka 的运维复杂度。

追问1: Redis Stream 的消息如何确保不丢失?和 Kafka 的不丢消息配置有什么对应关系?

Redis Stream 的消息不丢失需要两端的配合。

  • Producer 端:消息写入 Stream 后用 XADD 返回的 ID 确认写入成功,如果网络错误需要重试。持久化层面,Redis 必须配置 AOF(appendfsync everysec)保证写入持久化。
  • Consumer 端:消费者读完消息处理完毕后才 XACK,如果处理失败不 ACK,消息留在 PEL 中。可以启动一个 Monitor 消费者定期检查 PEL 中超时未确认的消息,用 XCLAIM 将这些消息重新分配给其他消费者处理。

这和 Kafka 的 Producer acks=all + Consumer 手动提交 offset 的思路一致——两端都要确认,而不是单靠一端。

📌 易错点 / 加分项:

  • Stream 的 MAXLEN 参数可以按条数或按消息 ID 截断——XADD mystream MAXLEN ~ 1000 * ...~ 是近似修剪,不会每条都精确修
  • PEL 中积压大量未确认消息是 Stream 性能问题的典型原因——说明消费者处理太慢或没 ACK
  • Stream 的分片(Partitioning)不原生支持——如果需要分片,自己维护多个 Stream key

10. Redis 热 Key 与大 Key 问题

题目: 什么是 Redis 的热 Key 和大 Key?它们各自带来什么问题?如何发现和解决?

💡 答案:

热 Key 是指被远超平均频率访问的 key——比如秒杀活动中某个商品的库存 key,QPS 可能达到数万甚至数十万。热 Key 的问题在于”单点瓶颈”——如果这个 key 落在某个 Redis 分片上,该分片的 CPU 和网络带宽被独占。在 Redis Cluster 中,热 Key 可能导致某个节点的 QPS 远超其他节点,集群负载严重倾斜。

大 Key 是指数据量很大的 key——比如一个 Hash 里有 100 万个 field、一个 List 里有 1000 万个元素、一个 String 存了 10MB 的 JSON。大 Key 的问题包括:

  • 内存不均衡:某个 key 占了几 GB,其他 key 才几十字节,无法均匀分配内存。
  • 操作阻塞:对大 Key 做 DEL 操作 Redis 会阻塞主线程(需要释放大量内存),DEL 一个 1GB 的 key 可能卡主 Redis 好几秒。
  • 网络传输:读取大 Key 会占用大量带宽,客户端接收数据也可能 OOM。
  • 数据迁移:Cluster 迁移槽时,大 Key 的迁移时间极长且失败率高。

热 Key 的发现用 redis-cli --hotkeys(Redis 4.0+,内部用 LFU 统计访问频率)。解决方案:二级缓存——在应用服务的本地内存中缓存热 Key(如 Caffeine),本地命中了就不访问 Redis,TTL 设短一些(如 1-5 秒)。热 Key 复制——在 Redis Cluster 的不同节点上手动创建热 Key 的副本(不同 key 名),客户端随机选择一个副本读取,分摊读压力,适合”读多写少且对一致性容忍度高”的场景。

大 Key 的发现用 redis-cli --bigkeysMEMORY USAGE key。解决方案:大 String 拆分为多个小 String 或压缩后存储。大 Hash 拆分为多个小 Hash 或使用”Field 分桶”。最重要的是避免 DEL 大 Key 导致阻塞——使用 UNLINK 命令(异步删除)。

📌 易错点 / 加分项:

  • redis-cli --hotkeys 需要先设置 maxmemory-policy 为 LFU 模式,否则无法统计热度
  • 热 Key 复制方案中副本没有自动同步——写操作需要同步到所有副本(成本高),适合只读场景
  • Redis 6.0+ 的 CLIENT TRACKING 可以做服务端协助的客户端缓存——热 Key 方案的更优雅实现

11. Redis 多线程模型(6.0/7.0)

题目: Redis 6.0 引入的多线程和”Redis 一直是单线程”矛盾吗?多线程到底用在了哪里?Redis 7.0 又有哪些改进?

💡 答案:

不矛盾。Redis 的核心”命令处理”始终是单线程的——从队列中取出命令、解析命令、执行命令、写入响应,这一串操作始终由一个主线程完成。Redis 6.0 引入的多线程是用在”网络 IO 处理”上的——多线程并发读取 socket 中的请求数据、多线程并发将响应数据写回 socket。命令执行仍然是单线程。这种设计利用了以下事实:Redis 的性能瓶颈在极高峰值下通常不是 CPU 的计算能力,而是网络 IO 的处理能力。加入 IO 多线程后,Redis 可以将数据的读和写分担到多个线程上并行处理。

具体配置通过 io-threads 参数(默认 1,即不用多线程 IO),设为 4 表示启用 4 个 IO 线程 + 1 个主线程。IO 线程只处理协议解析和数据回写,命令的执行依然是单线程。建议 IO 线程数不要超过 CPU 核数,通常 4-8 个就够了。

Redis 7.0 在 IO 多线程上做了进一步优化。引入了”IO 线程感知”——主线程在 IO 线程工作时继续处理其他任务(如过期 key 删除),更充分地利用 CPU。另外 7.0 重构了 AOF 为 Multi-Part AOF(base + incr 多文件),AOF 写入也从主线程剥离了一部分。但核心逻辑不变——命令执行永远是单线程,Redis 的设计哲学认为”单线程执行命令带来的代码简洁性和数据一致性”比”多线程并发执行命令带来的性能提升”更值得。

追问1: Redis 6.0 的多线程 IO 和 Memcached 的多线程模型有什么不同?

Memcached 从一开始就是多线程——命令的处理也是多线程并发执行的。为了维护并发安全,Memcached 内部用了大量锁(每个 slab class 有自己的锁)。Redis 选择只在网络 IO 层加多线程、命令执行保持单线程,这样 Redis 完全不需要任何锁来保护内部数据结构——跳表、哈希表、压缩列表全都是无锁的。这也是 Redis 代码量比 Memcached 小很多、bug 也更少的原因之一。

📌 易错点 / 加分项:

  • 多线程 IO 不是默认开启的——io-threads 默认是 1,需要根据实际压力测试来决定是否开启
  • 小实例(QPS < 10 万)开了多线程 IO 反而可能更慢——线程切换的开销大于 IO 并行的收益
  • Redis 7.0 的 --enable-debug-command 可以查看各 IO 线程的负载分布

12. Redis 内存优化实践

题目: Redis 的内存成本很高,你有哪些手段来节约 Redis 内存?从数据结构、编码、key 设计三个方面说。

💡 答案:

节约 Redis 内存是降本的核心技能,从以下三个维度展开。

数据结构层面:关键是”用对类型、用最小类型”。能存为数字的就不要存为字符串——“2026”存为 long 只占 8 字节,存为 String 至少占 RedisObject(16 字节) + SDS(8 字节 + 4 字节对齐) = 28+ 字节。能用 Bitmap 存储的布尔状态、签到状态不要用 Set。能用 HyperLogLog 做去重统计的不要用 Set 存储所有 ID——1 亿个 UV 用 Set 存需要 GB 级内存,用 HyperLogLog 只需要 12KB。

编码层面:了解小对象编码的阈值。Hash 的阈值 hash-max-ziplist-entries 512hash-max-ziplist-value 64——如果你的 Hash 都是”id: 123, name: 张三”这种短字段,可以适当调大阈值让更多 Hash 用 ziplist 编码,内存节省显著。

Key 设计层面:key 名本身也占内存。user:basic:info:10001 这种长 key 名在亿级数据量下,key 名本身占的内存就是一个可观的数字。优化为 u:b:i:10001u:10001 可以节省很多。但不能为了压缩导致不可读——需要制定一个统一的命名规范。

数据回收层面:设置合理的内存上限和淘汰策略。maxmemory 不要盲目设为 0(不限制),设一个合理值配合 maxmemory-policy allkeys-lruvolatile-lfu,让 Redis 自动淘汰不常用的数据。设置 key 的 TTL——不是所有数据都需要永久保存,缓存类型的数据要有过期时间。使用 MEMORY DOCTOR(4.0+)来分析内存问题——它会给出内存浪费的检测和优化建议。

📌 易错点 / 加分项:

  • ziplist 虽然省内存但操作复杂度是 O(n)——条目太多时性能会下降,阈值不是越大越好
  • 7.0 引入了 list-max-listpack-size(替代 ziplist),listpack 内部实现更优
  • MEMORY PURGE 可以主动归还操作系统中被 Redis 占用但实际可释放的内存页(碎片整理)

13. Redis 6.0/7.0 ACL 安全控制

题目: Redis 6.0 引入的 ACL(访问控制列表)解决了什么安全问题?如何用它实现最小权限原则?

💡 答案:

在 Redis 6.0 之前,安全控制几乎只有一层密码——requirepass。所有客户端通过同一个密码登录后拥有完全相同的权限,可以执行任何命令、访问任何 key。这意味着只要有一个客户端被攻破或者一个开发人员无意中执行了危险命令(FLUSHALLCONFIG SET),整个 Redis 实例就完蛋了。ACL 解决了”多用户、多权限”的问题——你可以创建多个用户,每个用户有独立的密码、独立的命令权限、独立的 key 访问权限。

ACL 的最小权限实践——为不同角色创建不同的用户。

  • 应用业务用户ACL SETUSER app_user on >password ~prefix:* +@all -@dangerous——只能访问以 prefix: 开头的 key,不能执行 FLUSHALLCONFIGKEYS 等危险命令。
  • 监控用户ACL SETUSER monitor_user on >password +@read +info +ping——只能读数据,不能写。
  • 管理员用户ACL SETUSER admin on >strongpassword +@all——拥有所有权限但需要强密码。

ACL 还支持子命令级别控制——ACL SETUSER limited on >password +set +get -config|set——允许 SET/GET 但不允许 CONFIG SET。另外 ACL LOG 可以查看被拒绝的请求记录——列出最近的拒绝记录(哪个用户、执行了什么命令、什么时间),方便排查安全问题和审计。

📌 易错点 / 加分项:

  • Redis 6.0 的 default 用户默认没有密码——这意味着不设 ACL 的话行为和 5.x 一样,不会自动变安全
  • ACL 配置文件 aclfilerequirepass 互斥——如果配置了 aclfile,requirepass 失效
  • ACL 的 ~ 权限中的 pattern 不是正则表达式而是 glob 模式——~prefix:* 匹配以 prefix: 开头的所有 key

14. Redis 客户端缓存与 RESP3

题目: Redis 6.0 的客户端缓存(Client Side Caching)是什么?它和传统的本地缓存(如 Caffeine)有什么区别?RESP3 协议和 RESP2 有什么改进?

💡 答案:

传统本地缓存(如 Caffeine + Redis)的问题在于”缓存一致性”——Redis 中的数据变了,本地缓存不知道,只能靠 TTL 过期。TTL 设短了 Redis 访问频繁、缓存命中率低;设长了数据不一致时间长。Redis 6.0 的客户端缓存解决了这个问题——Redis 服务端主动通知客户端”你缓存的 key 已失效”。

工作模式分为两种。默认模式(Server-assisted):客户端通过 CLIENT TRACKING on 开启,服务端在 key 被修改时通过”失效消息”通知客户端。广播模式:客户端订阅一个 key 前缀,服务端广播该前缀下的所有 key 变更。客户端收到失效通知后移除本地对应的缓存,下次从服务端重新拉取。这个设计使得本地缓存的 TTL 可以设得很长(甚至无过期),一致性由服务端推送保证而不依赖 TTL。

但它有一个核心缺陷——Redis 需要为每个开启了 tracking 的客户端维护一个”失效表”(记录该客户端缓存了哪些 key),如果客户端缓存了大量 key,这个失效表的开销很大。7.0 的改进是引入了”OPTIN”模式——客户端只缓存明确声明需要追踪的 key,而不是所有读过的 key 都追踪,大幅减少失效表的大小。

RESP3 是 Redis 6.0 引入的新协议版本,核心改进是从”纯文本、类型模糊”变成”类型明确”。RESP2 中所有返回值都是 Bulk String 或 Array——数字”123”也是返回 “123” 字符串,null 用 $-1 表示。RESP3 引入了明确的类型标记——数字返回 :123\r\n、Double 返回 ,3.14\r\n、布尔返回 #t\r\n/#f\r\n、Map 返回 %、Set 返回 ~。这让客户端不需要”猜”返回类型,也自然支持了客户端缓存的推送消息(Push 类型 >)。

📌 易错点 / 加分项:

  • 客户端缓存的”失效消息”是”尽力投递”——如果客户端网络断开,可能漏收失败通知,所以本地缓存还需要 TTL 兜底
  • RESP3 需要客户端显式启用——HELLO 3 命令切换到 RESP3,否则默认还是 RESP2
  • 目前 Lettuce(6.3+)和 Jedis(5.0+)都已支持 RESP3

15. Redis Cluster 数据迁移与客户端路由

题目: Redis Cluster 的在线扩容(新增节点)时数据是如何迁移的?迁移过程中客户端的请求如何处理?MOVED 和 ASK 的区别是什么?

💡 答案:

Redis Cluster 扩容新增一个节点后,需要把部分哈希槽从现有节点迁移到新节点。迁移过程是”逐槽进行、在线迁移”。每个槽的迁移分为三个阶段:

  1. 在目标节点上执行 CLUSTER SETSLOT <slot> IMPORTING <source_id>——目标节点标记为”正在接收该槽”。
  2. 在源节点上执行 CLUSTER SETSLOT <slot> MIGRATING <target_id>——源节点标记为”正在迁出该槽”。
  3. MIGRATE 命令逐个迁移该槽中的 key——MIGRATE 命令是原子操作,它从源节点读取 key 的 value + TTL 序列化打包发给目标节点,目标节点写入后源节点删除。

一个槽的所有 key 迁移完后,两个节点都执行 CLUSTER SETSLOT <slot> NODE <target_id> 把槽的所有权指向新节点。

迁移过程中的客户端请求处理:如果请求的 key 还在源节点上——源节点正常处理并返回。如果请求的 key 已经被迁走了——源节点返回 ASK 错误(-ASK <slot> <target_ip:port>),告诉客户端”你问的 key 可能已经迁移到目标节点了,去那边问”。客户端收到 ASK 后,需要先向目标节点发送 ASKING 命令,然后重试原命令。

MOVED 和 ASK 的核心区别:MOVED 是”槽的永久归属变更”——槽已经不属于这个节点了,客户端应该更新本地槽表,后续请求直接走新节点。ASK 是”槽迁移中的临时重定向”——槽还在迁移中,只有这个特定 key 被迁走了,槽中其他 key 可能还在源节点,客户端不应该更新槽表(因为迁移还没完成)。

📌 易错点 / 加分项:

  • 迁移过程中如果源节点和目标节点之间网络断开,MIGRATE 命令会失败,需要重试——槽保持 MIGRATING/IMPORTING 状态直到迁移完成
  • 扩容期间如果应用执行了需要跨槽的多 key 操作(如 MSET),可能会因为部分 key 还在旧节点而失败
  • Redis Cluster 的迁移工具 redis-cli --cluster reshard 内部就是逐槽执行 MIGRATE,做的是在线迁移