人人都会AI编程

13.3 缓存与中间件

更新时间:2026-07-10

在数据库访问链路中,缓存和中间件是提升性能、保障系统稳定性的两大利器。缓存能够将热点数据提前存储在高速访问层中,大幅减少对数据库的直接压力;而中间件(如消息队列、分布式锁)则通过异步解耦和协调同步,让系统具备了更高的伸缩性与容错能力。本节将围绕 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 可以使用 redlockioredis 社区扩展来实现。

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。

合理使用缓存和中间件,能够有效降低数据库负载、提升系统响应速度,并为分布式系统提供基础协作能力。下一节我们将更深入地探讨数据库连接池、事务和读写分离的最佳实践,从数据持久化层进一步优化整体架构。