文章

Redis 与缓存架构面试题 —— 从底层数据结构到分布式缓存的深度问答

覆盖Redis五大数据结构底层实现(SDS/ziplist/skiplist/dict)、持久化(RDB/AOF)、过期淘汰(LRU/LFU)、缓存穿透/击穿/雪崩、分布式锁(RedLock)、Cluster/哨兵/主从复制、缓存一致性模式,25 道高频题附架构图

Redis 与缓存架构面试题 —— 从底层数据结构到分布式缓存的深度问答

Redis 是后端面试的超高频考点——几乎所有后端岗位都会问 Redis 相关问题。能讲清楚 Redis 底层数据结构、缓存异常场景和分布式锁的人,展示的是对存储层和系统架构的深度理解。

这篇文章从底层实现 → 持久化 → 缓存模式 → 高可用 → 分布式五条线展开,每道题都带底层结构图,帮你从”会用 Redis”升级到”理解 Redis”。

📌 关联阅读:数据库面试题 · 系统设计面试题 · 高性能优化面试题


第一部分:底层数据结构

Q1:Redis 的 String 底层是怎么实现的?和 C 的 char* 有什么区别?

记忆点:Redis 的 String 用 SDS(Simple Dynamic String),不是 C 字符串

1
2
3
4
5
6
7
8
9
10
11
C 字符串:
┌─┬─┬─┬─┬──┐
│H│e│l│l│\0│    ← 以 \0 结尾,获取长度需要 O(n) 遍历
└─┴─┴─┴─┴──┘

SDS 结构:
┌──────┬──────┬──────┬─┬─┬─┬─┬──┐
│ len  │ free │ flags│H│e│l│l│\0│
│  4   │  6   │      │ │ │ │ │  │
└──────┴──────┴──────┴─┴─┴─┴─┴──┘
  ↑ O(1)获取长度   ↑ 预分配空间,减少扩容

SDS vs C 字符串

维度C 字符串SDS
获取长度O(n) 遍历O(1) 直接读 len
缓冲区溢出手动管理,容易溢出自动扩容,安全
修改时内存分配每次空间预分配 + 惰性释放
二进制安全否(\0 截断)是(用 len 判断结束)
兼容 C 函数是(末尾保留 \0)

空间预分配策略

  • 修改后 len < 1MB:分配 2 × len 的空间
  • 修改后 len ≥ 1MB:多分配 1MB
  • 减少连续增长时的内存分配次数

面试加分:Redis 的 String 底层有 3 种编码——int(纯数字)、embstr(≤44 字节,SDS 和 redisObject 一次分配)、raw(>44 字节,两次分配)。


Q2:Redis 的 Hash 底层什么时候用 ziplist,什么时候用 hashtable?

记忆点小 Hash 用 ziplist 省内存,大 Hash 转 hashtable 提性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ziplist(压缩列表):
┌────────┬────────┬───────┬───────┬───────┬───────┬────┐
│zlbytes │zltail  │zllen  │entry1 │entry2 │entry3 │end │
│总字节数│尾偏移  │节点数 │field1 │value1 │field2 │0xFF│
└────────┴────────┴───────┴───────┴───────┴───────┴────┘
  ↑ 连续内存,缓存友好!但查找 O(n)

hashtable(字典):
┌─────────┐
│ dict    │
│ ht[0]──→ ┌───┐
│         │ │[0]│→ entry(field1,val1) → NULL
│         │ │[1]│→ NULL
│ ht[1]  │ │[2]│→ entry(field2,val2) → entry(field3,val3)
│(rehash)│ │[3]│→ NULL
└─────────┘ └───┘
  ↑ 链式哈希,O(1) 查找

转换条件(任一满足就转 hashtable):

参数默认值含义
hash-max-ziplist-entries128field 数量超过 128
hash-max-ziplist-value64任何 value 长度超过 64 字节

Redis 7.0 后 ziplist 被 listpack 替代,解决了 ziplist 的级联更新问题。


Q3:Redis 的 ZSet(有序集合)底层为什么用跳表而不是红黑树?

