预计时间
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 返回实践
- 本地装 Redis:
brew install redis && redis-server - 用
redis-cli把五种数据类型各练一遍 - 写一个最简单的 Cache-Aside 封装函数
- 模拟:故意让热点 key 同时过期,观察击穿 → 加锁解决