Redis 深入面经
Redis 深水区知识
1. 数据结构底层实现
| 类型 | 底层结构 | 编码 |
|---|---|---|
| String | SDS(简单动态字符串) | int/embstr/raw |
| List | quicklist(小节点用 ziplist,大节点用 listpack) | - |
| Hash | ziplist(小)/ hashtable(大) | - |
| Set | intset(小整数)/ hashtable | - |
| ZSet | ziplist(小)/ skiplist+hashtable | - |
SDS vs C 字符串
- 获取长度 O(1)(存储 len)vs O(n)
- 二进制安全,可存储任意数据
- 空间预分配 + 惰性释放,减少内存重分配
跳表(skiplist)
- 多层有序链表,平均查询 O(log n)
- 第一层包含所有节点,每层节点数约为下层的 1/2
- 为什么不用红黑树:实现简单、支持范围查询更自然
2. 持久化深入
RDB 快照
Save 条件:
- save 900 1:900秒内至少 1 个键变化
- save 300 10:300秒内至少 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