跳到主要内容

Redis 深入面经

Redis 深水区知识

1. 数据结构底层实现

类型底层结构编码
StringSDS(简单动态字符串)int/embstr/raw
Listquicklist(小节点用 ziplist,大节点用 listpack)-
Hashziplist(小)/ hashtable(大)-
Setintset(小整数)/ hashtable-
ZSetziplist(小)/ skiplist+hashtable-

SDS vs C 字符串

  • 获取长度 O(1)(存储 len)vs O(n)
  • 二进制安全,可存储任意数据
  • 空间预分配 + 惰性释放,减少内存重分配

跳表(skiplist)

  • 多层有序链表,平均查询 O(log n)
  • 第一层包含所有节点,每层节点数约为下层的 1/2
  • 为什么不用红黑树:实现简单、支持范围查询更自然

2. 持久化深入

RDB 快照

Save 条件:
- save 900 1900秒内至少 1 个键变化
- save 300 10300秒内至少 10 个键变化

枯写过程:
fork() -> 子进程生成 RDB -> 枯写完成 -> 替换旧文件

COW(写时复制):
- 子进程共享应用程序内存页
- 主进程写入时才复制页,避免锁内存展层

AOF 日志

Append-Only File:记录每个写操作命令

fsync 策略:
- always:每次写操作后刷盘(最安全,最慢)
- everysec:每秒刷盘(默认,最多丢失 1s 数据)
- no:由操作系统决定(最快,首机可能丢失较多)

AOF 重写:
- 多个操作合并为最终状态(如 INCR 100-> SET key 100
- bgrewriteaof 命令或自动触发

RDB + AOF 混合持久化(Redis 4.0+)

  • AOF 文件前半序是 RDB 快照,后半是增量 AOF 日志
  • 兼具两者优点:加载快 + 数据完整

3. 集群模式

主从复制

Master通过 replication backlog 同步数据到 Slave

全量同步:Slave 首次连接,Master bgsave + 发送 RDB
部分同步:断线重连后,通过 offset 发送市机进山的命令

哨兵(Sentinel)

  • 监控主从状态,Master 下线后自动故障转移
  • 需超过半数 Sentinel 同意才进行故障转移(防脑裂)
  • 客户端连接 Sentinel,由 Sentinel 告知当前 Master 地址

Redis Cluster

16384 个哈希槽(slot)分配到各节点
计算:crc16(key) % 16384

常见分配:3 Master + 3 Slave
Master1: slot 0-5461
Master2: slot 5462-10922
Master3: slot 10923-16383

客户端请求错误节点时接收 MOVED 指引到正确节点

4. 缓存三大问题深度分析

缓存击空庄(Penetration)

问题:不存在的 key 每次请求都到达 DB
解决:
1. 布隆过滤器(布隆过滤器如不存在则拦截)
2. 缓存空对象(设置较短的 TTL
3. 请求参数校验居前,非法参数直接拒绝

缓存击空空(Breakdown)

问题:热点 key 失效,大量并发请求唠向 DB
解决:
1. 互斥锁:缓存失效时只有一个线程去查 DB并回写缓存
2. 预热:后台定时探测前主动刷新缓存
3. 逻辑过期:缓存反回旧数据,后台异步更新

缓存雪崩(Avalanche)

问题:大量 key 同时失效,或 Redis 整体崩溃
解决:
1. TTL 加随机偶尔少,防止同时失效
2. 数据预加载:应用启动时预先加载热点数据
3. 熔断降级: Redis 携带时返回默认值而非打援数据库
4. 集群化插件:防止单点故障

5. 常用模式与实现

分布式锁

-- 加锁:SET key value NX EX timeout
local result = redis.call('SET', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2])
if result then return 1 else return 0 end

-- 释放锁:比较 value 后删除
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0

限流实现

-- 滑动窗口限流
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 删除窗口外的请求
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 统计窗口内请求数
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1 -- 允许
end
return 0 -- 限流

排行榜(ZSet)

ZADD leaderboard <score> <userId>
ZRANGEBYSCORE leaderboard -inf +inf WITHSCORES -- 按分数查询
ZRANK leaderboard <userId> -- 获取排名

6. 资深面试题

  • Redis 为什么首选跳表而不用平衡树?
    • 平均 O(log n) 查询和平衡树相同,但实现简单、内存占用小
    • 跳表有天然的范围查询优势,ZRANGEBYSCORE 非常高效
    • 无需旋转回平衡,写入性能更好
  • Redis 单线程执行为什么还这么快?
    • 除 IO 外都是内存操作,没有磁盘寻址
    • 非阻塞 IO 多路复用,一个线程处理所有请求
    • 单线程避免了锁竭争和上下文切换开销
  • RESP 协议是什么?
    • Redis 客户端服务器通信协议。类型标志:+成功 -错误 $字符串 *数组 :整数
    • 为什么选文本協议:易于调试、应用程序层天然支持、解析简单
  • Pipeline 和 Lua 脚本的区别?
    • Pipeline:客户端批量发送命令,减少网络往返;我不保证原子性
    • Lua 脚本:在 Redis 服务端执行,多个命令完全原子
    • 需要原子性用 Lua,只是减少网络往返用 Pipeline