Skip to content

Redis

Redis 面试必考三件套:数据结构与应用、持久化、缓存三大问题(穿透/击穿/雪崩)。

Q1: Redis 的五种基本数据结构及应用场景? 「🟢 校招/初级」

考察点:选型能力,要能说出"什么业务用什么结构"。

参考答案

  • String:缓存、计数器(INCR)、分布式锁(SET NX EX)、限流。
  • Hash:对象属性存取(如用户信息),比多个 String 键更省内存。
  • List:消息队列(LPUSH + BRPOP)、最新动态列表。
  • Set:去重、共同好友(交集)、抽奖(随机弹出)。
  • ZSet(Sorted Set):排行榜、延迟队列(score 存执行时间)、滑动窗口限流。

进阶结构:Bitmap(签到、布隆过滤器自建)、HyperLogLog(UV 统计)、Stream(完整消息队列,支持消费组)、GEO。

追问延伸

  • ZSet 底层为什么用跳表?(见数据结构篇)
  • Redis 对象编码优化了解吗?(ziplist/listpack、intset)

Q2: Redis 为什么这么快? 「🟡 中级」

考察点:高性能架构的综合理解。

参考答案

  • 纯内存操作:数据在内存中,读写无磁盘瓶颈。
  • 单线程模型(核心命令处理):避免锁和上下文切换;6.0 后 IO 多线程但命令执行仍单线程。
  • IO 多路复用:epoll 同时监听海量连接。
  • 高效数据结构:SDS、ziplist、跳表、哈希表等针对性优化。

追问延伸

  • 单线程为什么还要避免大 key 和耗时命令(KEYS)?
  • Redis 6.0 的多线程解决的是什么问题?

Q3: RDB 和 AOF 的区别? 「🟡 中级」

考察点:持久化机制与数据安全性取舍。

参考答案

  • RDB:定时全量快照(fork 子进程 + COW),文件紧凑恢复快,但两次快照之间的数据可能丢失。
  • AOF:追加写命令日志,appendfsync 可选 always/everysec/no;数据更安全,文件大恢复慢,靠重写压缩。
  • 4.0+ 支持混合持久化:RDB 快照 + 增量 AOF,兼顾恢复速度和数据安全。
  • 生产常见配置:AOF everysec(最多丢 1 秒)+ 定期 RDB 备份。

追问延伸

  • fork 的时候为什么会有性能抖动?(大内存下 COW 页复制)
  • AOF 重写是怎么做到不阻塞的?

Q4: 缓存穿透、击穿、雪崩分别是什么?怎么解决? 「🟡 中级」

考察点:最高频的缓存综合题。

参考答案

  • 穿透:查询不存在的 key,请求全部打到数据库。解法:空值缓存(短 TTL)、布隆过滤器拦截。
  • 击穿:热点 key 过期瞬间,海量请求同时打到数据库。解法:互斥锁重建缓存、逻辑过期(不真删)、热点 key 永不过期。
  • 雪崩:大量 key 同时失效或 Redis 宕机。解法:过期时间加随机抖动、多级缓存(本地 + Redis)、熔断降级、Redis 高可用部署。

追问延伸

  • 互斥锁重建缓存时,其他线程等待还是返回旧值?各有什么取舍?
  • 本地缓存怎么保证和 Redis 的一致性?

Q5: 缓存和数据库的一致性怎么保证? 「🔴 高级」

考察点:分布式场景下的数据一致性思维。

参考答案

  • 主流方案:Cache Aside 模式——读:先缓存后数据库并回填;写:先更新数据库,再删除缓存
  • 为什么是"删缓存"而不是"更新缓存":避免并发写导致缓存与数据库顺序错乱,也避免无效计算。
  • 为什么先更新库后删缓存:极端并发下仍有短暂不一致窗口,可配合延迟双删、订阅 binlog 异步删除(Canal)兜底。
  • 强一致需求:直接读数据库或加锁串行化,缓存方案本质是最终一致。

追问延伸

  • 删除缓存失败怎么办?(重试队列 / binlog 补偿)
  • 读写并发下"旧值回填"问题怎么解?

Q6: Redis 分布式锁怎么实现?有什么坑? 「🔴 高级」

考察点:分布式锁的正确性细节。

参考答案

  • 基本实现:SET key uniqueValue NX EX 30,value 存唯一标识防止误删别人的锁,删除用 Lua 脚本保证"判断 + 删除"原子。
  • 坑 1:锁过期但业务没执行完 → 看门狗自动续期(Redisson 默认 30 秒,每 10 秒续一次)。
  • 坑 2:主从切换丢锁 → RedLock 向多个独立实例加锁(有争议,工程上用 Redisson 单节点 + 业务幂等更常见)。
  • 可重入:Redisson 用 Hash 结构记录重入次数。

追问延伸

  • RedLock 的争议点是什么?(Martin Kleppmann 时钟跳变质疑)
  • 分布式锁和数据库乐观锁怎么选?

Q7: Redis 的过期策略和内存淘汰策略? 「🟡 中级」

考察点:资源管理机制。

参考答案

  • 过期策略:惰性删除(访问时才删)+ 定期删除(随机抽样清理),两者结合平衡 CPU 和内存。
  • 内存淘汰(达到 maxmemory 时):
    • noeviction:报错拒绝写入(默认)。
    • allkeys-lru / volatile-lru:近似 LRU(随机采样后淘汰最旧的)。
    • allkeys-lfu:按访问频率(4.0+)。
    • volatile-ttl:淘汰快过期的。

追问延伸

  • 为什么 Redis 用近似 LRU 而不是精确 LRU?(内存开销)
  • 大量 key 同时过期会有什么问题?

Q8: Redis 的内存模型和对象编码是什么? 「🔴 高级」

考察点:Redis 高性能和省内存的底层原理。

参考答案

Redis 对象 = redisObject + 底层编码结构

redisObject 核心字段:

  • type:对象类型(string、list、hash、set、zset 等)
  • encoding:底层编码方式(决定用什么数据结构存)
  • ptr:指向底层数据结构的指针
  • lru:LRU 时间/信息(用于内存淘汰)
  • refcount:引用计数(对象共享、内存回收)

各类型的编码方式:

  • String
    • int:存 8 字节以内的整数(直接用 ptr 存值,不额外分配)
    • embstr:短字符串(≤ 44 字节),redisObject 和 SDS 连续分配,一次 malloc
    • raw:长字符串,SDS 独立分配
  • List
    • quicklist(Redis 3.2+):ziplist + linkedlist 的结合,每个节点是一个 ziplist
    • 旧版本:元素少且小用 ziplist,元素多或大用 linkedlist
  • Hash
    • ziplist(元素少且值小):field 和 value 连续存储,省内存
    • hashtable:元素多或值大时转成哈希表
  • Set
    • intset(全是整数且数量少):有序整数数组
    • hashtable:有非整数或数量多时转换
  • ZSet
    • ziplist(元素少且 member 小):element 和 score 连续存储
    • skiplist + hashtable:元素多时,跳表保证有序范围查询,hashtable 保证 O(1) 按 member 查找

编码转换是单向的(从小转大,不会自动转回),可通过配置阈值控制(如 hash-max-ziplist-entrieshash-max-ziplist-value)。

追问延伸

  • ziplist 为什么省内存?有什么缺点?(连续存储、无指针开销;连锁更新问题)
  • quicklist 相比 ziplist 和 linkedlist 有什么优势?(平衡了空间和插入/删除性能)

Q9: Redis 的集群方案有哪些?主从、哨兵、Cluster 的区别? 「🔴 高级」

考察点:Redis 高可用和水平扩展方案的掌握。

参考答案

三种主流方案,能力从低到高:

1. 主从复制(Master-Slave)

  • 一个主节点,多个从节点;主写从读,读写分离
  • 数据同步:全量同步(RDB + 复制缓冲区)+ 增量同步(repl_backlog_buffer)
  • 问题:主节点挂了需要手动切换,不能自动故障转移;写能力受限于单节点

2. 哨兵模式(Sentinel)

  • 在主从基础上增加 Sentinel 节点集群(3 个以上,奇数,防止脑裂)
  • 四大功能:监控、自动故障转移、配置中心、通知
  • 故障转移流程:主观下线 → 客观下线 → 选举领头 Sentinel → 选举新主节点
  • 问题:还是只有一个主节点提供写,写能力和存储能力无法水平扩展

3. Redis Cluster

  • 去中心化,多个节点组成集群,每个节点既负责数据又负责协调
  • 数据分片:16384 个槽(slot),每个节点负责一部分槽
  • 客户端路由:计算 key 的 CRC16 % 16384 确定槽,直接找对应节点;MOVED/ASK 重定向
  • 高可用:每个主节点有从节点,主挂了自动切换从节点
  • 特点:支持水平扩展、去中心化;不支持跨槽事务和批量操作(mget 等需用 hash tag 解决)

选型对比:

方案高可用水平扩展写能力适用场景
主从❌ 手动切换单节点简单读写分离、数据量小
哨兵✓ 自动故障转移单节点读多写少、数据量不大
Cluster✓ 自动故障转移✓ 分片多节点数据量大、需要水平扩展

追问延伸

  • 哨兵的客观下线和主观下线的区别?(主观:单个 Sentinel 认为下线;客观:超过 quorum 个 Sentinel 都认为下线)
  • Redis Cluster 怎么扩容?(添加节点 → 迁移槽 → 确认完成)

Q10: Redis 大 Key 是什么?怎么发现和处理? 「🟡 中级」

考察点:线上 Redis 性能问题的排查能力。

参考答案

大 Key 定义:value 很大的 key(经验阈值:string 超过 10KB,list/hash/set/zset 元素超过几千个)。

危害

  • 读写大 Key 耗时长,阻塞 Redis 单线程
  • 网络传输慢,占用带宽
  • 内存占用大,淘汰/过期时影响更严重
  • 删除大 Key 导致阻塞(4.0 前是同步删除)
  • 主从同步时大 Key 同步放大问题

发现方法

  • redis-cli --bigkeys(生产慎用,会 scan 整个库,阻塞)
  • 业务侧监控埋点(记录大 value 的写入)
  • 第三方工具:RedisLive、RedisInsight
  • RDB 分析工具:rdbtools(离线分析 dump 文件)

处理方法

  • 拆分大 Key
    • 大 string:拆成多个小 key(按业务分片),或存成 hash
    • 大 list/hash/zset:按业务维度分片(如按天分、按用户分、按区域分)
  • 异步删除(UNLINK):4.0+ 支持,后台线程删,不阻塞主线程
  • 清理过期数据:定期清理不需要的元素(如历史数据归档)
  • 压缩存储:value 存压缩后的内容(如 gzip、snappy),用 CPU 换内存

追问延伸

  • 为什么不能直接 DEL 大 Key?(阻塞主线程,影响所有请求)
  • 一个 ZSet 有 100 万元素,怎么安全删除?(UNLINK 或分批 ZREMRANGEBYRANK)

Q11: Redis 热 Key 是什么?怎么发现和解决? 「🟡 中级」

考察点:热点数据问题的处理方案。

参考答案

热 Key 定义:访问量极高的 key(如秒杀商品、热点新闻首页、活动页配置)。

危害

  • 单节点流量过大,打满带宽或 CPU
  • 缓存击穿(热 key 失效瞬间,大量请求打到数据库)
  • 影响同一节点上的其他 key(资源被挤占)

发现方法

  • 客户端统计:埋点记录访问量(聚合上报)
  • redis-cli --hotkeys(Redis 4.0+,基于 LFU 统计)
  • 代理层统计:如 Twemproxy、Codis、公司自研代理
  • Redis MONITOR(生产慎用,严重影响性能,仅短时排查用)

解决方案

  1. 本地缓存:应用层加本地缓存(Caffeine / Guava Cache),减少 Redis 访问;TTL 设短(秒级)
  2. 热 Key 打散:复制多份相同数据到不同 key(key_01、key_02 ... key_N),随机访问,分散流量
  3. 二级缓存:本地缓存 + Redis,本地缓存设置较短 TTL,Redis 设较长 TTL
  4. 读写分离:读请求走从节点(注意一致性,适合读远多于写的场景)
  5. 永不过期 + 异步更新:热 key 不设过期,后台异步定时更新

