在数据库访问链路中,缓存和中间件是提升性能、保障系统稳定性的两大利器。缓存能够将热点数据提前存储在高速访问层中,大幅减少对数据库的直接压力;而中间件(如消息队列、分布式锁)则通过异步解耦和协调同步,让系统具备了更高的伸缩性与容错能力。本节将围绕 Redis 这一核心组件,系统讲解缓存的典型用法、常见数据类型,并延伸到分布式锁、限流与消息队列的实现方案。
13.3.1 Redis 基础与 Node.js 集成
Redis 是一款基于内存的键值存储系统,支持丰富的数据结构(字符串、哈希、列表、集合、有序集合等),并且内置了持久化、复制、集群等高级特性。在 Node.js 项目中,最常用的 Redis 客户端是 ioredis,它支持 Promise、连接池、集群和哨兵模式,API 简洁且性能出色。
npm install ioredis
基础连接与操作:
const Redis = require('ioredis');
// 创建客户端(支持连接字符串或配置对象)
const redis = new Redis({
host: '127.0.0.1',
port: 6379,
password: 'optional',
db: 0, // 选择数据库编号
retryStrategy(times) {
return Math.min(times * 50, 2000); // 重连策略
}
});
// 字符串操作
await redis.set('user:1001', JSON.stringify({ name: 'Alice', age: 30 }));
const user = JSON.parse(await redis.get('user:1001'));
// 设置过期时间(秒)
await redis.setex('session:token123', 3600, 'userid:1001');
// 原子递增
await redis.incr('article:view:2024');
// 哈希操作
await redis.hset('user:profile:1001', 'name', 'Alice');
await redis.hset('user:profile:1001', 'age', 30);
const name = await redis.hget('user:profile:1001', 'name');
const profile = await redis.hgetall('user:profile:1001');
// 列表(可用作队列)
await redis.lpush('task:queue', JSON.stringify({ type: 'sendEmail', to: '...' }));
const task = JSON.parse(await redis.rpop('task:queue'));
// 有序集合(排行榜)
await redis.zadd('ranking', 95, 'playerA');
await redis.zadd('ranking', 87, 'playerB');
const top3 = await redis.zrevrange('ranking', 0, 2, 'WITHSCORES');
生产环境中建议配置连接池和 Lazy Connect,ioredis 默认已内置连接池,无需额外处理。对于高并发场景,可启用 maxRetriesPerRequest 等参数控制失败策略。
13.3.2 核心数据类型与应用场景
理解 Redis 的数据结构是设计缓存方案的基础,不同数据结构直接对应着典型的业务场景。
| 类型 | 典型命令 | 应用场景 |
|------|---------|---------|
| String | GET / SET / INCR / SETEX | 缓存单值(JSON序列化)、计数器、分布式锁 |
| Hash | HSET / HGET / HGETALL | 存储对象属性(用户资料、配置),减少字段传输 |
| List | LPUSH / RPOP / LRANGE | 消息队列(生产者-消费者)、最新动态列表 |
| Set | SADD / SISMEMBER / SINTER | 标签、共同好友、去重、抽奖 |
| Sorted Set | ZADD / ZRANGE / ZREVRANK | 排行榜、时间线、延时队列(按分数排序) |
| Stream (5.0+) | XADD / XREAD / XGROUP | 持久消息队列,支持消费者组、ACK机制 |
在实际项目中,合理选择数据结构可以避免复杂的业务代码。例如,用 Sorted Set 实现“最近 7 天热门文章”时,将文章 ID 作为成员,发布时间戳作为分数,就可以轻松取出指定时间段的 Top N。
13.3.3 缓存策略与设计模式
缓存并非简单的“查询不到就去数据库查并填充”,它牵涉到数据一致性、穿透、雪崩等问题。常用的缓存策略有如下几种:
1. Cache-Aside(旁路缓存)
应用代码直接管理缓存与数据库的读写顺序。这是最普遍的模式。
- 读:先查缓存,若命中则返回;若未命中则查数据库,并将结果写入缓存,设置过期时间。
- 写:先更新数据库,然后删除缓存(或更新缓存)。删除缓存的方式能避免并发写入造成缓存脏数据。
async function getArticle(id) {
const cacheKey = `article:${id}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const article = await db.findArticleById(id);
if (article) {
// 设置过期时间,防止占用过多内存
await redis.setex(cacheKey, 600, JSON.stringify(article));
}
return article;
}
2. Read/Write-Through(读写穿透)
缓存作为数据层的代理,应用只与缓存打交道。读不到时缓存负责从数据库加载;写操作直接写入缓存,并由缓存同步到数据库。Node.js 中较少原生支持,一般需借助中间件或自行封装。
3. Write-Behind(异步回写)
写请求只更新缓存,异步批量写入数据库。适合写极频繁但允许少量数据丢失的场景(如浏览计数),可以通过递增后异步批量写入实现。
4. 缓存预热与缓存降级
- 预热:系统启动或缓存清空后,主动加载热点数据到缓存,避免冷启动压力。
- 降级:当缓存服务不可用时,可直接回源到数据库,并触发告警;或直接返回默认值/空值,保证功能可用。
5. 缓存三大问题及应对
- 缓存穿透:查询不存在的数据,导致每次都请求数据库。解决方案:缓存空值(短期 TTL)、布隆过滤器提前拦截。
- 缓存击穿:热点 key 过期瞬间,大量请求涌向数据库。解决方案:互斥锁(只让一个线程去加载,其余等待)、提前异步刷新。
- 缓存雪崩:大量 key 同时过期或缓存集群宕机。解决方案:过期时间增加随机偏移、高可用集群、多级缓存(本地 + 远程)、熔断降级。
13.3.4 Redis 实现分布式锁
在分布式系统中,多个 Node.js 进程可能同时处理相同的数据,此时需要用分布式锁来保证操作的互斥性。Redis 通过 SET key value NX PX milliseconds 命令可以轻松实现一个简单的锁。
单节点锁实现
const lockKey = 'lock:order:1001';
const lockValue = Date.now() + ':' + Math.random();
const lockTTL = 10000; // 10 秒
// 获取锁
const acquired = await redis.set(lockKey, lockValue, 'NX', 'PX', lockTTL);
if (acquired === 'OK') {
try {
// 执行临界区代码
await processOrder(1001);
} finally {
// 释放锁时检查辨识值,避免误删其他客户端的锁
const current = await redis.get(lockKey);
if (current === lockValue) {
await redis.del(lockKey);
}
// 注意:get + del 并非原子操作,可以使用 Lua 脚本或 redlock 库
}
}
为了安全释放锁,推荐使用 Lua 脚本保证原子性:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
Redlock 算法
在 Redis 集群环境下,单节点锁可能因为节点故障而失效。Redlock 算法(在 Redis 官方文档中描述)通过在多个独立 Redis 实例上获取锁,多数派成功才视为获得锁。Node.js 可以使用 redlock 或 ioredis 社区扩展来实现。
npm install redlock
const Redlock = require('redlock');
const redlock = new Redlock([redis]);
async function doWithLock() {
const resource = 'locks:order:1001';
const ttl = 10000;
const lock = await redlock.acquire([resource], ttl);
try {
// 业务操作
} finally {
await lock.release();
}
}
生产环境使用锁时需注意:设置合理的 TTL(防止死锁),避免锁被长时间持有;评估锁的粒度,过粗会导致并发下降,过细则增加锁管理成本。
13.3.5 Redis 实现限流
在高并发场景下,限制接口访问频率是保护后端服务的常见需求。Redis 凭借原子递增和过期机制,可以实现多种限流算法。
固定窗口计数器
最简单的方式:以用户 IP + URL 为 key,每次请求递增,设定过期时间。
async function rateLimit(userId, limit, windowSeconds) {
const key = `ratelimit:${userId}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, windowSeconds);
}
return current <= limit;
}
// 使用:同一个 userId 最多 10 次/秒
const allowed = await rateLimit('user123', 10, 1);
if (!allowed) throw new Error('请求太频繁');
固定窗口的缺点是在窗口交界处可能出现两倍流量的突发请求,更平滑的选择是滑动窗口或令牌桶。
滑动窗口(基于有序集合)
用 Sorted Set 记录每次请求的时间戳,移除窗口外的记录,统计剩余数量。
async function slidingWindowLimit(userId, limit, windowSeconds) {
const key = `sliding:${userId}`;
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
// 添加当前请求时间戳,并移除窗口外的数据
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.zremrangebyscore(key, 0, windowStart);
// 设置过期(避免 key 长久存在)
await redis.expire(key, windowSeconds + 1);
const count = await redis.zcard(key);
return count <= limit;
}
令牌桶(Token Bucket)
令牌桶允许一定的突发流量,平滑出口。可以使用 Lua 脚本实现原子操作:每次请求尝试扣减令牌,并定期添加令牌。虽然实现稍复杂,但更符合生产需求,尤其是对突发请求有一定容忍性的场景(如 API 网关)。常用成熟的库如 rate-limiter-flexible 提供了 Redis 后端支持。
const { RateLimiterRedis } = require('rate-limiter-flexible');
const limiter = new RateLimiterRedis({
storeClient: redis,
keyPrefix: 'middleware',
points: 10, // 10 次
duration: 1, // 每秒
});
try {
await limiter.consume(userId);
// 放行
} catch (rejRes) {
// 限流,返回 429
}
13.3.6 Redis 实现消息队列
Redis 的 List 可以轻松构建简单的消息队列(生产者/消费者),但它缺少消息确认和重试机制。对于需要可靠消息传递的场景,可以使用 Streams 或成熟的第三方库(如 Bull)。
基于 List 的简单队列
生产者使用 LPUSH,消费者使用 BRPOP(阻塞弹出)等待消息。
// 生产者
await redis.lpush('email:queue', JSON.stringify({ to: 'user@example.com', body: 'Hi' }));
// 消费者
while (true) {
const [, msg] = await redis.brpop('email:queue', 0); // 阻塞等待
const task = JSON.parse(msg);
await sendEmail(task); // 处理
}
这种方式没有持久化保证(若消费者崩溃则消息丢失),但非常适合日志收集、实时分析等允许偶尔丢失的场景。
基于 Stream 的可靠队列
Redis 5.0 引入的 Stream 支持消费者组、消息确认以及重试机制,与 Kafka 类似的理念。可以使用 ioredis 直接操作。
// 生产者
await redis.xadd('stream:orders', '*', 'orderId', '1001', 'amount', '99');
// 创建消费者组
await redis.xgroup('CREATE', 'stream:orders', 'group1', '0', 'MKSTREAM');
// 消费者读取
const results = await redis.xreadgroup(
'GROUP', 'group1', 'consumer1',
'COUNT', 10,
'BLOCK', 2000,
'STREAMS', 'stream:orders', '>'
);
// 处理消息后确认
for (const [, messages] of results) {
for (const [id, fields] of messages) {
// 业务处理
await redis.xack('stream:orders', 'group1', id);
}
}
Bull:生产级任务队列
对于复杂的任务调度(延时执行、重试、进度报告),Bull 是基于 Redis 的成熟解决方案,内部使用 Redis 的集合和列表实现任务存储和调度。
npm install bull
const Queue = require('bull');
const emailQueue = new Queue('email', {
redis: { port: 6379, host: '127.0.0.1' }
});
// 生产者
await emailQueue.add(
{ to: 'user@example.com', body: 'Welcome' },
{ delay: 5000, // 延迟 5 秒
attempts: 3, // 失败重试次数
backoff: 2000 // 重试间隔
}
);
// 消费者
emailQueue.process(async (job) => {
await sendEmail(job.data);
// 任务进度
job.progress(50);
});
Bull 提供了完善的 Dashboard UI(bull-board),方便监控队列状态和失败任务。它还支持多进程消费、事件监听等企业级特性,是 Node.js 项目中处理邮件发送、数据导出、批量处理等异步任务的常用方案。
13.3.7 缓存与中间件选型建议
在实际项目中,Redis 常常承担缓存、分布式锁、限流、消息队列等多种角色,但需要注意职责边界:
- 缓存:Redis 充当首选,结合本地缓存(如
node-cache)实现多级缓存。 - 分布式锁:对于数据一致性要求高的操作,使用 Redlock;对于允许短暂重复的幂等操作(如更新最后登录时间),可简化方案。
- 限流:接口限流优先使用
rate-limiter-flexible(抽象化多种后端),避免自研复杂窗口实现。 - 消息队列:简单通知用 Redis List;需要可靠性的用 Bull 或 Redis Stream;当吞吐量非常巨大且需要严格顺序持久化时,可评估 RabbitMQ 或 Kafka。
合理使用缓存和中间件,能够有效降低数据库负载、提升系统响应速度,并为分布式系统提供基础协作能力。下一节我们将更深入地探讨数据库连接池、事务和读写分离的最佳实践,从数据持久化层进一步优化整体架构。