CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
后端入门实战/课时/Redis 与缓存
RawBlame
课时目录12 节

后端入门实战

  1. 01后端入门概览
  2. 02Git 与托管
  3. 03语言选型
  4. 04PostgreSQL
  5. 05数据库进阶
  6. 06Redis 与缓存
  7. 07REST API
  8. 08认证
  9. 09ORM
  10. 10单元测试
  11. 11集成与功能测试
  12. 12综合练习

Redis 与缓存

缓存策略入门。

25 分钟

Redis 与缓存

这一课你将能做到

  1. 用 redis-cli 熟练操作五大基础数据类型,并为每种类型说出一个真实业务场景
  2. 写出旁路缓存(Cache-Aside)的读/写流程代码,并在本机跑通
  3. 准确区分缓存穿透、击穿、雪崩,并各自给出至少一种对策

为什么要学

上一课你已经见识了「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:

// 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;

讲清楚要点

  1. 旁路缓存是默认模式:缓存层与应用代码互动(先查缓存、未命中回源、回填、写时删缓存)。读写穿透/Cache-As-SoR 模式把同步逻辑交给缓存组件,国内使用较少,知道名字即可。
  2. 先更新数据库,再删缓存:这是 Cache-Aside 写路径的社区共识顺序。反过来「先删缓存再更新库」时,删除后到库更新完成之间,其他请求会把旧值重新填进缓存。该顺序仍有极小概率不一致(读旧值回填),业务要求严格时加 TTL 兜底或用延迟双删。
  3. TTL 是安全带,必设:不设过期的 key 只会在内存满了被 LRU 挤掉——你想留的数据可能被误杀,不该留的常年占坑。给 TTL 加随机抖动(如 60 + random(0,30) 秒)可避免同批 key 同时过期。
  4. 穿透、击穿、雪崩一句话区分:
    • 穿透:查的数据根本不存在(缓存和库都没有)→ 空值缓存、布隆过滤器、参数合法性校验
    • 击穿:某个热 key 过期瞬间,海量请求同时打到库 → 互斥锁(只放一个请求回源)、逻辑过期、热点永不过期
    • 雪崩:大批 key 同时过期(或 Redis 整体宕机)→ TTL 加随机、多级缓存、集群高可用 + 限流降级
  5. 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 内
缓存和数据库双写各写各的顺序错乱造成脏数据固定套路:先库后删缓存,写入方统一收口

卡点急救

  1. connect ECONNREFUSED 127.0.0.1:6379:Redis 没起。docker start redis-demo;docker exec -it redis-demo redis-cli 能 PONG 说明服务正常。
  2. Node 连接报 WRONGPASS 或远程连不上:本例没设密码,检查代码里是否误配了 password;远程 Redis 需在配置中设 requirepass 并用 createClient({ password })。
  3. 脚本卡住不退出:忘了 await redis.disconnect();或开头有未 await 的连接。确保 await redis.connect() 和结尾断开成对出现。
  4. TTL 显示 -1:这个 key 没有过期时间。用 EXPIRE key 秒数 补上,或写入时用 SET ... EX。
  5. 内存满了写入报 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 加随机后缀打散到多实例、读写分离副本。

自测三问

  1. 不看资料,默写旁路缓存的读路径和写路径(含先后顺序)。
  2. 限时 60 秒:说清穿透/击穿/雪崩的区别,每个给两种对策。
  3. 把步骤 4 的 getNote 改造成:并发 100 个请求同时读同一 id,只允许 1 个请求回源数据库(提示:SET nx 做互斥锁)。

小结一句话

Redis 用内存换速度,五大类型各司其职;旁路缓存「读未命中回源回填、写先更库再删缓存」,TTL 必设,穿透/击穿/雪崩各有对策——缓存的所有问题本质都是「缓存和库之间的一致性与可用性」问题。

延伸

  • Redis 官方数据类型速查
  • 下一课:REST API——把数据库和缓存的能力包装成规范的接口,交付给前端。
加载本节练习…
← 上一课数据库进阶下一课 →REST API