追问延伸

  • 本地缓存和 Redis 缓存怎么保证一致性?(短 TTL + 主动失效通知 / 订阅 MQ 删本地缓存)
  • 热 key 突然失效(击穿)怎么处理?(互斥锁重建、逻辑过期、永不过期)

Q12: Redis Pipeline 和事务的区别?Lua 脚本的原子性? 「🟡 中级」

考察点:Redis 批量操作和原子性的理解。

参考答案

Pipeline(管道)

  • 把多个命令打包一次性发给 Redis,减少网络往返次数(RTT)
  • 只是减少网络开销,不保证原子性(执行过程中可能插入其他命令)
  • 客户端攒一批命令发出去,服务端批量返回结果

事务(MULTI + EXEC)

  • 保证一组命令原子执行(EXEC 后一次性执行,中间不插入其他命令)
  • 入队时不执行,EXEC 时一起执行
  • 不支持回滚:语法错误会全部不执行,运行时错误继续执行后面的
  • 可配合 WATCH 实现乐观锁(WATCH 的 key 被修改则事务取消)

Lua 脚本

  • Redis 2.6+ 支持,执行 Lua 脚本
  • 整个脚本是原子的(执行期间不执行其他命令,类似事务)
  • 比事务更灵活:可以有逻辑判断、循环、计算等
  • 常用场景:分布式锁释放(判断 + 删除原子)、原子扣减库存、复合操作

对比总结:

特性Pipeline事务(MULTI/EXEC)Lua 脚本
原子性
减少网络开销❌(每个命令单独发)
逻辑控制
复杂度
典型场景批量写入简单原子操作复杂原子逻辑

追问延伸

  • Redis 执行 Lua 脚本有什么注意事项?(不要有耗时操作、控制脚本大小、用参数传 key 避免硬编码)
  • 为什么 Redis 事务不支持回滚?(作者认为不需要,运行时错误都是编程错误,应该在开发阶段发现)

Q13: Redis 缓存更新策略有哪些?Cache Aside、Read/Write Through、Write Behind? 「🟡 中级」

考察点:缓存设计模式的理解和选型。

参考答案

四种常见策略:

1. Cache Aside(旁路缓存)—— 最常用

  • :先读缓存,命中返回;没命中读数据库,写入缓存后返回
  • :先更新数据库,再删除缓存
  • 优点:简单、灵活、业务侵入小
  • 缺点:有短暂不一致窗口(数据库已更新、缓存还没删的瞬间)

2. Read Through(读穿透)

  • :直接读缓存,缓存自己负责从数据库加载并回填
  • 应用层只和缓存交互,不用关心数据库
  • 应用:Caffeine、Guava Cache 的 loader 机制

3. Write Through(写穿透)

  • :先写缓存,缓存同步写数据库,两者都成功才返回
  • 应用层只写缓存,由缓存负责写数据库
  • 优点:数据一致性好
  • 缺点:写延迟高(两次写),缓存层复杂度高

4. Write Behind(写回 / 异步写)

  • :只写缓存,异步批量写数据库
  • 优点:写性能极高(可以合并多次写)
  • 缺点:数据有丢失风险(缓存挂了未刷盘的数据丢失)
  • 应用:操作系统页缓存、消息队列刷盘、MySQL 的 Buffer Pool

选型建议:

策略一致性性能复杂度适用场景
Cache Aside最终一致绝大多数业务
Read Through最终一致读多、缓存框架支持
Write Through强一致一般写多、一致性要求高
Write Behind弱一致极好写极多、允许丢少量数据

追问延伸

  • Cache Aside 为什么是删缓存而不是更新缓存?(避免并发写导致缓存与数据库顺序错乱;避免无效计算)
  • 先删缓存再更新数据库有什么问题?(线程 A 删缓存 → 线程 B 读穿透读旧值回写 → 线程 A 更新数据库 → 缓存永久旧值)

Q14: Redis 可以做消息队列吗?有哪些方式? 「🟡 中级」

考察点:Redis 的消息队列方案及适用性。

参考答案

Redis 可以做消息队列,但不是专业 MQ,适合简单、轻量的场景。

1. List + LPUSH / BRPOP

  • 生产者 LPUSH,消费者 BRPOP 阻塞消费
  • 优点:简单、轻量、零依赖
  • 缺点:
    • 不支持多消费者组(一个消息只能被一个消费者消费)
    • 没有 ACK 机制(消息取出就没了,消费者挂了就丢了)
    • 不支持消息回溯

2. Pub/Sub(发布订阅)

  • 发布者 publish 到频道,订阅者 subscribe 接收
  • 优点:支持多订阅者、广播模式、实时推送
  • 缺点:
    • 消息不持久化(发布时没订阅就丢了)
    • 消费者下线消息丢了
    • 没有 ACK、没有重放、不支持堆积

3. Stream(Redis 5.0+)

  • 专门的消息队列结构,支持消费组
  • 特点:
    • 消息持久化(存在 Redis 内存中)
    • 支持消费组(组内竞争消费,组间广播)
    • 支持 ACK(确认机制,XACK)
    • 支持消息回溯(按 ID 消费)
    • 支持 pending 消息(已取出未确认的)
  • 缺点:
    • 没有死信队列
    • 没有原生延迟消息
    • 不如 Kafka 等专业 MQ 功能完善、吞吐量有差距

选型建议:

方案持久化消费组ACK回溯适用场景
List简单异步任务、对可靠性要求不高
Pub/Sub多订阅者广播通知、实时消息推送
Stream需要消费组、ACK、可靠性较高的轻量场景
专业 MQ高可靠、高吞吐、海量数据

追问延伸

  • Stream 和 Kafka 的区别?(持久化介质、吞吐量、生态、运维复杂度)
  • Redis 做延迟队列怎么实现?(ZSet + score 存执行时间 + 轮询消费)

Q15: Redis 6.0 的多线程 IO 是怎么回事?还是单线程吗? 「🔴 高级」

考察点:Redis 6.0 多线程改动的深入理解。

参考答案

Redis 6.0 的多线程:

  • 命令执行仍然单线程:所有命令的执行(读写数据、逻辑处理)还是单线程
  • IO 多线程:网络读写(读取客户端请求、发送响应)可以用多线程
  • 目的:解决网络 IO 瓶颈(高 QPS 下网络 IO 成为瓶颈)

为什么改:

  • 5.x 之前:单线程处理所有事情(IO + 命令执行),网络 IO 在高并发下成为瓶颈
  • 6.0:把网络 IO 交给多线程,命令执行保持单线程(保证线程安全,无锁)

工作流程:

  1. IO 线程池读取客户端命令(多线程并行)
  2. 命令解析后排队,主线程单线程执行(保证原子性)
  3. IO 线程池发送响应(多线程并行)

配置:

  • io-threads 4:设置 IO 线程数(建议 CPU 核数 / 2,不超过 8)
  • io-threads-do-reads yes:IO 线程也负责读(默认只负责写)

适用场景:

  • 高 QPS(10万+)、大 value、网络 IO 是瓶颈的场景
  • 小 value、低 QPS 时多线程 IO 提升不明显

其他 6.0 新特性:

  • ACL(访问控制列表):细粒度权限控制
  • TLS 支持:加密通信
  • RESP3 协议:更丰富的数据类型返回
  • 客户端缓存:Server-assisted client-side caching

追问延伸

  • 命令执行为什么不也用多线程?(保证无锁、简单、避免并发问题)
  • 怎么验证多线程 IO 的效果?(压测对比)

Q16: Redis 主从同步的完整流程?全量同步和增量同步的区别? 「🟡 中级」

考察点:Redis 复制机制的详细原理。

参考答案

主从同步流程(新从节点第一次连接主节点):

  1. 全量同步(Full Resync):

    • 从节点发送 PSYNC 命令(2.8+ 支持 PSYNC,5.0+ 用 PSYNC2)
    • 主节点判断需要全量同步 → 执行 BGSAVE 生成 RDB → 发送给从节点
    • 同时,主节点把生成 RDB 期间的新写入命令存到复制缓冲区(repl_backlog_buffer)
    • 从节点加载 RDB → 追加执行缓冲区的命令 → 同步完成
  2. 增量同步(Partial Resync):

    • 从节点断线重连后,发送自己的 offset
    • 主节点判断 offset 在 repl_backlog_buffer 范围内 → 只发送差异部分
    • 条件:断线时间短、offset 还在环形缓冲区内
    • 否则退化为全量同步

全量同步触发条件:

  • 从节点第一次连接
  • 从节点断线太久,offset 不在缓冲区内
  • 主节点切换(新主没有旧主的复制数据)

repl_backlog_buffer:

  • 环形缓冲区(默认 1MB)
  • 主节点写入命令时同时写入此缓冲区
  • 全量同步期间的新命令也存这里
  • 太小可能导致频繁全量同步(建议调大:repl-backlog-size 256mb)

追问延伸

  • 主从同步有延迟怎么办?(读写分离时考虑延迟、等待同步完成)
  • 全量同步时主节点性能影响?(BGSAVE fork 开销、网络传输)

Q17: Redis 集群的脑裂问题是什么?怎么解决? 「🔴 高级」

考察点:分布式系统的经典问题在 Redis 中的体现。

参考答案

脑裂(Split Brain):网络分区导致一个集群分成两个子集群,各自选出 Leader/Master,分区恢复后数据冲突。

Redis 场景下的脑裂:

  1. 主从模式:主节点网络隔离,哨兵认为主节点挂了,选举从节点为新主。旧主恢复后还是 Master,出现两个 Master。
  2. Cluster 模式:网络分区导致部分节点互相不可达,各自选出主节点。

危害:

  • 两个 Master 都接受写入,分区恢复后数据冲突
  • 旧 Master 的写入可能丢失(被新 Master 覆盖)

解决方案:

  1. min-slaves-to-write / min-slaves-good-lag

    • 主节点必须有至少 N 个从节点在指定延迟内同步,才接受写入
    • 不满足条件 → 主节点拒绝写入(报错)
    • 网络分区时,旧主大概率连不上足够从节点 → 不能写入 → 避免脑裂
    • Redis 4.0+ 改为 min-replicas-to-write / min-replicas-max-lag
  2. Cluster 的 failover 机制

    • 节点故障检测需要多个节点确认(Gossip 协议)
    • 从节点升级需要先自选为候选,再获得半数以上主节点投票
    • 降低脑裂概率(但不是 100% 避免)
  3. 客户端配合

    • 客户端感知故障转移,切换到新主
    • 旧主的写入通过 retry 机制自然失败
  4. 业务幂等

    • 业务设计幂等,即使脑裂导致重复写入也无害

根本问题:分布式系统无法完全避免脑裂,只能在一致性和可用性之间权衡(CAP 理论)

追问延伸

  • Redis 的脑裂和 ZooKeeper 的脑裂有什么区别?
  • min-replicas-to-write 设为多少合适?(至少 1,但设太高影响可用性)

Q18: Redis 怎么实现延迟队列?ZSet 方案有什么优缺点? 「🟡 中级」

考察点:Redis 延迟队列的实现方案和工程实践。

参考答案

Redis 延迟队列方案:

  1. ZSet 方案(常用):

    • 原理:ZSet 的 score 存消息到期时间戳,member 存消息内容/ID
    • 生产者:ZADD delay_queue timestamp messageId
    • 消费者:定时轮询 ZRANGEBYSCORE delay_queue 0 now 获取到期消息
    • 取到后 ZREM delay_queue messageId 删除(保证不重复消费)
    • ZREM 返回 1 表示抢到,返回 0 表示被其他消费者抢了

    优点:简单、可靠、支持任意延迟时间

    缺点:

    • 需要轮询(有延迟窗口)
    • ZSet 太大会影响性能
    • 多消费者需要处理竞争(ZREM 原子操作保证只有一个抢到)
    • 不支持消费组(多组都消费同一条消息需要存多份)
  2. 过期通知方案(不推荐):

    • 利用 Redis 的过期事件通知(Keyspace Notifications)
    • 设置 key 的 TTL,过期时通知
    • 缺点:不可靠(过期通知可能丢失)、不精确、性能差
  3. Redis Stream 方案

    • 消息自带时间戳,消费时判断是否到期
    • 不如 ZSet 直观,少用
  4. 时间轮方案(参考):

    • Netty 的 HashedWheelTimer(单机)
    • 高效但不能持久化

