10 - Redis进行缓存、限流、队列

第 1 步:Redis 入门

Redis 在你项目里干什么

PostgreSQL 管持久化数据(用户、订单、库存),掉电不丢。

Redis 管内存里的临时数据(读多写少、允许过期),减轻 DB 压力。本课用 String + JSON,以及 List 做队列。

用途 场景 命令 文件
缓存 GET /api/products GET / SETEX src/product.ts
限流 登录 / 注册防刷 INCR / EXPIRE src/middleware/rateLimit.tssrc/auth.ts
队列 下单后 mock 发邮件 LPUSH / BRPOP src/queue/emailQueue.tssrc/workers/emailWorker.ts

Prisma 管 PG,Redis 单独连,模式同 src/lib/prisma.ts。三项共用 src/redis.ts.env 里的 REDIS_URL


环境准备

bash 复制代码
npm install ioredis
npm install -D @types/ioredis

.env 增加:

env 复制代码
REDIS_URL="redis://localhost:6379"

启动 Redis(Docker)

bash 复制代码
docker run -d --name redis-study -p 6379:6379 redis:7-alpine

-p 6379:6379 = 本机 6379 映射到容器内 Redis;Node 连 redis://localhost:6379

bash 复制代码
docker ps                         # 看是否在跑
docker start redis-study          # 停止后再启
docker exec -it redis-study redis-cli PING   # 应回 PONG

创建redis客户端并尝试连接, src/redis.ts

typescript 复制代码
import Redis from "ioredis";

const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379";
export const redis = new Redis(redisUrl);

redis.on("connect", () => console.log("Redis 已连接"));
redis.on("error", (err) => console.error("Redis 连接错误:", err.message));

Worker 进程需单独加载环境变量:import "dotenv/config";(API 入口已有则 Worker 自己也要加);作用是:在程序启动时自动加载 .env 文件里的环境变量,并写入 process.env。


缓存(Cache-Aside)

目的: 热点读走 Redis,少打 PostgreSQL。
#mermaid-svg-RRbLdUjKqHmwmv6k{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RRbLdUjKqHmwmv6k .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RRbLdUjKqHmwmv6k .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RRbLdUjKqHmwmv6k .error-icon{fill:#552222;}#mermaid-svg-RRbLdUjKqHmwmv6k .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RRbLdUjKqHmwmv6k .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RRbLdUjKqHmwmv6k .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RRbLdUjKqHmwmv6k .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RRbLdUjKqHmwmv6k .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RRbLdUjKqHmwmv6k .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RRbLdUjKqHmwmv6k .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RRbLdUjKqHmwmv6k .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RRbLdUjKqHmwmv6k .marker.cross{stroke:#333333;}#mermaid-svg-RRbLdUjKqHmwmv6k svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RRbLdUjKqHmwmv6k p{margin:0;}#mermaid-svg-RRbLdUjKqHmwmv6k .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-RRbLdUjKqHmwmv6k .cluster-label text{fill:#333;}#mermaid-svg-RRbLdUjKqHmwmv6k .cluster-label span{color:#333;}#mermaid-svg-RRbLdUjKqHmwmv6k .cluster-label span p{background-color:transparent;}#mermaid-svg-RRbLdUjKqHmwmv6k .label text,#mermaid-svg-RRbLdUjKqHmwmv6k span{fill:#333;color:#333;}#mermaid-svg-RRbLdUjKqHmwmv6k .node rect,#mermaid-svg-RRbLdUjKqHmwmv6k .node circle,#mermaid-svg-RRbLdUjKqHmwmv6k .node ellipse,#mermaid-svg-RRbLdUjKqHmwmv6k .node polygon,#mermaid-svg-RRbLdUjKqHmwmv6k .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RRbLdUjKqHmwmv6k .rough-node .label text,#mermaid-svg-RRbLdUjKqHmwmv6k .node .label text,#mermaid-svg-RRbLdUjKqHmwmv6k .image-shape .label,#mermaid-svg-RRbLdUjKqHmwmv6k .icon-shape .label{text-anchor:middle;}#mermaid-svg-RRbLdUjKqHmwmv6k .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RRbLdUjKqHmwmv6k .rough-node .label,#mermaid-svg-RRbLdUjKqHmwmv6k .node .label,#mermaid-svg-RRbLdUjKqHmwmv6k .image-shape .label,#mermaid-svg-RRbLdUjKqHmwmv6k .icon-shape .label{text-align:center;}#mermaid-svg-RRbLdUjKqHmwmv6k .node.clickable{cursor:pointer;}#mermaid-svg-RRbLdUjKqHmwmv6k .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RRbLdUjKqHmwmv6k .arrowheadPath{fill:#333333;}#mermaid-svg-RRbLdUjKqHmwmv6k .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RRbLdUjKqHmwmv6k .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RRbLdUjKqHmwmv6k .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RRbLdUjKqHmwmv6k .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RRbLdUjKqHmwmv6k .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RRbLdUjKqHmwmv6k .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RRbLdUjKqHmwmv6k .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RRbLdUjKqHmwmv6k .cluster text{fill:#333;}#mermaid-svg-RRbLdUjKqHmwmv6k .cluster span{color:#333;}#mermaid-svg-RRbLdUjKqHmwmv6k div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RRbLdUjKqHmwmv6k .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RRbLdUjKqHmwmv6k rect.text{fill:none;stroke-width:0;}#mermaid-svg-RRbLdUjKqHmwmv6k .icon-shape,#mermaid-svg-RRbLdUjKqHmwmv6k .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RRbLdUjKqHmwmv6k .icon-shape p,#mermaid-svg-RRbLdUjKqHmwmv6k .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RRbLdUjKqHmwmv6k .icon-shape .label rect,#mermaid-svg-RRbLdUjKqHmwmv6k .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RRbLdUjKqHmwmv6k .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RRbLdUjKqHmwmv6k .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RRbLdUjKqHmwmv6k :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} HIT
MISS
GET /api/products
缓存命中?
redis.get → JSON.parse → 200
findMany → setex TTL → 200