记忆点:跳表 = 实现简单 + 范围查询快 + 并发友好

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ZSet 底层结构(score + member):

当元素少(<128个,每个<64B)→ ziplist
当元素多 → dict + skiplist 双结构

dict:member → score  的 O(1) 查找
skiplist:按 score 排序,支持范围查询

┌──────────────┐         ┌───────────────────────────┐
│    dict      │         │       skiplist             │
│ "alice"→90   │         │ Level 3: → 60 ──────→ NIL │
│ "bob"  →60   │         │ Level 2: → 60 → 85 ──→ NIL│
│ "carol"→85   │         │ Level 1: → 60 → 85 → 90→NIL│
└──────────────┘         └───────────────────────────┘
   ↑ O(1)按名查分             ↑ O(logN)范围查询

跳表 vs 红黑树 for Redis

维度跳表红黑树
实现复杂度简单(~300行)复杂(旋转+着色)
范围查询 ZRANGEBYSCOREO(logN)+O(M),天然链表O(logN)+中序遍历,较复杂
内存占用每节点平均 1.33 个指针每节点 3 个指针+颜色
并发修改局部锁/CAS 即可旋转可能影响祖先

面试加分:Redis 作者 antirez 说选跳表的原因——”跳表实现起来更简单,调试也容易,而且支持 ZRANGEBYSCORE 这样的范围操作非常自然。”


Q4:Redis 的 dict(字典)怎么做 rehash?为什么要渐进式?

记忆点:渐进式 rehash = 每次操作迁移一点,避免一次性阻塞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
渐进式 rehash 过程:

初始状态:
  ht[0]: [■][■][■][■]    ← 4 个桶,已满
  ht[1]: (空)
  rehashidx: -1(未在 rehash)

开始 rehash:
  ht[0]: [■][■][■][■]    ← 旧表
  ht[1]: [_][_][_][_][_][_][_][_]  ← 新表(2倍大小)
  rehashidx: 0(从第 0 个桶开始迁移)

每次增删改查时,顺便迁移 ht[0][rehashidx] → ht[1]:
  ht[0]: [_][■][■][■]    ← 第0桶已迁移
  ht[1]: [_][■][_][_][_][_][_][_]
  rehashidx: 1

  ...逐步迁移...

完成:
  ht[0]: [_][_][_][_]    ← 全部迁移完,释放
  ht[1]: [■][_][■][■][_][■][_][■]  ← 新表
  rehashidx: -1
  ht[0] = ht[1], ht[1] = NULL

rehash 期间的操作规则

  • 查找:先查 ht[0],没找到再查 ht[1]
  • 新增:只往 ht[1] 写(保证 ht[0] 只减不增)
  • 删除/修改:两个表都要操作

为什么不一次性 rehash? 如果有百万级 key,一次迁移可能阻塞几秒,对于单线程的 Redis 来说是致命的。


第二部分:持久化

Q5:RDB 和 AOF 的区别?各自的优缺点?

记忆点:RDB = 快照(数据紧凑),AOF = 日志(不丢数据)

1
2
3
4
5
6
7
8
9
RDB(定时快照):
  时间线:──────[快照1]──────[快照2]──────[崩溃]
                                          ↑
                              丢失最后一次快照后的数据

AOF(追加日志):
  时间线:──[cmd1][cmd2][cmd3]...[cmdN][崩溃]
                                       ↑
                             最多丢 1 秒数据(everysec)
维度RDBAOF
持久化方式定时全量快照追加每条写命令
文件大小紧凑(二进制)较大(文本命令)
恢复速度快(直接加载)慢(重放命令)
数据安全可能丢几分钟数据最多丢 1 秒(everysec)
fork 开销大(生成快照)小(只 rewrite 时 fork)
IO 开销低(定时一次)高(持续写文件)

AOF 的三种刷盘策略

策略行为数据安全性能
always每条命令都 fsync不丢数据最慢
everysec每秒 fsync最多丢 1 秒推荐
no交给 OS 决定可能丢很多最快

面试加分:Redis 4.0 引入混合持久化——AOF rewrite 时先写 RDB 格式(快),后续追加 AOF 命令。兼顾恢复速度和数据安全。


