Appearance
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 连续分配,一次 mallocraw:长字符串,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-entries、hash-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(生产慎用,严重影响性能,仅短时排查用)
解决方案:
- 本地缓存:应用层加本地缓存(Caffeine / Guava Cache),减少 Redis 访问;TTL 设短(秒级)
- 热 Key 打散:复制多份相同数据到不同 key(key_01、key_02 ... key_N),随机访问,分散流量
- 二级缓存:本地缓存 + Redis,本地缓存设置较短 TTL,Redis 设较长 TTL
- 读写分离:读请求走从节点(注意一致性,适合读远多于写的场景)
- 永不过期 + 异步更新:热 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 交给多线程,命令执行保持单线程(保证线程安全,无锁)
工作流程:
- IO 线程池读取客户端命令(多线程并行)
- 命令解析后排队,主线程单线程执行(保证原子性)
- 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 复制机制的详细原理。
参考答案:
主从同步流程(新从节点第一次连接主节点):
全量同步(Full Resync):
- 从节点发送 PSYNC 命令(2.8+ 支持 PSYNC,5.0+ 用 PSYNC2)
- 主节点判断需要全量同步 → 执行 BGSAVE 生成 RDB → 发送给从节点
- 同时,主节点把生成 RDB 期间的新写入命令存到复制缓冲区(repl_backlog_buffer)
- 从节点加载 RDB → 追加执行缓冲区的命令 → 同步完成
增量同步(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 场景下的脑裂:
- 主从模式:主节点网络隔离,哨兵认为主节点挂了,选举从节点为新主。旧主恢复后还是 Master,出现两个 Master。
- Cluster 模式:网络分区导致部分节点互相不可达,各自选出主节点。
危害:
- 两个 Master 都接受写入,分区恢复后数据冲突
- 旧 Master 的写入可能丢失(被新 Master 覆盖)
解决方案:
min-slaves-to-write / min-slaves-good-lag:
- 主节点必须有至少 N 个从节点在指定延迟内同步,才接受写入
- 不满足条件 → 主节点拒绝写入(报错)
- 网络分区时,旧主大概率连不上足够从节点 → 不能写入 → 避免脑裂
- Redis 4.0+ 改为
min-replicas-to-write/min-replicas-max-lag
Cluster 的 failover 机制:
- 节点故障检测需要多个节点确认(Gossip 协议)
- 从节点升级需要先自选为候选,再获得半数以上主节点投票
- 降低脑裂概率(但不是 100% 避免)
客户端配合:
- 客户端感知故障转移,切换到新主
- 旧主的写入通过 retry 机制自然失败
业务幂等:
- 业务设计幂等,即使脑裂导致重复写入也无害
根本问题:分布式系统无法完全避免脑裂,只能在一致性和可用性之间权衡(CAP 理论)
追问延伸:
- Redis 的脑裂和 ZooKeeper 的脑裂有什么区别?
- min-replicas-to-write 设为多少合适?(至少 1,但设太高影响可用性)
Q18: Redis 怎么实现延迟队列?ZSet 方案有什么优缺点? 「🟡 中级」
考察点:Redis 延迟队列的实现方案和工程实践。
参考答案:
Redis 延迟队列方案:
ZSet 方案(常用):
- 原理:ZSet 的 score 存消息到期时间戳,member 存消息内容/ID
- 生产者:
ZADD delay_queue timestamp messageId - 消费者:定时轮询
ZRANGEBYSCORE delay_queue 0 now获取到期消息 - 取到后
ZREM delay_queue messageId删除(保证不重复消费) - ZREM 返回 1 表示抢到,返回 0 表示被其他消费者抢了
优点:简单、可靠、支持任意延迟时间
缺点:
- 需要轮询(有延迟窗口)
- ZSet 太大会影响性能
- 多消费者需要处理竞争(ZREM 原子操作保证只有一个抢到)
- 不支持消费组(多组都消费同一条消息需要存多份)
过期通知方案(不推荐):
- 利用 Redis 的过期事件通知(Keyspace Notifications)
- 设置 key 的 TTL,过期时通知
- 缺点:不可靠(过期通知可能丢失)、不精确、性能差
Redis Stream 方案:
- 消息自带时间戳,消费时判断是否到期
- 不如 ZSet 直观,少用
时间轮方案(参考):
- 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 导致碎片
排查方法:
INFO memory:used_memory:实际使用的内存used_memory_rss:操作系统分配给 Redis 的内存mem_fragmentation_ratio= used_memory_rss / used_memory- 比率 > 1.5 说明碎片较多(理想值 1.0-1.5)
MEMORY STATS:更详细的内存统计MEMORY USAGE key:查看单个 key 的内存占用
解决方案:
重启 Redis:
- 最直接的方法,重启后重新分配内存
- 缺点:服务中断,需要做故障转移
主动碎片整理(4.0+):
config set activedefrag yes- Redis 在运行时自动整理碎片(复制数据到新位置,释放旧空间)
- 对性能影响小(在后台慢慢做)
- 相关参数:
active-defrag-ignore-bytes:碎片小于此值不整理active-defrag-threshold-lower:碎片率超过此值开始整理active-defrag-cycle:CPU 占用比例
合理设置 maxmemory 和淘汰策略:
- 让 Redis 主动淘汰不用的 key
- 减少内存压力
预防:
- 避免频繁创建删除不同大小的 key
- 使用合适的数据结构(Hash 替代多个 String)
- 定期监控 mem_fragmentation_ratio
追问延伸:
- 为什么 jemalloc 会产生碎片?(固定大小分配 + 大小变化)
- 重启和 activedefrag 各有什么优缺点?
Q20: Redis 的布隆过滤器是什么?怎么用?误判率怎么控制? 「🟡 中级」
考察点:布隆过滤器的原理和 Redis 中的实现。
参考答案:
布隆过滤器(Bloom Filter):一种概率型数据结构,用于判断元素是否存在集合中。
- 特点:可能有误判(说存在可能不存在),但不会漏判(说不存在一定不存在)
- 空间效率极高:比 HashSet 节省几个数量级的内存
原理:
- 一个 BitMap(位数组),初始全为 0
- k 个独立的哈希函数
- 添加元素:对元素做 k 次哈希,将对应的 k 个位置设为 1
- 查询元素:对元素做 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 中的实现:
RedisBloom 模块(推荐):
BF.ADD filter element:添加元素BF.EXISTS filter element:查询元素BF.RESERVE filter 0.001 1000000:创建过滤器(误判率0.001,容量100万)
手动用 BitMap 实现:
- 用 SETBIT/GETBIT 操作位
- 需要自己实现多个哈希函数
Redisson 的 RBloomFilter:
- Java 客户端封装
应用场景:
- 缓存穿透防护:查询前先过布隆过滤器,不存在直接返回
- 去重:URL 去重、用户去重
- 黑名单检查
- 邮箱/用户名是否已注册
局限性:
- 不能删除元素(标准布隆过滤器不支持删除)
- Counting Bloom Filter 可以支持删除(用计数器代替位)
- 误判率不能为 0(只能尽量小)
追问延伸:
- 布隆过滤器为什么不能删除?(删除可能影响其他元素的判断)
- 怎么解决不能删除的问题?(Counting Bloom Filter / Cuckoo Filter)
Q21: Redis 哨兵(Sentinel)的工作原理?主观下线和客观下线有什么区别? 「🟡 中级」
考察点:Redis 哨兵自动故障转移的完整流程。
参考答案:
哨兵的核心职责:监控、通知、自动故障转移、配置中心。
工作流程:
监控:
- 每个哨兵定时(每秒)向 Master、Slave、其他哨兵发送 PING
- Master 在
down-after-milliseconds(默认30秒)内没回复 → 标记主观下线
主观下线(SDOWN):
- 单个哨兵认为 Master 不可用
- 发送
SENTINEL is-master-down-by-addr给其他哨兵,询问是否同意
客观下线(ODOWN):
- 超过 quorum(法定数量)个哨兵都认为 Master 不可用
- 标记为客观下线,准备故障转移
选举领头哨兵:
- 所有哨兵互相发竞选请求
- 先收到请求的投票给对方(先到先得)
- 获得半数以上选票的哨兵成为领头
故障转移:
- 领头哨兵选择一个 Slave 作为新 Master:
- 排除不健康的 Slave
- 优先级(slave-priority)高的优先
- 复制偏移量大(数据新)的优先
- Run ID 小的优先
- 对新 Master:
SLAVEOF NO ONE(取消从属关系) - 对其他 Slave:
SLAVEOF 新Master(指向新 Master) - 更新哨兵配置,通知客户端
- 领头哨兵选择一个 Slave 作为新 Master:
客户端感知:
- 客户端连接哨兵而非直连 Redis
- 请求时哨兵返回当前 Master 地址
- Master 切换后客户端自动更新
部署建议:
- 至少 3 个哨兵(奇数,避免脑裂)
- 哨兵和 Redis 不在同一机器
- quorum = N/2 + 1(半数以上)
追问延伸:
- 哨兵之间怎么发现彼此?(通过 Master 的 pub/sub)
- 哨兵模式能水平扩展吗?(不能,只有一个 Master)
Q22: Redis Cluster 怎么扩容?槽迁移怎么做? 「🟡 中级」
考察点:Redis 集群水平扩展的实战操作。
参考答案:
Redis Cluster 有 16384 个槽(slot),每个节点负责一部分。
扩容流程(新增一个节点):
加入集群:
redis-cli --cluster add-node 新节点IP:端口 已有节点IP:端口新节点加入但还没有分配槽。
迁移槽:
- 从现有节点迁移一部分槽到新节点
- 迁移是逐个槽进行的:
# 迁移槽 0 从源节点到目标节点 redis-cli --cluster reshard 集群任意节点:端口 --cluster-from 源节点ID --cluster-to 目标节点ID --cluster-slots 1000 # 迁移1000个槽槽迁移底层步骤:
- 在目标节点标记槽为 IMPORTING
- 在源节点标记槽为 MIGRATING
- 源节点执行
CLUSTER GETKEYSINSLOT 槽号 数量,获取该槽中的 key - 逐个
MIGRATEkey 到目标节点 - 迁移完成后,向所有节点广播槽的归属变更
验证:
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:客户端名称
排查流程:
- 开启慢查询日志(设合适阈值)
- 慢查询定期分析(脚本 + 告警)
- 常见慢查询原因:
- 大 Key 操作(HGETALL 大 Hash、LRANGE 大 List、SMEMBERS 大 Set)
- KEYS 命令(生产禁用,用 SCAN 替代)
- FLUSHALL/FLUSHDB(阻塞所有操作)
- 复杂 Lua 脚本
- SUNION/SUNIONSTORE/SINTER 等集合操作
- 优化方案:
- 拆分大 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 不写
- 结果:新文件只包含恢复到当前状态所需的最少命令
重写流程:
- 触发:
- 手动:
BGREWRITEAOF命令 - 自动:当前 AOF 文件大小超过
auto-aof-rewrite-percentage(默认100%,即翻倍)且超过auto-aof-rewrite-min-size(默认64MB)
- 手动:
- 主进程 fork 子进程
- 子进程遍历数据,写入临时 AOF 文件
- 主进程继续处理命令,新命令同时写入旧 AOF 缓冲区和 AOF 重写缓冲区
- 子进程写完后通知主进程
- 主进程把重写缓冲区的内容追加到新 AOF 文件
- 原子替换旧 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 分片方案的理解和选型。
参考答案:
两种分片方式:
客户端分片(Client-Side Sharding):
- 客户端自己计算 key 属于哪个 Redis 节点
- 常见方案:Twemproxy(代理层分片)、Codis(代理层分片)、Jedis ShardedJedis
- 路由方式:一致性哈希 或 槽位映射
- 优点:客户端直连,延迟低
- 缺点:
- 客户端逻辑复杂
- 扩缩容需要客户端感知
- 不支持跨节点操作(MGET、事务等)
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 Stream | Kafka |
|---|---|---|
| 持久化 | 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+ 树:
- 实现简单:跳表代码远比 B+ 树简单(插入/删除不需要旋转)
- 内存友好:跳表每个节点小,不像 B+ 树需要按页分配
- 范围查询好:跳表 L0 就是有序链表,范围查询直接遍历
- 并发友好:跳表局部修改,不像 B+ 树可能涉及多级分裂
- 内存数据库:Redis 是内存数据库,不需要考虑磁盘 IO → B+ 树的优势(页式读取)不适用
- 内存效率:跳表每个节点按需分配层数,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 单线程也能这么快:
- 全内存操作(无磁盘 IO)
- IO 多路复用(一个线程处理大量连接)
- 单线程无锁、无上下文切换
- 高效的数据结构(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 过期键管理 = 惰性删除 + 定期删除 + 内存淘汰
惰性删除(Lazy Expiration):
- 访问 key 时检查是否过期,过期就删除
- 优点:CPU 友好(不主动扫描)
- 缺点:过期 key 不被访问就一直占内存(内存泄漏)
定期删除(Periodic Extraction):
- 每 100ms 随机抽取一些设置了过期时间的 key 检查
- 删除过期的 key
- 如果过期 key 比例超过 25%,继续扫描
- 控制执行时间(不超过 25ms),避免阻塞
- 优点:主动回收内存
- 缺点:随机抽样,可能遗漏
内存淘汰(当内存不足时):
maxmemory达到上限时触发- 8 种淘汰策略:
noeviction:不淘汰,拒绝写入(默认)allkeys-lru:所有 key 中淘汰最久未使用的(推荐)allkeys-lfu:所有 key 中淘汰最不经常使用的allkeys-random:随机淘汰volatile-lru:设了过期的 key 中淘汰 LRUvolatile-lfu:设了过期的 key 中淘汰 LFUvolatile-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 语言字符串的缺点:
- 获取长度 O(n):需要遍历到
\0才知道长度 - 缓冲区溢出:拼接字符串不会自动扩容,可能溢出到相邻内存
- 频繁内存分配:每次修改都要 realloc
- 不能保存二进制数据:遇到
\0就截断(不能存图片、序列化数据) - 只能用
strchar等函数操作,不安全
SDS(Simple Dynamic String,简单动态字符串):
c
struct sdshdr {
int len; // 已使用长度
int alloc; // 分配的总长度
char flags; // 类型标志(sdshdr5/8/16/32/64)
char buf[]; // 实际数据(柔性数组)
};SDS 的优势:
- O(1) 获取长度:直接读 len 字段
- 防止缓冲区溢出:拼接前检查空间,不够自动扩容
- 减少内存分配:
- 空间预分配:扩容时多分配空间(<1MB 翻倍,>=1MB 多1MB)
- 惰性释放:缩短时不立即释放内存,留作下次使用
- 二进制安全:不用
\0判断结束,用 len 判断长度 → 可以存任意二进制数据 - 兼容 C 字符串函数:buf 末尾仍保留
\0,可以直接用 printf 等函数
SDS 的类型优化:
- sdshdr5:长度 < 32 字节(1字节头部)
- sdshdr8:长度 < 256 字节(2字节头部)
- sdshdr16:长度 < 65536 字节(3字节头部)
- sdshdr32/64:更大长度
- 根据字符串大小选择不同的头部结构,节省内存
追问延伸:
- SDS 的空间预分配策略是什么?
- 为什么 Redis 要设计 5 种 SDS 类型?
Q33: Redis 为什么快?单线程为什么也能高性能? 「🟢 校招」
考察点:Redis 高性能的核心原因,单线程模型的优势。
参考答案:
Redis 快的五大原因:
基于内存操作:数据全在内存,读写速度是磁盘的数万倍,避免磁盘 IO 瓶颈。
单线程模型(核心命令执行):
- 避免多线程的锁竞争和上下文切换开销
- 实现简单,无并发问题,代码容易维护
- 不会因为线程切换浪费 CPU 时间
IO 多路复用:
- 单线程用 epoll 同时监听大量客户端连接
- 哪个连接有数据就处理哪个,不阻塞等待
- 一个线程能处理数万并发连接
高效的数据结构:
- SDS(O(1) 获取长度、二进制安全)
- 哈希表(渐进式 rehash,不阻塞)
- 跳表(O(logN) 范围查询,实现比红黑树简单)
- ziplist/listpack(小数据紧凑存储,省内存)
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 ─┘ (多线程并行) (单线程) (多线程并行)工作流程详解:
- 主线程通过 epoll_wait 等待客户端连接就绪
- 把就绪的客户端分配给 IO 线程池读取命令(多线程并行)
- 主线程等待所有 IO 线程读取完成
- 主线程单线程串行执行所有命令(保证原子性,无锁)
- 主线程把结果分配给 IO 线程池发送(多线程并行)
- 主线程等待所有 IO 线程发送完成
- 回到步骤 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);
}
}多路复用的底层实现(按平台):
| 平台 | 实现 | 特点 |
|---|---|---|
| Linux | epoll | 事件驱动,O(1),支持大量连接 |
| macOS | kqueue | 类似 epoll |
| Windows | select | 有连接数限制(1024),效率低 |
| - | Redis 封装 | ae.c 统一接口 aeApiCreate/Poll/Add/Del |
epoll 的优势(对比 select/poll):
| 维度 | select | poll | epoll |
|---|---|---|---|
| 连接数限制 | 1024 | 无限制 | 无限制 |
| 时间复杂度 | O(n) 遍历 | O(n) 遍历 | O(1) 事件驱动 |
| fd 拷贝 | 每次全量拷贝 | 每次全量拷贝 | 只注册一次 |
| 工作方式 | 水平触发 | 水平触发 | 水平/边沿触发 |
Redis 单线程 Reactor 模型 vs Netty 主从 Reactor:
| 维度 | Redis | Netty |
|---|---|---|
| 线程模型 | 单线程 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 种配合):
惰性删除:访问 key 时检查过期,过期则删除
- 优点:CPU 友好
- 缺点:不访问就一直占内存
定期删除:每隔一段时间随机抽样检查
- 每 100ms 抽样,过期比例 > 25% 继续扫描
- 控制执行时间避免阻塞
内存淘汰:兜底机制,内存满时强制淘汰
内存淘汰策略(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 256mb2.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 哨兵故障转移包含两个选举:
- 选举领头 Sentinel(谁来执行故障转移)
- 选举新 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 # 例如得到 5474Hash 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
endjava
// 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 秒
- 客户端宕机后看门狗停止,锁自动过期释放
应用场景
- 防超卖:秒杀、抢购扣减库存
- 防重复提交:接口幂等性
- 定时任务防重:多实例部署只有一个执行
- 分布式协调:资源互斥访问
单节点 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解决方案
- 拆分大 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 ...- 异步删除(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 # 服务器内部删除异步- 分批删除大 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));- 压缩存储:
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 受影响 │
└────────────────────────────────────────────────────┘具体影响:
- 单节点 CPU/带宽打满
- 缓存击穿(热 key 过期瞬间,大量请求穿透到数据库)
- 影响同节点其他 key(资源被挤占)
- 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,聚合上报解决方案
- 本地缓存(最有效):
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"); // 先查本地- 热 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- 永不过期 + 异步更新:
java
// 热 key 不设过期时间
jedis.set("hotkey:123", value); // 不设 TTL
// 后台定时更新
@Scheduled(fixedRate = 5000) // 每5秒更新
public void refreshHotKey() {
String newValue = loadDataFromDB();
jedis.set("hotkey:123", newValue); // 覆盖更新
}- 读写分离:
bash
# 读请求走从节点(分散主节点压力)
# 注意:主从同步有延迟,适合读多写少场景方案对比
| 方案 | 效果 | 复杂度 | 一致性 | 适用场景 |
|---|---|---|---|---|
| 本地缓存 | 极好 | 中 | 最终一致 | 读极高 |
| 热键打散 | 好 | 中 | 强一致 | Cluster 分散 |
| 永不过期 | 好 | 低 | 最终一致 | 数据变化少 |
| 读写分离 | 中 | 低 | 最终一致 | 读远多于写 |
热点 Key 突然失效(缓存击穿)
热 key 失效瞬间:
┌─────────┐
请求1 ────────────→│ │
请求2 ────────────→│ 缓存miss│──→ 全部打到数据库
请求3 ────────────→│ │
请求N ────────────→└─────────┘解决:
- 互斥锁重建缓存(只让一个线程查数据库)
- 逻辑过期(不真删,标记过期后台更新)
- 永不过期
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 = 10Redis 中使用
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");应用场景
- 缓存穿透防护:
查询流程:
客户端 → 布隆过滤器判断
├── 不存在 → 直接返回(不查缓存和数据库)
└── 可能存在 → 查缓存
├── 缓存命中 → 返回
└── 缓存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;
}- 其他场景: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 1java
// 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 和数据库库存不一致怎么处理?(定时对账、补偿机制)