NestJS 教程 Part 4 — 高级进阶

5 阅读25分钟

本册涵盖:A1 DDD 实战 · A2 性能调优诊断 · A3 经典事故 10 例 · A4 架构演进 · A5 软实力


A1 - 复杂业务建模(DDD 实战)

A1.1 什么时候需要 DDD

不是所有系统都该 DDD。判断标准:

系统类型推荐方法
CRUD 后台(用户、商品、订单)直接 Service + Repository,不要 DDD
复杂业务规则(计费、对账、保险、调度)战术 DDD(聚合根、值对象、领域事件)
多团队、多子域(电商 + 物流 + 客服 + 财务)战略 DDD(限界上下文、防腐层、Context Map)
探索期 MVP跳过 DDD,跑通先

💡 DDD 不是银弹 Eric Evans 的书里 60% 是战略 DDD(组织、上下文、语言),40% 是战术(代码模式)。大部分团队只学了战术,跳过战略 → 写出一堆 Entity / ValueObject 但业务边界仍然乱。先理顺边界,再谈代码。

A1.2 战略 DDD:限界上下文与 Context Map

以 SaaS 计费系统为例,识别 4 个限界上下文:

┌──────────────────┐      ┌──────────────────┐
│  Pricing(定价) │◄────│  Catalog(产品)  │
│  - Plan          │      │  - Product       │
│  - Discount      │      │  - SKU           │
└────────┬─────────┘      └──────────────────┘
         │ ACL(防腐层)
         ▼
┌──────────────────┐      ┌──────────────────┐
│  Billing(计费) │─────►│  Accounting(对账)│
│  - Invoice       │      │  - Ledger        │
│  - Charge        │      │  - JournalEntry  │
└──────────────────┘      └──────────────────┘

关键决策:

  • 每个上下文有自己的模型 + 自己的术语。同一个"用户",Pricing 里关心 plan_id,Accounting 里关心 tax_id —— 不强求统一
  • 上下文之间用 ACL(防腐层) 翻译。Pricing 不会让自己的 Plan 被 Catalog 的字段污染
  • Context Map 上画出关系:Customer-Supplier(谁定义协议)、Conformist(单向跟随)、Partnership(双向协商)

A1.3 战术 DDD:聚合根

聚合:一组对象的边界,对外只暴露根,事务一次只改一个聚合

// src/modules/billing/domain/invoice.aggregate.ts
import { randomUUID } from "crypto";

export class InvoiceLine {
  constructor(
    public readonly id: string,
    public readonly productSku: string,
    public readonly quantity: number,
    public readonly unitPriceCents: number,
  ) {
    if (quantity <= 0) throw new InvalidQuantityError();
    if (unitPriceCents < 0) throw new InvalidPriceError();
  }

  get subtotalCents(): number {
    return this.quantity * this.unitPriceCents;
  }
}

export class Invoice {
  private constructor(
    public readonly id: string,
    public readonly tenantId: string,
    public readonly customerId: string,
    private _status: InvoiceStatus,
    private _lines: InvoiceLine[],
    private _events: DomainEvent[] = [],
  ) {}

  // 工厂方法,创建合法状态
  static create(tenantId: string, customerId: string): Invoice {
    const inv = new Invoice(randomUUID(), tenantId, customerId, "DRAFT", []);
    inv._events.push(new InvoiceCreated(inv.id, tenantId, customerId));
    return inv;
  }

  // 行为方法:封装规则,不允许外部直接改 _lines
  addLine(productSku: string, quantity: number, unitPriceCents: number): void {
    if (this._status !== "DRAFT") throw new InvoiceLockedError(this.id);
    if (this._lines.length >= 100) throw new TooManyLinesError();
    const line = new InvoiceLine(randomUUID(), productSku, quantity, unitPriceCents);
    this._lines.push(line);
    this._events.push(new InvoiceLineAdded(this.id, line.id, line.subtotalCents));
  }

  issue(): void {
    if (this._status !== "DRAFT") throw new InvalidStateTransition(this._status, "ISSUED");
    if (this._lines.length === 0) throw new EmptyInvoiceError();
    this._status = "ISSUED";
    this._events.push(new InvoiceIssued(this.id, this.totalCents));
  }

  pay(amountCents: number): void {
    if (this._status !== "ISSUED") throw new InvalidStateTransition(this._status, "PAID");
    if (amountCents !== this.totalCents) throw new AmountMismatchError();
    this._status = "PAID";
    this._events.push(new InvoicePaid(this.id, amountCents));
  }

  get totalCents(): number {
    return this._lines.reduce((sum, l) => sum + l.subtotalCents, 0);
  }

  get status(): InvoiceStatus { return this._status; }
  get lines(): readonly InvoiceLine[] { return this._lines; }

  // 取出领域事件并清空(由 Application Service 在事务后发布)
  pullEvents(): DomainEvent[] {
    const events = this._events;
    this._events = [];
    return events;
  }
}

要点:

  • 不变量在构造器/方法里强制(InvoiceLine 构造时验数量、价格)
  • 状态机用方法 + 异常表达(不允许 ISSUED → DRAFT)
  • 不可变接口 readonly 防止外部偷改集合
  • 领域事件累积在聚合内,Application Service 在事务提交后统一发布到 outbox(参考第 6 章)