ZSet 延迟队列优化:

  • 消息内容存到 Hash(ZSet 只存 ID),避免 ZSet 太大
  • 轮询用 ZRANGEBYSCORE + LIMIT 0 N 分批获取
  • 多消费者用 Lua 脚本保证原子性(ZRANGEBYSCORE + ZREM)
  • 消息处理失败 → 重新放回 ZSet(改 score 为新的到期时间)

生产建议:

  • 量小 → Redis ZSet 够用
  • 量大 → 专业 MQ(RocketMQ 延迟消息、RabbitMQ TTL+死信)

追问延伸

  • ZSet 延迟队列的消息怎么保证可靠(不丢)?
  • 大量延迟消息同一时间到期怎么处理?(避免雪崩,加随机抖动)

Q19: Redis 的内存碎片是什么?怎么排查和解决? 「🟡 中级」

考察点:Redis 内存管理的运维能力。

参考答案

内存碎片:Redis 分配的内存中,有部分无法被有效使用的空间。

  • 分配器(jemalloc)按固定大小分配内存,实际使用量可能小于分配量
  • 频繁修改、删除不同大小的 key 导致碎片

排查方法:

  1. INFO memory

    • used_memory:实际使用的内存
    • used_memory_rss:操作系统分配给 Redis 的内存
    • mem_fragmentation_ratio = used_memory_rss / used_memory
    • 比率 > 1.5 说明碎片较多(理想值 1.0-1.5)
  2. MEMORY STATS:更详细的内存统计

  3. MEMORY USAGE key:查看单个 key 的内存占用

解决方案:

  1. 重启 Redis

    • 最直接的方法,重启后重新分配内存
    • 缺点:服务中断,需要做故障转移
  2. 主动碎片整理(4.0+):

    • config set activedefrag yes
    • Redis 在运行时自动整理碎片(复制数据到新位置,释放旧空间)
    • 对性能影响小(在后台慢慢做)
    • 相关参数:
      • active-defrag-ignore-bytes:碎片小于此值不整理
      • active-defrag-threshold-lower:碎片率超过此值开始整理
      • active-defrag-cycle:CPU 占用比例
  3. 合理设置 maxmemory 和淘汰策略

    • 让 Redis 主动淘汰不用的 key
    • 减少内存压力

预防:

  • 避免频繁创建删除不同大小的 key
  • 使用合适的数据结构(Hash 替代多个 String)
  • 定期监控 mem_fragmentation_ratio

追问延伸

  • 为什么 jemalloc 会产生碎片?(固定大小分配 + 大小变化)
  • 重启和 activedefrag 各有什么优缺点?

Q20: Redis 的布隆过滤器是什么?怎么用?误判率怎么控制? 「🟡 中级」

考察点:布隆过滤器的原理和 Redis 中的实现。

参考答案

布隆过滤器(Bloom Filter):一种概率型数据结构,用于判断元素是否存在集合中。

  • 特点:可能有误判(说存在可能不存在),但不会漏判(说不存在一定不存在)
  • 空间效率极高:比 HashSet 节省几个数量级的内存

原理:

  1. 一个 BitMap(位数组),初始全为 0
  2. k 个独立的哈希函数
  3. 添加元素:对元素做 k 次哈希,将对应的 k 个位置设为 1
  4. 查询元素:对元素做 k 次哈希,如果 k 个位置都为 1 → 可能存在;有一个为 0 → 一定不存在

误判率控制:

  • 参数:n(元素数量)、k(哈希函数数)、m(位数组大小)
  • 误判率 p ≈ (1 - e^(-nk/m))^k
  • 最优 k = (m/n) * ln(2)
  • 给定 n 和 p,最优 m = -n * ln(p) / (ln2)^2

Redis 中的实现:

  1. RedisBloom 模块(推荐):

    • BF.ADD filter element:添加元素
    • BF.EXISTS filter element:查询元素
    • BF.RESERVE filter 0.001 1000000:创建过滤器(误判率0.001,容量100万)
  2. 手动用 BitMap 实现

    • 用 SETBIT/GETBIT 操作位
    • 需要自己实现多个哈希函数
  3. Redisson 的 RBloomFilter

    • Java 客户端封装

应用场景:

  • 缓存穿透防护:查询前先过布隆过滤器,不存在直接返回
  • 去重:URL 去重、用户去重
  • 黑名单检查
  • 邮箱/用户名是否已注册

局限性:

  • 不能删除元素(标准布隆过滤器不支持删除)
  • Counting Bloom Filter 可以支持删除(用计数器代替位)
  • 误判率不能为 0(只能尽量小)

追问延伸

  • 布隆过滤器为什么不能删除?(删除可能影响其他元素的判断)
  • 怎么解决不能删除的问题?(Counting Bloom Filter / Cuckoo Filter)

Q21: Redis 哨兵(Sentinel)的工作原理?主观下线和客观下线有什么区别? 「🟡 中级」

考察点:Redis 哨兵自动故障转移的完整流程。

参考答案

哨兵的核心职责:监控、通知、自动故障转移、配置中心。

工作流程:

  1. 监控

    • 每个哨兵定时(每秒)向 Master、Slave、其他哨兵发送 PING
    • Master 在 down-after-milliseconds(默认30秒)内没回复 → 标记主观下线
  2. 主观下线(SDOWN)

    • 单个哨兵认为 Master 不可用
    • 发送 SENTINEL is-master-down-by-addr 给其他哨兵,询问是否同意
  3. 客观下线(ODOWN)

    • 超过 quorum(法定数量)个哨兵都认为 Master 不可用
    • 标记为客观下线,准备故障转移
  4. 选举领头哨兵

    • 所有哨兵互相发竞选请求
    • 先收到请求的投票给对方(先到先得)
    • 获得半数以上选票的哨兵成为领头
  5. 故障转移

    • 领头哨兵选择一个 Slave 作为新 Master:
      • 排除不健康的 Slave
      • 优先级(slave-priority)高的优先
      • 复制偏移量大(数据新)的优先
      • Run ID 小的优先
    • 对新 Master:SLAVEOF NO ONE(取消从属关系)
    • 对其他 Slave:SLAVEOF 新Master(指向新 Master)
    • 更新哨兵配置,通知客户端

客户端感知:

  • 客户端连接哨兵而非直连 Redis
  • 请求时哨兵返回当前 Master 地址
  • Master 切换后客户端自动更新

部署建议:

  • 至少 3 个哨兵(奇数,避免脑裂)
  • 哨兵和 Redis 不在同一机器
  • quorum = N/2 + 1(半数以上)

追问延伸

  • 哨兵之间怎么发现彼此?(通过 Master 的 pub/sub)
  • 哨兵模式能水平扩展吗?(不能,只有一个 Master)

Q22: Redis Cluster 怎么扩容?槽迁移怎么做? 「🟡 中级」

考察点:Redis 集群水平扩展的实战操作。

参考答案

Redis Cluster 有 16384 个槽(slot),每个节点负责一部分。

扩容流程(新增一个节点):

  1. 加入集群

    redis-cli --cluster add-node 新节点IP:端口 已有节点IP:端口

    新节点加入但还没有分配槽。

  2. 迁移槽

    • 从现有节点迁移一部分槽到新节点
    • 迁移是逐个槽进行的:
    # 迁移槽 0 从源节点到目标节点
    redis-cli --cluster reshard 集群任意节点:端口
    --cluster-from 源节点ID
    --cluster-to 目标节点ID
    --cluster-slots 1000  # 迁移1000个槽

    槽迁移底层步骤:

    • 在目标节点标记槽为 IMPORTING
    • 在源节点标记槽为 MIGRATING
    • 源节点执行 CLUSTER GETKEYSINSLOT 槽号 数量,获取该槽中的 key
    • 逐个 MIGRATE key 到目标节点
    • 迁移完成后,向所有节点广播槽的归属变更
  3. 验证

    redis-cli --cluster check 集群任意节点:端口
    redis-cli --cluster info 集群任意节点:端口

迁移期间的影响:

  • 槽迁移期间,被迁移的 key 暂时不可用
  • 客户端访问正在迁移的槽时:
    • 源节点返回 ASK 重定向(临时跳转,不更新路由表)
    • 目标节点如果没导入完返回 MOVED(永久跳转)
  • 业务影响小(少量 key 短暂不可用)

缩容流程:

  • 迁移该节点的所有槽到其他节点
  • 移除节点:redis-cli --cluster del-node 节点ID

最佳实践:

  • 扩容在低峰期进行
  • 分批迁移(每次迁移少量槽)
  • 迁移前备份
  • 监控迁移进度

追问延伸

  • 为什么要 16384 个槽?(CRC16 的范围 + 网络包大小考虑)
  • 迁移过程中 ASK 和 MOVED 的区别?

Q23: Redis 的慢查询日志怎么用? 「🟡 中级」

考察点:Redis 性能排查工具的使用。

参考答案

慢查询日志配置:

  • slowlog-log-slower-than:阈值(微秒),默认 10000(10ms),设为 0 记录所有命令,设为负数关闭
  • slowlog-max-len:最多保存多少条慢查询记录,默认 128

查看慢查询:

  • SLOWLOG GET [n]:获取最近的 n 条慢查询
  • SLOWLOG LEN:当前慢查询日志数量
  • SLOWLOG RESET:清空慢查询日志

慢查询日志结构:

  • id:唯一标识
  • timestamp:时间戳
  • duration:执行时长(微秒)
  • arguments:命令参数(不包含 key 的完整内容)
  • client_ip_port:客户端地址
  • client_name:客户端名称

排查流程:

  1. 开启慢查询日志(设合适阈值)
  2. 慢查询定期分析(脚本 + 告警)
  3. 常见慢查询原因:
    • 大 Key 操作(HGETALL 大 Hash、LRANGE 大 List、SMEMBERS 大 Set)
    • KEYS 命令(生产禁用,用 SCAN 替代)
    • FLUSHALL/FLUSHDB(阻塞所有操作)
    • 复杂 Lua 脚本
    • SUNION/SUNIONSTORE/SINTER 等集合操作
  4. 优化方案:
    • 拆分大 Key
    • 用 SCAN 替代 KEYS
    • 用 HSCAN/SSCAN 替代全量操作
    • 异步删除(UNLINK)

其他性能排查工具:

  • INFO:看 connected_clients、used_memory、qps 等
  • LATENCY:延迟监控(latency history、latency graph)
  • CLIENT LIST:查看客户端连接
  • MONITOR:实时打印所有命令(调试用,生产慎用,性能影响大)

追问延伸

  • MONITOR 为什么影响性能?(每条命令都要输出,消耗 CPU 和网络)
  • 怎么监控 Redis 的 QPS 和延迟?

Q24: Redis 的 AOF 重写(Rewrite)是怎么做的? 「🟡 中级」

考察点:AOF 持久化优化机制的深入理解。

参考答案

AOF 问题:随着命令不断写入,AOF 文件越来越大,恢复时间越来越长。

AOF 重写:生成一个更小的 AOF 文件,只保留最终状态所需的最小命令集。

重写原理:

  • 遍历当前 Redis 所有数据
  • 重新生成写入命令(如一个 Hash 有 10 个 field,不再记 10 条 HSET,而记一条 HMSET)
  • 已删除/已过期的 key 不写
  • 结果:新文件只包含恢复到当前状态所需的最少命令

重写流程:

  1. 触发:
    • 手动:BGREWRITEAOF 命令
    • 自动:当前 AOF 文件大小超过 auto-aof-rewrite-percentage(默认100%,即翻倍)且超过 auto-aof-rewrite-min-size(默认64MB)
  2. 主进程 fork 子进程
  3. 子进程遍历数据,写入临时 AOF 文件
  4. 主进程继续处理命令,新命令同时写入旧 AOF 缓冲区和 AOF 重写缓冲区
  5. 子进程写完后通知主进程
  6. 主进程把重写缓冲区的内容追加到新 AOF 文件
  7. 原子替换旧 AOF 文件

