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=临时重定向