第 1 步:Redis 入门
Redis 在你项目里干什么
PostgreSQL 管持久化数据(用户、订单、库存),掉电不丢。
Redis 管内存里的临时数据(读多写少、允许过期),减轻 DB 压力。本课用 String + JSON,以及 List 做队列。
| 用途 | 场景 | 命令 | 文件 |
|---|---|---|---|
| 缓存 | GET /api/products |
GET / SETEX |
src/product.ts |
| 限流 | 登录 / 注册防刷 | INCR / EXPIRE |
src/middleware/rateLimit.ts、src/auth.ts |
| 队列 | 下单后 mock 发邮件 | LPUSH / BRPOP |
src/queue/emailQueue.ts、src/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-study → npm 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 60 → count > 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 # 测下单队列时需要