Q6:BGSAVE 用的 fork + COW 机制是什么?有什么风险?

记忆点:fork 出子进程做快照,COW 避免复制全部内存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
BGSAVE 流程:

1. 主进程 fork() → 子进程(COW 共享内存页)

   主进程              子进程
   ┌──────────┐       ┌──────────┐
   │ 页表 ─────┼──┐┌──┼── 页表   │
   └──────────┘  ││  └──────────┘
                 ↓↓
            ┌──────────┐
            │ 物理页面   │  ← 共享,只读标记
            └──────────┘

2. 子进程遍历数据,生成 RDB 文件
3. 主进程继续处理客户端请求
4. 主进程写入某页 → 触发 COW → 只复制该页

风险:
  如果主进程写入密集 → 大量页被 COW 复制
  → 内存可能翻倍!
  → 建议预留 maxmemory 的 50% 给 COW

实战配置

1
2
3
4
5
# 关闭透明大页(避免 COW 复制 2MB 大页而非 4KB 小页)
echo never > /sys/kernel/mm/transparent_hugepage/enabled

# 允许 overcommit(否则 fork 可能因内存不足失败)
sysctl vm.overcommit_memory=1

第三部分:过期与淘汰

Q7:Redis 的过期 key 是怎么删除的?

记忆点定期删除 + 惰性删除双保险

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
惰性删除(被动):
  GET key → 发现已过期 → 删除 → 返回 nil
  ✅ 无 CPU 浪费
  ❌ 过期 key 不被访问就永远不删,内存泄漏

定期删除(主动):
  每 100ms 执行一次:
  1. 随机取 20 个设了过期时间的 key
  2. 删除其中已过期的
  3. 如果过期比例 > 25%,重复步骤 1
  4. 限制每次执行时间 ≤ 25ms(避免阻塞)
  ✅ 主动清理
  ❌ 仍可能有漏网之鱼

两者结合 = 尽量及时清理 + 不阻塞主线程

Q8:内存不够时,Redis 的淘汰策略有哪些?怎么选?

记忆点8 种策略,分为 3 类——不淘汰 / 全部 key / 只看有过期时间的

策略作用范围行为
noeviction-不淘汰,写入报错(默认)
allkeys-lru所有 keyLRU 最近最少使用 ✅推荐
allkeys-lfu所有 keyLFU 最不经常使用(Redis 4.0+)
allkeys-random所有 key随机淘汰
volatile-lru有过期时间LRU
volatile-lfu有过期时间LFU
volatile-random有过期时间随机
volatile-ttl有过期时间淘汰 TTL 最短的

LRU vs LFU

1
2
3
4
5
6
7
8
9
10
11
LRU(最近最少使用):
  按最后访问时间淘汰
  问题:偶尔被访问一次的冷数据会"续命"

LFU(最不经常使用):
  按访问频率淘汰,频率低的先淘汰
  更精确,但实现更复杂

Redis 的近似 LRU:
  不是精确 LRU(太贵),而是随机采样 5 个 key,淘汰其中最久没用的
  maxmemory-samples 参数控制采样数(越大越精确,但越慢)

选择建议

场景推荐策略
缓存(所有数据都可丢)allkeys-lruallkeys-lfu
部分数据必须保留volatile-lru(只淘汰设了过期的)
所有 key 等概率访问allkeys-random

第四部分:缓存异常场景

Q9:什么是缓存穿透?怎么解决?

记忆点:穿透 = 查不存在的数据,每次都打到数据库

1
2
3
4
5
6
正常流程:
  请求 → Redis(命中)→ 返回         ← 大部分请求在这里挡住

缓存穿透:
  请求 key="不存在的ID" → Redis(未命中)→ DB(也没有)→ 返回空
  攻击者大量请求不存在的 key → 全部打到 DB → DB 崩溃!

解决方案