关键点:

  • fork 创建子进程(利用 COW,不需要暂停服务)
  • 重写期间的新命令同时写旧 AOF + 重写缓冲区(保证不丢)
  • 重写完成后用新文件替换旧文件
  • 如果重写期间 Redis 崩溃,旧 AOF 还在(不影响)

AOF 重写的风险:

  • fork 消耗内存(COW 机制,但如果写操作多,COW 复制页会占内存)
  • 大量写入时 fork 耗时(可能导致毫秒级暂停)
  • 磁盘 IO 压力(写新 AOF 文件)

Redis 7.0 的 AOF 多文件:

  • AOF 拆分为 base file(RDB 格式快照)+ incremental file(增量 AOF 命令)
  • 恢复更快(先加载 RDB 再回放增量)

追问延伸

  • AOF 重写和 RDB 的 BGSAVE 有什么区别?
  • fork 消耗内存怎么办?(控制实例大小、错峰执行)

Q25: Redis 客户端分片和 Server 端分片有什么区别? 「🟡 中级」

考察点:Redis 分片方案的理解和选型。

参考答案

两种分片方式:

  1. 客户端分片(Client-Side Sharding):

    • 客户端自己计算 key 属于哪个 Redis 节点
    • 常见方案:Twemproxy(代理层分片)、Codis(代理层分片)、Jedis ShardedJedis
    • 路由方式:一致性哈希 或 槽位映射
    • 优点:客户端直连,延迟低
    • 缺点:
      • 客户端逻辑复杂
      • 扩缩容需要客户端感知
      • 不支持跨节点操作(MGET、事务等)
  2. Server 端分片(Redis Cluster):

    • 服务端负责分片(16384 个槽)
    • 客户端缓存路由表,直接找对应节点
    • 节点变更时通过 Gossip 协议通知客户端
    • 优点:
      • 客户端简单(标准 Redis 客户端即可)
      • 自动故障转移
      • 支持在线扩缩容
    • 缺点:
      • 跨槽操作不支持(MSET、事务、Lua 脚本跨 key)
      • 需要用 Hash Tag({tag}key)控制 key 到同一槽

对比:

维度客户端分片Server 分片(Cluster)
路由计算客户端服务端(客户端缓存路由表)
扩缩容复杂(需改客户端)原生支持(迁移槽)
故障转移不支持(需外部)原生支持
跨节点操作不支持不支持
多 Key 操作不支持需 Hash Tag
客户端复杂度

选型建议:

  • 数据量不大、高可用为主 → 哨兵模式
  • 数据量大、需水平扩展 → Redis Cluster(Server 分片)
  • 遗留系统或特殊需求 → Twemproxy/Codis(代理层分片)

追问延伸

  • 一致性哈希和 Redis Cluster 的 16384 槽有什么区别?
  • Hash Tag 是什么?怎么用?

Q26: Redis 的 Stream 数据结构是什么?和 Kafka 比有什么优劣? 「🟡 中级」

考察点:Redis 5.0+ 新数据结构的理解。

参考答案

Redis Stream(5.0+):

  • 类似 Kafka 的消息流结构
  • 每条消息有自动生成的 ID(时间戳-序号)
  • 支持消费组(Consumer Group)
  • 支持消息持久化、ACK、回溯

核心命令:

  • XADD mystream * field1 value1 field2 value2:追加消息(* 自动生成 ID)
  • XLEN mystream:消息数量
  • XRANGE mystream - +:读取所有消息
  • XREAD COUNT 10 STREAMS mystream 0:读取消息
  • XGROUP CREATE mystream mygroup $:创建消费组
  • XREADGROUP GROUP mygroup consumer1 COUNT 10 STREAMS mystream >:消费组消费
  • XACK mygroup mystream msgid:确认消息
  • XPENDING mystream mygroup:查看未确认消息
  • XCLAIM mystream mygroup consumer2 60000 msgid:转移消息给其他消费者(超时后)

消费组机制:

  • 每个消费组维护一个 PEL(Pending Entry List)记录已读取未确认的消息
  • 消费者读取消息后需要 XACK 确认
  • 未确认的消息可以转移给其他消费者(XCLAIM)
  • 支持消息回溯(按 ID 读取历史消息)

Stream vs Kafka:

维度Redis StreamKafka
持久化AOF/RDB磁盘日志
吞吐量中(万级QPS)极高(百万级)
消费组支持支持
ACK支持支持(offset提交)
消息回溯支持(按ID)支持(按offset)
延迟消息不支持不支持
死信队列不支持不支持(需自建)
分区不支持(单节点)支持(Partition)
运维成本
数据量小(内存限制)大(磁盘)

适用场景:

  • 简单消息队列、量不大、可靠性要求中 → Redis Stream
  • 高吞吐、大数据量、复杂消费 → Kafka

追问延伸

  • Stream 的 PEL 满了怎么办?(需要定期清理未确认消息)
  • Stream 怎么做延迟队列?(不支持原生延迟,需配合 ZSet)

Q27: Redis 的跳表是怎么实现的?为什么用跳表不用 B+ 树? 「🔴 高级」

考察点:ZSet 底层数据结构的深入理解。

参考答案

跳表(Skip List):一种概率型平衡数据结构,通过多级索引实现快速查找。

跳表结构:

  • 每个节点有多个层级指针(L0-L32)
  • L0 是完整的有序链表
  • L1、L2... 是稀疏的索引层(每隔几个节点抽一个)
  • 查找时从最高层开始,逐层缩小范围 → 类似二分查找

跳表层高设置:

  • Redis 跳表最大 32 层(ZSKIPLIST_MAXLEVEL = 32)
  • 新节点的层高概率:ZSKIPLIST_P = 0.25(每层以25%概率升一层)
  • 期望层高 = 1/(1-0.25) = 1.33,大部分节点只有 1-2 层
  • 高层节点少,低层节点多 → 查找效率 O(logN)

跳表节点结构:

c
typedef struct zskiplistNode {
    sds ele;                    // 元素值
    double score;               // 分数
    struct zskiplistNode *backward;  // 后退指针(L0层)
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned long span;              // 跨度(用于计算排名)
    } level[];                  // 层级数组
} zskiplistNode;

ZSet = 跳表 + 哈希表:

  • 跳表:按 score 有序,支持范围查询和排名
  • 哈希表:member → score,O(1) 查找
  • 两者共用元素指针,不重复存储

为什么用跳表不用 B+ 树:

  1. 实现简单:跳表代码远比 B+ 树简单(插入/删除不需要旋转)
  2. 内存友好:跳表每个节点小,不像 B+ 树需要按页分配
  3. 范围查询好:跳表 L0 就是有序链表,范围查询直接遍历
  4. 并发友好:跳表局部修改,不像 B+ 树可能涉及多级分裂
  5. 内存数据库:Redis 是内存数据库,不需要考虑磁盘 IO → B+ 树的优势(页式读取)不适用
  6. 内存效率:跳表每个节点按需分配层数,B+ 树节点固定大小

跳表时间复杂度:

  • 查找/插入/删除:O(logN)
  • 范围查询:O(logN + M)(M 是结果数量)
  • 排名查询:利用 span 字段累加,O(logN)

追问延伸

  • 跳表和红黑树的区别?
  • 为什么 ZSet 要同时维护跳表和哈希表?

Q28: Redis 的网络模型是怎样的?IO 多路复用怎么实现的? 「🔴 高级」

考察点:Redis 高性能的网络层原理。

参考答案

Redis 的网络模型:Reactor 模式 + IO 多路复用

事件循环(Event Loop):

while (true) {
    // 1. 调用 epoll_wait 等待事件
    events = epoll_wait(epfd, ...);
    
    // 2. 处理就绪事件
    for (event in events) {
        if (event 是读事件) {
            // 3. 读取客户端命令
            read(event.fd, buffer);
            // 4. 解析命令
            cmd = parse(buffer);
            // 5. 执行命令(单线程,保证线程安全)
            result = execute(cmd);
            // 6. 写响应到客户端缓冲区
            write_to_client(event.fd, result);
        }
        if (event 是连接事件) {
            accept(new_connection);
        }
    }
}

Redis 6.0 之前(纯单线程):

  • IO(读/写)+ 命令执行都在主线程
  • epoll 监听所有客户端连接
  • 哪个连接有数据就处理哪个
  • 单线程避免锁竞争、上下文切换

Redis 6.0+(多线程 IO):

  • IO(读/写)交给 IO 线程池
  • 命令执行仍在主线程(保证无锁)
  • 配置:io-threads 4 + io-threads-do-reads yes
  • 解决高 QPS 下网络 IO 瓶颈

IO 多路复用的实现:

  • Linux:epoll(默认)
  • macOS:kqueue
  • Windows:IOCP
  • Redis 封装了统一的 API(aeEventLoop)

为什么 Redis 单线程也能这么快:

  1. 全内存操作(无磁盘 IO)
  2. IO 多路复用(一个线程处理大量连接)
  3. 单线程无锁、无上下文切换
  4. 高效的数据结构(SDS、跳表、ziplist 等)

追问延伸

  • Redis 的 Reactor 模式和 Netty 的有什么区别?
  • 为什么 Redis 不用多线程执行命令?

Q29: Redis 的哈希表是怎么扩容的?rehash 期间读写怎么办? 「🔴 高级」

考察点:Redis 哈希表渐进式 rehash 的深入理解。

参考答案

Redis 的字典(dict)有两个哈希表(ht[0] 和 ht[1]):

  • 正常时只用 ht[0]
  • rehash 时用 ht[1] 做新表,逐步迁移

触发扩容条件:

  • 没有做 BGSAVE/BGREWRITEAOF 时:负载因子 >= 1(used/size >= 1)
  • 正在做 BGSAVE/BGREWRITEAOF 时:负载因子 >= 5(避免内存页过多写时复制)
  • 负载因子 = used / size(已使用槽数 / 哈希表大小)

扩容大小:

  • 扩容到第一个 >= used * 2 的 2^n 次方
  • 如 used=5 → 扩到 16(大于 10 的最小 2^n)

渐进式 rehash(关键设计):

  • 不是一次性迁移所有数据(会阻塞),而是分多次
  • 每次 dict 操作(增删改查)时,顺便迁移 ht[0] 的 1 个桶到 ht[1]
  • 后台定时任务也会辅助迁移(每次迁移少量桶)

rehash 期间的读写:

  • :先查 ht[1],没有再查 ht[0](两个表都查)
  • :新增只写 ht[1](保证 ht[0] 只减不增)
  • 删/改:两个表都查

rehash 完成后:

  • 释放 ht[0]
  • ht[1] 变成 ht[0]
  • 在 ht[1] 创建新的空表(为下次 rehash 准备)

类似 Java HashMap 的扩容:

  • HashMap:一次性扩容(2倍),链表/树迁移
  • Redis dict:渐进式扩容(不阻塞),适合 Redis 单线程模型

追问延伸

  • 渐进式 rehash 有什么缺点?
  • Redis 缩容(收缩)怎么触发?

Q30: Redis 的压缩列表(ziplist)和 listpack 是什么? 「🟡 中级」

考察点:Redis 底层编码结构的深入理解。

参考答案

ziplist(压缩列表):

  • 一种紧凑的连续内存结构,用于 List、Hash、ZSet 的元素较少时
  • 每个 entry 包含:prevlen(前一个 entry 长度)、encoding(编码类型)、data(实际数据)
  • 优点:内存紧凑(无指针开销)、小数据时省内存
  • 缺点:
    • 连锁更新问题:如果某个 entry 的 prevlen 从1字节变为5字节,会导致后续所有 entry 的 prevlen 都变 → 连锁修改
    • 不支持随机访问(必须遍历)

listpack(Redis 7.0+):

  • ziplist 的改进版,解决了连锁更新问题
  • 每个 entry 包含:encoding、data、backlen(当前 entry 长度,不含前一个 entry)
  • 不记录前一个 entry 的长度 → 不会有连锁更新
  • Redis 7.0 开始,List、Hash、ZSet 的小数据编码从 ziplist 改为 listpack

编码转换条件(Hash 为例):

  • ziplist/listpack → hashtable:
    • 元素数超过 hash-max-ziplist-entries(默认128)
    • 单个元素大小超过 hash-max-ziplist-value(默认64字节)
  • 编码转换是单向的(从小转大,不会转回)

