第5章 5.4 Redis 缓存
🎯 开场 3 分钟:为什么要学这个?
上一章我们折腾完了 MongoDB,学会了怎么把数据存进数据库。但你有没有遇到过这种情况:用户访问一个热门文章列表,每次访问都要从数据库里查一遍,数据库表示「我太难了」。或者抢购的时候,同一个商品库存被 N 个人同时读到,全都不知不觉超卖了。
这就是缓存要解决的问题。
Redis 就是一个超快的「临时小仓库」,把频繁读取的数据放在内存里,读起来快到飞起。今天这章我们就来搞定 Redis 缓存,让你的应用跑得既快又稳。
学完本文,你将能:
- 用 Node.js 连接 Redis 并读写数据
- 理解 String/Hash/List/Set 四种数据结构怎么选
- 解决缓存击穿这个经典坑
- 实战一个带缓存的查询工具
🧱 基础 25 分钟:核心概念
什么是 Redis?
生活类比:Redis 就像你家门口的快递柜。
- 数据库是公司仓库(远,要走很久,但容量大)
- Redis 是快递柜(家门口,随取随用,但格子有限)
每次用户要数据,先看快递柜有没有(缓存命中),没有再去仓库拿(回源)。快递柜满了就清掉最久没人用的格子(LRU 策略)。
为什么要用:
- 数据库扛不住高并发(1000 个人同时读同一份数据)
- 有些数据「读多写少」,每次都查数据库太浪费
- 用户等太久会直接关页面走人
安装与连接
先装个 Redis 在本地(macOS 用 Homebrew):
brew install redis
redis-server
然后在项目里装客户端:
npm install ioredis
连接 Redis:
```code:javascript
const Redis = require('ioredis');
const redis = new Redis(); // 默认连 localhost:6379
redis.ping((err, result) => {
console.log(result); // 输出 PONG,说明连接成功
});
一行代码就能跑,ioredis 默认连接本地 Redis。`PONG` 回来就是打招呼成功。
### String(字符串):最常用的缓存单位
**生活类比**:String 就像快递柜里的**一个格子**,放一个东西。
存一个值:
```javascript
redis.set('name', '小明');
redis.get('name', (err, result) => {
console.log(result); // 输出: 小明
});
或者用 async/await 更香:
await redis.set('name', '小明');
const name = await redis.get('name');
console.log(name); // 小明
带过期时间:商品库存这类数据,缓存太久会出bug,加个 TTL(time to live):
await redis.set('stock:iphone', 100, 'EX', 300); // 300秒后自动消失
EX 300 = 300 秒过期。想象快递柜格子自动清空,最适合「临时缓存」场景。
Hash(哈希):存对象,省内存
生活类比:Hash 就像一个带抽屉的收纳盒,每个抽屉有标签。
存用户信息:
await redis.hset('user:1001', 'name', '张三', 'age', 28, 'city', '北京');
const user = await redis.hgetall('user:1001');
console.log(user);
// 输出: { name: '张三', age: '28', city: '北京' }
对比 String 用法:如果用 String 存,得存 3 个 key(user:1001:name、user:1001:age...),Hash 一个 key 全搞定,还省内存。
List(列表):有序队列
生活类比:List 就像排队买奶茶的队伍,按顺序来。
await redis.lpush('queue:jobs', '任务A');
await redis.lpush('queue:jobs', '任务B');
await redis.rpush('queue:jobs', '任务C');
const jobs = await redis.lrange('queue:jobs', 0, -1);
console.log(jobs);
// 输出: [ '任务B', '任务A', '任务C' ]
// lpush 从左边插,rpush 从右边插,所以 B 在最前面
常用场景:消息队列、任务队列、最新评论列表。
Set(集合):去重 + 交集
生活类比:Set 就像KTV点歌列表,同一首歌只能点一次。
await redis.sadd('tags:article:1', 'Redis', '缓存', 'Node.js', 'Redis');
const tags = await redis.smembers('tags:article:1');
console.log(tags);
// 输出: Set { 'Redis', '缓存', 'Node.js' }
// 自动去重,Redis 重复了只留一个
查交集(两个人都点了哪些歌):
await redis.sadd('songs:user1', '歌A', '歌B', '歌C');
await redis.sadd('songs:user2', '歌B', '歌C', '歌D');
const common = await redis.sinter('songs:user1', 'songs:user2');
console.log(common); // 输出: [ '歌B', '歌C' ]
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):缓存一个 API 响应
需求:调用外部 API 获取天气,缓存 5 分钟,避免重复请求。
const Redis = require('ioredis');
const axios = require('axios'); // npm install axios
const redis = new Redis();
const CACHE_TTL = 300; // 5分钟
async function getWeather(city) {
const cacheKey = `weather:${city}`;
// 第1步:先看缓存有没有
const cached = await redis.get(cacheKey);
if (cached) {
console.log('命中缓存!');
return JSON.parse(cached);
}
// 第2步:缓存没有,调API
console.log('调API查询...');
const response = await axios.get(`https://api.example.com/weather?city=${city}`);
const data = response.data;
// 第3步:存进缓存
await redis.set(cacheKey, JSON.stringify(data), 'EX', CACHE_TTL);
return data;
}
getWeather('北京').then(data => console.log('天气数据:', data));
预期输出:
调API查询...
天气数据: { temp: 22, condition: '晴' }
(再次调用)
命中缓存!
天气数据: { temp: 22, condition: '晴' }
第一次调 API,第二次直接读缓存,飞快。
项目 2(15 分钟):用 Hash 存商品列表 + 缓存击穿处理
需求:从 CSV 文件读取商品列表,存入 Redis,查询时防止缓存击穿(热点 key 过期瞬间大量请求打到数据库)。
先生成测试 CSV:
// generate_products.js(只跑一次)
const fs = require('fs');
const products = [
{ id: 'P001', name: 'iPhone 15', price: 5999, stock: 100 },
{ id: 'P002', name: 'MacBook Pro', price: 9999, stock: 50 },
{ id: 'P003', name: 'AirPods', price: 999, stock: 200 },
];
fs.writeFileSync('products.csv', products.map(p => Object.values(p).join(',')).join('\n'));
console.log('CSV已生成');
node generate_products.js
核心代码,带缓存击穿保护:
const Redis = require('ioredis');
const fs = require('fs');
const redis = new Redis();
const csvContent = fs.readFileSync('products.csv', 'utf-8');
const products = csvContent.trim().split('\n').map(line => {
const [id, name, price, stock] = line.split(',');
return { id, name, price: Number(price), stock: Number(stock) };
});
// 初始化:把所有商品存进 Redis Hash
async function initProducts() {
for (const p of products) {
await redis.hset(`product:${p.id}`, 'name', p.name, 'price', p.price, 'stock', p.stock);
}
console.log('商品数据已初始化');
}
// 查询商品 + 缓存击穿保护
async function getProduct(id) {
const cacheKey = `product:${id}`;
// 尝试获取缓存
const cached = await redis.hgetall(cacheKey);
if (cached && Object.keys(cached).length > 0) {
console.log(`[缓存命中] 商品 ${id}`);
return cached;
}
// 缓存未命中,查源数据(数据库/文件)
const product = products.find(p => p.id === id);
if (!product) return null;
// ===== 缓存击穿保护:加锁 =====
const lockKey = `lock:product:${id}`;
const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
if (!lockAcquired) {
// 没拿到锁,说明另一个请求正在查,短暂等一下再试
await new Promise(r => setTimeout(r, 100));
return getProduct(id); // 递归重试
}
// 拿到锁了,查询并写入缓存
console.log(`[缓存未命中,加载中] 商品 ${id}`);
await redis.hset(cacheKey, 'name', product.name, 'price', product.price, 'stock', product.stock);
await redis.expire(cacheKey, 600); // 10分钟过期
await redis.del(lockKey); // 释放锁
return product;
}
async function main() {
await initProducts();
const result = await getProduct('P001');
console.log('查询结果:', result);
redis.quit();
}
main();
预期输出:
商品数据已初始化
[缓存未命中,加载中] 商品 P001
查询结果: { id: 'P001', name: 'iPhone 15', price: 5999, stock: 100 }
缓存击穿原理:当热点数据过期瞬间,如果没有锁保护,1000 个请求会同时穿透到数据库。有了 SET ... NX EX 加锁,只有一个请求去查库,其他请求等待后从缓存读。

