第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 加锁,只有一个请求去查库,其他请求等待后从缓存读。

配图1 - 配图1

项目 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

这个组合用到了:
- Setsismember/sadd 防重复计数
- ZSetzincrby 原子递增 + zrevrange 按分数排序

配图2 - 配图2


💪 进阶 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 设为 小红,然后读取并打印
- 预期输出:小红
- 提示:setget,配套使用

练习 2(2 分钟):加个过期时间
- 输入:把 token:abc123 设为 valid,10 秒后过期
- 预期输出:立刻读取是 valid,10 秒后读取是 null
- 提示:第三个参数 'EX' 表示秒级过期

练习 3(3 分钟):Hash 读用户信息
- 输入:用 HGETALL 读取 user:1001,已知有 nameage 字段
- 预期输出:{ 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 做缓存,下一章我们把这俩串起来,做一个完整的博客系统,从发文章到列表页,全栈实战等着你!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。