本册涵盖:03 数据层(Prisma/Postgres/Redis)· 04 认证授权 · 05 API 设计 · 06 异步与可靠性
03 - 数据层:Prisma + PostgreSQL + Redis
目标:建一个事务正确、N+1 可控、缓存一致、可演进的数据层。涵盖 schema 设计、迁移、连接池、事务、缓存、行级安全。
1. Prisma 上手与 schema 基线
apps/api/prisma/schema.prisma:
prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions", "relationJoins", "tracing"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [pg_uuidv7, citext, pg_trgm]
}
model Tenant {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
name String
createdAt DateTime @default(now()) @db.Timestamptz(6)
users User[]
@@map("tenants")
}
model User {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
tenantId String @db.Uuid
email String @db.Citext
name String
password String
createdAt DateTime @default(now()) @db.Timestamptz(6)
updatedAt DateTime @updatedAt @db.Timestamptz(6)
deletedAt DateTime? @db.Timestamptz(6)
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Restrict)
@@unique([tenantId, email])
@@index([tenantId, createdAt(sort: Desc)])
@@map("users")
}
要点拆解:
- 主键 UUIDv7 :
uuid_generate_v7()由扩展pg_uuidv7提供,需先CREATE EXTENSION - 时区 :全用
Timestamptz(6),微秒精度 + 带时区 Citext:大小写不敏感的字符串,做 email 唯一索引时省去lower()包装- 复合唯一
@@unique([tenantId, email]):同租户内邮箱唯一,跨租户允许重复 - 索引顺序 :
[tenantId, createdAt(sort: Desc)]把租户分区在前,常用排序键放后,列表查询命中度极高 onDelete: Restrict:租户被删时禁止级联删 user,逼你显式处理
💡 原理:复合索引列顺序怎么定? 把等值过滤 字段放在最前(
tenantId = ?),范围/排序字段放后(createdAt DESC)。Postgres 的 B-tree 索引能用一次扫描同时完成过滤和排序。倒过来则不能,要做 sort。
⚠️ 不要用@default(uuid())------ 那是 v4 全随机的,导致 B-tree 写性能差(00 章已讲)。
1.1 启用扩展的初始 migration
sql
-- prisma/migrations/<timestamp>_init_extensions/migration.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_uuidv7";
CREATE EXTENSION IF NOT EXISTS "citext";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
之后 pnpm prisma migrate dev --name init 让 Prisma 续上 schema migration。
2. PrismaService 与 NestJS 集成
typescript
// src/infra/prisma/prisma.service.ts
import { Injectable, Logger, OnApplicationShutdown, OnModuleInit } from "@nestjs/common";
import { PrismaClient, Prisma } from "@prisma/client";
import { requestContext } from "@/common/context/request-context";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnApplicationShutdown {
private readonly logger = new Logger(PrismaService.name);
constructor() {
super({
log: [
{ emit: "event", level: "query" },
{ emit: "event", level: "warn" },
{ emit: "event", level: "error" },
],
transactionOptions: {
maxWait: 5_000, // 等待开始事务最多 5s
timeout: 10_000, // 事务体最多 10s
isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted,
},
});
}
async onModuleInit(): Promise<void> {
// 慢查询日志(> 100ms)
this.$on("query" as never, (e: Prisma.QueryEvent) => {
if (e.duration > 100) {
this.logger.warn({ msg: "slow_query", duration_ms: e.duration, query: e.query });
}
});
this.$on("error" as never, (e: Prisma.LogEvent) => this.logger.error(e));
await this.$connect();
}
async onApplicationShutdown(): Promise<void> {
await this.$disconnect();
}
}
prisma.module.ts:
typescript
@Global()
@Module({ providers: [PrismaService], exports: [PrismaService] })
export class PrismaModule {}
💡 为什么
@Global()? Prisma 几乎每个模块都要用,且必须单例 (连接池)。这种"基础设施 + 单例 + 全员用"的情况是@Global的正当用例。
2.1 连接池配置
通过连接串参数控制:
bash
DATABASE_URL=postgresql://user:pass@host:5432/db?schema=public&connection_limit=10&pool_timeout=10
| 参数 | 默认 | 推荐 |
|---|---|---|
connection_limit |
num_physical_cpus * 2 + 1 |
按容器 CPU 调:API 副本数 × limit ≤ 数据库 max_connections × 0.7 |
pool_timeout |
10s | 不变 |
connect_timeout |
5s | 不变 |
socket_timeout |
--- | 60(防长连接卡死) |
⚠️ 连接数算式 :Postgres 默认
max_connections=100。如果你有 5 个 API pod × 每个 limit=10 = 50,留 30% buffer 给 worker、psql 排查、迁移。超出会被拒(SQLSTATE 53300)。
💡 何时需要 PgBouncer/RDS Proxy 当 API 副本数 × limit 接近 max_connections 时,加一层连接池代理。注意 Prisma 不完全兼容 PgBouncer 的 transaction pooling 模式 :某些 prepared statement 行为会出错。要么用 session pooling(连接利用率下降),要么用 PgBouncer 的pool_mode=transaction同时给 Prisma 加?pgbouncer=true(关闭 prepared statement)。Prisma 5.10+ 已显著改善,但仍要测试。
3. Repository 模式(轻量)
我们不全套 Repository。但业务服务直接调 Prisma 会让单元测试难写(02 章原理:Domain 不依赖基础设施)。折中:
typescript
// modules/users/users.repository.ts
@Injectable()
export class UsersRepository {
constructor(private readonly prisma: PrismaService) {}
findByEmail(tenantId: string, email: string) {
return this.prisma.user.findUnique({
where: { tenantId_email: { tenantId, email } },
});
}
list(tenantId: string, params: { take: number; cursor?: string }) {
return this.prisma.user.findMany({
where: { tenantId, deletedAt: null },
orderBy: { createdAt: "desc" },
take: params.take,
...(params.cursor && { cursor: { id: params.cursor }, skip: 1 }),
});
}
}
服务编排:
typescript
@Injectable()
export class UsersService {
constructor(
private readonly repo: UsersRepository,
private readonly hasher: PasswordHasher,
) {}
async create(tenantId: string, dto: CreateUserInput) {
const existing = await this.repo.findByEmail(tenantId, dto.email);
if (existing) throw new ConflictException("email already in use");
const password = await this.hasher.hash(dto.password);
return this.repo.create({ tenantId, ...dto, password });
}
}
💡 Repository 的真正价值 不是"未来可换 ORM",而是:1) Service 单测可以只 mock 一层;2) 复杂查询的 SQL/Prisma 调用集中,不散落 controller;3) 加索引时知道改哪里。不要为简单 CRUD 强行写 Repository。
4. 事务:何时用,怎么用
4.1 三种事务写法
typescript
// 1. 顺序事务($transaction with array)------ 不允许中间逻辑
const [user, profile] = await prisma.$transaction([
prisma.user.create({ data: { ... } }),
prisma.profile.create({ data: { ... } }),
]);
// 2. 交互事务($transaction with callback)------ 允许中间逻辑
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: { ... } });
if (await someCheck(user)) throw new Error("rollback");
return tx.profile.create({ data: { userId: user.id } });
}, {
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
timeout: 5_000,
});
// 3. 原始 SQL 事务(罕用)
await prisma.$executeRaw`BEGIN`;
// ...
await prisma.$executeRaw`COMMIT`;
⚠️ 交互事务里千万不要做 HTTP/外部 IO 事务期间 connection 被独占,Network IO 让事务可能挂几秒到几十秒,直接打爆连接池 。规则:事务体只能做 DB 操作和很快的内存计算。外部副作用走 Outbox(06 章)。
4.2 隔离级别
| 级别 | 防 | 默认 | 用途 |
|---|---|---|---|
| Read Committed | 脏读 | ✅ Postgres 默认 | 大部分读操作 |
| Repeatable Read | 不可重复读 | 报表、对账 | |
| Serializable | 幻读、并发异常 | 写敏感(余额、库存) |
实战策略:
- 读路径:默认 Read Committed
- 写关键路径 (余额、库存、唯一性约束 + 检查):用
Serializable或SELECT ... FOR UPDATE
4.3 防超卖的两种正确姿势
A. 乐观锁(版本号 / WHERE 条件)
typescript
const result = await prisma.product.updateMany({
where: { id, stock: { gte: qty } },
data: { stock: { decrement: qty } },
});
if (result.count === 0) throw new ConflictException("Out of stock");
B. 悲观锁
typescript
await prisma.$transaction(async (tx) => {
const product = await tx.$queryRaw<{ stock: number }[]>`
SELECT stock FROM products WHERE id = ${id} FOR UPDATE
`;
if (product[0].stock < qty) throw new ConflictException("Out of stock");
await tx.product.update({ where: { id }, data: { stock: { decrement: qty } } });
});
💡 优先用 A :更新条件里写库存,Postgres 给行加排它锁后再判断,一次原子操作,不阻塞读。B 适合需要做多步检查的场景。
⚠️ 永远不要先 SELECT 再 UPDATE(无 FOR UPDATE) 经典 race condition:两个请求都看到 stock=1,都 -1,卖出两单库存只剩 -1。这是面试题级别的坑,但生产代码里经常见到。
5. N+1:监测、根因、消除
5.1 监测
启用 query 事件 + 给 trace 加 SQL 数量统计:
typescript
// 简化版:把同一 traceId 下的查询计数,>5 报警
const counts = new Map<string, number>();
prisma.$on("query", (e) => {
const id = getContext().traceId ?? "no-trace";
counts.set(id, (counts.get(id) ?? 0) + 1);
if ((counts.get(id) ?? 0) === 6) {
logger.warn({ msg: "potential_n_plus_1", traceId: id });
}
});
生产用 OpenTelemetry instrumentation 自动收集每请求的 DB span 数量(09 章)。
5.2 根因案例
typescript
// 坏:查 100 个 user 然后挨个查 posts
const users = await prisma.user.findMany({ take: 100 });
for (const u of users) {
u.posts = await prisma.post.findMany({ where: { userId: u.id } });
}
// 101 次查询
修法一:Prisma include
typescript
const users = await prisma.user.findMany({
take: 100,
include: { posts: true },
});
// 2 次查询(user + post),Prisma 自动 JOIN 或 IN 查询
修法二:批查询 + 内存 join(更可控)
typescript
const users = await prisma.user.findMany({ take: 100 });
const posts = await prisma.post.findMany({
where: { userId: { in: users.map(u => u.id) } },
});
const byUser = Map.groupBy(posts, p => p.userId);
const enriched = users.map(u => ({ ...u, posts: byUser.get(u.id) ?? [] }));
💡
includevs 手动 join 怎么选? 简单一对多用include。大表 + 多关联 时 Prisma 生成的 JOIN/IN 可能很糟,手动两次查询 + 内存 join 反而快(Postgres 一个WHERE id IN (...)走主键索引飞快)。用EXPLAIN ANALYZE验证。
⚠️relationJoinspreview feature (Prisma 5.7+):开启后部分场景用 LATERAL JOIN 代替多次查询,实测对 1-N 关联性能更稳定。schema.prisma 里previewFeatures = ["relationJoins"]即可。
5.3 DataLoader 模式
如果你必须按一次次"按 ID 取"的接口(如 GraphQL resolver):
typescript
import DataLoader from "dataloader";
@Injectable({ scope: Scope.REQUEST }) // 注意:这里 REQUEST scope 是必要的
export class UserLoader {
constructor(private prisma: PrismaService) {}
private loader = new DataLoader<string, User>(async (ids) => {
const users = await this.prisma.user.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(users.map(u => [u.id, u]));
return ids.map(id => byId.get(id) ?? new Error(`User ${id} not found`));
});
load(id: string) { return this.loader.load(id); }
}
⚠️ DataLoader 必须每请求一个实例 (否则跨请求"串"缓存)。REST API 中不要主动用 DataLoader,你应该直接写 batch 查询。GraphQL 中 DataLoader 是标配。
6. 软删除策略
不推荐"全表加 deletedAt + 全查询过滤":
- 唯一索引失效(同 email 软删过的也算占用)→ 需要 partial index
- 每个查询都要记得加
deletedAt: null - 索引选择性变差
推荐方案A:审计表 + 硬删除
prisma
model UserAudit {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
userId String @db.Uuid
action String // CREATE | UPDATE | DELETE
before Json?
after Json?
actorId String?
createdAt DateTime @default(now()) @db.Timestamptz(6)
}
业务上需要"回收站 30 天"时用方案 B:
方案B:状态字段 + Partial Index
prisma
model User {
status DataStatus @default(ACTIVE)
// ...
@@unique([tenantId, email], where: { status: { equals: "ACTIVE" }}) // Prisma 5.7+
}
或在 SQL 层:
sql
CREATE UNIQUE INDEX users_email_unique_active
ON users (tenant_id, email) WHERE status = 'ACTIVE';
💡 partial index 是软删除的正确解法 已删除的行不进入这个索引,所以唯一性约束只对"活"数据生效,且索引体积更小。
7. 行级安全(RLS):多租户最后防线
Step 1:开启 RLS
sql
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id::text = current_setting('app.tenant_id', true));
Step 2:应用层每次操作前 set 上下文
typescript
@Injectable()
export class TenantPrisma {
constructor(private readonly prisma: PrismaService) {}
async run<T>(tenantId: string, fn: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
return this.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
return fn(tx);
});
}
}
用法:
typescript
return this.tenantPrisma.run(tenantId, (tx) =>
tx.user.findMany() // RLS 自动加 tenant_id 过滤
);
⚠️
set_config(..., true)的第三个参数是is_localtrue= 仅当前事务有效(SET LOCAL)。如果用了连接池(PgBouncer transaction mode),必须true,否则别的请求复用连接时会继承租户上下文 = 跨租户数据泄露事故。
💡 RLS 对 superuser 不生效 应用账号必须是普通用户,不能是 superuser。生产数据库账号最小权限。
7.1 RLS 性能注意
current_setting() 在每行计算一次,但 Postgres 优化器在 PG14+ 能把它"提取"到外层。即便如此,建议:复合索引第一列就是 tenant_id,让 RLS 过滤走索引前缀。
8. Redis:缓存、锁、计数
8.1 客户端选型
| 客户端 | 优势 | 劣势 |
|---|---|---|
| ioredis | 功能全,Cluster/Sentinel 一流,BullMQ 用它 | 略大 |
| node-redis | 官方,新版本性能好 | 集群支持弱一些 |
本教程用 ioredis。
8.2 RedisModule
typescript
// src/infra/redis/redis.module.ts
import { Module, Global } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Redis from "ioredis";
export const REDIS = Symbol("REDIS");
@Global()
@Module({
providers: [{
provide: REDIS,
inject: [ConfigService],
useFactory: (cfg: ConfigService) => {
const url = cfg.getOrThrow<string>("REDIS_URL");
const client = new Redis(url, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});
client.on("error", (e) => console.error("[redis]", e));
return client;
},
}],
exports: [REDIS],
})
export class RedisModule {}
⚠️ BullMQ 用 Redis 时必须
maxRetriesPerRequest: null和enableReadyCheck: false。给 BullMQ 单独建一个连接,别复用业务 Redis(06 章)。
8.3 缓存策略
Cache-aside(最常用):
typescript
async get(userId: string) {
const key = `user:${userId}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const user = await repo.find(userId);
if (user) {
await redis.set(key, JSON.stringify(user), "EX", 300);
}
return user;
}
async update(userId: string, data: Partial<User>) {
const u = await repo.update(userId, data);
await redis.del(`user:${userId}`); // 先写库,再删缓存
return u;
}
8.4 缓存三大问题
| 问题 | 现象 | 解法 |
|---|---|---|
| 缓存穿透 | 查不存在的 key 反复打 DB | 缓存空值(短 TTL)+ Bloom filter |
| 缓存击穿 | 热 key 失效瞬间大量请求穿透 | 用 SETNX 单飞:同一时刻只一个请求回源 |
| 缓存雪崩 | 大量 key 同一时刻过期 | TTL 加随机抖动 ±10% |
单飞实现:
typescript
async function getWithSingleFlight<T>(
key: string,
loader: () => Promise<T>,
ttl: number,
): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached) as T;
const lockKey = `lock:${key}`;
const ok = await redis.set(lockKey, "1", "PX", 3000, "NX");
if (!ok) {
// 另一个请求在加载,等一下重试
await sleep(50);
return getWithSingleFlight(key, loader, ttl);
}
try {
const data = await loader();
await redis.set(key, JSON.stringify(data), "EX", ttl + jitter(ttl, 0.1));
return data;
} finally {
await redis.del(lockKey);
}
}
8.5 缓存一致性:为什么"先写库后删缓存"?
| 顺序 | 风险 |
|---|---|
| 先删缓存 → 写库 | 删除后写之前,另一个读请求把旧值再写入缓存 → 不一致 |
| 先写库 → 删缓存(推荐) | 罕见情况下:读请求先查到旧缓存,写请求完成更新+删除,读请求慢于写完成才回写旧值 → 短暂不一致 |
| 先写库 → 更新缓存 | 两个并发写可能乱序写缓存 |
💡 "延迟双删":先写库 → 删缓存 → 等 50-200ms → 再删一次。能消除上面的罕见不一致。但 99% 业务无需这种强度。
⚠️ 不要用 transactional outbox 之外的方式同步数据库到 Redis 。需要强一致就用 CDC(Debezium 之类),让 Redis 作为只读视图,写永远只走 DB。
8.6 分布式锁(redlock 替代方案)
简单单实例 Redis 锁:
typescript
async function withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T> {
const token = randomUUID();
const ok = await redis.set(`lock:${key}`, token, "PX", ttlMs, "NX");
if (!ok) throw new ConflictException("Locked");
try {
return await fn();
} finally {
// 释放时校验 token,避免误删别人的锁
await redis.eval(
`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`,
1, `lock:${key}`, token,
);
}
}
⚠️ Redlock(多 Redis 节点投票锁)在 Martin Kleppmann 的著名争论后不推荐。单 Redis + 短 TTL + fence token 在 99% 场景够用。需要强 mutual exclusion 时考虑 Postgres advisory lock 或 etcd/Zookeeper。
9. 迁移工程化
9.1 三种环境的命令
| 环境 | 命令 | 行为 |
|---|---|---|
| 开发 | prisma migrate dev |
生成迁移文件 + 应用 + reseed |
| 测试/CI | prisma migrate deploy |
仅应用已存在的迁移 |
| 生产 | prisma migrate deploy |
同上,不会改 schema |
⚠️ 生产永远用
migrate deploy,不用migrate dev。后者会要求开发模式权限(创建/删除 shadow db)。
9.2 安全迁移规则
DB 迁移要做到向后兼容,先发后改:
| 操作 | 安全做法 |
|---|---|
| 加列(non-null) | 1) 加 nullable;2) 回填;3) 改 NOT NULL |
| 删列 | 1) 应用先停止读写该列;2) 至少跨一个发布周期再删 |
| 改类型 | 加新列 + 双写 + 切读 + 删旧列 |
| 加索引(大表) | CREATE INDEX CONCURRENTLY(Prisma 用 --create-only 后手改 SQL) |
| 改约束 | NOT VALID + 后台 VALIDATE |
CREATE INDEX CONCURRENTLY 注意点:不能在事务里。Prisma 自动包事务,所以这种迁移必须手编辑:
sql
-- prisma/migrations/xxx/migration.sql
-- prisma-skip-transaction
CREATE INDEX CONCURRENTLY users_created_at_idx ON users(created_at DESC);
💡 零停机迁移的本质 :新代码兼容旧 schema,新 schema 兼容旧代码。两个方向都要兼容,才能错峰发布。
9.3 Seed 数据
prisma/seed.ts:
typescript
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
await prisma.tenant.upsert({
where: { id: "00000000-0000-7000-8000-000000000001" },
update: {},
create: { id: "00000000-0000-7000-8000-000000000001", name: "Acme" },
});
}
main().finally(() => prisma.$disconnect());
package.json:
json
"prisma": {
"seed": "tsx prisma/seed.ts"
}
⚠️ Seed 用
upsert不用create,这样重跑不会因为唯一冲突挂掉。
10. 读写分离
Postgres 主从下,读走只读副本可以扛 90% 流量。Prisma 没有内建分离,自己路由:
typescript
@Injectable()
export class ReadPrismaService extends PrismaClient {
constructor(cfg: ConfigService) {
super({ datasources: { db: { url: cfg.getOrThrow("DATABASE_READONLY_URL") } } });
}
}
注入两个,默认写主,显式读副本:
typescript
constructor(
private write: PrismaService,
private read: ReadPrismaService,
) {}
async listUsers() {
return this.read.user.findMany();
}
async createUser(dto: CreateUserInput) {
return this.write.user.create({ data: dto });
}
⚠️ 副本延迟陷阱 :用户刚提交完表单立刻跳转列表页,从副本读可能看不到自己刚插入的数据(主从延迟)。规则:写完后的"读自己"必须走主库,或前端用乐观更新。
💡 更优雅的方案是 Prisma 5.7+ 的$transaction({ readOnly: true })------ 但目前只在某些数据源生效。手动分离更可控。
11. 性能优化清单
按 ROI 排序:
- 加索引(B-tree + 复合)→ 10-1000 倍
- 消除 N+1 → 3-100 倍
- 缓存热点查询 → 5-50 倍
SELECT列收敛(只查需要的字段)→ 1.2-3 倍- 批量操作 (
createMany/updateMany)→ 5-20 倍 - 读写分离 → 写扩 + 读 2-5 倍
- 分区表(时间分区、租户分区) → 大数据集 10 倍
- 物化视图 + refresh(报表场景) → 100 倍以上
💡 最大的优化收益永远来自 schema 设计和索引,而不是代码 。一个好的复合索引能让 100ms 查询变 1ms。永远先
EXPLAIN ANALYZE,再优化代码。
12. 健康检查端点
typescript
@Controller("health")
export class HealthController {
constructor(
private prisma: PrismaService,
@Inject(REDIS) private redis: Redis,
) {}
@Public()
@Get("live")
liveness() { return { status: "ok" }; }
@Public()
@Get("ready")
async readiness() {
const [db, cache] = await Promise.allSettled([
this.prisma.$queryRaw`SELECT 1`,
this.redis.ping(),
]);
const ok = db.status === "fulfilled" && cache.status === "fulfilled";
if (!ok) throw new ServiceUnavailableException();
return { status: "ok" };
}
}
K8s probe:
- liveness:进程没死就 OK(不要查 DB,否则 DB 抖一下整个集群被杀)
- readiness:依赖都通,可以接流量
延伸阅读
- Prisma performance tips
- PostgreSQL Indexing
- Use Postgres' RLS for multi-tenant SaaS
- Kyle Kingsbury 的 Jepsen 系列 --- 隔离级别真实表现
04 - 认证授权:JWT + Refresh + RBAC/CASL + 安全加固
目标:从 0 实现工业级认证授权。涵盖密码哈希、JWT + Refresh 双令牌、Token 旋转、Cookie 会话、CSRF、CASL 细粒度权限、OAuth、限速防爆破。
1. Authentication vs Authorization
- Authentication(认证 / authn) :你是谁?(密码、OTP、社交登录)
- Authorization(授权 / authz) :你能干什么?(角色、权限、CASL ability)
两层独立。混在一起是技术债的开始。
2. 选 JWT 还是 Session?
| 维度 | JWT | Server Session(cookie + store) |
|---|---|---|
| 跨域/移动端 | 天然支持 | 需配 SameSite + CORS |
| 撤销 | 难(黑名单/短 TTL) | 一键 delete session |
| 体积 | 大(每次请求带) | 小(只有 sid) |
| 状态 | 无状态(扩展容易) | 有状态(需共享 store) |
| 安全风险 | 长 token、签名密钥泄露、alg=none |
session fixation、CSRF |
生产推荐:
- Web 端 :HTTP-only Cookie Session(更安全) + CSRF token
- 移动端 / 第三方 API :JWT Access + Refresh(短 access + 长 refresh)
- 同一个后端两套都支持也很常见
本章实现 JWT Access + Refresh,后面加一节讲 Cookie Session,你按需选。
3. 密码哈希
唯一推荐:argon2id (argon2 npm 包)。备选 bcrypt(成本上限不够、不抗 GPU)。永远不要用 MD5/SHA1/PBKDF2 默认参数。
typescript
// src/modules/auth/password.hasher.ts
import { Injectable } from "@nestjs/common";
import argon2 from "argon2";
@Injectable()
export class PasswordHasher {
hash(plain: string): Promise<string> {
return argon2.hash(plain, {
type: argon2.argon2id,
memoryCost: 19_456, // 19 MB
timeCost: 2,
parallelism: 1,
});
}
verify(hash: string, plain: string): Promise<boolean> {
return argon2.verify(hash, plain);
}
needsRehash(hash: string): boolean {
return argon2.needsRehash(hash, { type: argon2.argon2id, memoryCost: 19_456, timeCost: 2 });
}
}
参数依据:OWASP Password Storage Cheat Sheet 推荐 argon2id 最低 19 MiB / t=2 / p=1。
💡
needsRehash:用户登录时检查参数是否过时(因为你提了参数),用新参数重哈希存回。这样不强制重置密码也能逐步升级。
⚠️ 千万不要"自创盐" 。argon2/bcrypt 自带盐机制,盐与 hash 一起存在同一个字段 ($argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>)。自己加一层盐是无用功还容易出错。
4. JWT 设计
4.1 三个关键决策
| 决策 | 推荐 |
|---|---|
| 算法 | EdDSA(Ed25519) 或 RS256;避免 HS256 用于跨服务 |
| Access TTL | 15 分钟 |
| Refresh TTL | 7-30 天,滚动(每次使用都旋转新 refresh) |
| Payload | sub(user id)、tid(tenant)、roles、jti、iat、exp、typ |
💡 为什么 RS/EdDSA 优于 HS? HS256 用对称密钥,你的所有服务都得有签发密钥 → 任何一个被攻破,攻击者可签发任意 token。RS/EdDSA 是非对称,只有 auth 服务持私钥签发,其他服务用公钥验签即可。可用 JWKS endpoint 分发公钥。
⚠️ 绝对禁掉alg: none。一些旧库默认允许。你的 verify 配置必须显式列允许的算法。
4.2 模块组装
typescript
// src/modules/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import { PassportModule } from "@nestjs/passport";
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
privateKey: cfg.getOrThrow("JWT_PRIVATE_KEY"),
publicKey: cfg.getOrThrow("JWT_PUBLIC_KEY"),
signOptions: { algorithm: "EdDSA", expiresIn: "15m", issuer: "my-app" },
verifyOptions: { algorithms: ["EdDSA"], issuer: "my-app" },
}),
}),
],
providers: [AuthService, PasswordHasher, RefreshTokenService],
controllers: [AuthController],
exports: [JwtModule, AuthService],
})
export class AuthModule {}
生成 Ed25519 密钥对(开发用,生产用 KMS 或 secret manager):
bash
openssl genpkey -algorithm ED25519 -out jwt_ed25519.key
openssl pkey -in jwt_ed25519.key -pubout -out jwt_ed25519.pub
4.3 签发与验证
typescript
// src/modules/auth/auth.service.ts
@Injectable()
export class AuthService {
constructor(
private readonly jwt: JwtService,
private readonly users: UsersRepository,
private readonly hasher: PasswordHasher,
private readonly refresh: RefreshTokenService,
) {}
async login(email: string, password: string, deviceInfo: DeviceInfo) {
const user = await this.users.findByEmail(email);
if (!user) {
// 故意做一次 hash,防止用响应时间猜邮箱是否存在
await this.hasher.verify(DUMMY_HASH, password);
throw new UnauthorizedException();
}
const ok = await this.hasher.verify(user.password, password);
if (!ok) throw new UnauthorizedException();
if (this.hasher.needsRehash(user.password)) {
const newHash = await this.hasher.hash(password);
await this.users.updatePassword(user.id, newHash);
}
return this.issueTokens(user, deviceInfo);
}
async issueTokens(user: User, deviceInfo: DeviceInfo) {
const jti = randomUUID();
const access = await this.jwt.signAsync(
{ sub: user.id, tid: user.tenantId, roles: user.roles, typ: "access" },
{ jwtid: jti, expiresIn: "15m" },
);
const refresh = await this.refresh.issue(user.id, deviceInfo);
return { access, refresh };
}
}
💡 恒定时间响应:用户不存在和密码错误都要花同样时间(都做一次哈希)。否则攻击者从响应时间能枚举邮箱。
5. Refresh Token:旋转 + 复用检测
核心规则:
- Refresh token 是一次性的:用一次立刻作废,签发新的
- 检测复用:如果一个已失效的 refresh 又被用,说明被盗 → 撤销整个家族(用户所有 device session)
refresh_tokens 表:
prisma
model RefreshToken {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
userId String @db.Uuid
familyId String @db.Uuid // 一个登录会话的家族 ID
tokenHash String // 存哈希,不存明文
expiresAt DateTime @db.Timestamptz(6)
revokedAt DateTime? @db.Timestamptz(6)
replacedBy String? @db.Uuid // 下一个 token 的 id
ip String?
userAgent String?
createdAt DateTime @default(now()) @db.Timestamptz(6)
@@index([userId, familyId])
@@index([tokenHash])
}
typescript
@Injectable()
export class RefreshTokenService {
constructor(private prisma: PrismaService) {}
async issue(userId: string, device: DeviceInfo, familyId?: string) {
const token = base64UrlEncode(randomBytes(48));
const tokenHash = sha256(token);
const row = await this.prisma.refreshToken.create({
data: {
userId,
familyId: familyId ?? randomUUID(),
tokenHash,
expiresAt: addDays(new Date(), 30),
ip: device.ip,
userAgent: device.userAgent,
},
});
return `${row.id}.${token}`;
}
async rotate(presented: string, device: DeviceInfo) {
const [id, token] = presented.split(".");
const row = await this.prisma.refreshToken.findUnique({ where: { id } });
const expectedHash = sha256(token ?? "");
if (!row || row.tokenHash !== expectedHash) {
throw new UnauthorizedException("Invalid refresh");
}
// 已被撤销或已被替换 → 复用攻击,撤销整个家族
if (row.revokedAt || row.replacedBy) {
await this.prisma.refreshToken.updateMany({
where: { familyId: row.familyId, revokedAt: null },
data: { revokedAt: new Date() },
});
throw new UnauthorizedException("Refresh reuse detected, all sessions revoked");
}
if (row.expiresAt < new Date()) throw new UnauthorizedException("Expired");
// 旋转
const newToken = await this.issue(row.userId, device, row.familyId);
const [newId] = newToken.split(".");
await this.prisma.refreshToken.update({
where: { id: row.id },
data: { revokedAt: new Date(), replacedBy: newId },
});
return { userId: row.userId, refresh: newToken };
}
}
💡 为什么存哈希而非明文? 数据库泄露不能直接拿来用。任何"密钥/token"性质的东西存库都要哈希(API key、webhook secret 也是)。
💡 family 设计的意义 用户登录三个设备 = 三个 family。一台被偷了 → 检测到 reuse,只撤销那个 family,另两台不受影响。
5.1 控制器
typescript
@Controller("auth")
export class AuthController {
constructor(private auth: AuthService, private refresh: RefreshTokenService) {}
@Public()
@Post("login")
@HttpCode(200)
async login(@Body(new ZodValidationPipe(LoginSchema)) dto: LoginInput, @Req() req: FastifyRequest) {
return this.auth.login(dto.email, dto.password, {
ip: req.ip,
userAgent: String(req.headers["user-agent"] ?? ""),
});
}
@Public()
@Post("refresh")
@HttpCode(200)
async refreshTokens(@Body() dto: { refresh: string }, @Req() req: FastifyRequest) {
const { userId, refresh } = await this.refresh.rotate(dto.refresh, {
ip: req.ip,
userAgent: String(req.headers["user-agent"] ?? ""),
});
const user = await this.users.findById(userId);
const access = await this.auth.signAccess(user);
return { access, refresh };
}
@Post("logout")
@HttpCode(204)
async logout(@CurrentUser() user: JwtPayload, @Body() dto: { refresh: string }) {
await this.refresh.revoke(dto.refresh, user.sub);
}
}
6. Cookie Session(Web 推荐)
如果只服务 Web 端,Cookie Session 更安全(token 不暴露给 JS,免疫 XSS 偷 token)。
6.1 实现
用 Redis 存 session:
typescript
@Injectable()
export class SessionService {
constructor(@Inject(REDIS) private redis: Redis) {}
async create(userId: string, data: SessionData): Promise<string> {
const sid = base64UrlEncode(randomBytes(32));
await this.redis.set(`sess:${sid}`, JSON.stringify(data), "EX", 7 * 24 * 3600);
return sid;
}
async get(sid: string): Promise<SessionData | null> {
const raw = await this.redis.get(`sess:${sid}`);
return raw ? JSON.parse(raw) : null;
}
async destroy(sid: string) {
await this.redis.del(`sess:${sid}`);
}
async touch(sid: string) {
await this.redis.expire(`sess:${sid}`, 7 * 24 * 3600);
}
}
Cookie 设置(Fastify):
typescript
reply.setCookie("sid", sid, {
httpOnly: true,
secure: true, // 生产必须 true(强制 HTTPS)
sameSite: "lax", // 大多场景;跨站表单提交需要 lax/none
domain: ".example.com",
path: "/",
maxAge: 7 * 24 * 3600,
signed: true, // 用 server secret 签名,防篡改
});
6.2 CSRF 防护
Cookie 会被浏览器自动带上,CSRF 风险固有存在。两种防御:
方案 A:Double-Submit Cookie + 自定义请求头
- 服务端生成随机
csrf_token,种到非 httpOnly cookie - 前端 JS 读取并加入请求头
X-CSRF-Token - 后端校验 cookie 中的 csrf_token == 请求头中的
方案 B:SameSite=strict(更简单但限制大)
- 完全防御 CSRF,但跨站跳转登录态会丢
💡 推荐组合 :
SameSite=lax+ 写操作要 CSRF token + 关键操作(改密、转账)要二次验证(密码 / OTP)。
⚠️ GET 不能改状态 。CSRF 防护通常只保护非 GET。如果你的 GET 端点能改数据(如GET /delete?id=1),CSRF 防御失效。RESTful 习惯:GET 纯读。
7. RBAC vs ABAC vs ReBAC
| 模型 | 表达 | 适用 |
|---|---|---|
| RBAC(角色) | "管理员可以删用户" | 简单后台 |
| ABAC(属性) | "部门主管可以删本部门用户" | 复杂业务 |
| ReBAC(关系) | "文档创建人和被分享者可以读" | 协作类(Notion / Figma) |
中后台 80% 是 RBAC + 少量 ABAC。本教程用 CASL ------ 它在 RBAC 上叠加属性谓词,自然过渡。
8. CASL 实战
8.1 定义能力
typescript
// src/modules/auth/abilities/ability.factory.ts
import { AbilityBuilder, PureAbility, AbilityClass } from "@casl/ability";
import { Injectable } from "@nestjs/common";
type Actions = "manage" | "create" | "read" | "update" | "delete";
type Subjects = "User" | "Invoice" | "all";
export type AppAbility = PureAbility<[Actions, Subjects]>;
@Injectable()
export class AbilityFactory {
createFor(user: { id: string; tenantId: string; roles: string[] }): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(PureAbility as AbilityClass<AppAbility>);
if (user.roles.includes("admin")) {
can("manage", "all");
} else if (user.roles.includes("manager")) {
can("read", "User", { tenantId: user.tenantId });
can("update", "User", { tenantId: user.tenantId });
cannot("update", "User", { roles: { $in: ["admin"] } });
} else {
can("read", "User", { id: user.id });
can("update", "User", { id: user.id });
}
return build({
detectSubjectType: (item) => (item.constructor as any).modelName ?? "all",
});
}
}
8.2 装饰器 + Guard
typescript
export const CHECK_ABILITY = "check_ability";
export type AbilityCheck = (ability: AppAbility) => boolean;
export const CheckAbility = (...checks: AbilityCheck[]) =>
SetMetadata(CHECK_ABILITY, checks);
@Injectable()
export class AbilityGuard implements CanActivate {
constructor(private reflector: Reflector, private factory: AbilityFactory) {}
canActivate(ctx: ExecutionContext): boolean {
const checks = this.reflector.get<AbilityCheck[]>(CHECK_ABILITY, ctx.getHandler()) ?? [];
if (!checks.length) return true;
const req = ctx.switchToHttp().getRequest();
const ability = this.factory.createFor(req.user);
return checks.every((c) => c(ability));
}
}
控制器:
typescript
@Get(":id")
@CheckAbility((a) => a.can("read", "User"))
async findOne(@Param("id") id: string, @CurrentUser() user: JwtPayload) {
const target = await this.users.findById(id);
const ability = this.factory.createFor(user);
if (!ability.can("read", subject("User", target))) throw new ForbiddenException();
return target;
}
💡 两段式检查 Guard 阶段做"是否有此动作的可能性"(粗粒度);拿到具体资源后做"是否能对这个资源做"(细粒度)。CASL 优雅地支持两层用同一份能力定义。
8.3 与查询过滤结合(accessibleBy)
typescript
import { accessibleBy } from "@casl/prisma";
async listUsers(@CurrentUser() user: JwtPayload) {
const ability = this.factory.createFor(user);
return this.prisma.user.findMany({
where: { AND: [accessibleBy(ability).User] },
});
}
accessibleBy 把 CASL 规则翻译成 Prisma where ------ 同一份规则同时做控制和过滤,不会出现"列表显示但点详情 403"或反过来的不一致。
⚠️ 规则膨胀的代价:复杂规则会生成大 SQL,Postgres 优化器吃不消。规则超过 10 条时建议拆分多个 ability 文件 + 各自调优。
9. OAuth / SSO
NestJS + Passport 接 OAuth:
bash
pnpm add passport passport-google-oauth20 @nestjs/passport
pnpm add -D @types/passport-google-oauth20
Strategy:
typescript
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
constructor(cfg: ConfigService) {
super({
clientID: cfg.getOrThrow("GOOGLE_CLIENT_ID"),
clientSecret: cfg.getOrThrow("GOOGLE_CLIENT_SECRET"),
callbackURL: cfg.getOrThrow("GOOGLE_CALLBACK_URL"),
scope: ["email", "profile"],
});
}
async validate(_at: string, _rt: string, profile: Profile) {
return {
provider: "google",
providerId: profile.id,
email: profile.emails?.[0]?.value,
name: profile.displayName,
};
}
}
Controller:
typescript
@Public() @Get("oauth/google") @UseGuards(AuthGuard("google"))
googleAuth() {}
@Public() @Get("oauth/google/callback") @UseGuards(AuthGuard("google"))
async callback(@Req() req: any, @Res({ passthrough: true }) res: FastifyReply) {
const externalUser = req.user;
const localUser = await this.auth.upsertOAuthUser(externalUser);
const tokens = await this.auth.issueTokens(localUser, { ip: req.ip, userAgent: req.headers["user-agent"] });
// 重定向到前端,附加 token 或种 cookie
res.redirect(`${frontendUrl}/oauth/callback#access=${tokens.access}&refresh=${tokens.refresh}`);
}
⚠️ OAuth 用户绑定的两难
- 严格:OAuth 邮箱必须等于已注册账号邮箱,否则强制走绑定流程。推荐。
- 宽松:自动用相同 email 合并账号 → 被恶意 OAuth 提供商伪造 email 时账户会被劫持(参考"未验证邮箱"漏洞)。
10. 多因素认证(MFA / TOTP)
bash
pnpm add otplib qrcode
typescript
import { authenticator } from "otplib";
import qrcode from "qrcode";
async enableMfa(userId: string) {
const secret = authenticator.generateSecret();
await this.users.setMfaSecret(userId, secret, false); // 未激活
const user = await this.users.findById(userId);
const otpauth = authenticator.keyuri(user.email, "MyApp", secret);
return { secret, qrcode: await qrcode.toDataURL(otpauth) };
}
async verifyAndActivate(userId: string, code: string) {
const user = await this.users.findById(userId);
if (!authenticator.check(code, user.mfaSecret)) throw new UnauthorizedException();
await this.users.activateMfa(userId);
return this.generateRecoveryCodes(userId);
}
登录流程二阶段:
- 密码正确 → 返回
mfaRequired: true,签发短期 MFA 临时 token - 用户提交 OTP + MFA token → 验证通过签发正式 access/refresh
💡 恢复码 :用户丢手机时的最后救命稻草。生成 10 个一次性恢复码(显示给用户一次),哈希存库。永远要支持恢复码,否则线上一定会有用户被永久锁定。
11. 防爆破:速率限制 + 锁定
11.1 IP + 账号级速率限制
bash
pnpm add @nestjs/throttler
typescript
ThrottlerModule.forRoot([
{ name: "default", ttl: 60_000, limit: 60 },
{ name: "auth", ttl: 60_000, limit: 5 },
]),
typescript
@Throttle({ auth: { limit: 5, ttl: 60_000 } })
@Post("login")
login() {}
但 IP 维度容易被绕(代理池)。叠加 账号维度:
typescript
async login(email: string, password: string) {
const failKey = `auth:fail:${email}`;
const fails = Number(await this.redis.get(failKey) ?? 0);
if (fails >= 10) {
throw new TooManyRequestsException("Account temporarily locked");
}
const user = await this.users.findByEmail(email);
const ok = user && await this.hasher.verify(user.password, password);
if (!ok) {
await this.redis.incr(failKey);
await this.redis.expire(failKey, 15 * 60);
throw new UnauthorizedException();
}
await this.redis.del(failKey);
return this.issueTokens(user, ...);
}
💡 指数退避 / CAPTCHA 触发 失败 3 次后开始要求 CAPTCHA(hCaptcha / Turnstile),用户体验比"15 分钟锁定"好。
11.2 防枚举
- 登录:无论邮箱是否存在,都返回"邮箱或密码错误",且响应时间一致
- 注册:不返回"该邮箱已被注册";改为始终"验证邮件已发送"(已注册邮箱发的是"提示有人尝试注册")
- 重置密码:同上
12. 上线前安全清单(本章相关)
- 密码 argon2id,参数符合 OWASP 当年的推荐
- JWT 算法 EdDSA / RS256,永远不接受 alg=none
- Access TTL ≤ 30 分钟,Refresh 一次性 + 旋转 + 家族检测
- Token 写入 cookie 时
HttpOnly + Secure + SameSite=lax - 所有写操作 require auth,公开端点显式
@Public() - CSRF token 保护非 GET 端点(Cookie 模式)
- 登录 / 注册 / 改密 / 重置密码 限速 + 防枚举
- 关键操作要二次验证(改密、改邮箱、关闭 MFA)
- 用户能在"设置 → 安全 → 活跃会话"看到所有 device session,可逐一撤销
- 重要事件(登录、改密、新设备登录)发邮件通知
更全的清单见 10 - 安全加固与上线 Checklist。
延伸阅读
- OWASP 系列:Authentication / Session Management / Password Storage
- RFC 6749 OAuth 2.0 / RFC 6750 Bearer Tokens
- CASL 文档
- JWT Best Current Practices(RFC 8725)
05 - API 设计:DTO / 校验 / 版本 / 错误模型 / 幂等 / 限流
目标:把 API 当产品设计。一致、可演进、可测试、对前端/移动端/外部集成都友好。
1. 资源建模:URL 形状
REST 不是宗教,但有用的约束:
- 名词、复数:
/users不是/getUsers - 嵌套两层为上限:
/orgs/:orgId/users还行,/orgs/:orgId/users/:userId/posts/:postId/comments就该改 - 集合 + 单体:
GET /users(集合)、GET /users/:id(单体) - 动作不适合 CRUD 时用子资源:
POST /users/:id/suspend(不是PATCH /users/:idwith{status: "suspended"})
💡 何时不走 REST?
- 复杂查询(图状、按需字段)→ GraphQL
- 强类型 RPC、前后端同仓库 → tRPC
- 高吞吐内网服务 → gRPC
本教程以 REST 为主线,因为它是最低公分母。tRPC 章节在 07 章前端部分提一下。
2. URL 版本 vs Header 版本
| 方案 | 优 | 劣 |
|---|---|---|
URL /v1/users |
一目了然,缓存友好 | 全量升级麻烦 |
Header Accept: application/vnd.app.v1+json |
优雅,可按 endpoint 单升 | 客户端难调试 |
Query ?v=1 |
简单 | 缓存复杂 |
推荐 URL 版本。NestJS 内置支持:
typescript
app.enableVersioning({ type: VersioningType.URI, defaultVersion: "1" });
@Controller({ path: "users", version: "1" })
export class UsersV1Controller {}
@Controller({ path: "users", version: "2" })
export class UsersV2Controller {}
💡 版本演进原则
- 加字段 = 不破坏,不需要新版本
- 改字段语义、删字段 = 破坏 → 新版本
- 新版本应能与旧版并存至少 6 个月,给客户端时间迁移
- 默认版本永远是最低还在用的版本,不要悄悄改
3. DTO 与共享 Schema
01 章已建 packages/shared,所有 DTO Schema 用 Zod:
typescript
// packages/shared/src/schemas/user.ts
import { z } from "zod";
export const UserIdSchema = z.string().uuid();
export const ListUsersQuerySchema = z.object({
cursor: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
q: z.string().trim().min(1).max(120).optional(),
role: z.enum(["admin", "manager", "member"]).optional(),
}).strict();
export const CreateUserSchema = z.object({
email: z.string().email().max(254),
name: z.string().min(1).max(80),
password: z.string().min(12).max(200),
roles: z.array(z.enum(["admin", "manager", "member"])).default(["member"]),
}).strict();
export const UpdateUserSchema = CreateUserSchema.partial().omit({ password: true });
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
💡
.strict()是默认要求 没标注的字段会被拒。否则前端多发字段你的服务"默默接受",将来字段语义改变是隐患。输入严,输出可演进。
3.1 输入 vs 输出 Schema 分离
绝对不要 把 DB 实体直接序列化返回,绝对不要用同一个 schema 校验入参和构造响应。
typescript
// 入参(明文密码)
export const CreateUserSchema = z.object({ email, password, name });
// 出参(永远不带密码)
export const UserDtoSchema = z.object({
id: z.string().uuid(),
email: z.string(),
name: z.string(),
roles: z.array(z.string()),
createdAt: z.string().datetime(),
});
export type UserDto = z.infer<typeof UserDtoSchema>;
序列化在 service 出口:
typescript
toDto(u: User): UserDto {
return {
id: u.id, email: u.email, name: u.name, roles: u.roles,
createdAt: u.createdAt.toISOString(),
};
}
⚠️ 泄露字段是头号事故源 一个团队上线后两周才发现
/me端点返回了passwordHash。Schema 分离 + 响应也要 schema 校验(可选)能挡住这种事。
4. 分页:游标 vs 偏移
| 方案 | 优 | 劣 |
|---|---|---|
?page=2&size=20(偏移) |
直观,可跳页 | 深页性能差,中途插入数据会跳跃/重复 |
?cursor=...&limit=20(游标) |
一致、稳定、性能恒定 | 不能跳页 |
API 强推游标:
typescript
// 响应格式
{
"data": [...],
"nextCursor": "01HG..." // null 表示到底
}
// 实现
async list(params: ListUsersQuery) {
const items = await prisma.user.findMany({
where: { ... },
orderBy: { id: "desc" }, // 用 UUIDv7 作 cursor 天然有序
take: params.limit + 1, // 多查一条判断是否还有下一页
...(params.cursor && { cursor: { id: params.cursor }, skip: 1 }),
});
const hasMore = items.length > params.limit;
return {
data: items.slice(0, params.limit),
nextCursor: hasMore ? items[params.limit - 1].id : null,
};
}
💡 多字段排序的游标怎么做? 把所有排序字段编入 cursor:
base64({createdAt, id})。WHERE 用元组比较:(createdAt, id) < (?, ?)。复杂但稳定。
5. 错误模型:RFC 7807(application/problem+json)
02 章已实现 filter。这里强化字段约定:
typescript
{
"type": "https://errors.myapp.com/validation", // 错误类型 URI(可文档)
"title": "Validation failed", // 短人类可读
"status": 400,
"detail": "email must be a valid email", // 长描述,具体
"instance": "/v1/users", // 出错的 URL
"code": "VALIDATION_FAILED", // 程序可读,枚举
"requestId": "req_01HG...", // 用于客服查 log
"errors": { // 字段级,可选
"email": ["must be a valid email"]
}
}
错误码规范:
- 形如
DOMAIN_ACTION_REASON:USER_EMAIL_TAKEN、ORDER_PAYMENT_DECLINED - 维护一份枚举(放 shared 包),客户端可强类型判断
- HTTP status 表"类",
code表"具体原因"
typescript
// packages/shared/src/errors/codes.ts
export const ErrorCode = {
VALIDATION_FAILED: "VALIDATION_FAILED",
AUTH_INVALID_CREDENTIALS: "AUTH_INVALID_CREDENTIALS",
AUTH_TOKEN_EXPIRED: "AUTH_TOKEN_EXPIRED",
USER_EMAIL_TAKEN: "USER_EMAIL_TAKEN",
USER_NOT_FOUND: "USER_NOT_FOUND",
RATE_LIMITED: "RATE_LIMITED",
CONFLICT_VERSION: "CONFLICT_VERSION",
} as const;
export type ErrorCode = typeof ErrorCode[keyof typeof ErrorCode];
⚠️
code一旦发出就是契约,不能改名。需要细分时新增,不要重命名。
6. 幂等性:写操作的护身符
6.1 哪些操作必须幂等?
- 所有
PUT/DELETE(REST 语义) - 涉及钱、外部影响的
POST(创建订单、扣库存、发邮件) - 用户可能重试的
POST(网络抖动、超时)
6.2 实现:Idempotency-Key Header
typescript
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
constructor(@Inject(REDIS) private redis: Redis) {}
async intercept(ctx: ExecutionContext, next: CallHandler): Promise<Observable<unknown>> {
const req = ctx.switchToHttp().getRequest<FastifyRequest>();
const key = req.headers["idempotency-key"] as string | undefined;
if (!key || !["POST", "PUT", "PATCH"].includes(req.method)) {
return next.handle();
}
const userId = (req as any).user?.sub ?? "anon";
const cacheKey = `idem:${userId}:${req.method}:${req.url}:${key}`;
const cached = await this.redis.get(cacheKey);
if (cached) {
const { status, body } = JSON.parse(cached);
ctx.switchToHttp().getResponse().status(status);
return of(body);
}
return next.handle().pipe(
tap(async (body) => {
const status = ctx.switchToHttp().getResponse().statusCode;
await this.redis.set(cacheKey, JSON.stringify({ status, body }), "EX", 24 * 3600);
}),
);
}
}
更稳的方案:入参指纹也参与 key,防止"相同 key 发了不同请求":
typescript
const fingerprint = sha256(JSON.stringify(req.body));
const cacheKey = `idem:${userId}:${req.method}:${req.url}:${key}:${fingerprint}`;
⚠️ 幂等 ≠ 重复请求合法 同一个 Idempotency-Key + 不同 body → 应该返回 422 而不是默默用旧结果。Stripe API 就是这样设计的。
💡 DB 层幂等:唯一约束 业务层加Idempotency-Key,数据库层也要兜底。orders.idempotency_key加唯一索引,即使中间件失效,DB 会拒绝重复插入。
7. 限流:Token Bucket vs Sliding Window
NestJS Throttler 默认是固定窗口,容易"边界突发"(每分钟限 60,在 0:59 和 1:00 各打 60 = 120/2s)。生产推荐 滑动窗口 或 token bucket。
typescript
// 简单滑动窗口(Redis ZSET)
async function checkRate(key: string, limit: number, windowMs: number): Promise<boolean> {
const now = Date.now();
const min = now - windowMs;
const multi = redis.multi();
multi.zremrangebyscore(key, 0, min);
multi.zadd(key, now, `${now}-${randomBytes(8).toString("hex")}`);
multi.zcard(key);
multi.pexpire(key, windowMs + 1000);
const [, , count] = await multi.exec();
return Number(count?.[1]) <= limit;
}
或用现成的 rate-limiter-flexible(支持多种算法、多维度、burst)。
7.1 多维度限流
不只是 IP:
- IP 维度:防爬虫
- 用户维度:防滥用(已登录用户)
- API key 维度:对外 API,按计划差别限速
- 资源维度:写 invoice 接口,限到 invoice 维度(防止同一发票被刷)
7.2 响应头(必加)
makefile
RateLimit-Limit: 60
RateLimit-Remaining: 23
RateLimit-Reset: 35
Retry-After: 35
遵循 IETF RFC 9331 草案。
💡
Retry-After让客户端能"礼貌重试",而不是傻 retry。SDK 实现重试时必须遵守它。
8. 缓存与条件请求(ETag / If-Modified-Since)
typescript
@Get(":id")
async findOne(@Param("id") id: string, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) {
const user = await this.users.findById(id);
if (!user) throw new NotFoundException();
const etag = `"${crypto.createHash("md5").update(JSON.stringify(user)).digest("hex")}"`;
res.header("ETag", etag);
if (req.headers["if-none-match"] === etag) {
res.status(304).send();
return;
}
return this.toDto(user);
}
💡 ETag 让前端轮询免费 CDN/浏览器拿着 ETag 重发,服务端命中返回 304(零 body),省去序列化和带宽。
Cache-Control:
- 私密数据 :
Cache-Control: private, no-cache - 可缓存 :
Cache-Control: public, max-age=60, stale-while-revalidate=600
9. 一致的响应风格
集合响应(分页):
json
{ "data": [...], "nextCursor": "..." }
单体响应:
json
{ "id": "...", "email": "..." }
写操作响应:
201 Created+ 资源 body +Location: /v1/users/:id204 No Content(删除、不需要 body 的)- 避免 200 +
{ success: true }(参考 02 章原理)
10. OpenAPI / Swagger
Zod schema 可以一键生成 OpenAPI:
bash
pnpm add zod-to-openapi @asteasolutions/zod-to-openapi
pnpm add @nestjs/swagger
typescript
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
extendZodWithOpenApi(z);
const CreateUserSchema = z.object({
email: z.string().email().openapi({ example: "alice@example.com" }),
name: z.string().min(1).openapi({ example: "Alice" }),
password: z.string().min(12),
});
在 main.ts:
typescript
const config = new DocumentBuilder()
.setTitle("My App API")
.setVersion("1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("docs", app, document);
💡 OpenAPI 不只是给人看 用它生成 client SDK(
openapi-typescript-codegen),前端拿到强类型 API 客户端,改后端 schema 重新生成即可,无需手抄类型。
⚠️ 生产环境不要默认开/docs。如果开,要么加鉴权,要么只在内网/staging 开。
11. CORS
只让信任的 origin 通过:
typescript
app.enableCors({
origin: (origin, cb) => {
const allowed = ["https://app.example.com", "https://staging.example.com"];
if (!origin || allowed.includes(origin)) return cb(null, true);
return cb(new Error("CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization", "Idempotency-Key", "X-CSRF-Token", "X-Request-ID"],
exposedHeaders: ["RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset", "X-Request-ID"],
maxAge: 600,
});
⚠️
origin: "*"+credentials: true是配置错误,浏览器会拒绝。生产 CORS 必须显式 allowlist。
12. 上传/下载
12.1 大文件:走预签名 URL,不走 API
不要让 NestJS 转发文件流(浪费带宽 + 阻塞 event loop):
typescript
@Post("upload-url")
async getUploadUrl(@Body() dto: { filename: string; contentType: string }) {
const key = `uploads/${randomUUID()}/${dto.filename}`;
const url = await this.s3.getSignedUrl("putObject", {
Bucket: BUCKET,
Key: key,
Expires: 300,
ContentType: dto.contentType,
});
return { url, key };
}
前端拿到 URL 直接 PUT 到 S3,完成后回调 API 注册资源。
12.2 下载:同理预签名
typescript
@Get(":id/download-url")
async getDownloadUrl(@Param("id") id: string) {
const file = await this.files.findById(id);
this.guard(file, currentUser);
const url = await this.s3.getSignedUrl("getObject", {
Bucket: BUCKET, Key: file.s3Key, Expires: 600,
});
return { url };
}
💡 签名 URL 是流量层最佳实践:不仅省后端带宽,还天然支持断点续传、并发分片、CDN 加速。
13. Webhook(对外发)
如果你提供 webhook 给第三方:
- 签名 :HMAC-SHA256,
X-Webhook-Signature: t=<timestamp>,v1=<hex> - 重放保护:timestamp 5 分钟内才接受
- 重试:指数退避,最少 7 次(覆盖 24h)
- 超时:5s
- 幂等 :每个事件带
event_id,接收方按 id 去重 - content-type :
application/json
typescript
function sign(payload: string, secret: string): string {
const ts = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac("sha256", secret).update(`${ts}.${payload}`).digest("hex");
return `t=${ts},v1=${sig}`;
}
接收方校验:
typescript
function verify(header: string, body: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const ts = Number(parts.t);
if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${ts}.${body}`).digest("hex");
return crypto.timingSafeEqual(Buffer.from(parts.v1, "hex"), Buffer.from(expected, "hex"));
}
⚠️ 签名比较一定要
timingSafeEqual,普通===有时间侧信道。
14. 经验总结:API 设计 10 条
- 给所有错误编
code并枚举,6 个月内不改名 - 分页只用游标,响应里给
nextCursor - 写操作要支持
Idempotency-Key - 所有日期 ISO 8601 + UTC + 字符串
- 钱用最小单位整数(分),不用 float
- 用 ULID/UUIDv7,不用自增 ID 对外
- 字段名
camelCase,项目内统一 - 删除用 DELETE 不用 POST/GET
- 输入 schema
.strict(),响应 schema 也校验 - 写好 OpenAPI,自动生成客户端
延伸阅读
- Microsoft REST API Guidelines
- Stripe API Reference --- 业界 API 设计标杆
- Zalando RESTful API Guidelines
- IETF RateLimit Header Fields for HTTP
06 - 异步与可靠性:BullMQ / 事件 / Outbox / Saga
目标:让"涉及外部副作用的逻辑"在故障下也不丢、不重、不乱序。涵盖 BullMQ 队列、领域事件、Transactional Outbox、补偿事务(Saga)、定时任务、长任务。
1. 为什么不同步做?
| 同步 | 异步 |
|---|---|
| 注册后即时发欢迎邮件 → 邮件服务挂 → 注册失败 | 入库后投递事件,worker 慢慢发 |
| 下单后调风控 → 风控慢 → 下单 p99 高 | 下单只入库,风控异步消费 |
| 上传后处理缩略图 → 上传卡死 | 上传完成投递任务,worker 处理 |
异步的代价:复杂度上升 (顺序、重试、死信、监控)。所以只有满足以下任一才异步:
- 外部调用(邮件、短信、第三方 API)
- 计算耗时 > 200ms
- 失败可重试且能延迟
- 多个下游消费(事件广播)
2. BullMQ 入门
2.1 模块
typescript
// src/infra/queue/queue.module.ts
import { BullModule } from "@nestjs/bullmq";
import { ConfigService } from "@nestjs/config";
@Module({
imports: [
BullModule.forRootAsync({
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({
connection: {
host: cfg.get("REDIS_HOST"),
port: cfg.get("REDIS_PORT"),
maxRetriesPerRequest: null, // 必须
enableReadyCheck: false, // 必须
},
defaultJobOptions: {
attempts: 5,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { age: 24 * 3600, count: 5000 },
removeOnFail: { age: 7 * 24 * 3600 },
},
}),
}),
BullModule.registerQueue({ name: "emails" }, { name: "billing" }),
],
exports: [BullModule],
})
export class QueueModule {}
⚠️
removeOnComplete必须设,否则 Redis 内存会被完成的 job 撑爆,几天内就 OOM。
2.2 生产者
typescript
@Injectable()
export class EmailService {
constructor(@InjectQueue("emails") private q: Queue) {}
async sendWelcome(userId: string) {
await this.q.add("welcome", { userId }, {
jobId: `welcome:${userId}`, // 用稳定 jobId 防重
});
}
}
2.3 消费者(Worker)
typescript
// apps/worker/src/processors/email.processor.ts
@Processor("emails", { concurrency: 10 })
export class EmailProcessor extends WorkerHost {
constructor(private mailer: Mailer, private users: UsersRepository) { super(); }
async process(job: Job): Promise<unknown> {
switch (job.name) {
case "welcome": return this.welcome(job.data.userId);
default: throw new Error(`Unknown job name: ${job.name}`);
}
}
private async welcome(userId: string) {
const user = await this.users.findById(userId);
if (!user) return; // 软失败,不重试
await this.mailer.send({
to: user.email,
template: "welcome",
data: { name: user.name },
});
}
@OnWorkerEvent("failed")
onFailed(job: Job, err: Error) {
this.logger.error({ jobId: job.id, attempts: job.attemptsMade, err });
}
}
💡
concurrency是单 worker 进程内并发数。横向扩展再加副本即可,总并发 = 副本数 × concurrency。给 DB 留余量(03 章连接池规划)。
2.4 Worker 独立部署
apps/worker/src/main.ts:
typescript
import { NestFactory } from "@nestjs/core";
import { WorkerAppModule } from "./worker-app.module";
async function bootstrap() {
const app = await NestFactory.createApplicationContext(WorkerAppModule, {
bufferLogs: true,
});
app.enableShutdownHooks();
// 不监听 HTTP 端口,但要响应 K8s probe → 启一个最小 HTTP server
const probe = http.createServer((_, res) => res.end("ok"));
probe.listen(8081);
await new Promise(() => {}); // keep alive
}
bootstrap();
worker-app.module.ts 只 import 处理器模块,不 import controllers。
💡 为什么 API 和 Worker 要分进程?
- Worker 可独立扩缩容(队列堆积时单独加 worker)。2) Worker 崩溃不影响 API。3) Worker 不需要 HTTP/CORS 等。4) 资源画像不同(API 多内存 Worker 多 CPU/IO)。
3. 任务设计的五条铁律
3.1 必须幂等
worker 在任何时刻可能"任务已完成但 ack 失败 → 被重投递"。
typescript
async function chargeOrder(orderId: string) {
// 错:直接扣
await stripe.charge({ amount, orderId });
// 对:幂等(用 Stripe 的 idempotency key)
await stripe.charge({ amount, orderId }, { idempotencyKey: `charge:${orderId}` });
// 对:DB 层幂等
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (order.status === "paid") return;
// ...
}
3.2 必须超时
typescript
@Processor("emails", { stalledInterval: 30_000 })
并在 process 内自己超时:
typescript
async process(job: Job) {
return Promise.race([
this.doWork(job),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 25_000)),
]);
}
⚠️ 没有超时 = 卡死 worker 槽位。一个慢任务能让整个队列堵住。
3.3 必须有死信策略
attempts 用完后:
- BullMQ 默认把 job 标记 failed,留在 failed 列表(removeOnFail 控制保留期)
- 必须有面板或告警监控 failed 数量
- 死信任务永远不要自动重新 enqueue,必须人工排查 + 修复 + 显式重投
3.4 必须可观测
每个 job 必带 trace:
typescript
await q.add("welcome", { userId }, {
// 把当前 trace 上下文传到 worker
jobId: ...,
data: { ..., __traceparent: getCurrentTraceparent() },
});
// worker
async process(job: Job) {
return tracer.startActiveSpan("worker.welcome", { attributes: { jobId: job.id } }, async (span) => {
// ...
});
}
09 章详讲 OpenTelemetry。
3.5 必须设资源上限
attempts: 5-10(看任务幂等性和重要性)concurrency: 跟 DB 池协调timeout: 比 SLO 紧 50%removeOnComplete/removeOnFail: 必设
4. 延迟任务 / 定时任务
4.1 延迟任务
typescript
await q.add("reminder", { id }, { delay: 24 * 3600 * 1000 });
4.2 定时任务(repeat)
typescript
await q.add("daily-report", {}, {
repeat: { pattern: "0 2 * * *", tz: "Asia/Shanghai" }, // 每天凌晨 2 点
jobId: "daily-report", // 必须固定 jobId,否则重启会重复
});
⚠️
repeatjob 重启时若不带稳定 jobId,会创建多个副本 。每次启动 worker 时调用q.removeRepeatable清理过期模式 + 重新注册。
4.3 替代:@nestjs/schedule
简单的本地 cron(只在单实例):
typescript
@Cron("0 2 * * *")
async dailyReport() {}
⚠️ 多实例下
@Cron会多次触发 。要么:1) 加分布式锁;2) 只让一个副本跑 cron(Leader Election);3) 用 BullMQ repeat(推荐)。
5. 领域事件(Domain Events)
业务模块解耦的关键。UserService.create 完成后:
- 发欢迎邮件(EmailModule)
- 给推荐人记积分(ReferralModule)
- 同步 CRM(IntegrationModule)
如果 UsersService 直接调它们 = 紧耦合,改一个模块要动 users。改用事件:
typescript
// 业务模块只关心发出"用户已创建"事件,不知道谁消费
await this.events.publish("user.created", { userId: user.id, tenantId });
5.1 NestJS EventEmitter(进程内)
bash
pnpm add @nestjs/event-emitter
typescript
EventEmitterModule.forRoot({ wildcard: true });
// 发布
constructor(private events: EventEmitter2) {}
await this.events.emitAsync("user.created", { userId });
// 订阅
@OnEvent("user.created")
async handleUserCreated(payload: { userId: string }) { ... }
⚠️ EventEmitter 是进程内的! 多实例时只触发本进程订阅者。适合 :进程内立刻做的轻动作。不适合:需要可靠性、跨进程、需重试 → 用 BullMQ。
5.2 进程内事件 → BullMQ 任务
混合模式(本教程推荐):
typescript
@OnEvent("user.created")
async onUserCreated(payload: { userId: string }) {
await Promise.all([
this.emailQueue.add("welcome", { userId: payload.userId }, { jobId: `welcome:${payload.userId}` }),
this.crmQueue.add("sync-user", { userId: payload.userId }, { jobId: `sync:${payload.userId}` }),
]);
}
业务代码 emit 一次,handler 负责"投到 BullMQ"。下游异步处理可靠。
⚠️ 下面这个时序问题极易踩:
typescriptawait this.prisma.$transaction(async (tx) => { const user = await tx.user.create({ ... }); await this.events.emit("user.created", { userId: user.id }); // 事件已发 if (someCheck()) throw new Error("rollback"); // 事务回滚但事件已发 });事务回滚了,事件却已经触发了! 邮件已经发出去了,但 user 在数据库里不存在。解决方案 → Outbox 模式。
6. Transactional Outbox
6.1 原理
把"事件发布"也当作数据库操作,与业务变更在同一事务内:
sql
┌─────────────────────────────────────┐
│ BEGIN TRANSACTION │
│ INSERT INTO users ... │
│ INSERT INTO outbox_events ... │
│ COMMIT │
└─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Outbox Worker (拉取未发布 → 投递 → │
│ 标记已发布) │
└─────────────────────────────────────┘
│
▼
BullMQ / Kafka / 外部
事务保证:业务变更入库 ↔ 事件入 outbox 同生同死。
6.2 Schema
prisma
model OutboxEvent {
id String @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
aggregateType String
aggregateId String @db.Uuid
eventName String
payload Json
occurredAt DateTime @default(now()) @db.Timestamptz(6)
publishedAt DateTime? @db.Timestamptz(6)
attempts Int @default(0)
lastError String?
@@index([publishedAt, occurredAt])
@@map("outbox_events")
}
6.3 写入
typescript
async createUser(dto: CreateUserInput) {
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({ data: { ...dto } });
await tx.outboxEvent.create({
data: {
aggregateType: "User",
aggregateId: user.id,
eventName: "user.created",
payload: { userId: user.id, tenantId: user.tenantId, email: user.email },
},
});
return user;
});
}
6.4 发布 worker
typescript
@Injectable()
export class OutboxPublisher {
constructor(
private prisma: PrismaService,
@InjectQueue("domain-events") private q: Queue,
) {}
@Cron("*/5 * * * * *") // 每 5 秒
async tick() {
const events = await this.prisma.outboxEvent.findMany({
where: { publishedAt: null, attempts: { lt: 10 } },
orderBy: { occurredAt: "asc" },
take: 100,
});
for (const e of events) {
try {
await this.q.add(e.eventName, e.payload, {
jobId: e.id, // 用 outbox id 做 jobId → BullMQ 自动去重
});
await this.prisma.outboxEvent.update({
where: { id: e.id },
data: { publishedAt: new Date() },
});
} catch (err: any) {
await this.prisma.outboxEvent.update({
where: { id: e.id },
data: { attempts: { increment: 1 }, lastError: err.message },
});
}
}
}
}
6.5 进阶:多实例的 outbox poller
@Cron 在多副本下会重复执行。三种解法:
- 分布式锁:每次 tick 先抢锁,只有持锁者执行
- Advisory lock :
SELECT pg_try_advisory_lock(...),Postgres 原生 - LISTEN/NOTIFY:Postgres 写 outbox 时发通知,worker LISTEN 拿到 → 立刻处理(比轮询低延迟)
最稳的生产方案:Debezium / Conduit 通过 WAL 解析 outbox 表变更,直接推到 Kafka/BullMQ。延迟 < 1s,无轮询开销。中小项目用轮询足够。
💡 Outbox 保证 at-least-once 下游(消费 BullMQ 的 worker)必须幂等。这一点已经在 3.1 强调过 ------ 整条链上"任何一处可能重复",所以所有消费者必须幂等。
7. Saga(长事务 / 补偿)
跨多个服务/资源的事务,例如:
- 扣库存 → 2. 扣余额 → 3. 创建订单 任何一步失败,前面的成功步骤要补偿(回滚)。
7.1 编排式(Orchestration)
一个 Saga 编排者管控所有步骤:
typescript
@Injectable()
export class OrderSaga {
async place(input: PlaceOrderInput) {
const compensations: Array<() => Promise<void>> = [];
try {
const reservation = await this.inventory.reserve(input);
compensations.unshift(() => this.inventory.release(reservation.id));
const charge = await this.billing.charge(input);
compensations.unshift(() => this.billing.refund(charge.id));
const order = await this.orders.create({ ...input, reservation, charge });
return order;
} catch (err) {
for (const undo of compensations) {
await undo().catch(e => this.logger.error("Compensation failed", e));
}
throw err;
}
}
}
⚠️ 补偿不是回滚 你已经发了邮件,你只能"再发一封'忽略上一封'"。补偿动作必须设计成可表达且幂等。
7.2 异步编排(用队列)
复杂 Saga 用状态机 + 队列:
- 把每步当作一个 job:
reserve→charge→confirm - 每个 job 完成投下一个 job,失败投补偿 job
- Saga 实例存表,有
currentStep和history
库:@nestjs-cqrs/saga、Temporal(强推),或自己写。
💡 Temporal 是 Saga 的金弹 它把"长事务"作为一等公民:你写普通 TS 函数,Temporal 在每个 await 处持久化状态;crash 重启后从断点恢复。学习成本一周,值。
8. 限流式消费 / 背压
外部 API 限速 100 req/s,但你队列里堆了 10 万任务怎么办?
typescript
@Processor("crm-sync", {
concurrency: 5,
limiter: { max: 100, duration: 1000 }, // 每秒最多 100 个
})
如果是延迟敏感的,可加优先级:
typescript
await q.add("sync", data, { priority: 1 }); // 数字越小越先
💡 背压(backpressure):让上游"感受到压力"而不是无脑塞队列。如果队列堆积超过阈值,生产者应该减缓投递(throttle、丢非关键任务、报警)。
9. 流式处理 / 大批量任务
9.1 任务拆分(scatter-gather)
发 10 万封邮件不要一个 job:
typescript
// scatter
async sendBulk(userIds: string[]) {
const batches = chunk(userIds, 100);
await Promise.all(batches.map((b, i) =>
this.q.add("send-batch", { userIds: b, batchIndex: i }),
));
}
// process
async process(job: Job<{ userIds: string[] }>) {
for (const id of job.data.userIds) {
await this.send(id);
}
}
BullMQ Flows 可以表达父子任务依赖,大批量场景天然适合。
9.2 用 stream 处理大数据
导出 100 万行 CSV:绝对不要先 findMany 全拉到内存 。用 Prisma findMany 分页 + 写入 stream:
typescript
async exportAll(stream: Writable) {
let cursor: string | undefined;
while (true) {
const rows = await this.prisma.invoice.findMany({
take: 1000, orderBy: { id: "desc" },
...(cursor && { cursor: { id: cursor }, skip: 1 }),
});
if (!rows.length) break;
for (const r of rows) stream.write(toCsvLine(r));
cursor = rows[rows.length - 1].id;
}
stream.end();
}
10. 监控:必须可视化的指标
| 指标 | 警戒线 |
|---|---|
| 队列 waiting 长度 | 持续 > 1000 报警 |
| job 平均处理时间 | 超 SLO 1.5x 报警 |
| failed job 数 | 滚动 5 分钟 > 0 报警 |
| stalled job 数 | > 0 即报警 |
| worker 在线数 | < 期望值 报警 |
bullmq 提供 metric API,接 Prometheus(09 章)。可视化用 Bull Board 或 Taskforce。
typescript
const board = createBullBoard({
queues: [new BullMQAdapter(emailsQueue), new BullMQAdapter(billingQueue)],
});
app.use("/admin/queues", board.getRouter()); // 加管理员鉴权
⚠️ Bull Board 必须加鉴权,默认开放 = 任何人都能查看/重试/删除你的任务。
11. 灾难恢复
11.1 队列丢了怎么办?
Redis 数据丢失 = 所有 in-flight 任务丢失。两个保护:
- Outbox 是事实之源:重要事件先入 DB(outbox),再投队列。Redis 没了 → 重新跑 outbox poller 即可恢复
- Redis 持久化 :开启 AOF(
appendonly yes)+ RDB,生产用托管 Redis(自带备份)
11.2 Worker 跑慢了怎么办?
ruby
检查清单:
□ 是不是某个慢任务(单个 job duration 飙高)?
□ 是不是 DB / 第三方 API 慢?(看下游延迟)
□ 是不是 worker 数不够?(临时扩容)
□ 是不是 concurrency 配太低?
□ 是不是 OOM / GC 频繁?(看 Node metrics)
应急手段:先扩 worker 副本数,临时给该队列优先级,清退非关键任务。事后复盘。
12. 速记卡
| 想做 | 用 |
|---|---|
| 进程内立刻处理的副作用 | EventEmitter |
| 可靠的异步副作用 | Outbox + BullMQ |
| 定时任务(多副本安全) | BullMQ repeat |
| 单实例 cron | @nestjs/schedule + 锁 |
| 长事务、跨服务 | Saga(简单)/ Temporal(复杂) |
| 限速消费 | BullMQ limiter |
| 大批量 | Flow / scatter-gather |
| 大数据导出 | stream + cursor 分页 |
延伸阅读
- BullMQ 官方文档
- Microservices.io: Saga Pattern / Transactional Outbox
- Temporal ------ 工业级 workflow 引擎
- Martin Kleppmann, Designing Data-Intensive Applications, Chapter 11(stream processing)