💡 聚合根的根本约束 "一次事务只改一个聚合实例"。如果你的用例必须改两个聚合 → 90% 的情况是聚合边界划错了,合并;10% 的情况是真有跨聚合事务,用 Saga / 领域事件最终一致

⚠️ 常见错误:贫血模型 + 万能 Service

// ❌ 贫血:Invoice 只是数据袋
class Invoice { id; status; lines; total; }
class InvoiceService {
  addLine(invoice, ...) { /* 业务规则全在这 */ }
}

业务规则散落到 Service → Service 越变越大,聚合等于没用。规则属于聚合,Service 只编排。

A1.4 值对象(Value Object)

没有 ID、用值比较相等、不可变。把"3 行代码就能写错"的概念封装起来。

// Money 值对象
export class Money {
  private constructor(public readonly cents: number, public readonly currency: Currency) {
    if (!Number.isInteger(cents)) throw new Error("cents must be integer");
  }

  static of(cents: number, currency: Currency) { return new Money(cents, currency); }
  static zero(currency: Currency) { return new Money(0, currency); }

  add(other: Money): Money {
    this.requireSameCurrency(other);
    return new Money(this.cents + other.cents, this.currency);
  }
  multiply(factor: number): Money {
    return new Money(Math.round(this.cents * factor), this.currency);
  }
  equals(other: Money) {
    return this.cents === other.cents && this.currency === other.currency;
  }

  private requireSameCurrency(other: Money) {
    if (this.currency !== other.currency) {
      throw new CurrencyMismatchError(this.currency, other.currency);
    }
  }
}

💡 为什么值得 之前到处是 number cents + Currency 配对,很容易把 USD 加到 CNY 上不报错。换成 Money 后,单测都不用写,类型系统 + 构造器把这类 bug 灭了。

⚠️ 不要把所有 string 都包成 VO(UserIdOrderId 包了有用,UserName 包了多半是噪声)。判断标准:有没有规则需要强制?有 → 包;没有 → 别

A1.5 领域服务(Domain Service)

当一个行为不自然属于任何一个聚合时,放到 Domain Service。注意:不是 Application Service

// 转账涉及两个 Account 聚合 → 不属于单个 Account
export class TransferService {
  transfer(from: Account, to: Account, amount: Money): { events: DomainEvent[] } {
    if (from.balance.lessThan(amount)) throw new InsufficientFundsError();
    from.debit(amount);
    to.credit(amount);
    return {
      events: [
        new MoneyTransferred(from.id, to.id, amount),
      ],
    };
  }
}

💡 领域服务 vs 应用服务

  • 领域服务:纯业务规则,不接触框架/IO,可单测无 mock
  • 应用服务(NestJS Service):编排用例,事务边界、调聚合、调仓储、发布事件

A1.6 与 Prisma 的取舍

教科书 DDD 要求 Repository 接口和实现分离、聚合通过 Repository 重建。Prisma 自动生成的类型很方便,但不符合 DDD 纯洁性

实用主义方案(本教程推荐):

  • 聚合是 plain class,带 Prisma decorator
  • Repository 负责"Prisma row ↔ 聚合"映射(简单时一个 mapper 函数即可)
  • 不为了"换 ORM"做完整接口抽象 —— 5 年内不会换
// src/modules/billing/infrastructure/invoice.repository.ts
@Injectable()
export class InvoiceRepository {
  constructor(private prisma: PrismaService) {}

  async findById(id: string): Promise<Invoice | null> {
    const row = await this.prisma.invoice.findUnique({
      where: { id },
      include: { lines: true },
    });
    return row ? this.toAggregate(row) : null;
  }

  async save(invoice: Invoice): Promise<void> {
    // 简化:全量 upsert;复杂时用 unit-of-work 跟踪变更
    await this.prisma.invoice.upsert({
      where: { id: invoice.id },
      update: { status: invoice.status, /* ... */ },
      create: { /* ... */ },
    });
  }

  private toAggregate(row: PrismaInvoice & { lines: PrismaLine[] }): Invoice {
    return Invoice.rehydrate(/* 重建,不走 create */);
  }
}

聚合加一个 rehydrate 静态方法,用于从持久化恢复时跳过业务规则(数据库里的状态已经合法)。

A1.7 CQRS 何时引入

Command Query Responsibility Segregation:写模型用聚合,读模型用直接查询/投影。

何时引入何时不要
读写比 10:1+读写比接近 1:1
复杂报表 / 多种 viewCRUD 简单页面
已经在用领域事件 + Outbox还没用领域事件
团队接受最终一致性业务要求强一致

简单 CQRS(不引入事件溯源):

// Command 侧:走聚合
await this.invoiceRepo.save(invoice);
await this.eventBus.publishAll(invoice.pullEvents());

// Query 侧:直接 SQL / Prisma raw,可绕过聚合
@Get(":id/summary")
async getSummary(id: string) {
  return this.prisma.$queryRaw`
    SELECT i.id, i.total, COUNT(l.id) as line_count, ...
    FROM invoices i LEFT JOIN invoice_lines l ON ...
    WHERE i.id = ${id}
  `;
}

