引言
Redis 是后端面试中出现频率最高的中间件之一。本文按照由浅入深的顺序,将 Redis 面试高频知识点整理为 12 个模块,每个知识点都配有简洁明了的解释,适合面试前快速复习。
一、Redis 基础
1.1 什么是 Redis?
Redis(Remote Dictionary Server)是一个开源的键值对内存数据库,具有以下特点:
- 多数据结构:不只是简单的 Key-Value,支持 String、List、Hash、Set、ZSet 等
- 持久化:支持 RDB 和 AOF 两种持久化方式
- 高可用:支持主从复制、哨兵模式、Cluster 集群
- 高性能:单机支撑 10 万+ QPS
1.2 Redis 为什么这么快?
| 原因 | 说明 |
|---|---|
| 纯内存操作 | 数据存储在内存中,读写速度比磁盘快 10 万倍 |
| 单线程模型 | 避免线程竞争和上下文切换开销 |
| IO 多路复用 | epoll 实现单线程同时处理大量连接 |
| 高效数据结构 | SDS、跳表、压缩列表等针对性优化 |
| C 语言实现 | 底层用 C 实现,执行效率高 |
1.3 Redis 真的是单线程吗?
主线程是单线程,但不是所有操作都是单线程:
| 操作类型 | 线程模型 |
|---|---|
| 命令处理(读写) | 单线程 |
| 持久化(RDB/AOF) | 后台子线程/子进程 |
| 网络 IO(Redis 6.0+) | 多线程 IO |
单线程的好处:无锁竞争、无上下文切换、实现简单、效率高。
1.4 应用场景
- 热点缓存:数据库查询结果缓存
- 分布式锁:
SET key NX EX - 计数器:文章阅读量、点赞数
- 排行榜:ZSet 天然支持排序
- 限流:滑动窗口计数
- 会话共享:Session 集中存储
- 签到:Bitmap 位操作
- 地理位置:Geo 查找附近的人
- 消息队列:Stream 可靠消息
- 基数统计:HyperLogLog UV 统计
二、5 大基础数据类型 + 特殊类型
2.1 基础数据类型
| 类型 | 底层实现 | 典型应用 |
|---|---|---|
| String | SDS(简单动态字符串) | 缓存、计数器、分布式锁 |
| List | quicklist(链表 + 压缩列表) | 消息队列、栈 |
| Hash | ziplist / hashtable | 存储对象、用户信息 |
| Set | intset / hashtable | 去重、交集并集差集 |
| ZSet | ziplist / 跳表(skiplist) | 排行榜、延时队列 |
2.2 特殊数据类型
| 类型 | 底层实现 | 典型应用 |
|---|---|---|
| Bitmap | 位数组 | 用户签到、在线状态 |
| HyperLogLog | 概率算法 | UV 去重统计(误差 0.81%) |
| Geo | ZSet + GeoHash | 附近的人、门店搜索 |
| Stream | Radix Tree | 可靠消息队列(替代 Pub/Sub) |
三、底层核心数据结构
3.1 SDS(Simple Dynamic String)
Redis 没有直接使用 C 字符串,而是自己实现了 SDS:
struct sdshdr {
int len; // 已使用长度
int free; // 剩余可用空间
char buf[]; // 字符数组
};
SDS 相比 C 字符串的优势:
| 特性 | C 字符串 | SDS |
|---|---|---|
| 获取长度 | O(n) 遍历 | O(1) 直接读取 len |
| 缓冲区溢出 | 可能发生 | 空间不够自动扩展 |
| 二进制安全 | 否(遇 \0 截断) | 是(按 len 判断) |
| 空间预分配 | 无 | 减少内存分配次数 |
3.2 跳表(Skip List)
跳表是 ZSet 的核心实现之一,是一种多层有序链表:
Level 3: 1 ─────────────────→ 9
Level 2: 1 ────→ 4 ────→ 7 ──→ 9
Level 1: 1 → 3 → 4 → 5 → 7 → 8 → 9
跳表 vs 红黑树:
| 特性 | 跳表 | 红黑树 |
|---|---|---|
| 实现难度 | 简单 | 复杂 |
| 范围查询 | O(logn + m),天然支持 | 需要中序遍历 |
| 内存占用 | 可控 | 每个节点固定开销 |
| 插入/删除 | O(logn) | O(logn) + 旋转调整 |
Redis 选择跳表而不是红黑树,核心原因是:实现简单、范围查询性能好、内存占用可控。
3.3 RedisObject
Redis 中所有值都被封装为 RedisObject(Redis 对象):
typedef struct redisObject {
unsigned type:4; // 类型(String/List/Hash/Set/ZSet)
unsigned encoding:4; // 编码(底层数据结构)
unsigned lru:LRU_BITS; // LRU 淘汰信息
int refcount; // 引用计数(内存回收)
void *ptr; // 指向实际数据的指针
} robj;
一个类型可以有多种编码,Redis 会根据数据量自动选择最优编码:
| 类型 | 小数据编码 | 大数据编码 |
|---|---|---|
| String | int / embstr | raw |
| List | ziplist | quicklist |
| Hash | ziplist | hashtable |
| Set | intset | hashtable |
| ZSet | ziplist | skiplist + hashtable |
四、过期删除与内存淘汰
4.1 过期键删除策略
Redis 采用惰性删除 + 定期删除的组合策略:
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 惰性删除 | 访问 Key 时才检查是否过期 | 节省 CPU | 浪费内存(过期 Key 不访问就不删) |
| 定期删除 | 每隔一段时间随机抽查部分 Key | 平衡 CPU 和内存 | 可能漏掉一些过期 Key |
两种策略配合使用,既不会占用太多 CPU,也不会让过期 Key 堆积太多内存。
4.2 内存淘汰策略
当内存不足时,Redis 会根据配置的策略淘汰 Key:
| 策略 | 淘汰范围 | 淘汰依据 |
|---|---|---|
noeviction | 不淘汰 | 内存满时写入报错 |
allkeys-lru | 所有 Key | LRU(最近最少使用) |
allkeys-lfu | 所有 Key | LFU(最不经常使用) |
allkeys-random | 所有 Key | 随机淘汰 |
volatile-lru | 设置了过期时间的 Key | LRU |
volatile-lfu | 设置了过期时间的 Key | LFU |
volatile-random | 设置了过期时间的 Key | 随机淘汰 |
volatile-ttl | 设置了过期时间的 Key | TTL 最小的优先淘汰 |
推荐配置:
- 缓存场景:
allkeys-lru(最常用) - 有冷热数据区分:
allkeys-lfu - 不想丢失数据:
noeviction
LRU vs LFU:
| 算法 | 全称 | 关注点 | 缺点 |
|---|---|---|---|
| LRU | Least Recently Used | 最后访问时间 | 偶尔访问的 Key 不会被淘汰 |
| LFU | Least Frequently Used | 访问频率 | 新 Key 可能因频率低被误淘汰 |
五、持久化
5.1 RDB(Redis Database Backup)
将内存数据以二进制快照的形式写入磁盘。
# 手动触发
SAVE # 阻塞主线程(不推荐)
BGSAVE # 后台子进程执行(推荐)
| 特性 | 说明 |
|---|---|
| 优点 | 文件小、恢复速度快 |
| 缺点 | 可能丢失最后一次快照后的数据 |
| 触发方式 | 手动 BGSAVE、配置自动触发 |
5.2 AOF(Append Only File)
将每条写命令追加到日志文件中。
AOF 刷盘策略:
| 策略 | 刷盘时机 | 数据安全性 | 性能 |
|---|---|---|---|
always | 每条命令执行后 | 最高,不丢数据 | 最慢 |
everysec | 每秒一次 | 最多丢 1 秒数据 | 推荐 |
no | 操作系统决定 | 可能丢较多数据 | 最快 |
| 特性 | 说明 |
|---|---|
| 优点 | 数据安全性高、最多丢 1 秒 |
| 缺点 | 文件大、恢复速度慢 |
5.3 混合持久化(生产推荐)
Redis 4.0+ 支持 RDB + AOF 混合持久化:
AOF 文件结构:
┌─────────────┬────────────────────┐
│ RDB 快照 │ 增量 AOF 命令 │
└─────────────┴────────────────────┘
- 前半段是 RDB 格式的全量数据(恢复快)
- 后半段是 AOF 格式的增量命令(数据安全)
六、缓存三大问题
6.1 缓存穿透
问题:查询的数据在数据库中不存在,每次请求都穿透缓存直达数据库。
恶意请求 → Redis(没有)→ 数据库(也没有)→ 返回空
恶意请求 → Redis(没有)→ 数据库(也没有)→ 返回空
...无限重复,数据库压力巨大
解决方案:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 接口参数校验 | 拦截非法请求 | 所有场景 |
| 空值缓存 | 查不到时缓存空值(TTL 较短) | 简单有效 |
| 布隆过滤器 | 预先判断 Key 是否可能存在 | 大规模数据 |
布隆过滤器原理:
请求 → 布隆过滤器 → 不存在?→ 直接返回(拦截穿透)
→ 可能存在 → 查缓存/数据库
6.2 缓存击穿
问题:某个热点 Key 过期的瞬间,大量并发请求同时打到数据库。
热点 Key 过期 → 100 个请求同时查缓存(miss)→ 100 个请求同时打到数据库 → 数据库压力飙升
解决方案:
| 方案 | 原理 |
|---|---|
| 互斥锁 | 只允许一个请求重建缓存,其他请求等待 |
| 热点 Key 永不过期 | 不设 TTL,通过后台任务更新 |
| 定时续期 | 后台定时任务在 Key 过期前续期 |
6.3 缓存雪崩
问题:大量 Key 在同一时间过期,或 Redis 宕机,导致大量请求直接打到数据库。
解决方案:
| 方案 | 原理 |
|---|---|
| 过期时间加随机值 | 避免同时过期:TTL = base + random(0, 300) |
| 集群高可用 | 哨兵 + Cluster 避免单点故障 |
| 熔断降级 | 数据库压力过大时返回默认值 |
| 多级缓存 | 本地缓存(Caffeine)+ Redis + 数据库 |
七、缓存一致性
7.1 常用方案:先更新库,再删缓存
# 更新数据
def update(key, value):
db.update(key, value) # 1. 先更新数据库
redis.delete(key) # 2. 再删除缓存
time.sleep(0.5) # 3. 延时(防止主从延迟导致脏数据)
redis.delete(key) # 4. 再删一次(延时双删)
延时双删流程:
1. 更新数据库
2. 删除缓存
3. 延时等待(如 500ms)
4. 再次删除缓存
为什么要延时双删?因为主从同步有延迟,在延迟期间其他请求可能读到旧数据并写入缓存。
7.2 强一致方案:Canal 监听 Binlog
数据库变更 → Binlog → Canal 监听 → 异步更新/删除 Redis 缓存
- 业务代码不需要关心缓存更新
- 由 Canal 中间件负责同步
- 适合对一致性要求较高的场景
八、事务与 Lua 脚本
8.1 Redis 事务
MULTI # 开启事务
SET key1 value1 # 命令入队
SET key2 value2 # 命令入队
EXEC # 执行事务
Redis 事务的特点:
| 特性 | 说明 |
|---|---|
| 原子性 | 命令要么全部执行,要么全部放弃(但不支持回滚) |
| 隔离性 | 事务中的命令串行执行 |
| 不支持回滚 | 某条命令报错,其他命令仍然执行 |
8.2 Watch(乐观锁)
WATCH key # 监控 key
MULTI # 开启事务
SET key newValue # 修改 key
EXEC # 如果 key 被其他客户端修改,事务失败
8.3 Lua 脚本
Lua 脚本可以将多条命令原子执行,是替代事务的更好选择:
# 原子操作:检查并设置
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('set', KEYS[1], ARGV[2]) else return 0 end" 1 mykey oldval newval
Lua 脚本的优势:
- 多条命令原子执行,不会被其他命令打断
- 减少网络往返次数(一次发送多条命令)
- 替代简单的事务场景
九、分布式锁
9.1 基本实现
# 加锁:SET key value NX EX 过期时间
SET lock:order:1001 "uuid" NX EX 30
# 释放锁(Lua 脚本保证原子性)
EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock:order:1001 "uuid"
9.2 常见问题与解决
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 死锁 | 持有锁的进程崩溃,锁未释放 | 加过期时间(EX) |
| 锁超时误删 | 业务执行完但锁已过期,删除了别人的锁 | 加唯一标识(UUID),释放时校验 |
| 不可重入 | 同一线程无法多次获取同一把锁 | 使用 Redisson 可重入锁 |
| 主从锁失效 | 主节点加锁后宕机,从节点未同步 | RedLock(红锁) |
9.3 Redisson 看门狗机制
客户端加锁(默认 30s 过期)
→ 后台线程每 10s 检查一次
→ 如果锁还在使用,自动续期到 30s
→ 业务完成后释放锁,停止续期
Redisson 提供的功能:
- 自动续期:看门狗机制,避免业务未完成锁就过期
- 可重入锁:同一线程可多次加锁
- 公平锁:按请求顺序获取锁
- 读写锁:读锁共享、写锁互斥
十、高可用架构
10.1 主从复制
Master(主节点)──复制──→ Slave1(从节点)
└────复制──→ Slave2(从节点)
同步方式:
| 方式 | 触发条件 | 过程 |
|---|---|---|
| 全量同步 | 首次连接 / offset 不可用 | Master 生成 RDB 发送给 Slave |
| 增量同步 | 短时间断线重连 | Master 发送积压缓冲区中的命令 |
repl_backlog(积压缓冲区):一个环形缓冲区,保存最近的写命令,用于增量同步,减少全量同步的频率。
10.2 哨兵(Sentinel)
哨兵模式解决主从架构中 Master 的单点故障问题:
Sentinel1 ──监控──→ Master ←──监控── Sentinel2
│ ↑
↓ │
Slave ──监控──── Sentinel3
哨兵的工作流程:
- 监控:定期检测 Master 和 Slave 是否正常运行
- 主观下线(SDOWN):单个 Sentinel 认为节点不可达
- 客观下线(ODOWN):多个 Sentinel(达到 quorum 阈值)都认为不可达
- 自动选主:从 Slave 中选举新的 Master
- 故障转移:将其他 Slave 指向新 Master,通知客户端
10.3 Cluster 集群
Redis Cluster 将数据分布在多个节点上,支持水平扩展:
Master1 (0-5460) Master2 (5461-10922) Master3 (10923-16383)
↕ ↕ ↕
Slave1 Slave2 Slave3
核心概念:
| 概念 | 说明 |
|---|---|
| 哈希槽 | 共 16384 个槽,分配给不同节点 |
| CRC16 算槽 | slot = CRC16(key) % 16384 |
| 分片 | 每个节点负责一部分槽位 |
为什么是 16384 个槽?
- 消息头大小:节点间通信使用 bitmap 表示槽位,16384 位 = 2KB,刚好能放进消息头
- 带宽考虑:Redis 集群节点数通常不超过 1000 个,16384 个槽平均分配足够
- 如果用 65536:bitmap 占用 8KB,心跳包太大,浪费带宽
跨槽问题:
# 不同 Key 可能在不同槽,无法批量操作
MGET key1 key2 key3 # key1 在节点 A,key2 在节点 B → 报错
# 解决:使用 HashTag 让相关 Key 分配到同一槽
SET {user:1001}:name "张三"
SET {user:1001}:age "25" # {user:1001} 相同,分到同一槽
十一、高级特性与问题优化
11.1 Pipeline(管道)
批量发送多条命令,减少网络往返:
# 没有 Pipeline:10 条命令 = 10 次网络往返
for i in range(10):
redis.set(f"key{i}", f"value{i}")
# 使用 Pipeline:10 条命令 = 1 次网络往返
pipe = redis.pipeline()
for i in range(10):
pipe.set(f"key{i}", f"value{i}")
pipe.execute()
11.2 Pub/Sub vs Stream
| 特性 | Pub/Sub | Stream |
|---|---|---|
| 持久化 | 无(消息发完即丢) | 有(消息持久存储) |
| 消费确认 | 无 | 有(ACK 机制) |
| 消息回溯 | 不支持 | 支持(按 ID 读取历史) |
| 推荐场景 | 实时通知 | 可靠消息队列 |
11.3 大 Key 问题
什么是大 Key?
- String 类型:值大于 10KB
- Hash/List/Set/ZSet:元素数量超过 5000 个
大 Key 的危害:
- 阻塞主线程(删除大 Key 时)
- 网络带宽占用高
- 内存不均衡
解决方案:
| 方案 | 说明 |
|---|---|
| 拆分结构 | Hash 拆成多个小 Hash |
| 异步删除 | Redis 4.0+ 用 UNLINK 替代 DEL |
| 避免遍历 | 不用 KEYS *,用 SCAN 分批扫描 |
11.4 热 Key 问题
热 Key:某个 Key 的 QPS 远高于其他 Key。
解决方案:
| 方案 | 说明 |
|---|---|
| 本地缓存 | 热点数据缓存在应用进程内存中 |
| 集群分片 | 将热 Key 复制多份分散到不同节点 |
| 请求限流 | 对热 Key 的访问做限流保护 |
11.5 慢查询
# 查看慢查询日志
SLOWLOG GET 10
# 配置慢查询阈值
CONFIG SET slowlog-log-slower-than 10000 # 10ms
常见慢查询原因:
- 大 Key 操作(
KEYS *、HGETALL) - 复杂命令(
SORT、SINTERSTORE) - 短时间内大量命令
十二、高频面试简答题
| 问题 | 简要回答 |
|---|---|
| Redis 单线程为什么快? | 纯内存操作 + IO 多路复用 + 单线程无锁 + 高效数据结构 |
| RDB 和 AOF 的区别? | RDB 快但可能丢数据,AOF 安全但恢复慢,生产推荐混合持久化 |
| 跳表为什么不用红黑树? | 实现简单、范围查询性能好、内存占用可控 |
| 集群为什么 16384 个槽? | 适配集群通信的 bitmap,带宽占用合理 |
| 怎么保证缓存和数据库一致? | 延时双删、Canal 订阅 Binlog |
| 缓存穿透怎么解决? | 空值缓存 + 布隆过滤器 |
| 缓存击穿怎么解决? | 互斥锁 + 热点 Key 永不过期 |
| 缓存雪崩怎么解决? | 过期时间加随机值 + 多级缓存 + 集群高可用 |
| 分布式锁怎么实现? | SET key NX EX + Lua 脚本释放 + Redisson 看门狗续期 |
| LRU 和 LFU 的区别? | LRU 看最后访问时间,LFU 看访问频率 |
学习检查清单
- 能说清 Redis 为什么快
- 掌握 5 种基础数据类型和底层实现
- 理解 SDS、跳表、RedisObject 的作用
- 能说出过期删除和内存淘汰策略
- 理解 RDB、AOF 和混合持久化的区别
- 能解释缓存穿透、击穿、雪崩的原因和解决方案
- 理解延时双删和 Canal 方案
- 掌握分布式锁的实现和常见问题
- 理解主从、哨兵、Cluster 三种架构
- 了解 Pipeline、大 Key、热 Key 的优化方案