NestJS 教程 Part 2 — 数据层、API 设计与业务异步

本册涵盖: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
  • 写关键路径 (余额、库存、唯一性约束 + 检查):用 SerializableSELECT ... 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) ?? [] }));

💡 include vs 手动 join 怎么选? 简单一对多用 include大表 + 多关联 时 Prisma 生成的 JOIN/IN 可能很糟,手动两次查询 + 内存 join 反而快(Postgres 一个 WHERE id IN (...) 走主键索引飞快)。用 EXPLAIN ANALYZE 验证。
⚠️ relationJoins preview 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_local true = 仅当前事务有效(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: nullenableReadyCheck: 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 排序:

  1. 加索引(B-tree + 复合)→ 10-1000 倍
  2. 消除 N+1 → 3-100 倍
  3. 缓存热点查询 → 5-50 倍
  4. SELECT 列收敛(只查需要的字段)→ 1.2-3 倍
  5. 批量操作 (createMany / updateMany)→ 5-20 倍
  6. 读写分离 → 写扩 + 读 2-5 倍
  7. 分区表(时间分区、租户分区) → 大数据集 10 倍
  8. 物化视图 + 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:依赖都通,可以接流量

延伸阅读


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)、rolesjtiiatexptyp

💡 为什么 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:旋转 + 复用检测

核心规则:

  1. Refresh token 是一次性的:用一次立刻作废,签发新的
  2. 检测复用:如果一个已失效的 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);
  }
}

如果只服务 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);
}

登录流程二阶段:

  1. 密码正确 → 返回 mfaRequired: true,签发短期 MFA 临时 token
  2. 用户提交 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


延伸阅读


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/:id with {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_TAKENORDER_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/:id
  • 204 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 条

  1. 给所有错误编 code 并枚举,6 个月内不改名
  2. 分页只用游标,响应里给 nextCursor
  3. 写操作要支持 Idempotency-Key
  4. 所有日期 ISO 8601 + UTC + 字符串
  5. 钱用最小单位整数(分),不用 float
  6. 用 ULID/UUIDv7,不用自增 ID 对外
  7. 字段名 camelCase,项目内统一
  8. 删除用 DELETE 不用 POST/GET
  9. 输入 schema .strict(),响应 schema 也校验
  10. 写好 OpenAPI,自动生成客户端

延伸阅读


06 - 异步与可靠性:BullMQ / 事件 / Outbox / Saga

目标:让"涉及外部副作用的逻辑"在故障下也不丢、不重、不乱序。涵盖 BullMQ 队列、领域事件、Transactional Outbox、补偿事务(Saga)、定时任务、长任务。

1. 为什么不同步做?

同步 异步
注册后即时发欢迎邮件 → 邮件服务挂 → 注册失败 入库后投递事件,worker 慢慢发
下单后调风控 → 风控慢 → 下单 p99 高 下单只入库,风控异步消费
上传后处理缩略图 → 上传卡死 上传完成投递任务,worker 处理

异步的代价:复杂度上升 (顺序、重试、死信、监控)。所以只有满足以下任一才异步:

  1. 外部调用(邮件、短信、第三方 API)
  2. 计算耗时 > 200ms
  3. 失败可重试且能延迟
  4. 多个下游消费(事件广播)

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 要分进程?

  1. 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,否则重启会重复
});

⚠️ repeat job 重启时若不带稳定 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"。下游异步处理可靠。

⚠️ 下面这个时序问题极易踩:

typescript 复制代码
await 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(长事务 / 补偿)

跨多个服务/资源的事务,例如:

  1. 扣库存 → 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:reservechargeconfirm
  • 每个 job 完成投下一个 job,失败投补偿 job
  • Saga 实例存表,有 currentStephistory

库:@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 BoardTaskforce

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 任务丢失。两个保护:

  1. Outbox 是事实之源:重要事件先入 DB(outbox),再投队列。Redis 没了 → 重新跑 outbox poller 即可恢复
  2. 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 分页

延伸阅读


相关推荐
渐儿1 小时前
Next.js 教程 Part 2 — 数据获取、Server Actions 与状态
前端
用户125758524361 小时前
XYGo Admin ArtTable 表格组件:一行代码搞定加载、刷新与分页
前端
gogoing1 小时前
Prettier 配置说明
前端·javascript
十有八七1 小时前
Hermes Agent 自进化实现:从源码到架构的深度拆解
前端·人工智能
渐儿1 小时前
NestJS 生产级开发教程
前端
前端毕业班1 小时前
uni-app onShareAppMessage hook 原理分析
前端·javascript
gogoing1 小时前
React 分包加载优化
前端·react.js
gogoing1 小时前
Babel 配置与工具
前端·javascript
亲亲小宝宝鸭1 小时前
重新install,项目就跑不起来了?!
前端·npm