⚠️ 不要一上来就 Event Sourcing 事件溯源(把所有变更存为事件流,从事件重建状态)能力强但代价大:事件 schema 演进、快照、读模型重建工具链、调试复杂度全部翻倍。绝大多数项目用"普通持久化 + Outbox"即可。

A1.8 状态机:用类型而非字符串

// ❌ 字符串状态
class Order {
  status: string;  // "draft" | "confirmed" | "shipped" | "delivered" | "cancelled" | "refunded"
  ship() {
    if (this.status !== "confirmed") throw new Error("wrong state");
    this.status = "shipped";
  }
}

// ✅ 类型化状态机(typestate pattern)
type DraftOrder = { kind: "draft"; items: Item[] };
type ConfirmedOrder = { kind: "confirmed"; items: Item[]; confirmedAt: Date };
type ShippedOrder = { kind: "shipped"; items: Item[]; trackingNo: string };

class Order {
  static confirm(o: DraftOrder): ConfirmedOrder { /* ... */ }
  static ship(o: ConfirmedOrder, trackingNo: string): ShippedOrder { /* ... */ }
  // ship(DraftOrder) 在编译期就被拒绝
}

或用专门的 state-machine 库(XState,前后端通用),可视化所有转移。

💡 可视化状态机的价值 XState inspector 能画出真实的状态图。你和 PM/QA 对着同一张图谈,减少 50% 的"这种情况怎么办"的讨论


A2 - 性能调优诊断手艺

不是"我应该用什么 metric",而是"从一个慢请求,一路定位到那行 SQL / 那次锁 / 那次 GC"。

A2.1 排查心法

┌─────────────────────────────────────────────────┐
│ 1. 量化:慢成什么样?p50/p95/p99?偶发还是持续? │
├─────────────────────────────────────────────────┤
│ 2. 定位层级:网络/应用/DB/外部?(分层排除)    │
├─────────────────────────────────────────────────┤
│ 3. 找原因:flamegraph / EXPLAIN / 锁等待          │
├─────────────────────────────────────────────────┤
│ 4. 验证假设:改一处看一处,不要"all in one"     │
├─────────────────────────────────────────────────┤
│ 5. 验证修复:压测 + 灰度,而不是"应该好了"      │
└─────────────────────────────────────────────────┘

💡 永远先量化再优化 "感觉慢" → 没数据没修。先看 metric / trace 拿到具体数字,再动手。否则你优化了 10ms 但用户感受的 800ms 没动。

A2.2 慢请求的标准排查流程

Step 1:看 trace(09 章已配 OpenTelemetry)

打开 Tempo/Jaeger,找一个慢 trace。看每个 span 耗时:

GET /invoices/123              980ms ◄ 总耗时
├─ JwtAuthGuard                  3ms
├─ InvoicesController.findOne  975ms
│  ├─ prisma.invoice.findUnique 950ms ◄ 罪魁
│  └─ map to DTO                10ms

950ms 落在 Prisma 那一刻,知道问题在 DB 层

Step 2:看慢查询日志

03 章配过 Prisma 慢查询日志(>100ms 警告)。查日志:

{ "msg":"slow_query", "duration_ms": 947, "query":"SELECT ... FROM invoices WHERE ..." }

Step 3:EXPLAIN ANALYZE

把那条 SQL 在 psql 里跑 EXPLAIN (ANALYZE, BUFFERS):

Seq Scan on invoices  (cost=0.00..18450.00 rows=1 width=...) (actual time=947.123..947.124 rows=1 loops=1)
  Filter: (id = '...'::uuid)
  Rows Removed by Filter: 999999
  Buffers: shared read=18450
Planning Time: 0.123 ms
Execution Time: 947.5 ms

Seq Scan + Rows Removed by Filter: 999999 = 全表扫描 100 万行只为找 1 行 = 缺索引

加索引:

CREATE INDEX CONCURRENTLY idx_invoices_id ON invoices(id);

重跑 EXPLAIN,变 Index Scan ... actual time=0.123..0.124 = 完事。

A2.3 N+1 的诊断

线索:一个请求里 trace 显示 100+ 个 DB span。

// ❌ N+1
const users = await prisma.user.findMany({ take: 100 });
for (const u of users) {
  u.posts = await prisma.post.findMany({ where: { userId: u.id } });
}
// 101 个 query

诊断工具:在 dev 环境开 Prisma 的 query log,数 query 数。设个上限 alert(03 章给过示例)。

修法:include 或两步批查(03 章详述)。

A2.4 锁等待:pg_stat_activity

线索:某些请求偶发卡 5 秒以上,trace 显示在 BEGINCOMMIT 等待。

排查:

SELECT pid, usename, application_name, state, wait_event_type, wait_event, query, age(now(), query_start) as duration
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;

wait_event_type = Lock 的 session:谁在等谁?

SELECT blocked_locks.pid AS blocked_pid, blocking_locks.pid AS blocking_pid,
       blocked_activity.query AS blocked_query, blocking_activity.query AS blocking_query
FROM pg_locks blocked_locks
JOIN pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_locks blocking_locks ON blocking_locks.locktype = blocked_locks.locktype
  AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
  AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
  AND blocking_locks.granted
JOIN pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

常见根因:

  • 长事务持锁(教训:事务里别做 HTTP,见 03 章)
  • 全表 UPDATE 没条件
  • 迁移 + 业务并发(教训:大表加索引用 CONCURRENTLY)