quicklist(List 的底层结构,3.2+):

  • quicklist = 双向链表 + 每个节点是一个 ziplist/listpack
  • 结合了链表的灵活性和 ziplist 的紧凑性
  • 每个 ziplist 节点大小可控(list-max-ziplist-size

intset(Set 的底层结构):

  • 整数集合,用于 Set 全是整数且数量少时
  • 有序整数数组,二分查找
  • 升级机制:如果加入更大类型整数,整个 intset 升级(int16 → int32 → int64)

追问延伸

  • ziplist 的连锁更新是什么?
  • quicklist 和普通链表的区别?

Q31: Redis 的过期删除策略详细原理?惰性删除和定期删除怎么配合? 「🟡 中级」

考察点:过期键管理的完整机制理解。

参考答案

Redis 过期键管理 = 惰性删除 + 定期删除 + 内存淘汰

  1. 惰性删除(Lazy Expiration):

    • 访问 key 时检查是否过期,过期就删除
    • 优点:CPU 友好(不主动扫描)
    • 缺点:过期 key 不被访问就一直占内存(内存泄漏)
  2. 定期删除(Periodic Extraction):

    • 每 100ms 随机抽取一些设置了过期时间的 key 检查
    • 删除过期的 key
    • 如果过期 key 比例超过 25%,继续扫描
    • 控制执行时间(不超过 25ms),避免阻塞
    • 优点:主动回收内存
    • 缺点:随机抽样,可能遗漏
  3. 内存淘汰(当内存不足时):

    • maxmemory 达到上限时触发
    • 8 种淘汰策略:
      • noeviction:不淘汰,拒绝写入(默认)
      • allkeys-lru:所有 key 中淘汰最久未使用的(推荐)
      • allkeys-lfu:所有 key 中淘汰最不经常使用的
      • allkeys-random:随机淘汰
      • volatile-lru:设了过期的 key 中淘汰 LRU
      • volatile-lfu:设了过期的 key 中淘汰 LFU
      • volatile-random:设了过期的 key 中随机淘汰
      • volatile-ttl:淘汰将要过期的

过期 key 不立即删除的原因:

  • 立即删除需要维护定时器(每个 key 一个),CPU 开销大
  • 惰性删除 + 定期删除的配合平衡了 CPU 和内存

过期 key 对持久化的影响:

  • RDB:过期 key 不写入快照
  • AOF:过期 key 被 Redis 删除时,追加一条 DEL 命令
  • 主从复制:从节点不主动删除过期 key,由主节点同步 DEL 命令

追问延伸

  • LRU 和 LFU 的区别?
  • Redis 的 LRU 不是精确的 LRU,为什么?

Q32: Redis 为什么不用 C 语言的字符串?SDS 是什么? 「🟡 中级」

考察点:Redis 底层数据结构设计的理解。

参考答案

C 语言字符串的缺点:

  1. 获取长度 O(n):需要遍历到 \0 才知道长度
  2. 缓冲区溢出:拼接字符串不会自动扩容,可能溢出到相邻内存
  3. 频繁内存分配:每次修改都要 realloc
  4. 不能保存二进制数据:遇到 \0 就截断(不能存图片、序列化数据)
  5. 只能用 strchar 等函数操作,不安全

SDS(Simple Dynamic String,简单动态字符串):

c
struct sdshdr {
    int len;       // 已使用长度
    int alloc;     // 分配的总长度
    char flags;    // 类型标志(sdshdr5/8/16/32/64)
    char buf[];    // 实际数据(柔性数组)
};

SDS 的优势:

  1. O(1) 获取长度:直接读 len 字段
  2. 防止缓冲区溢出:拼接前检查空间,不够自动扩容
  3. 减少内存分配
    • 空间预分配:扩容时多分配空间(<1MB 翻倍,>=1MB 多1MB)
    • 惰性释放:缩短时不立即释放内存,留作下次使用
  4. 二进制安全:不用 \0 判断结束,用 len 判断长度 → 可以存任意二进制数据
  5. 兼容 C 字符串函数:buf 末尾仍保留 \0,可以直接用 printf 等函数

SDS 的类型优化:

  • sdshdr5:长度 < 32 字节(1字节头部)
  • sdshdr8:长度 < 256 字节(2字节头部)
  • sdshdr16:长度 < 65536 字节(3字节头部)
  • sdshdr32/64:更大长度
  • 根据字符串大小选择不同的头部结构,节省内存

追问延伸

  • SDS 的空间预分配策略是什么?
  • 为什么 Redis 要设计 5 种 SDS 类型?

Q33: Redis 为什么快?单线程为什么也能高性能? 「🟢 校招」

考察点:Redis 高性能的核心原因,单线程模型的优势。

参考答案

Redis 快的五大原因:

  1. 基于内存操作:数据全在内存,读写速度是磁盘的数万倍,避免磁盘 IO 瓶颈。

  2. 单线程模型(核心命令执行):

    • 避免多线程的锁竞争和上下文切换开销
    • 实现简单,无并发问题,代码容易维护
    • 不会因为线程切换浪费 CPU 时间
  3. IO 多路复用

    • 单线程用 epoll 同时监听大量客户端连接
    • 哪个连接有数据就处理哪个,不阻塞等待
    • 一个线程能处理数万并发连接
  4. 高效的数据结构

    • SDS(O(1) 获取长度、二进制安全)
    • 哈希表(渐进式 rehash,不阻塞)
    • 跳表(O(logN) 范围查询,实现比红黑树简单)
    • ziplist/listpack(小数据紧凑存储,省内存)
  5. Redis 6.0 多线程 IO:网络读写用多线程,命令执行仍单线程,解决高 QPS 下网络瓶颈。

单线程为什么也能高性能:

因素说明
内存操作单次命令执行极快(微秒级),单线程也能扛高 QPS
无锁无切换不浪费 CPU 在锁等待和线程切换上
IO 多路复用单线程处理海量连接,不阻塞
避免大 key核心前提:单命令不能太慢,否则阻塞所有请求
bash
# 单线程的体现:一个慢命令会阻塞所有请求
127.0.0.1:6379> KEYS *     # 生产禁用,会阻塞
127.0.0.1:6379> LRANGE biglist 0 -1  # 大 key 阻塞

追问延伸

  • Redis 单线程有什么缺点?(不能利用多核 CPU、单个慢命令阻塞所有请求)
  • 如果 Redis CPU 跑满了怎么排查?(慢查询、大 key、复杂 Lua 脚本)

Q34: Redis 哪些地方使用了多线程?6.0 的多线程模型? 「🟡 中级」

考察点:Redis 多线程使用的全面理解,6.0 多线程 IO 模型。

参考答案

Redis 中使用多线程的地方:

场景版本说明
后台异步任务2.6+BIO 线程:异步删除(UNLINK)、AOF fsync、close 文件描述符
RDB/AOF 持久化2.x+fork 子进程,利用 COW 生成快照/重写
网络 IO 读写6.0+IO 多线程,加速网络读写
命令执行从未始终单线程,保证无锁

Redis 6.0 多线程 IO 模型:

客户端1 ─┐
客户端2 ─┼─→ [IO线程池: 读] ─→ [主线程: 命令执行] ─→ [IO线程池: 写] ─→ 客户端
客户端3 ─┘        (多线程并行)        (单线程)            (多线程并行)

工作流程详解:

  1. 主线程通过 epoll_wait 等待客户端连接就绪
  2. 把就绪的客户端分配给 IO 线程池读取命令(多线程并行)
  3. 主线程等待所有 IO 线程读取完成
  4. 主线程单线程串行执行所有命令(保证原子性,无锁)
  5. 主线程把结果分配给 IO 线程池发送(多线程并行)
  6. 主线程等待所有 IO 线程发送完成
  7. 回到步骤 1

配置:

conf
# 开启多线程 IO
io-threads 4           # IO 线程数(建议 CPU 核数的一半,不超过 8)
io-threads-do-reads yes # IO 线程也负责读(默认只负责写)

为什么命令执行不用多线程:

  • 保证线程安全,无需加锁
  • Redis 核心操作都是内存操作,极快,单线程足够
  • 避免多线程带来的复杂性和 bug

适用场景:

  • 高 QPS(10万+)、大 value、网络 IO 是瓶颈时效果明显
  • 小 value、低 QPS 时提升不明显,反而有线程同步开销

其他 6.0 新特性:

  • ACL(访问控制列表):细粒度权限控制
  • TLS 支持:加密通信
  • RESP3 协议:更丰富的数据类型返回
  • 客户端缓存:Server-assisted client-side caching

追问延伸

  • IO 线程数设多少合适?(不超过 CPU 核数一半,一般 4-8)
  • 6.0 多线程 IO 和 5.x 单线程的性能差异有多大?(压测 QPS 提升 1-2 倍)

Q35: Redis 怎么实现的 IO 多路复用?网络模型是怎样的? 「🟡 中级」

考察点:Redis 网络模型和 IO 多路复用的实现原理。

参考答案

Redis 网络模型:单线程 Reactor 模式 + IO 多路复用。

IO 多路复用的核心思想:一个线程同时监听多个文件描述符(fd),哪个有事件就处理哪个,不需要为每个连接创建线程。

                    ┌─────────────────────┐
客户端1 (fd=3) ──────┤                     │
客户端2 (fd=4) ──────┤  epoll_wait(主线程)  ├─→ 有事件的 fd ─→ 处理命令
客户端3 (fd=5) ──────┤  同时监听所有连接     │
...                └─────────────────────┘

Redis 的事件循环(aeEventLoop):

c
// Redis 事件循环简化伪代码
void aeMain(aeEventLoop *eventLoop) {
    while (!eventLoop->stop) {
        // 1. 调用 epoll_wait 等待事件(带超时,处理定时任务)
        int numevents = aeApiPoll(eventLoop, tvp);
        
        // 2. 处理就绪的事件
        for (int j = 0; j < numevents; j++) {
            aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
            if (fe->mask & AE_READABLE) {
                fe->rfileProc(...)  // 读处理函数
            }
            if (fe->mask & AE_WRITABLE) {
                fe->wfileProc(...)  // 写处理函数
            }
        }
        
        // 3. 处理定时任务(时间事件)
        processTimeEvents(eventLoop);
    }
}

多路复用的底层实现(按平台):

平台实现特点
Linuxepoll事件驱动,O(1),支持大量连接
macOSkqueue类似 epoll
Windowsselect有连接数限制(1024),效率低
-Redis 封装ae.c 统一接口 aeApiCreate/Poll/Add/Del

epoll 的优势(对比 select/poll):

维度selectpollepoll
连接数限制1024无限制无限制
时间复杂度O(n) 遍历O(n) 遍历O(1) 事件驱动
fd 拷贝每次全量拷贝每次全量拷贝只注册一次
工作方式水平触发水平触发水平/边沿触发

Redis 单线程 Reactor 模型 vs Netty 主从 Reactor:

维度RedisNetty
线程模型单线程 Reactor主从 Reactor(多线程)
Boss 线程无(主线程处理)专门 accept 连接
Worker 线程无(主线程处理)多线程处理 IO
适用场景内存操作快,单线程够用通用网络框架

追问延伸

  • epoll 的水平触发和边沿触发有什么区别?Redis 用的哪种?
  • Redis 6.0 多线程 IO 后还是 Reactor 模式吗?(是,主从 Reactor 变体)

Q36: Redis 的过期删除策略和内存淘汰策略有什么区别? 「🟡 中级」

考察点:两个容易混淆的概念的区分和原理。

参考答案

这两个是 Redis 内存管理的不同机制,触发条件和目的不同:

维度过期删除策略内存淘汰策略
触发条件key 设置了过期时间且已过期内存使用达到 maxmemory 上限
目的清理已过期的 key内存不足时腾出空间
对象只针对设了 TTL 的 key取决于策略(allkeys 或 volatile)
时机访问时 + 定期扫描写入新数据内存不足时

过期删除策略(3 种配合):

  1. 惰性删除:访问 key 时检查过期,过期则删除

    • 优点:CPU 友好
    • 缺点:不访问就一直占内存
  2. 定期删除:每隔一段时间随机抽样检查

    • 每 100ms 抽样,过期比例 > 25% 继续扫描
    • 控制执行时间避免阻塞
  3. 内存淘汰:兜底机制,内存满时强制淘汰

内存淘汰策略(8 种):

┌─────────────────────────────────────────────────────┐
│                  内存达到 maxmemory                  │
├──────────────────────┬──────────────────────────────┤
│   allkeys-* (所有key) │    volatile-* (设了过期的key)  │
├──────────────────────┼──────────────────────────────┤
│ allkeys-lru          │ volatile-lru                  │
│ allkeys-lfu          │ volatile-lfu                  │
│ allkeys-random       │ volatile-random               │
│                      │ volatile-ttl                  │
├──────────────────────┴──────────────────────────────┤
│ noeviction: 不淘汰,拒绝写入(默认)                   │
└─────────────────────────────────────────────────────┘

选型建议:

场景推荐策略理由
纯缓存allkeys-lru缓存场景,淘汰最久没用的
有热点数据allkeys-lfu按访问频率淘汰,保留热点
缓存+持久数据混合volatile-lru只淘汰缓存数据,保留持久数据
所有 key 同等重要noeviction不淘汰,靠监控告警
conf
# 配置示例
maxmemory 2gb
maxmemory-policy allkeys-lru

追问延伸

  • Redis 的 LRU 为什么是近似 LRU?(采样而非全局,省内存)
  • 如果淘汰策略是 noeviction,内存满了会怎样?(写入报错 OOM)

Q37: Redis 的缓存失效会立即删除吗?为什么不是立即删除? 「🟡 中级」

考察点:Redis 过期 key 的删除时机和设计思想。

参考答案

Redis 的过期 key 不会立即删除,而是采用惰性删除 + 定期删除的配合策略。

为什么不立即删除(定时删除):

策略CPU 开销内存占用说明
定时删除(立即)极高最低每个 key 创建定时器,到期精确删除
惰性删除最低可能高访问时才检查删除
定期删除定时抽样检查
Redis 实际(惰性+定期)可控两者配合,平衡 CPU 和内存

立即删除(定时删除)的问题:

  • 每个 key 需要维护一个定时器(时间事件)
  • 大量 key 过期时,会瞬间产生大量定时器事件
  • 定时器本身有内存和 CPU 开销
  • 不符合 Redis 单线程模型(大量定时器会阻塞主线程)

Redis 的实际方案:

┌────────────────────────────────────────────────────────────┐
│                    过期 key 的生命周期                       │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  设置 TTL ──→ [key 存在但不活跃] ──→ 定期抽样检查 ──→ 删除  │
│                  ↓                    ↓                     │
│              惰性删除(访问时删)     内存淘汰(兜底)            │
│                                                            │
└────────────────────────────────────────────────────────────┘

定期删除的执行逻辑:

  • 每 100ms 执行一次
  • 随机抽取 20 个设了过期时间的 key
  • 删除其中已过期的
  • 如果过期比例 > 25%,重复扫描
  • 每次执行不超过 25ms(避免阻塞)
bash
# 验证:设置短 TTL 后不访问,观察 key 是否还在
127.0.0.1:6379> SET testkey hello EX 10
OK
# 10秒后
127.0.0.1:6379> EXISTS testkey    # 可能还存在(未被定期删除扫到)
# 但访问时会被惰性删除
127.0.0.1:6379> GET testkey       # 返回 nil(已过期,惰性删除)

内存淘汰兜底:

  • 即使惰性删除和定期删除都没清理到
  • 当内存达到 maxmemory 时,淘汰策略会强制回收
  • 三重保障确保过期 key 不会无限堆积

追问延伸

  • 大量 key 同时过期会有什么问题?(定期删除执行时间变长、缓存雪崩)
  • 怎么避免大量 key 同时过期?(TTL 加随机抖动)

Q38: Redis 主从同步的增量和完全同步怎么实现? 「🔴 高级」

考察点:Redis 主从复制的底层实现细节。

参考答案

Redis 主从同步有两种方式:全量同步和增量同步。

全量同步(Full Resync)

触发场景:

  • 从节点第一次连接主节点
  • 从节点断线太久,offset 超出复制积压缓冲区
  • 主节点切换(新主没有旧主的复制数据)

流程:

从节点                         主节点
  │                              │
  │── 1. PSYNC ? -1 ───────────→│  (第一次连接,发送 PSYNC)
  │                              │
  │←── 2. +FULLRESYNC runid offset ─│  (主节点判断需全量同步,返回 runid 和 offset)
  │                              │
  │                              │── 3. BGSAVE 生成 RDB
  │                              │── 同时新写入命令存入 repl_backlog_buffer
  │                              │
  │←── 4. 发送 RDB 文件 ─────────│
  │                              │
  │── 5. 加载 RDB                │
  │                              │
  │←── 6. 发送缓冲区增量命令 ─────│  (同步期间的新命令)
  │                              │
  │── 7. 增量命令执行,同步完成    │

增量同步(Partial Resync)

触发场景:

  • 从节点断线重连,且 offset 在复制积压缓冲区范围内

流程:

从节点                         主节点
  │                              │
  │── 1. PSYNC runid offset ──→│  (发送上次同步的 runid 和 offset)
  │                              │
  │   (主节点检查: runid 匹配 &&  │
  │    offset 在缓冲区内)         │
  │                              │
  │←── 2. +CONTINUE ─────────────│  (可以增量同步)
  │                              │
  │←── 3. 发送 offset 之后的命令 │  (只发差异部分)
  │                              │
  │── 4. 执行增量命令,同步完成    │

关键数据结构:

结构作用说明
replid (runid)主节点唯一标识从节点记录主节点的 replid,重连时比对
offset复制偏移量主从各自维护,表示同步进度
repl_backlog_buffer复制积压缓冲区环形缓冲区,默认 1MB,存最近的写入命令

复制积压缓冲区大小计算:

# 缓冲区大小 = 断线时长 × 每秒写入量 × 2(安全系数)
# 例:断线 10 秒,每秒写 1MB → 缓冲区至少 20MB
config set repl-backlog-size 256mb

2.8 版本前后的区别:

版本同步方式特点
2.6 及以前SYNC只有全量同步,断线重连必全量
2.8+PSYNC支持增量同步,减少全量同步
4.0+PSYNC2优化故障转移后的部分重同步
conf
# 主从复制关键配置
repl-backlog-size 256mb      # 复制积压缓冲区大小
repl-timeout 60              # 主从超时时间(秒)
min-replicas-to-write 1      # 从节点少于 N 个时主节点拒绝写入
min-replicas-max-lag 10      # 从节点最大延迟(秒)

追问延伸

  • 全量同步对主节点的性能影响?(fork 开销 + RDB 传输带宽)
  • 怎么减少全量同步?(调大 repl-backlog-size、优化网络、避免频繁断线)

Q39: Redis 哨兵机制的选主算法是怎样的? 「🔴 高级」

考察点:Sentinel 故障转移中选主算法的细节。

参考答案

Redis 哨兵故障转移包含两个选举:

  1. 选举领头 Sentinel(谁来执行故障转移)
  2. 选举新 Master(选哪个从节点升级)

第一步:选举领头 Sentinel

当 Master 被判定为客观下线(ODOWN)后,需要一个 Sentinel 来主导故障转移。

领头 Sentinel 选举(基于 Raft 算法):

Sentinel A    Sentinel B    Sentinel C
    │              │              │
    │── 请求投票 →  │              │  (A 想成为 leader)
    │              │← 请求投票 ────│  (C 也想成为 leader)
    │              │              │
    │   先到先得:每个 Sentinel 只投一票
    │   获得半数以上选票的成为领头
    │              │              │
    │← 投票给 A ───│              │  (A 先请求,B 投给 A)
    │              │              │
    │  A 获得 > N/2 票 → A 成为领头 Sentinel

规则:

  • 每个 Sentinel 只投一票(先到先得)
  • 需要获得半数以上(> N/2)选票
  • 如果一段时间没有选出,重新发起选举(等待随机时间,避免冲突)

第二步:选举新 Master

领头 Sentinel 从从节点中选一个升级为新 Master。选主算法(优先级排序):

┌──────────────────────────────────────────────────────────┐
│                   新 Master 选举优先级                    │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  1. 过滤不健康的从节点                                     │
│     - 断线的、最近 5 秒没回复的                           │
│     - 与旧 Master 断线时间过久的                          │
│     (down-after-milliseconds × 10)                       │
│                                                          │
│  2. slave-priority 值最小的优先(值越小优先级越高)        │
│     - 0 表示永不参与选举                                   │
│     - 默认 100                                             │
│                                                          │
│  3. 复制偏移量(offset)最大的优先(数据最新)              │
│                                                          │
│  4. runid 字典序最小的优先(最后兜底)                    │
│                                                          │
└──────────────────────────────────────────────────────────┘

故障转移完整流程:

1. 主观下线 (SDOWN)     单个 Sentinel 认为 Master 不可达

2. 客观下线 (ODOWN)     超过 quorum 个 Sentinel 同意下线

3. 选举领头 Sentinel     Raft 算法,半数以上投票

4. 选新 Master          按优先级 → offset → runid 排序

5. SLAVEOF NO ONE       新 Master 升级

6. 其他从节点指向新 Master  SLAVEOF newmaster:port

7. 旧 Master 变成从节点   旧 Master 恢复后自动变成新 Master 的从节点

8. 通知客户端            通过 pub/sub 通知配置变更
conf
# 哨兵配置
sentinel monitor mymaster 192.168.1.10 6379 2   # 监控的 master,quorum=2
sentinel down-after-milliseconds mymaster 30000  # 30秒无响应判主观下线
sentinel failover-timeout mymaster 180000        # 故障转移超时时间
sentinel parallel-syncs mymaster 1               # 每次有几个从节点同步新主

部署建议:

  • 至少 3 个 Sentinel(奇数,防脑裂)
  • quorum = N/2 + 1(半数以上)
  • Sentinel 和 Redis 不在同一机器

追问延伸

  • 如果所有从节点 priority 都是 0 会怎样?(无法故障转移,需手动处理)
  • 哨兵的 quorum 和 majority 有什么区别?(quorum 判客观下线,majority 选领头)

Q40: Redis Cluster 集群客户端怎么知道访问哪个分片? 「🔴 高级」

考察点:Redis Cluster 的路由机制和客户端实现。

参考答案

Redis Cluster 有 16384 个槽(slot),每个节点负责一部分。客户端通过计算 key 的 slot 值确定访问哪个节点。

槽位计算

bash
# slot = CRC16(key) mod 16384
slot = CRC16("mykey") % 16384   # 例如得到 5474

Hash Tag(控制 key 到同一槽):

bash
# 用 {} 指定哈希标签,只有 {} 内的内容参与槽计算
SET {user:1000}:name "Tom"    # slot = CRC16("user:1000") % 16384
SET {user:1000}:age 25        # 同一个 slot,支持跨 key 操作

客户端路由机制

┌──────────┐     1. 计算slot      ┌──────────┐
│  Client  │────────────────────→│  节点A    │  slot 0-5460
│ (缓存路由表)│                    │ (主节点)  │
└──────────┘                     └──────────┘
      │                                │
      │  2. 如果key不在本节点           │
      │←──── MOVED 重定向 ──────────────┘
      │                                │
      │  3. 更新路由表,访问正确节点      │
      ↓                                ↓
┌──────────┐                     ┌──────────┐
│  节点B    │  slot 5461-10922    │  节点C    │  slot 10923-16383
└──────────┘                     └──────────┘

MOVED 和 ASK 重定向

重定向类型说明客户端行为
MOVED永久重定向slot 已迁移到新节点更新本地路由表,访问新节点
ASK临时重定向slot 正在迁移中访问新节点但不更新路由表

MOVED 示例:

bash
# 客户端访问节点A,但key在节点B
127.0.0.1:7000> GET mykey
(error) MOVED 5474 127.0.0.1:7001   # 5474槽在7001节点
# 客户端更新路由表,重新访问7001
127.0.0.1:7001> GET mykey
"hello"

ASK 示例(槽迁移中):

bash
# slot 正在从 A 迁移到 B
127.0.0.1:7000> GET mykey
(error) ASK 5474 127.0.0.1:7001   # 临时去7001找
# 客户端不更新路由表,发送 ASKING + GET
127.0.0.1:7001> ASKING
OK
127.0.0.1:7001> GET mykey
"hello"

客户端路由表维护

客户端启动流程:
1. 执行 CLUSTER NODES 或 CLUSTER SLOTS 获取集群拓扑
2. 构建本地路由表(slot → node 映射)
3. 请求时直接计算 slot,访问对应节点
4. 收到 MOVED 时更新路由表
5. 收到 ASK 时临时重定向,不更新路由表
java
// Jedis 客户端示例
JedisCluster cluster = new JedisCluster(
    new HostAndPort("127.0.0.1", 7000)
);
// JedisCluster 自动维护路由表,处理 MOVED/ASK
cluster.set("mykey", "value");   // 自动路由到正确节点

集群限制(因为分片):

  • 不支持跨槽的多 key 操作(除非用 Hash Tag)
  • 不支持跨槽事务
  • 不支持跨槽 Lua 脚本
  • mget/mset 需要所有 key 在同一槽

追问延伸

  • 为什么是 16384 个槽而不是更多?(网络包大小 + CRC16 范围)
  • 槽迁移过程中客户端请求会失败吗?(不会,有 ASK 重定向保证)

Q41: Redis 分布式锁的实现原理?什么场景下用到? 「🟡 中级」

考察点:Redis 分布式锁的实现细节和应用场景。

参考答案

分布式锁的核心需求:互斥性、可重入、防死锁、高可用。

基本实现

bash
# 加锁:SET key value NX EX 30
# NX: 不存在才设置(互斥)
# EX: 过期时间(防死锁)
# value: 唯一标识(防止误删别人的锁)
127.0.0.1:6379> SET lock:order:123 "uuid-abc-123" NX EX 30
OK

解锁:Lua 脚本保证原子性

lua
-- 释放锁:判断 value 匹配才删除(防止误删)
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end
java
// Java 实现示例
String lockKey = "lock:order:" + orderId;
String lockValue = UUID.randomUUID().toString();

// 加锁
Boolean locked = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS);

