Skip to content

预计时间

1 周

学习目标

  • 理解 Redis 为什么快
  • 掌握五种核心数据类型
  • 学会 Cache-Aside 缓存策略
  • 能解决穿透 / 击穿 / 雪崩
  • 会用分布式锁

一、Redis 为什么快?

text
MySQL 查一行:0.5 ~ 5ms(磁盘 I/O)
Redis 查一个 key:0.01 ~ 0.1ms(纯内存)

差距 50~500 倍。为什么?

1. 纯内存 — 没有磁盘 I/O
2. 单线程 — 没有锁竞争、没有上下文切换开销
3. IO 多路复用 — 一个线程同时处理多个连接
4. 高效数据结构 — 不是为了"通用"而设计,为"快"而设计

类比

MySQL 是仓库——东西多、能存大件、但要进去翻找。Redis 是桌面——东西就在手边,秒拿。


二、五种核心数据类型

2.1 String(字符串)

最基础的类型。存什么都行——JSON、数字、序列化对象。

bash
# 基本操作
SET session:user123 '{"name":"张三","role":"admin"}' EX 1800
GET session:user123
# → '{"name":"张三","role":"admin"}'

# 计数器(INCR 是原子的,高并发下不会丢计数)
SET article:view:456 0
INCR article:view:456    # → 1
INCR article:view:456    # → 2
INCRBY article:view:456 10  # → 12

# 分布式锁
SET lock:order:789 "unique-value" NX EX 30
# NX = 不存在才 set,EX 30 = 30 秒后自动释放

场景:会话缓存、计数器、分布式锁、临时 Token。

2.2 Hash(哈希表)

一个 key 下面存多个 field-value。适合存对象。

bash
HSET user:123 name "张三" email "zhang@example.com" role "admin"
HGET user:123 name        # → "张三"
HGETALL user:123          # → 所有字段
HINCRBY user:123 login_count 1  # 原子递增某个字段

场景:用户信息、配置项、购物车(user:{id} → item_id → quantity)。

text
对比:为什么用 Hash 而不是 String 存 JSON?

String:  GET user:123  → 整个 JSON → 解析 → 改一个字段 → 序列化 → SET 回去
Hash:    HGET user:123 email  → 只取需要的字段
         HSET user:123 email "new@example.com"  → 只改一个字段

Hash 更省带宽,但嵌套结构不方便(只能一层)。两三层嵌套的对象用 String + JSON。

2.3 List(列表)

有序、可重复。底层是双向链表,两端操作 O(1)。

bash
LPUSH queue:tasks "task1" "task2"   # 左边推入
RPUSH queue:tasks "task3"            # 右边推入
LPOP queue:tasks                     # 左边弹出 → "task2"
BRPOP queue:tasks 5                  # 阻塞弹出,等 5 秒
LRANGE queue:tasks 0 -1              # 查看全部

场景:消息队列(简单场景)、最新消息列表、操作日志。

2.4 Set(集合)

无序、唯一。底层是哈希表。

bash
SADD tags:doc:1 "redis" "缓存" "数据库"
SADD tags:doc:2 "redis" "消息队列"
SINTER tags:doc:1 tags:doc:2   # 交集 → "redis"(共同标签)
SUNION tags:doc:1 tags:doc:2   # 并集 → 三个标签
SISMEMBER tags:doc:1 "redis"   # 是否在集合中 → 1(是)

场景:标签、点赞用户集合、好友关系(共同好友 = SINTER)、去重。

2.5 Sorted Set(有序集合)

每个元素带一个 score(分值),按 score 排序。底层是跳表 + 哈希表。

bash
ZADD leaderboard 100 "player1" 200 "player2" 150 "player3"
ZRANGE leaderboard 0 -1 WITHSCORES     # 按 score 升序
ZREVRANGE leaderboard 0 -1 WITHSCORES  # 降序(排行榜)
ZRANK leaderboard "player1"            # → 0(排名第一)
ZSCORE leaderboard "player2"           # → "200"

# 取 score 在 100-180 之间的
ZRANGEBYSCORE leaderboard 100 180

场景:排行榜、延迟队列(score = 执行时间戳)、带权重的标签。


三、缓存策略

3.1 Cache-Aside(旁路缓存)——最常用

text
读:
  1. 先查 Redis
  2. 命中 → 直接返回
  3. 未命中 → 查 DB → 写入 Redis(设 TTL)→ 返回

写:
  1. 更新 DB
  2. 删除 Redis 中的缓存(不是更新!)

为什么是删除而不是更新缓存?

更新缓存的问题:并发场景下 A 先更新 DB,B 后更新 DB,但 B 的缓存先到 → 缓存和 DB 不一致。

删除缓存的好处:下次读的时候自然会从 DB 加载最新数据。简单、安全。

3.2 代码示例