A2.5 内存泄漏定位:heap dump

线索:Pod 内存缓慢上涨,几小时后被 OOMKill。

收集 heap snapshot:

// 临时端点(只内网开)
import * as v8 from "v8";
@Get("__debug__/heapdump")
heapdump(@Res() res: FastifyReply) {
  const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
  v8.writeHeapSnapshot(filename);
  res.sendFile(filename);
}

或用 SIGUSR2 触发:

process.on("SIGUSR2", () => v8.writeHeapSnapshot(`/tmp/heap-${Date.now()}.heapsnapshot`));

kubectl exec 进 pod kill -SIGUSR2 1,然后 kubectl cp 拷出来。

分析:Chrome DevTools → Memory → Load。看:

  1. Comparison view:对比两个时间点的快照,看哪个对象一直在涨
  2. Containment / Retainers:对象被谁 hold 着不让 GC

常见根因:

  • 闭包持有大对象(每次请求 new 一个 cache,key 是 user id,从不清理)
  • listener 没 off(EventEmitter 反复 .on().off())
  • timer 没清(setIntervalclearInterval)
  • Prisma client 多实例(每次 new 一个 → 连接池泄漏)

A2.6 CPU 飙高:flamegraph

线索:Pod CPU 长期 90%+,延迟变高。

生成 flamegraph:

# 在 Pod 里(需要 perf 或 0x)
npm i -g 0x
0x -- node dist/main.js
# 跑一段时间后 Ctrl+C,生成 HTML 火焰图

或用 clinic.js flame

读图:横轴是采样占比,越宽越耗 CPU。从顶部往下看哪个函数横跨最宽 = 主要消耗点。

常见根因:

  • JSON.stringify 巨型对象(改流式或限制字段)
  • 同步加密(crypto.pbkdf2Sync → 换 async 版,或 argon2 用 worker)
  • 正则灾难性回溯(ReDoS)
  • 递归 / 大循环(算法本身有问题)

A2.7 Event loop 阻塞

线索:event_loop_lag metric 飙高,所有请求一起慢。

import { monitorEventLoopDelay } from "perf_hooks";
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

setInterval(() => {
  const p99 = h.percentile(99) / 1e6; // ms
  if (p99 > 100) logger.warn({ msg: "event_loop_lag_high", p99_ms: p99 });
  h.reset();
}, 5000);

阻塞来源:

  • 同步 IO(fs.readFileSynccrypto.*Sync)
  • 巨型 JSON 解析
  • for 循环 O(n²)
  • 同步压缩 / 加解密

修法:

  • async_hooks.AsyncResource + Worker Thread 把计算放后台
  • 或直接 BullMQ worker 异步

A2.8 数据库连接耗尽

线索:Timed out fetching a new connection from the connection pool,或 Postgres 报 too many clients already

排查:

SELECT count(*), state FROM pg_stat_activity WHERE datname = 'app' GROUP BY state;

常见根因:

  • 长事务没结束(代码 bug,事务里挂网络请求)
  • 连接池 size 不合理(03 章公式没算对)
  • 没用连接池(自己 new PrismaClient)
  • PgBouncer 模式错配(transaction mode 但用了 prepared statement)

A2.9 第三方调用慢

线索:trace 显示外部 HTTP span 偶发 30 秒。

立刻做:

  • 加超时(没超时 = 等到死)
  • 重试 + 指数退避(只对幂等 GET)
  • 熔断器(opossum)
  • 改异步队列(可异步的不要同步等)
import CircuitBreaker from "opossum";

const breaker = new CircuitBreaker(
  (url: string) => fetch(url, { signal: AbortSignal.timeout(3000) }),
  { errorThresholdPercentage: 50, resetTimeout: 30_000, timeout: 5000 },
);
breaker.fallback(() => ({ degraded: true }));

await breaker.fire("https://api.example.com/...");

A2.10 压测:k6 标准模板

修复后必须验证。没压过 = 没修过

// k6/load.js
import http from "k6/http";
import { check, sleep } from "k6";

export const options = {
  scenarios: {
    constant: {
      executor: "constant-arrival-rate",
      rate: 100,            // 每秒 100 个请求
      timeUnit: "1s",
      duration: "5m",
      preAllocatedVUs: 50,
      maxVUs: 200,
    },
  },
  thresholds: {
    http_req_duration: ["p(95)<500", "p(99)<1000"],
    http_req_failed: ["rate<0.01"],
  },
};

export default function () {
  const res = http.get("https://staging.example.com/invoices/123", {
    headers: { authorization: `Bearer ${__ENV.TOKEN}` },
  });
  check(res, { "status 200": (r) => r.status === 200 });
  sleep(1);
}
TOKEN=xxx k6 run k6/load.js

永远先在 staging 压,不要在 prod。

A2.11 性能问题诊断速查卡

现象第一刀后续
单接口慢Trace 看分布DB 查询 / 外部调用 / 序列化
整站慢event loop lag / CPU / 连接池flamegraph / heap dump
偶发卡顿GC 频率 / 长事务 / 锁gc trace / pg_stat
内存涨Heap snapshot 对比retainer 分析
上线后退化看 release 时间点,对比 metricgit diff,先回滚再排查
第三方变慢加超时 + 熔断改异步 / 换供应商

