Redis 与缓存
这一课你将能做到
- 用 redis-cli 熟练操作五大基础数据类型,并为每种类型说出一个真实业务场景
- 写出旁路缓存(Cache-Aside)的读/写流程代码,并在本机跑通
- 准确区分缓存穿透、击穿、雪崩,并各自给出至少一种对策
为什么要学
上一课你已经见识了「10 万行没索引要扫 20ms」,而 Redis 的查询在 1ms 以内甚至微秒级——它把数据放在内存里,用简单的 Key-Value 结构直接取。热点数据(首页推荐、商品详情、登录态、排行榜)都靠它扛流量,数据库只处理缓存没有的部分。
人话:数据库是仓库,Redis 是你工位旁边的抽屉。常用的东西放抽屉,一伸手就拿到;抽屉没有的才去仓库搬,搬回来顺手放一份进抽屉。这一「放」一「取」的策略,就是本课的核心:缓存模式。
面试口径提醒:穿透/击穿/雪崩三兄弟是后端面试出现频率最高的题目之一,本课会让你在代码里真正见过它们,而不是背名词。
先建立图像
旁路缓存(Cache-Aside)的读路径,一张图记住:
请求来了
│
▼
查 Redis ──命中──▶ 直接返回(微秒级) ← 90%+ 的请求走这里
│未命中
▼
查数据库 ──▶ 把结果写入 Redis(带过期时间)──▶ 返回
↑ 写入时必须设 TTL,防止抽屉塞爆
写数据时(重要顺序!):
先更新数据库 ──▶ 再删除 Redis 里对应的 key(不是更新缓存)
为什么写是「删缓存」而不是「改缓存」?因为改缓存可能在并发下把旧值写回去,而且很多缓存值是多表join的加工结果,删掉等下次读取重建,最简单也最不容易错。
跟着做
步骤 1:启动 Redis 并连接
# Docker 一行启动
docker run --name redis-demo -p 6379:6379 -d redis:7
docker exec -it redis-demo redis-cli
进入交互后先来三条灵魂命令:
PING # PONG —— 服务器活着
SET greeting "hello redis" EX 60 # 写入并设置 60 秒过期
GET greeting # "hello redis"
TTL greeting # 剩余秒数
DEL greeting # 删除
步骤 2:五大基础类型各就各位
# ① String:缓存 JSON、计数器、分布式锁
SET user:1001 '{"name":"alice","vip":2}' EX 300
INCR page:view:home # 原子自增,做计数器
INCRBY page:view:home 99
# ② List:最新消息列表、简单队列
LPUSH msg:1001 "你好" "在吗" # 左端压入
LRANGE msg:1001 0 4 # 取最新的 5 条
LLEN msg:1001 # 长度
LTRIM msg:1001 0 99 # 只保留前 100 条,防止无限膨胀
# ③ Hash:对象的字段级读写(比存整个 JSON 省、可局部更新)
HSET user:1001:profile name alice vip 2
HGET user:1001:profile vip
HGETALL user:1001:profile
# ④ Set:去重、共同关注(集合运算)
SADD follow:1001 alice bob carol
SADD follow:1002 bob carol dave
SINTER follow:1001 follow:1002 # 交集:共同关注了谁
SCARD follow:1001 # 粉丝数
# ⑤ ZSet:排行榜(按分数排序的集合)
ZADD rank:score 95 "alice" 87 "bob" 92 "carol"
ZREVRANGE rank:score 0 2 WITHSCORES # 前三名(从高到低)
ZINCRBY rank:score 5 "bob" # bob 加 5 分
业务场景对照:String→对象缓存/限流计数;List→最新动态/异步任务队列;Hash→用户资料;Set→标签去重/关系计算;ZSet→热搜榜/积分榜。面试时能按类型说场景,比背命令高一档。
步骤 3:过期策略——TTL 不是精确倒计时
SET k1 v1 EX 60
PERSIST k1 # 取消过期,变成永生 key(小心内存)
EXPIRE k1 120 # 重新设置过期
TTL k1 # -1 表示永不过期,-2 表示 key 已不存在
Redis 的删除是惰性 + 定期采样的组合:key 过期后不立即删除,访问时才发现并删(惰性),后台周期性随机抽查过期 key 删除(定期)。另外当内存到达 maxmemory,按 maxmemory-policy 淘汰(常用 allkeys-lru)——所以Redis 永远不该被当成可靠存储,它只是缓存。
步骤 4:用代码跑通旁路缓存
mkdir redis-aside && cd redis-aside && npm init -y && npm install redis
// cache-demo.mjs 运行:node cache-demo.mjs
import { createClient } from 'redis';
const redis = createClient();
await redis.connect();
// 模拟一次很慢的数据库查询(真实场景换成 PG 查询)
async function getNoteFromDb(id) {
console.log(' [DB] 查询数据库...(耗时 1 秒)');
await new Promise(r => setTimeout(r, 1000));
return { id, title: `笔记 ${id}`, content: '慢查询的结果' };
}
// 旁路缓存读路径
async function getNote(id) {
const key = `note:${id}`;
const cached = await redis.get(key);
if (cached) {
console.log(' [Redis] 缓存命中');
return JSON.parse(cached);
}
const data = await getNoteFromDb(id); // 未命中,回源
await redis.set(key, JSON.stringify(data), { EX: 60 }); // 回填,必须带 TTL
return data;
}
// 旁路缓存写路径:先更新 DB,再删缓存(演示中 DB 为模拟,仅删缓存)
async function updateNote(id, patch) {
console.log(` [DB] 更新数据库: ${JSON.stringify(patch)}`);
await redis.del(`note:${id}`); // 删除而非更新,等下次读重建
}
console.log('第 1 次:', await getNote(1)); // 未命中,慢
console.log('第 2 次:', await getNote(1)); // 命中,快
await updateNote(1, { title: '改过的标题' });
console.log('更新后:', await getNote(1)); // 未命中,读到新数据
await redis.disconnect();
运行两次观察控制台时间差:第一次约 1 秒,第二次几乎瞬间。这就是缓存的全部价值。
步骤 5:在代码里认识「穿透」
把 getNote(99999) 换成一个数据库里不存在的 id 再跑——你会发现每次都打穿到数据库:不存在的数据永远不会被缓存,恶意请求可以用一堆乱 id 把数据库打挂。最小修复(空值缓存):
const cached = await redis.get(key);
if (cached) return cached === 'null' ? null : JSON.parse(cached);
const data = await getNoteFromDb(id);
if (!data) {
await redis.set(key, 'null', { EX: 30 }); // 空结果也缓存 30 秒,挡住反复探测
return null;
}
await redis.set(key, JSON.stringify(data), { EX: 60 });
return data;
讲清楚要点
- 旁路缓存是默认模式:缓存层与应用代码互动(先查缓存、未命中回源、回填、写时删缓存)。读写穿透/Cache-As-SoR 模式把同步逻辑交给缓存组件,国内使用较少,知道名字即可。
- 先更新数据库,再删缓存:这是 Cache-Aside 写路径的社区共识顺序。反过来「先删缓存再更新库」时,删除后到库更新完成之间,其他请求会把旧值重新填进缓存。该顺序仍有极小概率不一致(读旧值回填),业务要求严格时加 TTL 兜底或用延迟双删。
- TTL 是安全带,必设:不设过期的 key 只会在内存满了被 LRU 挤掉——你想留的数据可能被误杀,不该留的常年占坑。给 TTL 加随机抖动(如 60 + random(0,30) 秒)可避免同批 key 同时过期。
- 穿透、击穿、雪崩一句话区分:
- 穿透:查的数据根本不存在(缓存和库都没有)→ 空值缓存、布隆过滤器、参数合法性校验
- 击穿:某个热 key 过期瞬间,海量请求同时打到库 → 互斥锁(只放一个请求回源)、逻辑过期、热点永不过期
- 雪崩:大批 key 同时过期(或 Redis 整体宕机)→ TTL 加随机、多级缓存、集群高可用 + 限流降级
- Redis 单线程为什么快:纯内存操作 + 单线程无锁竞争 + I/O 多路复用(epoll)+ 高效数据结构。单线程指命令执行,6.0 起网络 I/O 是多线程的。
你可能会懵的地方
-
为什么不把所有数据都放缓存里?
内存贵且易失;缓存的价值在于「命中率」,长尾数据缓存了也几乎读不到,纯浪费。一般只缓存读多写少的热点数据,命中率 90%+ 才划算。
-
「先更新库再删缓存」不是还有不一致窗口吗?到底能不能保证一致?
不能强一致,只能最终一致。Cache-Aside 是工程上的性价比最优解:不一致窗口极短,TTL 保证脏数据有最大存活期。要强一致就别用缓存,或上订阅 binlog 的方案(Canal 类)异步刷新。
-
布隆过滤器是什么原理?
一个大位数组 + 多个哈希函数:元素加入时把多个位标 1;查询时所有位都是 1 才「可能存在」,有 0 则「一定不存在」。用它挡穿透的思路:请求先问布隆过滤器,不存在直接返回,连 Redis 和数据库都不碰。代价是有小概率误判、不支持删除(计数型变体除外)。
-
分布式锁为什么用 Redis?怎么防死锁?
多台服务实例只有共享存储才能互斥。SET lock:order:1 uuid NX EX 10——NX 保证只有一个能设置成功,EX 防止持锁者崩溃后锁永不释放(死锁)。解锁要用 Lua 脚本校验 value 是自己的 uuid 再删,防止误删别人的锁。生产环境推荐 Redlock 思路的成熟客户端(如 Redisson)。
-
KEYS * 和 SCAN 有什么区别?
KEYS pattern 一次遍历全部 key,百万级 key 时会卡死整个 Redis(单线程!)——生产禁用。SCAN cursor MATCH pattern COUNT 100 分批游标遍历,每次只做一点,不阻塞。记结论:线上查 key 只用 SCAN。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 缓存不设 TTL | 内存膨胀、脏数据永生 | 一律带过期时间 + 随机抖动 |
| 写操作「更新缓存」 | 并发写互相覆盖、缓存了没人读的加工结果 | 先更新 DB,再删缓存 |
| 把 Redis 当主存储(关了持久化还存订单) | 宕机即丢数据 | Redis 只做缓存,持久化数据在数据库 |
| 生产环境用 KEYS * | 阻塞单线程,全线卡死 | 用 SCAN 游标遍历 |
| 大 value(几 MB JSON)直接缓存 | 占内存、网络慢、大 key 阻塞 | 拆字段/分页存,控制在 10KB 内 |
| 缓存和数据库双写各写各的 | 顺序错乱造成脏数据 | 固定套路:先库后删缓存,写入方统一收口 |
卡点急救
connect ECONNREFUSED 127.0.0.1:6379:Redis 没起。docker start redis-demo;docker exec -it redis-demo redis-cli 能 PONG 说明服务正常。
- Node 连接报
WRONGPASS 或远程连不上:本例没设密码,检查代码里是否误配了 password;远程 Redis 需在配置中设 requirepass 并用 createClient({ password })。
- 脚本卡住不退出:忘了
await redis.disconnect();或开头有未 await 的连接。确保 await redis.connect() 和结尾断开成对出现。
- TTL 显示 -1:这个 key 没有过期时间。用
EXPIRE key 秒数 补上,或写入时用 SET ... EX。
- 内存满了写入报 OOM:
CONFIG SET maxmemory-policy allkeys-lru(练习环境);生产应规划容量 + 拆分热点。
面试常问
-
Redis 为什么快?
纯内存、高效数据结构(SDS/跳表/压缩列表)、单线程命令执行免锁、I/O 多路复用。注意补一句:单线程也意味着慢命令(KEYS、大 key 操作)会阻塞所有请求,所以大 key 治理很重要。
-
穿透、击穿、雪崩的区别与方案?(背熟+能画图)
穿透=查不存在的数据,方案:空值缓存/布隆过滤器/入口校验;击穿=热 key 失效瞬间打崩库,方案:互斥锁重建/逻辑过期/热点永不过期;雪崩=大批 key 同时失效或实例宕机,方案:TTL 随机化/多级缓存/集群+限流降级。
-
如何保证缓存与数据库的一致性?
Cache-Aside:先更新库再删缓存 + TTL 兜底,最终一致;强一致场景考虑 binlog 订阅异步刷新或干脆不缓存;延伸可聊延迟双删与它的适用条件。
-
Redis 持久化 RDB 和 AOF 怎么选?
RDB 定期全量快照,文件小恢复快,可能丢最近几分钟;AOF 追加每条写命令(appendfsync everysec 折中),丢数据最多 1 秒但文件大恢复慢;实践两者同开,用 RDB 恢复基线 + AOF 补增量。
-
热 key 怎么发现和处理?
redis-cli --hotkeys、监控命令统计;处理:本地多级缓存(进程内 + Redis)、key 加随机后缀打散到多实例、读写分离副本。
自测三问
- 不看资料,默写旁路缓存的读路径和写路径(含先后顺序)。
- 限时 60 秒:说清穿透/击穿/雪崩的区别,每个给两种对策。
- 把步骤 4 的
getNote 改造成:并发 100 个请求同时读同一 id,只允许 1 个请求回源数据库(提示:SET nx 做互斥锁)。
小结一句话
Redis 用内存换速度,五大类型各司其职;旁路缓存「读未命中回源回填、写先更库再删缓存」,TTL 必设,穿透/击穿/雪崩各有对策——缓存的所有问题本质都是「缓存和库之间的一致性与可用性」问题。
延伸