if (locked) {
    try {
        // 执行业务逻辑
        doBusiness();
    } finally {
        // 解锁(Lua 脚本保证原子性)
        String luaScript = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "  return redis.call('del', KEYS[1]) " +
            "else " +
            "  return 0 " +
            "end";
        redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(lockKey),
            lockValue
        );
    }
}

常见问题与解决方案

问题原因解决方案
锁过期业务没执行完TTL 太短或业务执行慢看门狗自动续期(Redisson)
误删别人的锁value 不唯一value 存 UUID,Lua 脚本判断
主从切换丢锁主节点挂了,锁没同步到从RedLock(多节点加锁)
不可重入同一线程不能重复获取锁Redisson Hash 结构记录重入次数

Redisson 实现(生产推荐)

java
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

// 可重入锁(带看门狗自动续期)
RLock lock = redisson.getLock("lock:order:123");
try {
    // 默认看门狗:30秒过期,每10秒续期一次
    lock.lock();
    // 或指定超时:lock.lock(30, TimeUnit.SECONDS) 不自动续期
    doBusiness();
} finally {
    lock.unlock();
}

Redisson 看门狗原理:

  • 默认 TTL 30 秒
  • 后台线程每 10 秒(TTL/3)检查锁是否还持有
  • 持有则续期到 30 秒
  • 客户端宕机后看门狗停止,锁自动过期释放