A3 - 经典事故 10 例

每例都是真实形态(去敏化)。学别人的事故比读再多书更快。

案例 1:连接池打爆 → 全站不可用

现象:某天下午 4 点,所有 API 5xx 飙升,持续 20 分钟,自行恢复。

排查:

  • 日志:Timed out fetching a new connection from the connection pool 满屏
  • DB:pg_stat_activity 显示 100 个连接全占,大部分在 idle in transaction
  • 代码 review:发现新上线的一个端点在事务里调 axios.post(thirdParty),第三方那时挂了 30 秒

根因:事务里做 HTTP 调用 → 连接被一直占 → 池耗尽。

修复:

  1. 立即把那段 HTTP 调用移出事务(改 Outbox)
  2. 数据库连接加 statement_timeout = 30s,DB 层兜底
  3. axios 默认超时改 5 秒

预防:加 ESLint 自定义规则禁止在 $transaction 回调里 import 任何 HTTP 客户端;CR 必查。

教训:事务里只做 DB,外部副作用走 Outbox。这是 03/06 章反复强调的,但生产仍然会出。把它做成 lint 规则。

案例 2:缓存雪崩 → DB 被打爆

现象:活动开始的整点,DB CPU 100% 持续 2 分钟,服务卡死。

根因:1000 个热门商品的缓存设了同一个 TTL = 3600s,同时过期,瞬间 1000 个请求同时 miss → 全部回源 DB。

修复:

  • TTL 加随机抖动:ttl + random(0, ttl * 0.1)
  • 加单飞(SETNX),同一 key 同时只一个回源
  • 预热脚本:活动前主动刷一次缓存

预防:所有缓存 SET 必须经过封装,封装层强制抖动 + 单飞。

教训:03 章讲过,但生产代码经常直接调 redis.set(k, v, "EX", ttl)封装一层 cacheService.set(),堵死直调

案例 3:消息重复消费 → 用户收到 5 条短信

现象:用户投诉同一条注册成功短信收到 5 次。

根因:BullMQ 消费者处理完后挂了一下没及时 ack,消息被认为失败重投。worker 代码没幂等。

修复:

  • worker 内查 notifications_sent 表,以 userId + template 为唯一键,已存在则跳过
  • 第三方短信平台用 idempotency key

预防:所有 worker 默认幂等。CR 检查表 / 队列任务的"重复运行"行为。

教训:06 章反复强调"消息至少一次,消费者必须幂等"。每写一个 processor 都问:这个跑两次会怎样?

案例 4:JWT secret 太短 → 被暴破

现象:安全扫描报告 JWT 可被离线暴破(48 小时内可破)。

根因:JWT_SECRET=secret,长度 6,熵不足。

修复:

  • 改用 EdDSA / RS256 非对称(04 章)
  • 短期补救:升 secret 长度 ≥ 32,旋转后强制所有用户重登

预防:01 章的 Zod env 校验:z.string().min(32)配置错误启动就 fail,不让坏配置上生产。

案例 5:时区 bug → 报表数字差一天

现象:每月初的"月度报表"数据与财务对账差一天。

根因:

  • 数据库 timestamptz 存 UTC,但代码里 new Date().toISOString().split('T')[0] 当作"本地日期"用 → 北京时间凌晨 0-8 点的事件被算到前一天
  • 部分老代码用 timestamp(无时区)而非 timestamptz

修复:

  • 业务"日期"显式按业务时区计算,用 Intl.DateTimeFormat 或 dayjs-timezone
  • DB 字段统一 timestamptz,新加迁移把老字段转换
  • Report 端点接受 tz 参数,永远不依赖服务器本地时区

预防:Lint 规则禁止 new Date().toISOString().split 这类拼接;PR 模板必填"时区影响"。

教训:00 章不可逆决策"时间存 timestamptz" + 报表函数显式 tz 参数。生产里时区 bug 排第二多(仅次于权限)。

案例 6:IDOR → 越权访问

现象:用户 A 改一下 URL /orders/{B 的 order id} 看到了别人的订单。

根因:Controller 直接 prisma.order.findUnique({ where: { id } }),没校验归属。

修复:

  • 所有"按 id 查" endpoint 加归属检查
  • DB 开 RLS(03 章)兜底
  • 测试加 IDOR 用例(用 user A 访问 user B 资源,断言 403)

预防:Code Review 模板加一条"按 id 查的资源是否校验归属?"必勾。

教训:OWASP Top 10 第一名 BOLA(IDOR)。RLS 是兜底,应用层显式检查是首责

案例 7:K8s Liveness 误杀 → 雪崩

现象:DB 抖一下 → K8s 把所有 API pod 杀光重启 → 启动期间无法服务 → 雪崩。

根因:livenessProbe 配置查 DB,DB 临时不可用 → liveness fail → K8s 重启 → 启动时又查 DB → 失败 → 又重启…

修复:

  • Liveness 只检查"进程在跑"(返回 200 即可)
  • Readiness 查 DB(失败时摘流量,但不重启)

预防:K8s manifest review 必检 probe 配置。

教训:Liveness ≠ Readiness。03 章已强调,但模板抄错的人很多。

案例 8:Prisma findMany 无 take → 全表加载