实现要点(src/product.ts):

typescript 复制代码
const cacheKey = "products:list";
const CACHE_TTL = 60;

const cached = await redis.get(cacheKey);
if (cached) return res.status(200).json(JSON.parse(cached));

const products = await prisma.product.findMany({ orderBy: { id: "asc" } });
// set expire
await redis.setex(cacheKey, CACHE_TTL, JSON.stringify(products));
res.json(products);

验证: docker start redis-studynpm run dev → 连刷两次 GET /api/products,第二次应更快(60 秒内走缓存)。改数据后手动失效:redis-cli DEL products:list

需掌握的概念:

概念 含义 常见应对
TTL key 到期自动删,避免脏数据常驻 Time To Live,设置的过期时间
穿透 查不存在的数据,缓存拦不住 缓存空值(短 TTL)
击穿 热点 key 过期瞬间并发打 DB 互斥锁 / 逻辑过期
雪崩 大量 key 同时过期 TTL 加随机抖动

限流(Rate Limit)

目的: 同一 IP 在固定窗口内最多 N 次,超出返回 429

算法(固定窗口): INCR ratelimit:login:{ip} → 首次 EXPIRE 60count > max 则 429。

请求 行为
第 1 次 INCR → 1,EXPIRE 60
第 2~max 次 放行
第 max+1 次 429
60 秒后 key 过期,重新计数

与缓存的区别:限流用 INCR 计数 ,超限 拒绝请求 ;缓存用 GET/SETEX 存结果,未命中才回源 DB。

中间件(src/middleware/rateLimit.ts)核心:

typescript 复制代码
const ip = req.ip ?? req.socket.remoteAddress ?? "unknown";
const key = `${keyPrefix}:${ip}`;
const count = await redis.incr(key); // increment
if (count === 1) await redis.expire(key, windowSec);
if (count > max)
  return res.status(429).json({ error: "Too Many Requests, ..." });
next();

挂载(src/auth.ts)--- 写在 handler 前面:

typescript 复制代码
rateLimit({ windowSec: 60, max: 3, keyPrefix: "ratelimit:register" }); // 注册
rateLimit({ windowSec: 60, max: 5, keyPrefix: "ratelimit:login" }); // 登录

验证: 60 秒内连发 6 次 POST /api/auth/login,第 6 次应 429。

要点: count === 1 才 EXPIRE(不刷新窗口);用 IP 标识来源;生产还会加验证码、滑动窗口等。


队列(异步任务)

目的: 扣库存、写订单必须同步返回;发邮件可异步,不拖慢 HTTP。

复制代码
下单 → 事务成功 → LPUSH 任务(毫秒)→ 201 立刻返回
                        ↓
              Worker BRPOP → mock 发邮件
角色 文件 动作
生产者 src/order.ts 事务外 enqueueOrderEmail(...)
队列 Redis List email:queue LPUSH 入队
消费者 src/workers/emailWorker.ts BRPOP 阻塞出队

LPUSH(左进)+ BRPOP(右出)= FIFO。任务体存 JSON 字符串。

入队(src/queue/emailQueue.ts + order.ts):

typescript 复制代码
// emailQueue.ts
await redis.lpush(QUEUE_KEY, JSON.stringify(job));

// order.ts --- 事务成功后、return 201 之前
if (order?.user?.email) {
  await enqueueOrderEmail({ orderId, userId, email });
}

消费者(emailWorker.ts):

typescript 复制代码
while (true) {
  const result = await redis.brpop(QUEUE_KEY, 5);
  if (!result) continue;
  const job = JSON.parse(result[1]);
  console.log(`[Mock Email] 订单 #${job.orderId} → ${job.email}`);
}

验证(两个终端):

bash 复制代码
npm run dev              # 终端 1
npm run worker:email     # 终端 2,package.json 已配脚本

登录后 POST /api/orders → API 快速 201,Worker 控制台打印 mock 邮件。Worker 挂了任务不丢,重启后继续消费。

生产常用 BullMQ(重试、延迟、面板);本课手写 List 是为理解「入队 / 出队 / 独立进程」。


三项对照 & 本地启动

缓存 限流 队列
解决 读慢、DB 压力 防刷 HTTP 不等慢任务
Redis 类型 String String(计数) List
进程 1 1 2(API + Worker)
bash 复制代码
docker start redis-study
npm run dev
npm run worker:email    # 测下单队列时需要