应用场景

  1. 防超卖:秒杀、抢购扣减库存
  2. 防重复提交:接口幂等性
  3. 定时任务防重:多实例部署只有一个执行
  4. 分布式协调:资源互斥访问

单节点 vs RedLock

方案可靠性性能复杂度适用场景
单节点一般大多数业务
RedLock强一致要求高

RedLock 争议(Martin Kleppmann 质疑):

  • 依赖系统时钟,时钟跳变可能导致锁失效
  • GC 暂停可能导致客户端持有过期锁
  • 工程上更推荐:单节点 + 业务幂等

追问延伸

  • Redis 分布式锁和 ZooKeeper 分布式锁的区别?(CP vs AP、性能 vs 一致性)
  • 锁过期续期失败怎么办?(业务幂等兜底、主动释放资源)

Q42: Redis 大 Key 问题是什么?怎么解决? 「🟡 中级」

考察点:大 Key 问题的识别、危害和解决方案。

参考答案

大 Key 定义(经验阈值):

类型大 Key 阈值
String单个 value > 10KB
Hash/List/Set/ZSet元素数量 > 5000 或总大小 > 10MB
所有类型网络传输 > 10ms 的 key

大 Key 的危害:

危害链:
大 Key 操作 ──→ 阻塞单线程 ──→ 其他请求排队 ──→ 服务超时 ──→ 雪崩

具体影响:
1. 阻塞主线程(DEL/HGETALL/SMEMBERS 等操作)
2. 网络带宽占用(大 value 传输慢)
3. 内存不均(Cluster 中单节点内存过高)
4. 过期/淘汰时卡顿(同步删除大 key)
5. 主从同步延迟(大 key 同步慢)

发现大 Key

bash
# 方法1: redis-cli --bigkeys(离线分析,生产慎用)
redis-cli --bigkeys

# 方法2: redis-cli --memkeys(4.0+,按内存排序)
redis-cli --memkeys

# 方法3: MEMORY USAGE 查看单个 key
127.0.0.1:6379> MEMORY USAGE bigkey:123
(integer) 10240000

# 方法4: 离线分析 RDB 文件
rdb --command memory dump.rdb --bytes 10240 -f big_keys.csv

解决方案

  1. 拆分大 Key
java
// 拆分前:一个 Hash 存所有用户信息
HSET user:all field1 v1 field2 v2 ... field10000 v10000

// 拆分后:按业务维度拆分
HSET user:basic:123 name "Tom" age 25
HSET user:profile:123 address "..." bio "..."
HSET user:orders:123 order1 "..." order2 "..."
java
// 拆分大 List:按时间分片
LPUSH list:20260101 item1 item2 ...
LPUSH list:20260102 item1 item2 ...
  1. 异步删除(UNLINK)
bash
# Redis 4.0+: UNLINK 后台异步删除
127.0.0.1:6379> UNLINK bigkey:123
(integer) 1

# 对比 DEL(同步,阻塞)
127.0.0.1:6379> DEL bigkey:123   # 阻塞主线程

# 配置异步删除策略
config set lazyfree-lazy-eviction yes   # 淘汰时异步删
config set lazyfree-lazy-expire yes     # 过期时异步删
config set lazyfree-lazy-server-del yes # 服务器内部删除异步
  1. 分批删除大 Key
bash
# 大 Hash 分批删除(每次删 100 个 field)
redis-cli --bigkeys
# 用 HSCAN + HDEL 分批
HSCAN bighash 0 COUNT 100 HDEL bighash field1 field2 ...
HSCAN bighash cursor COUNT 100 ...

# 大 ZSet 分批删除
ZREMRANGEBYRANK bigzset 0 99  # 每次删前100个
java
// Java 分批删除大 Hash
String cursor = "0";
do {
    ScanResult<Map.Entry<String, String>> result = 
        jedis.hscan("bighash", cursor);
    List<Map.Entry<String, String>> entries = result.getResult();
    if (!entries.isEmpty()) {
        String[] fields = entries.stream()
            .map(Map.Entry::getKey)
            .toArray(String[]::new);
        jedis.hdel("bighash", fields);
    }
    cursor = result.getCursor();
} while (!"0".equals(cursor));
  1. 压缩存储
java
// 大 value 存压缩后的内容
String value = "很长的内容...";
byte[] compressed = compress(value.getBytes());  // gzip/snappy
jedis.set("bigkey", new String(compressed));

大 Key 预防

措施说明
代码 Review检查是否有无限增长的数据结构
监控告警监控 key 的大小和数量
合理设置过期避免数据无限堆积
拆分设计设计时就按维度拆分
限制集合大小业务上限制 List/Set 最大元素数

追问延伸

  • 大 Key 删除时为什么用 UNLINK 而不是 DEL?(UNLINK 异步删除不阻塞)
  • Cluster 模式下大 Key 怎么处理?(拆分到不同槽,分散到不同节点)

Q43: Redis 热 Key 问题是什么?怎么解决? 「🟡 中级」

考察点:热 Key 问题的识别和解决方案。

参考答案

热 Key 定义:访问量极高的 key,导致单节点压力过大。

典型场景:

  • 秒杀商品信息
  • 热点新闻首页
  • 活动页配置
  • 明星动态

热 Key 的危害:

┌────────────────────────────────────────────────────┐
│  Cluster 节点A (热 Key 所在)                         │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐            │
│  │ 请求1   │  │ 请求2   │  │ 请求N   │  ←─ 大量请求 │
│  └─────────┘  └─────────┘  └─────────┘    涌向单节点 │
│       CPU 飙升、带宽打满、其他 key 受影响              │
└────────────────────────────────────────────────────┘

具体影响:

  1. 单节点 CPU/带宽打满
  2. 缓存击穿(热 key 过期瞬间,大量请求穿透到数据库)
  3. 影响同节点其他 key(资源被挤占)
  4. Cluster 数据倾斜(流量不均)

发现热 Key

bash
# 方法1: redis-cli --hotkeys(4.0+,基于 LFU 统计)
# 需要先配置淘汰策略为 allkeys-lfu 或 volatile-lfu
config set maxmemory-policy allkeys-lfu
redis-cli --hotkeys

# 方法2: MONITOR 命令(生产慎用,严重影响性能)
# 短时间开启,统计高频访问的 key
redis-cli monitor | grep -oE '"GET [^"]+"' | sort | uniq -c | sort -rn

# 方法3: 代理层统计(Twemproxy/Codis)
# 代理层记录每个 key 的访问次数

# 方法4: 客户端埋点
# 应用层记录访问的 key,聚合上报

解决方案

  1. 本地缓存(最有效):
java
// Caffeine 本地缓存 + Redis 二级缓存
LoadingCache<String, String> localCache = Caffeine.newBuilder()
    .maximumSize(10000)
    .expireAfterWrite(5, TimeUnit.SECONDS)  // 短 TTL
    .build(key -> {
        // 本地缓存没命中,查 Redis
        return jedis.get(key);
    });

// 读取流程
String value = localCache.get("hotkey:123");  // 先查本地
  1. 热 Key 打散(多副本)
java
// 复制多份相同数据到不同 key
String[] keys = {"hotkey:1", "hotkey:2", "hotkey:3", "hotkey:4"};
for (String key : keys) {
    jedis.setex(key, 60, value);  // 复制到多个 key
}

// 读取时随机访问一个副本
int index = ThreadLocalRandom.current().nextInt(keys.length);
String value = jedis.get(keys[index]);
打散效果:
请求1 → hotkey:1
请求2 → hotkey:3
请求3 → hotkey:2
请求4 → hotkey:4

流量分散到 4 个副本,单 key 压力降为 1/4
  1. 永不过期 + 异步更新
java
// 热 key 不设过期时间
jedis.set("hotkey:123", value);  // 不设 TTL

// 后台定时更新
@Scheduled(fixedRate = 5000)  // 每5秒更新
public void refreshHotKey() {
    String newValue = loadDataFromDB();
    jedis.set("hotkey:123", newValue);  // 覆盖更新
}
  1. 读写分离
bash
# 读请求走从节点(分散主节点压力)
# 注意:主从同步有延迟,适合读多写少场景

方案对比

方案效果复杂度一致性适用场景
本地缓存极好最终一致读极高
热键打散强一致Cluster 分散
永不过期最终一致数据变化少
读写分离最终一致读远多于写

热点 Key 突然失效(缓存击穿)

热 key 失效瞬间:
                  ┌─────────┐
请求1 ────────────→│         │
请求2 ────────────→│ 缓存miss│──→ 全部打到数据库
请求3 ────────────→│         │
请求N ────────────→└─────────┘

解决:

  1. 互斥锁重建缓存(只让一个线程查数据库)
  2. 逻辑过期(不真删,标记过期后台更新)
  3. 永不过期