现象:某管理后台页打开后 5 分钟无响应,API pod 内存飙到 OOM。

根因:prisma.user.findMany({ where: { tenantId } }) 没有 take,而该租户有 200 万条数据 → 全部加载到内存。

修复:

  • take: 100 强制分页
  • 应用层加 lint:prisma.*.findMany 必须带 takecursor
  • DB 加 statement_timeout

预防:封装 Repository,禁止暴露 raw findMany;只暴露 list({ limit, cursor })

案例 9:迁移阻塞 → 业务停摆 30 分钟

现象:周二下午 2 点上线,迁移卡在 ALTER TABLE invoices ADD COLUMN xxx DEFAULT '...',锁表 30 分钟。

根因:Postgres 旧版本里 ADD COLUMN ... DEFAULT non-constant重写整张表。1 亿行的 invoices 表锁表 30 分钟,期间所有写操作阻塞。

修复:

  • 拆成多步:ADD COLUMN (nullable) → 后台回填 → SET NOT NULL
  • 大表加索引强制 CREATE INDEX CONCURRENTLY(03 章)

预防:

  • DBA review 所有 schema migration
  • CI 跑 pg-osc / pgroll 类工具检测危险变更
  • 大表(行 > 100 万)迁移走专门的 review + 时间窗口

案例 10:Worker 偷偷崩了几小时 → 队列堆积

现象:周一早上发现 5 万条邮件未发出,worker 已经崩了 4 小时。

根因:worker pod OOM 反复重启,但没人发现:

  • worker 的 livenessProbe 是 TCP socket(进程存在即 OK)
  • BullMQ 队列长度没接 alert
  • worker 错误日志被 Pino 默认 level=info 过滤掉了 debug 信息

修复:

  • 队列 waiting / failed 数接 Prometheus + alert(09 章)
  • worker 副本数监控:实际副本 < 期望 → 报警
  • 关键 worker 加自检端点

预防:每个新建队列必须配 alert。在 Helm chart 里用 ValueRequired 强制。

教训:可观测的盲区就是事故的温床。默默挂掉比"挂了刷屏"更可怕。


A4 - 架构演进与大规模

你 90% 的工作是改既有系统,不是从零搭。

A4.1 单体 → 模块化单体 → 微服务

绝大多数项目的路径:

v0  单文件 monolith        ─►  能跑就行
v1  按 feature 模块化       ─►  目录划清,共享 DB
v2  模块化单体(强边界)    ─►  禁止跨模块直接调,统一通过事件 / 接口
v3  抽出关键服务            ─►  把性能/独立性强的模块抽出
v4  完全微服务              ─►  极少团队真正需要走到这步

💡 大多数团队应该停在 v2/v3 微服务的运维成本是单体的 5-10 倍:分布式追踪、服务注册、配置中心、数据一致性、CI/CD 复杂度。没有 50+ 工程师团队 + 成熟基础设施,不要碰

A4.2 模块化单体的边界强制

NestJS 项目里:

src/modules/
├── billing/
│   ├── domain/         # 聚合、值对象、事件
│   ├── application/    # Service(用例)
│   ├── infrastructure/ # Repository, Prisma
│   └── interface/      # Controller(只对外暴露)
├── catalog/
└── users/

