本册涵盖: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(
UserId、OrderId包了有用,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 |
| 复杂报表 / 多种 view | CRUD 简单页面 |
| 已经在用领域事件 + 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 显示在 BEGIN 或 COMMIT 等待。
排查:
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。看:
- Comparison view:对比两个时间点的快照,看哪个对象一直在涨
- Containment / Retainers:对象被谁 hold 着不让 GC
常见根因:
- 闭包持有大对象(每次请求 new 一个 cache,key 是 user id,从不清理)
- listener 没 off(EventEmitter 反复
.on()没.off()) - timer 没清(
setInterval没clearInterval) - 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.readFileSync、crypto.*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 时间点,对比 metric | git 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 调用 → 连接被一直占 → 池耗尽。
修复:
- 立即把那段 HTTP 调用移出事务(改 Outbox)
- 数据库连接加
statement_timeout = 30s,DB 层兜底 - 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必须带take或cursor - 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.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 抽服务的时机判断
何时该把一个模块抽出?至少满足两条:
- 独立的伸缩诉求(billing 流量是其他模块 10 倍)
- 独立的发布节奏(团队 A 想每天发,团队 B 周发)
- 独立的技术栈(billing 想用 Go,其他还是 Node)
- 独立的故障隔离(billing 挂不能影响登录)
- 数据所有权清晰(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/nit。nit:前缀的可以忽略。明确性能 review 的态度,不要拖。
A5.3 技术债识别与偿还
债的种类:
| 类型 | 例子 | 处理 |
|---|---|---|
| 明知故犯(知道是债,赶时间) | "这次先 hardcode,下版抽出" | 写 TODO + ticket,两周内还 |
| 不慎结果(写时不知道) | 当时合理,业务变了不再合理 | 周会列出讨论 |
| 慢性病(代码烂积累) | 没人敢动的模块 | 拆 ticket 渐进重构 |
| 杠杆债(主动负债换速度) | 起步先用 sqlite 一年后换 pg | 提前规划,准时迁移 |
债务清单(team-level):
- 每周一次"债务会",过最近新增的 TODO / FIXME / hack-tag
- 每 sprint 留 10-20% 时间还债
- 不要"集中还债 sprint" → 总会被业务推掉
A5.4 上线沟通
破坏性变更(rename API、改协议、删字段)的标准流程:
- 提前 2 周:在团队群 + email + Slack 公告,写明"何时、影响、迁移方法"
- 加 deprecation 警告:旧 endpoint 加响应头
Sunset: <date>和Deprecation: true - 加 metric:统计旧 endpoint 调用方,催未迁移者
- 延迟拆除:计划日期到了再宽限 2 周(总有人没看)
- 删除后保留兜底:旧 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% |
💡 最大的区别是: 中级在解决问题。高级在防止问题发生 + 让团队能解决问题。