在 Node.js 的 Web 服务架构中,Redis 几乎是缓存层的首选。它不仅速度快、数据结构丰富,还能承担分布式锁、消息队列等中间件角色。本节从基础操作和数据类型入手,逐步延伸到生产环境中常用的缓存策略,并提供可直接落地的代码示例。
Redis 基础操作:连接、读写与断开
在 Node.js 中操作 Redis,最常用的库是 ioredis(也推荐 redis 官方库)。ioredis 支持 Promise、连接池、集群、哨兵等高级特性,适合绝大多数项目。
安装
npm install ioredis
创建连接
const Redis = require('ioredis');
// 默认连接本地 6379
const redis = new Redis({
host: '127.0.0.1',
port: 6379,
password: 'your-password', // 如果有密码
db: 0, // 默认数据库
retryStrategy(times) {
// 断线重连策略
return Math.min(times * 50, 2000);
}
});
// 监听连接事件
redis.on('connect', () => console.log('Redis 连接成功'));
redis.on('error', (err) => console.error('Redis 错误', err));
基础读写
// 设置字符串键,有效期 60 秒
await redis.set('user:1001:name', '张三', 'EX', 60);
// 读取字符串
const name = await redis.get('user:1001:name');
console.log(name); // "张三"
// 删除键
await redis.del('user:1001:name');
// 检查键是否存在
const exists = await redis.exists('user:1001:name');
// 设置过期时间(秒),返回值 1 表示成功
await redis.expire('user:1001:token', 3600);
// 查看剩余时间
const ttl = await redis.ttl('user:1001:token'); // 单位秒
生产环境中,建议将 Redis 连接实例封装为模块,避免多处重复创建连接。同时注意使用连接池(ioredis 内部已实现),无需手动管理。
五种核心数据类型及常用命令
Redis 不是简单的键值对存储,它提供了五种基本数据类型,每种都有独特的操作命令和适用场景。
1. String(字符串)
最基础的类型,可以存储文本、数字、序列化后的 JSON。常见场景:缓存 API 响应结果、存储 Session、计数器。
// 存储 JSON
const user = { id: 1001, name: '张三', role: 'admin' };
await redis.set('user:1001', JSON.stringify(user));
const userData = JSON.parse(await redis.get('user:1001'));
// 自增/自减(原子操作,适合计数)
await redis.incr('page:home:views'); // 页面访问量 +1
await redis.incrby('user:1001:score', 10); // 积分增加 10
const views = await redis.get('page:home:views');
2. Hash(哈希)
存储对象字段映射,适合存储结构化数据,如用户信息、配置项。相比 JSON 字符串,哈希可以单独更新某个字段,节省带宽和序列化开销。
// 一次性设置多个字段
await redis.hset('user:1001', {
name: '张三',
age: 28,
role: 'admin'
});
// 获取单个字段
const name = await redis.hget('user:1001', 'name');
// 获取所有字段
const userProfile = await redis.hgetall('user:1001');
console.log(userProfile); // { name: '张三', age: '28', role: 'admin' }
// 更新某个字段
await redis.hincrby('user:1001', 'age', 1); // age +1
3. List(列表)
双向链表,可以高效地从头尾做插入/弹出操作。常见场景:消息队列、最新动态列表、操作日志。
// 向列表左侧插入
await redis.lpush('notifications:1001', '您有新的消息', '系统通知');
// 从右侧弹出(返回最新插入的元素)
const msg = await redis.rpop('notifications:1001');
// 查看列表范围(不删除),0 表示第一个,-1 表示最后一个
const allMsg = await redis.lrange('notifications:1001', 0, -1);
// 保留最近 100 条记录
await redis.ltrim('notifications:1001', 0, 99);
4. Set(集合)
无序元素集合,支持交集、并集、差集操作。适合去重、标签系统、共同好友等场景。
// 添加成员
await redis.sadd('article:tags:100', 'nodejs', 'redis', 'backend');
// 判断是否包含某个标签
const hasTag = await redis.sismember('article:tags:100', 'nodejs'); // 1
// 获取所有成员
const tags = await redis.smembers('article:tags:100');
// 取差集(未读标签)
await redis.sadd('user:1001:tags_read', 'nodejs', 'backend');
const unreadTags = await redis.sdiff('article:tags:100', 'user:1001:tags_read');
5. Sorted Set(有序集合)
每个成员关联一个分数,按分数排序。适合排行榜、延迟队列、带权重的集合。
// 以文章热度作为分数
await redis.zadd('hot:articles', 100, 'article:100', 80, 'article:200', 60, 'article:300');
// 获取热度前 10 的文章(降序)
const topArticles = await redis.zrevrange('hot:articles', 0, 9, 'WITHSCORES');
// 增加文章热度
await redis.zincrby('hot:articles', 5, 'article:100'); // 分数 +5
// 获取某个文章的排名(从 0 开始)
const rank = await redis.zrevrank('hot:articles', 'article:100'); // 0 表示第一
在实际项目中,要根据数据结构选择最合适的类型:例如用户资料用 Hash,排行榜用 Sorted Set,简单缓存用 String。避免将复杂对象序列化后存入 String,这样修改某个字段就需要取出并更新整个对象,不仅效率低,在高并发下还可能造成数据丢失。
缓存策略:从基础读取到高并发防御
缓存的目的不仅仅是加速读取,更重要的是保护数据库不被突发流量击穿。我们需要设计合理的缓存读写模式,并应对三大经典问题:缓存穿透、缓存击穿、缓存雪崩。
基础缓存读取模式:Cache-Aside(旁路缓存)
这是最常用的缓存策略:读操作先查缓存,命中直接返回;未命中则查数据库,写回缓存并返回。写操作直接更新数据库,然后删除(或更新)缓存。
async function getUserById(userId) {
const cacheKey = `user:${userId}`;
// 1. 尝试从缓存读取
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. 查询数据库
const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
if (!user) {
return null;
}
// 3. 写入缓存,设置过期时间 3600 秒
await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}
async function updateUser(userId, updateData) {
// 1. 更新数据库
await db.query('UPDATE users SET ? WHERE id = ?', [updateData, userId]);
// 2. 删除缓存,让下次读取重载
await redis.del(`user:${userId}`);
}
写操作优先删除缓存而非更新缓存,原因有二:一是很多更新操作只涉及部分字段,即使更新了缓存对象,仍可能丢失其他未更新字段;二是可能存在并发更新,先删缓存再等后续读查询写入最新值,可避免缓存与数据库不一致。
缓存穿透
问题:查询一个数据库中不存在的数据,每次请求都会穿透缓存直接打到数据库上。恶意攻击者可以用大量不存在的 ID 灌入请求,导致数据库压力陡增。
解决方案:
- 缓存空值:对不存在的 key,也设置一个短时间(如 60 秒)的空标记,避免重复请求直接压库。
- 布隆过滤器(Bloom Filter):在缓存前加一层过滤器,快速判断 key 是否可能存在,大幅度拦截非法 key。
async function getUserByIdSafe(userId) {
const cacheKey = `user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) {
return cached === 'NULL' ? null : JSON.parse(cached);
}
const user = await db.query(...);
if (!user) {
// 缓存空值,避免穿透,有效期较短
await redis.set(cacheKey, 'NULL', 'EX', 60);
return null;
}
await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}
缓存击穿
问题:热点数据的缓存刚好过期,瞬间大量并发请求同时打到数据库,数据库压力倍增。
解决方案:使用互斥锁(或叫分布式锁),只允许一个请求去加载数据库,其余请求等待锁释放后直接取缓存。
async function getHotUser(userId) {
const cacheKey = `user:${userId}`;
const lockKey = `lock:user:${userId}`;
let user = await redis.get(cacheKey);
if (user) return JSON.parse(user);
// 尝试获取锁,set nx ex 实现
const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (lockAcquired) {
try {
// 双重检查:可能其他进程已经加载完毕
user = await redis.get(cacheKey);
if (user) return JSON.parse(user);
// 加载数据库
const userFromDb = await db.query(...);
if (userFromDb) {
await redis.set(cacheKey, JSON.stringify(userFromDb), 'EX', 3600);
}
return userFromDb;
} finally {
await redis.del(lockKey); // 释放锁
}
} else {
// 未获取锁,等待一小段时间后重试取缓存
await new Promise(resolve => setTimeout(resolve, 100));
return getHotUser(userId); // 递归重试
}
}
这里使用了 Redis 的 SET key value NX EX seconds 实现简单的分布式锁。生产环境建议使用 Redlock 或 ioredis 自带的 setnx 方法,避免死锁。
缓存雪崩
问题:大量缓存在同一时刻过期,或者 Redis 服务宕机,导致请求全部涌向数据库,造成数据库瘫痪。
解决方案:
- 为过期时间增加随机值:避免集中过期。设置过期时间时在基础值上加上一个随机秒数,如
3600 + Math.random() * 600。 - 使用多级缓存:本地内存缓存(如
lru-cache)+ Redis 缓存,Redis 不可用时降级到本地缓存,避免直接打库。 - 熔断与限流:在数据库层或网关层实现降级策略,当发现数据库压力过大时,主动拒绝部分请求并返回友好错误或兜底数据。
- 高可用部署:Redis 采用哨兵/集群模式,提高自身可用性。
// 过期时间添加随机量
const baseTTL = 3600;
const randomTTL = Math.floor(Math.random() * 600); // 0~599 秒
await redis.set(cacheKey, value, 'EX', baseTTL + randomTTL);
缓存更新时机选择
- 先更新数据库,再删除缓存:这是多数场景的最佳实践。如果先删缓存,在更新数据库之前有读请求进来,会把旧数据写回缓存,导致缓存脏数据。
- 延迟双删:在更新数据库后休眠极短时间(如 100ms),再次删除缓存,以清除并发读写可能写入的旧数据。适用于高并发且对一致性要求严格的场景。
- 利用 MySQL binlog 异步更新:通过 Canal 等工具监听数据库变更,异步更新或删除缓存,进一步降低耦合。
从功能到可靠性
Redis 在 Node.js 项目中不仅仅是简单的“放进去、拿出来”,而是承担着抗并发、护数据库的重要角色。掌握基础操作和数据类型只是第一步,设计合理的缓存策略才能让系统面对真实流量时从容不迫。在实际开发中,建议将缓存逻辑封装为独立的 Service 层,统一管理 key 命名规范、过期策略和降级逻辑,这样可维护性更高,也不容易在业务代码中到处散布缓存细节。
下一个小节,我们将探讨如何将 Redis 的能力扩展到分布式锁、限流、消息队列等更复杂的中间件场景。