强制边界:billing/* 不可 import catalog/infrastructure/*

// .eslintrc 自定义规则(或用 dependency-cruiser)
{
  "no-restricted-imports": ["error", {
    "patterns": [{
      "group": ["**/catalog/infrastructure/**", "**/catalog/domain/**"],
      "message": "billing 不可直接访问 catalog 内部,通过 catalog 的 application 接口或事件"
    }]
  }]
}

或用 dependency-cruiser:

// .dependency-cruiser.cjs
forbidden: [
  {
    name: "no-cross-module-internals",
    severity: "error",
    from: { path: "src/modules/([^/]+)/" },
    to: {
      path: "src/modules/(?!\\1/)([^/]+)/(domain|infrastructure)/",
      pathNot: "src/modules/\\1/.*"
    }
  }
]

CI 跑这条,违规直接 fail。

A4.3 抽服务的时机判断

何时该把一个模块抽出?至少满足两条:

  1. 独立的伸缩诉求(billing 流量是其他模块 10 倍)
  2. 独立的发布节奏(团队 A 想每天发,团队 B 周发)
  3. 独立的技术栈(billing 想用 Go,其他还是 Node)
  4. 独立的故障隔离(billing 挂不能影响登录)
  5. 数据所有权清晰(billing 表别人不直接读写)

只满足 1-2 条 → 继续模块化单体,加 HPA 即可。

A4.4 Strangler Fig:渐进式拆分

要把单体里的 billing 抽出,不要"重写 + 切换",用绞杀者模式:

Phase 1: 网关层加 billing 路由判断,默认全走老 monolith
Phase 2:  billing 服务起来,网关把 1% 流量导新服务
Phase 3: 数据同步:老库  新库(CDC / Outbox)
Phase 4: 增加新服务的流量比例,验证 metric
Phase 5: 100% 新服务,老 monolith  billing 模块标 deprecated
Phase 6: 删除老模块代码

每个 Phase 可回滚。任何一步 metric 异常,立即把流量切回老服务。

A4.5 数据库:读写分离演进

v0  单 Postgres 全部读写       ─►  ≤ 5K QPS,直接 RDS
v1  加只读副本,业务侧读写分流  ─►  10-50K QPS
v2  按业务分库(垂直拆)        ─►  100K QPS 或合规要求
v3  水平分片(sharding)        ─►  极少团队真需要,Vitess/Citus

业务侧读写分流(v1)在 03 章给过示例。注意"刚写完立刻读自己"必须强制读主库。

A4.6 大数据集:冷热分层与归档

订单表 10 亿行,99% 查询只用最近 30 天:

方案 A:分区表

CREATE TABLE orders (...)
PARTITION BY RANGE (created_at);

CREATE TABLE orders_2025_01 PARTITION OF orders FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

旧分区可独立 vacuum、独立索引、独立移到便宜的存储。

方案 B:归档表 + 软提示

  • 主表只保留近 90 天
  • 老数据 cron job 转到 orders_archive 表(同 schema 但用列存储 / 压缩)
  • 查询接口默认查主表,UI 提示"查 90 天以前请勾选包含归档"

方案 C:冷数据出到 S3 + Athena/DuckDB

  • 财务月底导一次到 parquet,放 S3
  • BI / 报表用 Athena 查 S3
  • 在线交易不查归档

A4.7 多租户的演进路径

v0  共享库 + 共享 schema + tenant_id      ─►  默认起步
v1  共享库 + 共享 schema + tenant_id + RLS ─►  加兜底(03 章)
v2  共享库 + per-tenant schema             ─►  大客户索引膨胀时
v3  per-tenant database                    ─►  合规 / 数据驻留要求

v0 → v3 的迁移成本递增。不要预先优化,等业务真到 v2 痛点(单表 > 10 亿、索引 > 100GB)再换。

A4.8 ADR(Architecture Decision Record)

每个重大决策写一份 ADR(短文档,200-500 字),进 git。模板:

# ADR-0023:把 billing 模块从主服务抽出独立部署

- **状态**: Accepted
- **日期**: 2025-03-15
- **背景**:
  billing 流量已是其他模块 8x,大促时主服务整体被拖累。
  财务团队希望独立发布节奏。
- **决策**:
  抽出独立 NestJS 服务 `billing-svc`,通过事件总线与主服务通信。
- **替代方案**:
  1. HPA 横向扩容主服务 — 拒绝,因为整服务都要扩,浪费资源
  2. 改为按 worker 隔离 billing 任务 — 仅部分缓解
- **后果**:
  - ✅ 独立扩缩、独立部署、故障隔离
  - ❌ 增加跨服务调用延迟、需要分布式追踪
  - ❌ 数据一致性挑战(用 Outbox 解决)
- **影响范围**:
  - 主服务移除 `src/modules/billing` 
  - 新增 `infra/billing-svc` 仓库
  - 数据库 `invoices` 表所有权转移到新服务
- **回滚方案**:
  Strangler Fig 渐进切流,任何 phase 流量异常可切回主服务。

💡 ADR 的价值不是"写规范" 6 个月后新人问"为什么这么设计?"时,ADR 让他读 5 分钟就能理解,不必去考古 Slack 历史。这是高级工程师与中级工程师的分水岭之一:留下决策记录


A5 - 工程师软实力

这部分不写代码,但决定你的代码会不会被采纳、产品能不能上线、团队能不能进步。

A5.1 写 RFC

复杂功能开干前先写 RFC(Request for Comments),团队 async review,降低返工。

RFC 模板:

# RFC: 支持发票分期付款

- 作者:@you
- 状态:Draft / Review / Approved / Implemented
- Review by:@a @b @c
- Deadline:2025-03-20

## 1. 目标
让客户可以把单张发票分 2-12 期支付。

## 2. 非目标
- 不支持分期手续费动态计算(走固定折扣)
- 不支持中途修改期数

## 3. 现状
当前 Invoice 只有 ISSUED → PAID 一次性支付。

## 4. 方案
新增 InstallmentPlan 聚合,关联 Invoice。
状态机:ACTIVE → COMPLETED / DEFAULTED
...

## 5. 替代方案
A. 在 Invoice 上加字段 — 不行,违反聚合边界
B. 用 OrderItem 实现 — 不行,语义混乱

## 6. 影响
- 数据库:加 2 张表 + 1 个 outbox event
- API:新增 2 个端点 + 修改 1 个
- 前端:发票详情页加分期视图
- 客服:培训新流程

## 7. 测试计划
- 集成测试覆盖 12 种状态转移
- 压测分期生成接口 (1000 RPS)
- 财务对账 SQL review

## 8. 上线计划
- Week 1: 后端开发 + 单测
- Week 2: 前端 + 集成测试
- Week 3: 灰度 10% → 50% → 100%
- Week 4: 监控 + 文档

💡 RFC 不是必须的,但:复杂度高、影响多个团队、有不可逆决策时 必写。能减少 80% 的"做完才发现方向错"。

A5.2 Code Review:看什么

按优先级:

1. 正确性(必查)

  • 业务逻辑是否符合需求?
  • 边界条件(空、null、0、负数、并发)?
  • 错误处理是否完整?

2. 安全(必查)

  • 鉴权 / 授权是否到位?
  • 输入是否校验?
  • 是否引入新依赖?是否扫描过?

3. 性能(关键路径必查)

  • DB 查询数(N+1)?
  • 缺索引?
  • 同步 IO?
  • 大对象序列化?

4. 可维护(尽量看)

  • 命名是否清晰?
  • 函数是否过长(> 50 行就该警惕)?
  • 抽象是否合理(不要"为复用而抽象")?
  • 测试是否覆盖关键路径?

5. 风格(自动化,人不必看)

  • 交给 ESLint / Prettier

💡 不要让 review 变成挑刺 提改进建议时区分 must-fix / nice-to-have / nitnit: 前缀的可以忽略。明确性能 review 的态度,不要拖。

A5.3 技术债识别与偿还

债的种类:

类型例子处理
明知故犯(知道是债,赶时间)"这次先 hardcode,下版抽出"写 TODO + ticket,两周内还
不慎结果(写时不知道)当时合理,业务变了不再合理周会列出讨论
慢性病(代码烂积累)没人敢动的模块拆 ticket 渐进重构
杠杆债(主动负债换速度)起步先用 sqlite 一年后换 pg提前规划,准时迁移

债务清单(team-level):

  • 每周一次"债务会",过最近新增的 TODO / FIXME / hack-tag
  • 每 sprint 留 10-20% 时间还债
  • 不要"集中还债 sprint" → 总会被业务推掉

A5.4 上线沟通

破坏性变更(rename API、改协议、删字段)的标准流程:

  1. 提前 2 周:在团队群 + email + Slack 公告,写明"何时、影响、迁移方法"
  2. 加 deprecation 警告:旧 endpoint 加响应头 Sunset: <date>Deprecation: true
  3. 加 metric:统计旧 endpoint 调用方,催未迁移者
  4. 延迟拆除:计划日期到了再宽限 2 周(总有人没看)
  5. 删除后保留兜底:旧 URL 返回 410 Gone + 文档链接

⚠️ 不要在 Friday 6PM 删除任何东西。出问题没人在线,周末烂尾。周二上午最安全

A5.5 故障复盘(Post-mortem)

模板:

# Post-mortem: 2025-03-15 API 错误率突增

## TL;DR
3 月 15 日 14:00-14:45 API 5xx 错误率从 0.1% 飙到 18%,
影响约 12 万用户登录,GMV 损失约 ¥50 万。

## 时间线
- 14:00 灰度 v2.3.0 至 5%
- 14:15 灰度至 25%
- 14:23 Sentry alert:`PrismaClientKnownRequestError P2002`
- 14:25 On-call 收到 PD
- 14:32 确认是新上的 unique index 冲突
- 14:35 决定回滚
- 14:38 helm rollback 完成
- 14:45 错误率回落

## 影响
- 12 万用户登录失败 / 重试
- GMV 损失 ¥50 万(估算)
- 客服 ticket +200

## 根因
1. 新 schema 加了 (tenant_id, email) unique
2. 历史数据里有约 800 条"软删除但仍在表里"的重复
3. v2.3.0 代码尝试 upsert 触发冲突
4. 上线前预跑迁移在 staging,但 staging 没有重复数据

## 已生效缓解
- 回滚 v2.3.0
- 紧急 SQL 清理 800 条历史重复数据
- 重新上线 v2.3.1(同代码 + 数据清理)

## Action Items(必须有 owner 和 ddl)
- [ ] @alice CI 加一步在 prod-snapshot 上跑迁移 — 2025-03-22
- [ ] @bob 把"软删除是否还在表里"补到 schema migration checklist — 2025-03-18
- [ ] @carol 灰度增加自动回滚阈值(错误率 > 5% 30s 立刻回滚)— 2025-03-25
- [ ] @dave 写一篇"Prisma unique constraint with soft delete"文档 — 2025-03-20

## 不追责个人,追责系统
- 当事人按流程操作,所有 check 都过了
- 根因是 staging 数据不能代表 prod,CI 应在 prod-snapshot 上预检
- 复盘后流程已修正,同类不会再发

💡 Blameless culture 是工程文化第一战场 追责个人 → 人下次出事会隐瞒 → 系统永远不改进。追责系统 → 大家敢说真话 → 系统持续变好。

A5.6 学习与成长建议

  • 读经典书:Designing Data-Intensive Applications、Site Reliability Engineering、Domain-Driven Design、Release It!
  • 读源码:挑一两个你天天用的(NestJS、Prisma、BullMQ)读它的核心模块
  • 写技术博客:把刚修的 bug 写下来,半年后回看
  • 参与开源:不必是大贡献,修 typo / 写文档都行,见识真实代码 review
  • 教别人:能讲清楚 = 真懂。带新人 / 内部分享 / 写文档

A5.7 高级 vs 中级的本质区别

维度中级高级
写代码完成需求完成需求 + 想清楚 5 年后怎么改
评审看语法看架构、看安全、看 review 文化
出故障修当下修当下 + 改流程让同类不再发
决策老板说怎么做写 RFC 主导决策
沟通写代码不写文档写决策记录、写 post-mortem
成长学新框架学新方法、读源码、研究失败案例
时间写代码 80%写代码 40%,设计 20%,沟通 20%,带人 20%

💡 最大的区别是: 中级在解决问题。高级在防止问题发生 + 让团队能解决问题。