方案原理优点缺点
缓存空值不存在也缓存 key → null,短 TTL简单浪费内存
布隆过滤器请求前先问布隆”这个 key 存在吗?”内存小、效果好有误判、不能删除
参数校验接口层校验 ID 格式合法性简单有效只能挡基础攻击
1
2
3
布隆过滤器方案:
  请求 → 布隆过滤器 → "不存在" → 直接返回(不查 DB)
                    → "可能存在" → Redis → DB

Q10:什么是缓存击穿?怎么解决?

记忆点:击穿 = 热点 key 过期瞬间,大量请求打到 DB

1
2
3
4
5
6
7
8
正常:    热点key → Redis(命中)→ 返回
                    ↓ key 过期
击穿瞬间:1000 个并发请求同时发现 key 过期
          → 1000 个请求同时查 DB → DB 被打崩

时间线:
  ─────[key有效]─────[key过期]─────[key重建]─────
                      ↑ 这个窗口期所有请求都穿透到DB

解决方案

方案原理实现
互斥锁只让 1 个请求去查 DB 重建缓存SETNX lock_key 1 EX 10
逻辑过期key 永不过期,value 里存过期时间发现逻辑过期后异步重建
热点预加载预判热点 key,提前续期定时任务刷新
1
2
3
4
5
6
7
8
9
10
互斥锁方案伪代码:
  value = redis.get(key)
  if value == null:
      if redis.setnx(lock_key, 1, ex=10):   # 获取锁
          value = db.query(key)               # 查 DB
          redis.set(key, value, ex=300)       # 重建缓存
          redis.del(lock_key)                 # 释放锁
      else:
          sleep(50ms)                         # 没拿到锁,等一下
          return redis.get(key)               # 重试获取

Q11:什么是缓存雪崩?怎么解决?

记忆点:雪崩 = 大量 key 同时过期 或 Redis 宕机,全部打到 DB

1
2
3
4
5
6
7
8
9
雪崩场景1:大批 key 同一时间过期
  ──[key1,key2,...key10000 同时过期]──
                 ↓
    10000 个请求同时穿透到 DB → 崩溃

雪崩场景2:Redis 节点宕机
  ──[Redis 挂了]──
      ↓
    所有请求直接打 DB → 崩溃

解决方案

场景方案实现
同时过期TTL 加随机值ttl = base_ttl + random(0, 300)
同时过期分级缓存L1 本地缓存 + L2 Redis
Redis 宕机高可用部署哨兵/Cluster
Redis 宕机熔断降级直接返回默认值/限流
Redis 宕机本地缓存兜底Caffeine/Guava Cache

三大缓存异常速记

1
2
3
穿透:查不存在的 → 布隆过滤器 / 缓存空值
击穿:热点key过期 → 互斥锁 / 逻辑过期
雪崩:大批同时过期 → TTL加随机 / 熔断降级

第五部分:缓存一致性

Q12:如何保证缓存和数据库的一致性?