java
// 互斥锁重建缓存
public String getWithMutex(String key) {
    String value = jedis.get(key);
    if (value == null) {
        // 获取分布式锁
        if (tryLock("lock:" + key, 10)) {
            try {
                // 双重检查
                value = jedis.get(key);
                if (value == null) {
                    value = loadDataFromDB();
                    jedis.setex(key, 300, value);
                }
            } finally {
                releaseLock("lock:" + key);
            }
        } else {
            // 没抢到锁,短暂等待后重试
            Thread.sleep(50);
            return getWithMutex(key);
        }
    }
    return value;
}

追问延伸

  • 本地缓存和 Redis 怎么保证一致性?(短 TTL + 发布订阅通知失效)
  • 热 key 的阈值怎么定?(根据业务 QPS 和 Redis 单节点容量定)

Q44: 布隆过滤器原理?怎么用? 「🟡 中级」

考察点:布隆过滤器的原理、使用和局限性。

参考答案

布隆过滤器(Bloom Filter):一种空间效率很高的概率型数据结构,用于判断元素是否在集合中。

特点:

  • 可能有误判(说存在,实际可能不存在)
  • 不会漏判(说不存在,一定不存在)
  • 空间效率极高(比 HashSet 省几个数量级内存)

原理

初始状态:m=16 位的位数组,全0

bit: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]

添加元素 "hello"(3个哈希函数 h1, h2, h3):
  h1("hello") % 16 = 3   → bit[3] = 1
  h2("hello") % 16 = 7   → bit[7] = 1
  h3("hello") % 16 = 11  → bit[11] = 1

bit: [0,0,0,1,0,0,0,1,0,0,0,1,0,0,0,0]

查询 "hello":
  h1=3(1), h2=7(1), h3=11(1) → 全是1 → 可能存在 ✓

查询 "world"(假设 h1=2, h2=7, h3=15):
  h1=2(0) → 有0 → 一定不存在 ✗

误判率控制

参数:n(元素数量)、k(哈希函数数)、m(位数组大小)、p(误判率)

最优哈希函数数:k = (m/n) × ln(2)
最优位数组大小:m = -n × ln(p) / (ln2)^2

示例:100万元素,误判率 0.1%
  m ≈ 14.4 MB(位)
  k = 10

Redis 中使用

bash
# 方法1: RedisBloom 模块(推荐)
# 安装后可以直接使用

# 创建布隆过滤器(误判率0.001,容量100万)
127.0.0.1:6379> BF.RESERVE myfilter 0.001 1000000
OK

# 添加元素
127.0.0.1:6379> BF.ADD myfilter "user:123"
(integer) 1

# 查询元素
127.0.0.1:6379> BF.EXISTS myfilter "user:123"
(integer) 1   # 可能存在

127.0.0.1:6379> BF.EXISTS myfilter "user:456"
(integer) 0   # 一定不存在
java
// 方法2: Redisson 客户端
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

RBloomFilter<String> bloomFilter = redisson.getBloomFilter("myfilter");
// 初始化(预计元素100万,误判率0.001)
bloomFilter.tryInit(1000000L, 0.001);

// 添加元素
bloomFilter.add("user:123");

// 查询
boolean mightExist = bloomFilter.contains("user:123");
boolean definitelyNotExist = bloomFilter.contains("user:456");
java
// 方法3: Guava BloomFilter(本地,非分布式)
BloomFilter<CharSequence> filter = BloomFilter.create(
    Funnels.stringFunnel(Charset.defaultCharset()),
    1000000,  // 预计元素数
    0.001     // 误判率
);
filter.put("user:123");
boolean exists = filter.mightContain("user:123");

应用场景

  1. 缓存穿透防护
查询流程:
客户端 → 布隆过滤器判断
  ├── 不存在 → 直接返回(不查缓存和数据库)
  └── 可能存在 → 查缓存
        ├── 缓存命中 → 返回
        └── 缓存miss → 查数据库 → 回填缓存
java
// 缓存穿透防护
public User getUser(String userId) {
    // 1. 布隆过滤器先判断
    if (!bloomFilter.contains("user:" + userId)) {
        return null;  // 一定不存在,直接返回
    }
    
    // 2. 查缓存
    String value = jedis.get("user:" + userId);
    if (value != null) {
        return JSON.parseObject(value, User.class);
    }
    
    // 3. 查数据库
    User user = userDao.findById(userId);
    if (user != null) {
        jedis.setex("user:" + userId, 300, JSON.toJSONString(user));
    }
    return user;
}
  1. 其他场景:URL 去重、黑名单、邮箱是否已注册

布隆过滤器 vs 其他结构

维度HashSet布隆过滤器Cuckoo Filter
空间极小
误判
删除支持不支持支持
查询O(1)O(k)O(1)

局限性

  • 不能删除元素(标准布隆过滤器)
    • 删除一个位可能影响其他元素
    • 解决:Counting Bloom Filter(用计数器代替位)
  • 误判率不能为 0(只能尽量小)

追问延伸

  • 布隆过滤器误判率怎么控制?(调 m 和 k 参数)
  • 怎么支持删除元素?(Counting Bloom Filter / Cuckoo Filter)

Q45: 如何设计秒杀场景处理高并发以及超卖现象? 「🔴 高级」

考察点:Redis 在高并发秒杀场景的综合应用。

参考答案

秒杀场景的核心挑战:瞬时高并发 + 防止超卖 + 保证用户体验。

整体架构

┌─────────────────────────────────────────────────────────────┐
│                        秒杀架构                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  用户请求                                                    │
│     │                                                       │
│     ↓                                                       │
│  ┌──────────┐   限流    ┌──────────┐                       │
│  │ Nginx    │──────────→│ 限流层    │ 挡住超量请求           │
│  └──────────┘           └──────────┘                       │
│                              │                              │
│                              ↓                              │
│  ┌──────────────────────────────────────┐                  │
│  │        应用层(本地缓存 + 逻辑)        │                  │
│  │  1. 本地缓存判断活动状态               │                  │
│  │  2. 布隆过滤器判断用户是否已购买        │                  │
│  └──────────────────────────────────────┘                  │
│                    │                                        │
│                    ↓                                        │
│  ┌──────────────────────────────────────┐                  │
│  │        Redis(库存扣减核心)            │                  │
│  │  1. Lua 脚本原子扣减库存              │                  │
│  │  2. 分布式锁防并发                    │                  │
│  │  3. 限流计数                          │                  │
│  └──────────────────────────────────────┘                  │
│                    │                                        │
│                    ↓                                        │
│  ┌──────────────────────────────────────┐                  │
│  │    MQ(异步削峰 + 异步下单)            │                  │
│  │  1. 扣减成功发消息                    │                  │
│  │  2. 消费者异步创建订单                │                  │
│  └──────────────────────────────────────┘                  │
│                    │                                        │
│                    ↓                                        │
│  ┌──────────────────────────────────────┐                  │
│  │         数据库(最终持久化)            │                  │
│  │  1. 创建订单记录                      │                  │
│  │  2. 扣减数据库库存(乐观锁兜底)        │                  │
│  └──────────────────────────────────────┘                  │
│                                                             │
└─────────────────────────────────────────────────────────────┘

核心方案

1. Redis Lua 脚本原子扣减库存(防超卖核心)

lua
-- seckill.lua
-- KEYS[1]: 库存key  KEYS[2]: 已购用户set
-- ARGV[1]: 用户ID   ARGV[2]: 商品ID

-- 1. 判断库存是否充足
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
    return -1  -- 库存不足
end

-- 2. 判断是否已购买(防重复购买)
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
    return -2  -- 已购买过
end

-- 3. 扣减库存
redis.call('DECR', KEYS[1])

-- 4. 记录已购用户
redis.call('SADD', KEYS[2], ARGV[1])

-- 5. 返回成功
return 1
java
// Java 调用 Lua 脚本
String luaScript = loadScript("seckill.lua");
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);

Long result = redisTemplate.execute(
    script,
    Arrays.asList("stock:product:123", "bought:product:123"),
    userId, "123"
);

if (result == 1) {
    // 扣减成功,发送 MQ 异步创建订单
    mqSender.send("order_topic", new OrderMessage(userId, "123"));
} else if (result == -1) {
    // 库存不足
    return "sold out";
} else if (result == -2) {
    // 已购买
    return "already bought";
}

2. 异步下单(削峰填谷)

秒杀请求


Redis 扣库存(Lua 原子操作)─── 成功 ──→ 发送 MQ
    │                                    │
    失败                                 ↓
    ↓                              消费者异步处理
返回"抢购中"                       ├── 创建订单
                                  ├── 扣减数据库库存
                                  └── 发送通知(短信/Push)
java
// MQ 消费者异步下单
@RocketMQMessageListener(topic = "order_topic", consumerGroup = "order_consumer")
public class OrderConsumer implements RocketMQListener<OrderMessage> {
    
    @Override
    @Transactional
    public void onMessage(OrderMessage msg) {
        // 1. 数据库扣减库存(乐观锁兜底)
        int affected = productDao.decrStock(msg.getProductId(), 
            "WHERE stock > 0");
        if (affected == 0) {
            // 数据库库存不足,回滚 Redis
            redisTemplate.opsForValue().increment("stock:product:" + msg.getProductId());
            redisTemplate.opsForSet().remove("bought:product:" + msg.getProductId(), 
                msg.getUserId());
            return;
        }
        
        // 2. 创建订单
        Order order = new Order();
        order.setUserId(msg.getUserId());
        order.setProductId(msg.getProductId());
        order.setStatus("PAID");
        orderDao.insert(order);
        
        // 3. 发送通知
        notifyService.send(msg.getUserId(), "抢购成功!");
    }
}

3. 多层限流

java
// 层1: Nginx 限流(IP 级别)
// nginx.conf
// limit_req_zone $binary_remote_addr zone=seckill:10m rate=10r/s;

// 层2: 应用层限流(用户级别)
// Guava RateLimiter
RateLimiter rateLimiter = RateLimiter.create(1000);  // 每秒1000个请求
if (!rateLimiter.tryAcquire()) {
    return "系统繁忙";
}

// 层3: Redis 限流(接口级别)
// 滑动窗口限流
String key = "rate_limit:seckill:" + System.currentTimeMillis() / 1000;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
    redisTemplate.expire(key, 1, TimeUnit.SECONDS);
}
if (count > 1000) {
    return "系统繁忙";
}

4. 数据库兜底(最终一致)

sql
-- 数据库乐观锁兜底(防止 Redis 异常导致超卖)
UPDATE product SET stock = stock - 1 
WHERE id = 123 AND stock > 0;
-- affected_rows = 0 说明库存不足

防超卖的多种方案对比

方案实现方式性能一致性适用场景
数据库行锁SELECT ... FOR UPDATE并发量低
数据库乐观锁WHERE stock > 0中等并发
Redis 分布式锁SET NX最终通用
Redis Lua 原子Lua 脚本扣减最终高并发秒杀
Redis + MQ 异步Lua + 异步下单极高最终超高并发

秒杀场景 Redis 数据预热

java
// 活动开始前预热
@Scheduled(cron = "0 0 9 * * ?")  // 每天9点执行
public void preheatSeckill() {
    List<Product> products = productDao.getSeckillProducts();
    for (Product product : products) {
        // 1. 预热库存到 Redis
        redisTemplate.opsForValue().set(
            "stock:product:" + product.getId(), 
            String.valueOf(product.getStock())
        );
        
        // 2. 初始化已购用户集合
        redisTemplate.delete("bought:product:" + product.getId());
        
        // 3. 布隆过滤器预热(已购买用户)
        RBloomFilter<String> filter = redisson.getBloomFilter(
            "filter:product:" + product.getId()
        );
        filter.tryInit(1000000L, 0.001);
    }
}

秒杀注意事项

事项说明
库存预热活动前把库存写入 Redis,避免实时查数据库
防刷机制验证码、IP 限流、用户频率限制
接口隐藏秒杀 URL 动态生成,防止刷接口
熔断降级库存为 0 后直接拒绝,不查 Redis
幂等设计同一用户同一商品只能买一次
数据对账活动结束后 Redis 和数据库库存对账

追问延伸

  • 秒杀场景下 Redis 宕机了怎么办?(降级到数据库乐观锁、限流保护数据库)
  • 如果 Redis 和数据库库存不一致怎么处理?(定时对账、补偿机制)