缓存与消息队列
高并发系统离不开两件利器:缓存扛读流量,队列削峰填谷。Redis 是两者共用的基础设施——既可以当缓存,也能靠 List、Pub/Sub 充当简单队列;更重的任务则交给 BullMQ 这类专门的任务队列。本文把这两条主线讲透。
一、缓存概念与作用
缓存把高频读取的数据放在内存里,让请求不再每次都打到数据库。Redis 是 Node 生态最常见的缓存服务,其典型应用场景:
| 场景 | 做法 | 效果 |
|---|---|---|
| 读缓存 | 热点数据(商品详情、用户信息)先查 Redis | 数据库 QPS 大幅下降 |
| 限流 | INCR 计数 + 过期时间,控制单位时间请求量 | 防刷、保护下游 |
| 分布式锁 | SETNX 抢锁,保证多实例互斥 | 防止重复下单、重复执行任务 |
| 计数器 | 点赞数、在线人数用 INCR/DECR | 原子操作、极高性能 |
| 会话存储 | Session 数据放入 Redis | 多实例共享登录态 |
# 安装 Redis 客户端
npm install ioredis
# 或官方 client
npm install redis二、Node.js Redis 客户端
2.1 连接与基础读写
const Redis = require("ioredis");
const redis = new Redis({ host: "localhost", port: 6379, password: "secret" });
// set / get
await redis.set("user:1", JSON.stringify({ name: "张三" }));
const user = JSON.parse(await redis.get("user:1"));
// 设置过期时间(秒),到期自动删除
await redis.set("code:13800000000", "123456", "EX", 300);
await redis.setex("session:abc", 7200, "uid:1");
// 删除与判断存在
await redis.del("user:1");
const exists = await redis.exists("code:13800000000");2.2 常用数据结构
| 类型 | 命令示例 | 用途 |
|---|---|---|
| String | set、get、incr、setex | 缓存值、计数器、限流 |
| Hash | hset、hget、hgetall | 存储对象字段(用户资料) |
| List | lpush、rpop、brpop | 消息队列、时间线 |
| Set | sadd、sismember | 去重、标签、共同好友 |
| ZSet | zadd、zrangebyscore | 排行榜、延迟队列 |
// 计数器:点赞 + 限流
await redis.incr("post:like:100");
await redis.incrby("post:like:100", 3);
// Hash:更新用户单个字段,避免整体读写
await redis.hset("user:1", { name: "李四", age: 20 });
const name = await redis.hget("user:1", "name");
// 限流:每用户每秒最多 10 次
async function rateLimit(key, limit, windowSeconds) {
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, windowSeconds);
return count <= limit;
}三、缓存策略:三大经典问题
3.1 缓存穿透
现象:请求查询一个不存在的 key(如非法 ID),缓存没有、数据库也没有,每次都打到数据库,恶意流量可直接打垮库。
方案:
| 方案 | 做法 | 说明 |
|---|---|---|
| 缓存空值 | 查库为空也缓存(短 TTL,如 60s) | 简单有效,注意防止空值占满 |
| 布隆过滤器 | 查询前先过滤不存在的 key | 内存占用小,适合海量 ID 判断 |
async function getUser(id) {
const cached = await redis.get(`user:${id}`);
if (cached !== null) return cached === "null" ? null : JSON.parse(cached);
const user = await db.findUser(id);
if (!user) {
await redis.set(`user:${id}`, "null", "EX", 60); // 缓存空值
return null;
}
await redis.set(`user:${id}`, JSON.stringify(user), "EX", 300);
return user;
}3.2 缓存击穿
现象:某个热点 key 过期瞬间,大量并发请求同时打到数据库。
方案:互斥锁重建(只允许一个请求回源,其余等待)或逻辑过期。
async function getHot(key, build) {
const value = await redis.get(key);
if (value) return JSON.parse(value);
// 抢锁:只有一个请求去查库重建缓存
const lockOk = await redis.set(`lock:${key}`, "1", "EX", 5, "NX");
if (!lockOk) {
await sleep(50);
return getHot(key, build); // 没抢到锁,稍后重试
}
try {
const data = await build();
await redis.set(key, JSON.stringify(data), "EX", 600);
return data;
} finally {
await redis.del(`lock:${key}`);
}
}3.3 缓存雪崩
现象:大量 key 在同一时间集中过期(或 Redis 宕机),请求蜂拥打向数据库。
方案:
| 方案 | 做法 |
|---|---|
| 过期时间加随机值 | 每个 key 的 TTL 加 random(0, 300) 秒,错开过期点 |
| 多级缓存 | 本地缓存(如 LRU-Cache)兜底,Redis 失效也能扛住 |
| Redis 高可用 | 主从 + 哨兵 / 集群,避免单点宕机 |
| 服务降级 | 缓存不可用时直接返回兜底数据,而不是全部打库 |
// 过期时间打散
const ttl = 600 + Math.floor(Math.random() * 300);
await redis.set(key, JSON.stringify(data), "EX", ttl);四、分布式锁
多个 Node 实例同时操作同一资源(如库存扣减)时,需要一把跨进程的锁。Redis SET NX EX 是经典实现:
const LOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
class RedisLock {
constructor(redis) { this.redis = redis; }
// 抢锁:NX 保证只有一个实例成功;EX 保证锁最终会过期,防止死锁
async acquire(key, token, ttl = 10000) {
const ok = await this.redis.set(key, token, "EX", ttl / 1000, "NX");
return ok === "OK";
}
// 释放锁:用 Lua 脚本保证"只有持有者才能释放",防止误删别人的锁
async release(key, token) {
await this.redis.eval(LOCK_SCRIPT, 1, key, token);
}
}
const lock = new RedisLock(redis);
const token = `${process.pid}-${Date.now()}`;
if (await lock.acquire("lock:order:100", token)) {
try {
// 扣减库存等临界区代码
} finally {
await lock.release("lock:order:100", token);
}
} else {
console.log("获取锁失败,稍后重试");
}要点:释放锁必须校验身份(token),否则可能释放掉后来者重新获得的锁;锁的 TTL 要大于临界区最长执行时间,必要时用 Redisson 式的"看门狗"续期。
五、消息队列概念
消息队列解决异步解耦:生产者把任务发进队列,消费者取出来慢慢处理,中间不直接调用。
生产者 ──发布──> 消息队列 ──拉取/推送──> 消费者| 术语 | 含义 |
|---|---|
| 生产者(Producer) | 发送消息的一方 |
| 消费者(Consumer) | 处理消息的一方 |
| 队列(Queue) | 消息暂存的容器,FIFO |
| 确认(Ack) | 消费者处理成功后通知队列删除消息 |
| 重试(Retry) | 处理失败的消息重新投递 |
典型场景:注册后发短信/邮件、订单创建后扣库存、日志异步落盘、爬虫任务分发。
六、Redis 队列实现
6.1 List 队列:LPUSH + BRPOP
用 List 实现可靠的 FIFO 队列,BRPOP 在没有消息时阻塞等待,避免空转轮询:
// 生产者:把任务塞入队列左侧
await redis.lpush("queue:email", JSON.stringify({ to: "a@example.com", body: "欢迎注册" }));
// 消费者:从右侧取出,无消息时阻塞最多 30 秒
async function consume() {
while (true) {
const result = await redis.brpop("queue:email", 30);
if (!result) continue;
const [, job] = result;
const task = JSON.parse(job);
try {
await sendEmail(task);
// 处理成功,消息已被 brpop 移除,天然完成确认
} catch (error) {
// 处理失败:重新放回队列(可放进"死信"队列并计数)
await redis.lpush("queue:email:retry", job);
console.error("任务失败:", error.message);
}
}
}
consume();6.2 Pub/Sub:广播
Pub/Sub 是广播模式:发布者发一条消息,所有订阅该频道的客户端都能收到。适合站内通知、实时刷新,但消息不持久化,消费者不在线就丢了。
// 订阅端
const sub = new Redis();
await sub.subscribe("channel:news");
sub.on("message", (channel, message) => {
console.log(`收到 ${channel}:`, message);
});
// 发布端
const pub = new Redis();
await pub.publish("channel:news", "系统公告:今晚 10 点维护");| 特性 | List 队列 | Pub/Sub |
|---|---|---|
| 消费模式 | 竞争消费(一条消息一个消费者) | 广播(所有订阅者都收到) |
| 持久化 | 消息存在内存/磁盘,可恢复 | 不持久化,断线即丢失 |
| 确认机制 | 弹出即删除(或 ack 后删) | 无确认 |
| 适用场景 | 任务队列、可靠投递 | 实时通知、聊天广播 |
七、BullMQ:生产级任务队列
BullMQ 基于 Redis,提供延迟任务、重试、进度、并发控制等完整能力,是 Node 生态事实上的标准:
npm install bullmqconst { Queue, Worker } = require("bullmq");
const connection = { host: "localhost", port: 6379 };
// 1. 定义队列
const emailQueue = new Queue("email", { connection });
// 2. 生产者:投递任务,可指定延迟与尝试次数
await emailQueue.add("send-welcome", { userId: 1 }, {
delay: 5000, // 5 秒后执行
attempts: 3, // 失败重试 3 次
backoff: { type: "exponential", delay: 2000 }, // 指数退避
removeOnComplete: true,
});
// 3. 消费者:处理任务
const worker = new Worker("email", async (job) => {
console.log(`处理任务 ${job.id}:`, job.data);
await sendEmail(job.data);
}, { concurrency: 10, connection });
worker.on("failed", (job, err) => {
console.error(`任务 ${job?.id} 失败:`, err.message);
});
worker.on("completed", (job) => {
console.log(`任务 ${job.id} 完成`);
});| 能力 | 说明 |
|---|---|
delay | 延迟任务:到点才投递给消费者 |
attempts + backoff | 失败重试,指数退避避免打爆下游 |
concurrency | 单进程并发消费数 |
| 事件监听 | completed、failed、stalled 等钩子 |
| 重复任务 | repeat 选项按 Cron 周期执行 |
| 可视面板 | bull-board 查看队列积压与任务状态 |
适合场景:邮件/短信发送、图片处理、报表生成、定时任务(如每天 0 点结算)。
八、场景对比:缓存 vs 队列
| 维度 | 缓存 | 消息队列 |
|---|---|---|
| 核心目的 | 加速读取(以空间换时间) | 异步解耦(削峰填谷) |
| 数据流向 | 请求 → 缓存 → 数据库 | 生产者 → 队列 → 消费者 |
| 一致性要求 | 允许短暂不一致(可接受脏读窗口) | 要求可靠投递与幂等处理 |
| 数据生命周期 | 有过期时间,自动淘汰 | 消费后删除(或按策略保留) |
| 典型组件 | Redis、本地 LRU | BullMQ、RabbitMQ、Kafka |
| 失败影响 | 缓存失效 → 直接打库(可降级) | 消息积压 → 下游延迟(可扩容) |
两者经常搭配使用:请求先查缓存(Redis),未命中再查库并回填;异步操作(发短信、更新索引)则投进 BullMQ 队列慢慢处理。选型口诀:读多写少用缓存,任务解耦用队列。