javascript
async function getDocument(docId) {
  // 1. 查缓存
  const cached = await redis.get(`doc:${docId}`);
  if (cached) return JSON.parse(cached);

  // 2. 查数据库
  const doc = await db.document.findUnique({ where: { id: docId } });
  if (!doc) return null;

  // 3. 写缓存(30 分钟 TTL)
  await redis.set(`doc:${docId}`, JSON.stringify(doc), 'EX', 1800);
  return doc;
}

async function updateDocument(docId, data) {
  // 1. 更新数据库
  await db.document.update({ where: { id: docId }, data });

  // 2. 删除缓存
  await redis.del(`doc:${docId}`);
}

四、缓存三大问题

4.1 缓存穿透

现象:大量查询不存在的数据(如 id=-1),每次都穿透缓存打到 DB。

text
请求 id=-1 → Redis 没有 → DB 也没有 → 返回 null

请求 id=-1 → Redis 没有 → DB 也没有 → ... 无限循环

解决

javascript
// 方案1:缓存空值(最简单)
async function getDocument(docId) {
  const cached = await redis.get(`doc:${docId}`);
  if (cached === 'NULL') return null;  // 空值缓存
  if (cached) return JSON.parse(cached);

  const doc = await db.document.findUnique({ where: { id: docId } });
  if (!doc) {
    await redis.set(`doc:${docId}`, 'NULL', 'EX', 60);  // 空值也缓存 60 秒
    return null;
  }
  await redis.set(`doc:${docId}`, JSON.stringify(doc), 'EX', 1800);
  return doc;
}

// 方案2:布隆过滤器(提前判断 key 是否存在)
// 内存中维护一个位图,快速判断"一定不存在"
// 适合数据量很大、不想缓存空值的场景

4.2 缓存击穿

现象:热点 key 过期的瞬间,大量请求同时打到 DB。

text
热点 key "article:hot:123" 过期

瞬间 10000 个请求过来 → Redis 没有 → 10000 个请求全部打到 DB

DB 可能被打挂

解决

javascript
// 互斥锁:只有一个请求去查 DB,其他等着
async function getHotArticle(id) {
  const cached = await redis.get(`article:${id}`);
  if (cached) return JSON.parse(cached);

  // 尝试获取锁
  const lockKey = `lock:article:${id}`;
  const locked = await redis.set(lockKey, '1', 'NX', 'EX', 10);

  if (locked) {
    // 拿到锁,去查 DB
    const article = await db.article.findUnique({ where: { id } });
    await redis.set(`article:${id}`, JSON.stringify(article), 'EX', 1800);
    await redis.del(lockKey);
    return article;
  } else {
    // 没拿到锁,等 50ms 再重试
    await sleep(50);
    return getHotArticle(id);  // 递归重试
  }
}
javascript
// 更简单的方案:永不过期 + 异步刷新
async function getHotArticleV2(id) {
  let cached = await redis.get(`article:${id}`);
  if (!cached) {
    // 首次加载
    const article = await db.article.findUnique({ where: { id } });
    await redis.set(`article:${id}`, JSON.stringify(article));
    return article;
  }

  // 检查 TTL,快过期就异步刷新
  const ttl = await redis.ttl(`article:${id}`);
  if (ttl < 60) {
    refreshCache(id).catch(console.error);  // 异步,不阻塞当前请求
  }
  return JSON.parse(cached);
}

4.3 缓存雪崩

现象:大量 key 在同一时间过期,或者 Redis 挂了。

text
方案1:TTL 加随机值
  不要:所有 key 都是 3600 秒
  要:3600 + random(0, 600) 秒 → 过期时间分散

方案2:多级缓存
  本地缓存 (内存) → Redis → DB
  即使 Redis 挂了,本地缓存还能扛一阵

方案3:Redis 高可用
  主从 + 哨兵 → 一台挂了自动切
  Redis Cluster → 数据分片 + 自动故障转移

五、AI Chat 系统缓存设计实战

text
场景:AI 知识库问答系统,用户提问 → LLM 生成回答

缓存层次:

1. 会话缓存 (Hash)
   Key: session:{sessionId}
   Fields: context(上下文摘要)、history(最近 N 条消息)
   TTL: 30 分钟 + random 5 分钟

2. Prompt 缓存 (String)
   Key: prompt:{hash(prompt)}
   Value: LLM 返回的完整回答
   TTL: 1 小时
   相同的 prompt 直接返回缓存,省 LLM 调用费

3. 搜索结果缓存 (String)
   Key: search:{hash(query)}
   Value: Top-K 文档 ID 列表
   TTL: 10 分钟
   相同问题在一段时间内搜索结果不变

4. 用户状态 (Hash)
   Key: user:{userId}
   Fields: plan, quota_used, quota_limit
   高频读取,直接 Redis 返回

实践

  1. 本地装 Redis:brew install redis && redis-server
  2. redis-cli 把五种数据类型各练一遍
  3. 写一个最简单的 Cache-Aside 封装函数
  4. 模拟:故意让热点 key 同时过期,观察击穿 → 加锁解决