Week 2 · Chapter 5 · Redis
复习难度:⭐⭐⭐ | 预计时长:2.5小时 | 重点程度:高
5.1 数据结构
原理
Redis 提供 5 种基础数据结构,每种都有独特的底层编码和适用场景。
底层编码(Redis Object):
typedef struct redisObject {
unsigned type:4; // 类型(String/Hash/List/Set/Zset)
unsigned encoding:4; // 编码(int/embstr/raw/listpack/hashtable/ziplist等)
void *ptr; // 指向实际数据结构的指针
} robj;
各数据结构与编码:
| 类型 | 底层编码 | 特点 |
|---|---|---|
| String | int / embstr / raw | 动态字符串,embstr < 44 字节一次分配 |
| Hash | ziplist / hashtable | 字段少用压缩列表,字段多用字典 |
| List | ziplist / quicklist | 3.2 前用 ziplist/linkedlist,3.2 后统一 quicklist |
| Set | intset / hashtable | 全整数用 intset,否则用字典 |
| Zset | ziplist / skiplist+dict | 分值少用压缩列表,多用跳表+字典 |
quicklist:多个 ziplist 用双向链表串起来,兼顾小数据紧凑和大数据灵活。
跳表(Skip List): - 平均 O(log N),最坏 O(N) 的有序数据结构 - Zset 用跳表实现,分值相同时按 member 字典序排序 - 跳表层数 = 随机 1~MAX_LEVEL(一般 MAX_LEVEL=32)
常见面试题
Q1:Zset 为什么用跳表而不是红黑树?
答:① 跳表实现更简单,无需旋转平衡;② 范围查询(ZRANGE)跳表只需遍历,跳过中间节点,红黑树需要中序遍历+回溯;③ 跳表插入只需要局部修改节点,红黑树可能需要多次旋转。
Q2:什么是 embstr vs raw?
答:embstr 将 RedisObject 和 SDS(简单动态字符串)分配在同一块内存,连续内存访问更友好,适合短字符串(≤44字节);raw 则分开分配。Redis 3.0 引入,6.0 后 embstr 上限到 44 字节是因为
robj(16) + sdshdr(8) + 20 = 44。
5.2 分布式锁
原理
Redis 分布式锁正确实现(Redlock 算法):
// 加锁
func AcquireLock(cli *redis.Client, key, val string, ttl time.Duration) bool {
// SET key val NX PX ttl
ok, err := cli.SetNX(key, val, ttl).Result()
return ok
}
// 解锁(Lua 脚本保证原子性)
func ReleaseLock(cli *redis.Client, key, val string) bool {
script := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
_, err := cli.Eval(script, []string{key}, val).Result()
return err == nil
}
为什么要用 SET NX EX 而不是 SETX + EXPIRE?
答:
SETNX+EXPIRE不是原子操作,客户端在两者之间崩溃会导致锁永远不过期。SET key val NX PX ttl是单命令原子操作。
为什么解锁要用 Lua 脚本?
答:判断 + 删除不是原子操作。Lua 脚本在 Redis 单线程中执行,保证检查和删除的原子性。
Redlock(多节点分布式锁): - 向 N 个独立 Redis 节点(大多数节点加锁成功才算成功) - 防单点故障,要求 ≥ N/2 + 1 节点成功
常见面试题
Q1:分布式锁过期但任务还没执行完怎么办?
答:① 锁续命(watchdog),如 Redisson 的
lock()每 10 秒自动续期;② 任务设计幂等性,即使被其他节点拿到锁也不会出问题;③ 用更长的 TTL 或根据任务预估时间设 TTL。
Q2:Redis 主从模式下分布式锁安全吗?
答:普通主从模式不安全,因为主从异步复制可能丢锁(Master 加锁后宕机,Slave 未同步)。Redlock 尝试解决但仍被质疑。更稳妥的方案:用 ZooKeeper / etcd(CP 模型,强一致)或 Redis 单节点 + 可靠消息队列。
5.3 缓存问题
原理
缓存三大问题:
| 问题 | 描述 | 解决方案 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据,每次都打穿到 DB | 布隆过滤器 / 空值缓存 / 严格参数校验 |
| 缓存击穿 | 热点 key 过期瞬间大量请求击穿到 DB | 互斥锁 / 永不过期+异步重建 / 热点数据不打过期 |
| 缓存雪崩 | 大量 key 同时过期 / Redis 宕机 | 过期时间加随机偏移 / Redis 高可用 / 多级缓存 |
布隆过滤器:
// 使用 Redisson 的 BloomFilter
RBloomFilter<String> bf = redisson.getBloomFilter("userIdBloom");
bf.add("123"); // 添加
bf.contains("123"); // 查询
缓存更新策略: - Cache Aside(最常用):读:cache miss → 读 DB → 写 cache;写:先写 DB → 删除 cache - Read Through:缓存自动加载,用户只操作缓存 - Write Through:写操作同时写缓存和 DB - Write Behind:异步写 cache,稍后批量刷 DB
常见面试题
Q1:为什么缓存更新用删除而不是更新?
答:① 删除代价更低(只需删 key);② 并发下更新+写入可能造成数据不一致(脏读);③ 如果缓存是复合结构(Hash/Set),局部更新缓存逻辑复杂。
Q2:如何保证数据库和缓存双写一致?
答:标准方案是 Cache Aside + 最终一致性。强一致场景:① 先更新 DB,再删除 cache(注意删除失败兜底);② 用 canal / binlog 监听 DB 变更异步更新 cache;③ 分布式事务(成本高,不推荐)。
5.4 集群
原理
Redis 3.0 前:无集群,只能主从 + Sentinel
Master(写) ← 异步复制 ← Slave(读)
↑
Sentinel(监控/选主)
Sentinel 机制: - 监控主从健康 - 主节点 down → 投票机制(Quorum)→ 自动选主 - 客户端通过 Sentinel 获取当前主节点地址
Redis Cluster(3.0+):
- 16384 个哈希槽均匀分布在所有主节点
- 每个 key 通过 CRC16(key) % 16384 确定槽位
- 每个主节点可以有多个从节点(Replica)
- MOVED 重定向:客户端缓存 slot→node 映射
客户端 ──→ Slot[0..5461] 在 Node1
客户端 ──→ Slot[5462..10922] 在 Node2
客户端 ──→ Slot[10923..16383] 在 Node3
主从复制原理:
- 全量复制:Master BGSAVE → 生成 RDB → 发送给 Slave → 加载
- 增量复制:Master 维护 repl_backlog 环形缓冲区,主从断线后通过 PSYNC 同步偏移量
- 无盘复制:repl-diskless-sync yes 直接 socket 传输 RDB
常见面试题
Q1:Redis Cluster 的槽迁移过程中访问了正在迁移的 key 怎么办?
答:集群返回
ASK重定向(不是MOVED),告诉客户端去新节点临时执行,客户端需先ASKING再执行命令。
Q2:Redis 主从复制是同步还是异步?
答:异步。Master 写成功后立即返回客户端,不等 Slave 复制完成。这是吞吐高的原因,但也意味着主从切换时可能丢数据(CAP 定理:Redis 选择了 AP)。
📝 本章高频问题速记
| 问题 | 核心答案 |
|---|---|
| String 最大长度 | 512MB |
| Zset 底层结构 | 跳表 + 字典(O(log N)范围查) |
| 分布式锁加锁命令 | SET key val NX PX ttl(原子) |
| 解锁为何用 Lua | 保证 check+delete 原子性 |
| 缓存穿透解决 | 布隆过滤器 / 空值缓存 |
| 缓存击穿解决 | 互斥锁 / 永不过期+异步重建 |
| 缓存雪崩解决 | TTL随机 / 多级缓存 / 高可用 |
| Redis Cluster 槽数 | 16384 |
| Redis 主从复制 | 异步复制(AP 模型) |
| MOVED vs ASK | MOVED=永久重定向,ASK=临时重定向 |