项目 3(15 分钟):组合做个「热门文章排行榜」
需求:模拟文章阅读量统计,用 Set 存已阅读用户(防重复计数),用 ZSet(有序集合)存阅读量排行榜。
const Redis = require('ioredis');
const redis = new Redis();
// 模拟:用户阅读文章
async function readArticle(userId, articleId) {
const readKey = `read:${articleId}`; // 记录谁读过了
const rankKey = 'ranking:articles'; // 排行
// 检查是否已读过(用 Set 去重)
const isRead = await redis.sismember(readKey, userId);
if (isRead) {
console.log(`用户 ${userId} 已读过文章 ${articleId},不重复计数`);
return false;
}
// 没读过,记录阅读
await redis.sadd(readKey, userId);
await redis.zincrby(rankKey, 1, articleId); // 阅读量 +1
console.log(`用户 ${userId} 阅读了文章 ${articleId}`);
return true;
}
// 获取排行榜 Top N
async function getTopArticles(n = 5) {
const results = await redis.zrevrange('ranking:articles', 0, n - 1, 'WITHSCORES');
// 返回格式:[articleId, score, articleId, score, ...]
const top = [];
for (let i = 0; i < results.length; i += 2) {
top.push({ articleId: results[i], views: Number(results[i + 1]) });
}
return top;
}
async function main() {
// 模拟阅读行为
await readArticle('U001', 'A001');
await readArticle('U002', 'A001');
await readArticle('U003', 'A001');
await readArticle('U001', 'A001'); // 重复阅读
await readArticle('U001', 'A002');
await readArticle('U002', 'A002');
console.log('\n--- 热门文章排行榜 ---');
const top = await getTopArticles(3);
top.forEach((item, i) => {
console.log(`${i + 1}. 文章 ${item.articleId},阅读量 ${item.views}`);
});
redis.quit();
}
main();
预期输出:
用户 U001 阅读了文章 A001
用户 U002 阅读了文章 A001
用户 U003 阅读了文章 A001
用户 U001 已读过文章 A001,不重复计数
用户 U001 阅读了文章 A002
用户 U002 阅读了文章 A002
--- 热门文章排行榜 ---
1. 文章 A001,阅读量 3
2. 文章 A002,阅读量 2
这个组合用到了:
- Set:sismember/sadd 防重复计数
- ZSet:zincrby 原子递增 + zrevrange 按分数排序

💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:缓存没设置过期时间,数据「永生」
❌ 错误:
await redis.set('session:123', data); // 没写过期时间
✅ 正确:
await redis.set('session:123', data, 'EX', 3600); // 1小时后自动清除
说白了:快递柜格子不放过期时间,东西会一直占着格子,直到你手动清。
坑 2:把所有数据都往 Redis 塞
❌ 错误:
// 几MB的数据也塞缓存
await redis.set('big:data', hugeJSONString);
✅ 正确:Redis 内存寸土寸金,大数据放数据库,Redis 只存「热点 key」。
坑 3:缓存和数据库双写没加锁
❌ 错误:
await redis.del('product:1');
await db.update(...); // 数据库更新失败,缓存已删,数据不一致
✅ 正确:先更库,再删缓存,且加锁防止并发问题。
坑 4:线上用了 KEYS * 命令
❌ 错误:
const keys = await redis.keys('*'); // 生产环境千万别这么干!
✅ 正确:用 SCAN 游标迭代,不会阻塞:
let cursor = '0';
do {
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'product:*', 'COUNT', 100);
cursor = newCursor;
// 处理 keys...
} while (cursor !== '0');
为什么:Redis 单线程,KEYS * 会遍历所有 key 并锁住 Redis,大数据量直接宕机。
坑 5:Redis 连接没复用,每次请求都 new
❌ 错误:
// 每个请求都 new 一个连接
async function handler(req, res) {
const redis = new Redis();
// 用完也不关...
}
✅ 正确:全局单例或连接池:
const redis = new Redis(); // 全局声明一次
// 或者用 ioredis-cluster 做连接池
性能小贴士:批量操作用 Pipeline
❌ 低效:逐条执行 N 次网络往返
for (const id of productIds) {
await redis.hgetall(`product:${id}`); // N 次 RTT
}
✅ 高效:Pipeline 打包一次发送
const pipeline = redis.pipeline();
for (const id of productIds) {
pipeline.hgetall(`product:${id}`);
}
const results = await pipeline.exec(); // 1 次 RTT
调试技巧:monitor 命令看实时操作
redis-cli monitor
实时看到所有 Redis 命令,适合排查「我存的 key 去哪了」这类问题。
✏️ 练习题 + 作业题
练习题(5 道,10 分钟内完成)
练习 1(2 分钟):String 存取值
- 输入:把 username 设为 小红,然后读取并打印
- 预期输出:小红
- 提示:set 加 get,配套使用
练习 2(2 分钟):加个过期时间
- 输入:把 token:abc123 设为 valid,10 秒后过期
- 预期输出:立刻读取是 valid,10 秒后读取是 null
- 提示:第三个参数 'EX' 表示秒级过期
练习 3(3 分钟):Hash 读用户信息
- 输入:用 HGETALL 读取 user:1001,已知有 name 和 age 字段
- 预期输出:{ name: '张三', age: '28' }
- 提示:Hash 一次取所有字段,比多个 String 省事
练习 4(3 分钟):List 出队
- 输入:向 tasks 列表从左边推入 '任务甲', '任务乙',然后从右边弹出
- 预期输出:先 LPUSH 两次,再用 RPOP,输出 任务甲(后进先出)
- 提示:LPUSH 两次,RPOP 一次
练习 5(挑战题,5 分钟):分析这个报错
redis.get('key', (err, result) => console.log(result));
await redis.get('key'); // 混用回调和async
- 预期输出:代码报错或行为异常
- 提示:
ioredis默认不支持回调混用 async,想用回调就别用 await
作业题(30 分钟 - 2 小时)
作业:做一个「课程缓存查询工具」
-
需求描述:做一个 Node.js 脚本,从 JSON 文件读取课程列表(至少 5 门课),存入 Redis,每次查询优先读缓存,缓存未命中再查原始数据,并实现缓存击穿保护。
-
功能点:
1. 从courses.json读取课程数据(字段:id, name, teacher, price)
2. 实现getCourse(id)函数,带缓存 + 击穿保护
3. 实现getAllCourses()函数,返回所有课程
4. 每次查询打印是「缓存命中」还是「加载中」 -
加分项:
1. 用 Pipeline 批量获取多门课程
2. 加一个clearCache()函数清空所有缓存 -
验收标准:能跑起来 + 输出符合预期 + 代码有注释
- 提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
一句话总结:今天搞定了 Redis 四大数据结构(String/Hash/List/Set),学会了用 ioredis 读写缓存,还掌握了缓存击穿保护的加锁技巧。
延伸学习:
- ioredis 官方文档 - 查 API 最准确的地方
- 《Redis 设计与实现》- 深入理解 Redis 内部机制
- Redis 命令参考 - 150+ 命令速查
互动钩子:你在实际项目里用过 Redis 吗?有没有遇到过缓存和数据库不一致的坑?评论区聊聊,老粉优先回复!
📌 下章预告:学了 MongoDB 存数据、Redis 做缓存,下一章我们把这俩串起来,做一个完整的博客系统,从发文章到列表页,全栈实战等着你!

评论(0)