记忆点先更新 DB,再删除缓存(Cache-Aside 模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
四种策略对比:

1. 先更新DB,再更新缓存  ❌
   线程A更新DB→          线程A更新缓存(新值)
   线程B更新DB→线程B更新缓存(新值)
   → 可能 A 的缓存覆盖了 B 的(竞争条件)

2. 先删缓存,再更新DB    ❌
   线程A删缓存→          线程A更新DB
   线程B读缓存(miss)→线程B读DB(旧值)→线程B写缓存(旧值)
   → 缓存中是脏数据!

3. 先更新DB,再删缓存    ✅ 推荐(Cache-Aside)
   线程A更新DB → 线程A删缓存
   → 下次读取时从DB重新加载
   → 仍有极小概率不一致(读写并发),但概率极低

4. 延迟双删              ✅ 增强版
   删缓存 → 更新DB → sleep(500ms) → 再删缓存
   → 第二次删除覆盖并发读写产生的脏数据

Cache-Aside 模式(最推荐)

1
2
3
4
5
6
7
读取:
  1. 读 Redis → 命中 → 返回
  2. 未命中 → 读 DB → 写入 Redis → 返回

写入:
  1. 更新 DB
  2. 删除 Redis 中的 key(下次读时重建)

为什么是”删除”而不是”更新”?

  • 更新可能存在竞争条件(A/B 线程交叉写入)
  • 删除是幂等操作,更安全
  • 缓存可能是计算后的结果(join/聚合),更新成本高

面试加分:对于强一致性需求,可以用 binlog 订阅(Canal)——监听 MySQL binlog,异步删除/更新 Redis,实现最终一致性。


第六部分:分布式锁

Q13:用 Redis 实现分布式锁的正确姿势是什么?

记忆点SET key value NX EX + 唯一标识 + Lua 释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
错误写法(逐步分析):

// 错误1:SETNX + EXPIRE 不是原子的
SETNX lock_key 1      ← 设置成功
EXPIRE lock_key 10    ← 如果这里崩溃,锁永不过期!

// 错误2:没有唯一标识
SET lock_key 1 NX EX 10
// 线程A加锁 → A超时 → 锁自动释放
// 线程B加锁成功
// 线程A完成后 DEL lock_key ← 删掉了B的锁!

// 正确写法:
SET lock_key <uuid> NX EX 10    ← 原子操作,值为唯一ID

// 释放锁(Lua 脚本保证原子性):
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

完整流程

1
2
3
4
5
6
7
8
9
10
加锁:
  SET lock:order:123 "uuid-xxx" NX EX 30
  ↑ key         ↑ 唯一标识  ↑不存在才设 ↑30秒超时

续期(看门狗):
  后台线程每 10 秒检查:如果业务还在执行,PEXPIRE 续期
  → Redisson 的 WatchDog 机制

释放:
  Lua 脚本:GET + 比较 uuid + DEL(原子操作)

Q14:RedLock 算法是什么?有什么争议?

记忆点:RedLock = 向 N 个独立 Redis 节点多数派加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
RedLock 流程(5 个独立 Redis 节点):

1. 获取当前时间 T1
2. 依次向 5 个节点发送 SET key uuid NX EX ttl
3. 统计成功数量,获取当前时间 T2
4. 如果成功 ≥ 3(多数派)且 T2-T1 < ttl → 加锁成功
5. 加锁成功:锁的有效时间 = ttl - (T2-T1)
6. 加锁失败:向所有节点发送 DEL 释放

  Node1: SET ✓
  Node2: SET ✓    3/5 成功
  Node3: SET ✗    → 加锁成功
  Node4: SET ✓
  Node5: SET ✗

争议(Martin Kleppmann vs antirez)

论点Kleppmann(反对)antirez(支持)
时钟问题节点时钟跳跃可能导致锁提前过期可以用单调时钟
GC 暂停拿到锁后 GC 暂停,锁超时fencing token 解决
网络延迟消息延迟导致判断错误超时时间减去获取耗时

实际建议

  • 效率要求的锁(防止重复计算)→ 单节点 Redis 锁够了
  • 正确性要求的锁(防止数据损坏)→ 用 ZooKeeper/etcd

第七部分:高可用架构

Q15:Redis 主从复制的原理?全量复制和增量复制?

记忆点首次全量(RDB)+ 后续增量(repl_backlog)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
全量复制(首次连接):
  Master                        Slave
    │  1. PSYNC ? -1              │
    │←─────────────────────────── │
    │  2. FULLRESYNC runid offset │
    │──────────────────────────→  │
    │  3. BGSAVE 生成 RDB          │
    │  4. 发送 RDB 文件            │
    │──────────────────────────→  │
    │  5. 发送 repl_backlog 中     │
    │     RDB 生成期间的增量命令    │
    │──────────────────────────→  │

增量复制(断线重连):
  Master                        Slave
    │  PSYNC runid offset         │
    │←─────────────────────────── │
    │  CONTINUE                   │
    │  发送 offset 之后的增量命令  │
    │──────────────────────────→  │

repl_backlog(环形缓冲区):
┌──────────────────────────────────┐
│ cmd1 │ cmd2 │ cmd3 │ ... │ cmdN │  ← 固定大小,写满后覆盖
└──────────────────────────────────┘
       ↑ slave_offset    ↑ master_offset
  如果 slave 断线太久,offset 被覆盖 → 只能全量复制

Q16:哨兵(Sentinel)的故障转移流程是什么?

记忆点主观下线 → 客观下线 → 选举 Leader → 故障转移

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
哨兵集群(至少3个哨兵):

    ┌─────────┐  ┌─────────┐  ┌─────────┐
    │Sentinel1│  │Sentinel2│  │Sentinel3│
    └────┬────┘  └────┬────┘  └────┬────┘
         │            │            │
    ┌────┴────────────┴────────────┴────┐
    │           Redis Master            │
    └──────┬──────────────┬─────────────┘
           ↓              ↓
    ┌──────────┐   ┌──────────┐
    │  Slave1  │   │  Slave2  │
    └──────────┘   └──────────┘

故障转移步骤:
1. 主观下线(SDOWN): 某个 Sentinel ping Master 超时
2. 客观下线(ODOWN): 超过 quorum 个 Sentinel 都认为 Master 下线
3. Leader 选举: Sentinel 之间用 Raft 选出 Leader
4. Leader 执行故障转移:
   a. 选一个 Slave 升级为 Master(优先级/复制偏移量/runid)
   b. 通知其他 Slave 指向新 Master
   c. 通知客户端新 Master 地址
   d. 将旧 Master 标记为 Slave(恢复后自动成为新 Master 的从节点)

Q17:Redis Cluster 的分片原理?

记忆点16384 个 slot,CRC16(key) % 16384 决定分配到哪个节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Redis Cluster(6 节点,3主3从):

Node A (Master): slots 0-5460        Node D (Slave of A)
Node B (Master): slots 5461-10922    Node E (Slave of B)
Node C (Master): slots 10923-16383   Node F (Slave of C)

key 路由:
  slot = CRC16("user:1001") % 16384 = 8923
  → slot 8923 属于 Node B → 请求路由到 Node B

客户端连接任意节点:
  Client → Node A: GET user:1001
  Node A: 这个 key 在 slot 8923,属于 Node B
  → 返回 MOVED 8923 192.168.1.2:6379
  Client → Node B: GET user:1001
  → 返回结果

Cluster vs Sentinel

维度SentinelCluster
数据分片不分片,每个节点全量分片(16384 slots)
容量上限单机内存理论无上限
复杂度简单复杂(slot 迁移等)
适用场景数据量 < 单机内存数据量大需要分片

第八部分:实战与优化

Q18:Redis 的大 Key 和热 Key 问题怎么解决?

记忆点:大 Key 影响内存和阻塞,热 Key 影响单节点负载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
大 Key 定义:
  String > 10KB
  Hash/Set/ZSet > 5000 个元素
  List > 10000 个元素

大 Key 问题:
  1. 内存不均(Cluster 中某节点内存远大于其他)
  2. 阻塞(DEL 大 key 可能阻塞几秒)
  3. 网络带宽(GET 大 value 影响其他请求)

解决:
  1. 拆分:大 Hash 按字段分多个小 Hash
  2. 异步删除:UNLINK 代替 DEL(后台线程删除)
  3. 压缩:value 压缩后再存储
  4. 发现:redis-cli --bigkeys 或 memory usage key

热 Key 定义:
  某个 key 的 QPS 远高于其他(如秒杀商品)

热 Key 问题:
  Cluster 中该 key 所在节点成为瓶颈

解决:
  1. 本地缓存:JVM/进程内缓存热点 key
  2. 读写分离:从节点分担读流量
  3. Key 分散:key 加后缀分散到多个节点
     key = "hotkey" → "hotkey_1", "hotkey_2", ... "hotkey_N"
     读取时随机选一个副本

Q19:Redis 6.0 的多线程是怎么回事?还是单线程吗?

记忆点命令执行仍然单线程,IO 读写变多线程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Redis 6.0 之前(纯单线程):
  ┌────────────────────────────────┐
  │ 主线程                         │
  │ 读请求 → 解析 → 执行 → 写响应  │  ← 全部单线程
  └────────────────────────────────┘

Redis 6.0 之后(IO多线程 + 执行单线程):
  ┌──────────┐ ┌──────────┐ ┌──────────┐
  │ IO线程1  │ │ IO线程2  │ │ IO线程3  │  ← 多线程读写网络
  │ 读socket │ │ 读socket │ │ 读socket │
  └────┬─────┘ └────┬─────┘ └────┬─────┘
       │            │            │
       ↓            ↓            ↓
  ┌────────────────────────────────────┐
  │           主线程                    │  ← 单线程执行命令
  │     解析 → 执行命令 → 生成响应      │     (无需加锁!)
  └────────────────────────────────────┘
       │            │            │
       ↓            ↓            ↓
  ┌──────────┐ ┌──────────┐ ┌──────────┐
  │ IO线程1  │ │ IO线程2  │ │ IO线程3  │  ← 多线程写响应
  │ 写socket │ │ 写socket │ │ 写socket │
  └──────────┘ └──────────┘ └──────────┘

为什么命令执行还是单线程?

  • 避免加锁,数据结构操作天然线程安全
  • Redis 的瓶颈不在 CPU(命令执行快),而在网络 IO
  • 多线程 IO 已经能显著提升吞吐(测试显示 2 倍+)

Q20:Pipeline、事务、Lua 脚本的区别?什么时候用哪个?

记忆点:Pipeline = 批量发送,事务 = 批量执行,Lua = 原子执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Pipeline(管道):
  无 Pipeline:  CMD1 → 响应1 → CMD2 → 响应2 → CMD3 → 响应3
                 ←──RTT──→    ←──RTT──→    ←──RTT──→

  有 Pipeline:  CMD1 CMD2 CMD3 → 响应1 响应2 响应3
                 ←──────── 1个RTT ────────→
  → 减少网络往返,不保证原子性

事务(MULTI/EXEC):
  MULTI
  SET key1 val1
  SET key2 val2
  EXEC
  → 命令排队,EXEC 时一起执行
  → 不支持回滚(某个命令失败其他仍执行)
  → 有 WATCH 做乐观锁

Lua 脚本:
  EVAL "redis.call('SET',KEYS[1],ARGV[1]) ..." 1 key val
  → 原子执行(脚本期间不会插入其他命令)
  → 可以有逻辑判断(if/else)
  → 最强大,但脚本不能太复杂(阻塞主线程)
特性Pipeline事务Lua
减少 RTT
原子性部分(无回滚)
逻辑判断有限(WATCH)
阻塞风险高(脚本慢就阻塞)
典型场景批量写入简单事务分布式锁/限流

第九部分:Redis 应用场景

Q21:如何用 Redis 实现限流?

记忆点滑动窗口 = ZSet,令牌桶 = Lua 脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- 滑动窗口限流(ZSet 实现)
-- 限制每个用户 60 秒内最多 100 次请求

local key = KEYS[1]           -- rate_limit:user:123
local now = tonumber(ARGV[1]) -- 当前时间戳(毫秒)
local window = 60000          -- 60秒窗口
local limit = 100

-- 删除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

-- 统计窗口内的请求数
local count = redis.call('ZCARD', key)

if count < limit then
    redis.call('ZADD', key, now, now .. math.random())
    redis.call('PEXPIRE', key, window)
    return 1  -- 允许
else
    return 0  -- 拒绝
end

Q22:如何用 Redis 实现延迟队列?

记忆点ZSet + score 存执行时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
延迟队列原理:
  ZADD delay_queue <execute_timestamp> <task_data>

  消费者循环:
  while true:
      tasks = ZRANGEBYSCORE delay_queue 0 <current_time> LIMIT 0 1
      if tasks:
          if ZREM delay_queue task:   # 原子取出(防止重复消费)
              process(task)
      else:
          sleep(100ms)

时间线:
  T=0: ZADD delay_queue 1000 "send_email"    ← 1000ms后执行
  T=0: ZADD delay_queue 5000 "check_payment" ← 5000ms后执行
  ...
  T=1000: 消费者取出 send_email 并执行
  T=5000: 消费者取出 check_payment 并执行

Q23:Redis 实现排行榜的方案?

记忆点:ZSet 天然有序 → ZADD 写入,ZREVRANGE 读取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 添加/更新分数
ZADD leaderboard 1000 "player:alice"
ZADD leaderboard 1500 "player:bob"
ZADD leaderboard 800  "player:carol"
ZINCRBY leaderboard 200 "player:alice"    # 加分

# TOP 10 排行榜
ZREVRANGE leaderboard 0 9 WITHSCORES
# 1) "player:bob"   1500
# 2) "player:alice"  1200
# 3) "player:carol"  800

# 查询某人排名
ZREVRANK leaderboard "player:alice"       # 返回 1(第2名,0-based)

# 查询某人分数
ZSCORE leaderboard "player:alice"         # 返回 1200

# 某个分数区间的玩家
ZRANGEBYSCORE leaderboard 800 1300 WITHSCORES

第十部分:面试综合题

Q24:Redis 为什么这么快?

记忆点内存 + 单线程 + IO多路复用 + 高效数据结构

1
2
3
4
5
6
7
8
9
10
11
12
速度因素拆解:

1. 纯内存操作              → 微秒级延迟
2. 单线程 → 无锁无上下文切换 → 极低开销
3. IO多路复用(epoll)       → 单线程处理万级连接
4. 高效数据结构:
   - SDS: O(1) 长度获取
   - ziplist: 紧凑内存,缓存友好
   - skiplist: O(logN) 有序操作
   - dict: O(1) 哈希查找
5. 通信协议简单(RESP)       → 解析开销小
6. 6.0 多线程 IO            → 网络吞吐翻倍

Q25:设计一个简化版 Redis,你会怎么做?

记忆点事件循环 + 字典 + 过期机制 + 持久化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
核心组件:

┌──────────────────────────────────────────┐
│                Event Loop                 │
│  ┌────────┐  ┌──────────┐  ┌──────────┐ │
│  │ Accept │  │ Read/    │  │ Timer    │ │
│  │ 新连接  │  │ Write IO │  │ 定时任务  │ │
│  └────────┘  └──────────┘  └──────────┘ │
│         ↓          ↓            ↓        │
│  ┌─────────────────────────────────────┐ │
│  │         Command Parser              │ │
│  └─────────────┬───────────────────────┘ │
│                ↓                         │
│  ┌─────────────────────────────────────┐ │
│  │         Data Store                  │ │
│  │  dict<string, RedisObject>          │ │
│  │  expires_dict<string, timestamp>    │ │
│  └─────────────────────────────────────┘ │
│                ↓                         │
│  ┌─────────────────────────────────────┐ │
│  │    Persistence (AOF/RDB)            │ │
│  └─────────────────────────────────────┘ │
└──────────────────────────────────────────┘

关键设计决策:
1. 数据存储:哈希表(dict),key→RedisObject
2. 过期管理:独立的过期字典 + 惰性删除 + 定期扫描
3. 事件循环:基于 epoll 的 reactor 模式
4. 持久化:AOF append + 后台 rewrite
5. 复制:主从 + repl_backlog 环形缓冲区

面试口诀速记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SDS 三优势:O(1)长度、二进制安全、预分配
小用 ziplist 大用 hashtable,阈值可配
ZSet 双结构:dict 查分、skiplist 排序

RDB 是快照恢复快,AOF 是日志数据全
混合持久化两全其美,4.0 引入

过期双删:惰性 + 定期
淘汰八策略,推荐 allkeys-lru

穿透查不存在 → 布隆过滤器
击穿热点过期 → 互斥锁
雪崩同时过期 → TTL加随机

一致性:先更新DB再删缓存
分布式锁:SET NX EX + UUID + Lua 释放
RedLock 多数派,争议看场景

主从全量靠 RDB,增量靠 backlog
哨兵三步:主观→客观→故障转移
Cluster 16384 槽,CRC16 路由

这篇文章覆盖了 Redis 与缓存架构的核心面试考点。Redis 是面试高频中的高频——建议在本地搭一个 Redis,把每个命令都敲一遍,比死记硬背有效十倍。

本文由作者按照 CC BY 4.0 进行授权