- 发布日期
第 38 讲:5 个真实生产案例复盘
5 个真实生产案例复盘:动态/静态误判、hydration 错误、缓存击穿等
前 37 讲讲了各种工具和原理,本讲是实战合卷。我会用 5 个仿真但极常见的真实事故,演示一遍"症状 → 监控 → trace → log → 复现 → root cause → 修复 → 复盘"的完整流程。每个案例都给出可运行的 fixture 让你亲手重现,并把对应章节交叉引用回去。
读完本讲,你应该能:
- 套用统一的事故排查模板(事故响应"金字塔")。
- 知道每个症状第一时间该看什么、问什么。
- 能写一份事故复盘文档:root cause + action items + preventive measure。
- 把前 37 讲学到的工具组合起来,而不是只会单点知识。
0. 事故响应模板
我们假定有一个生产环境标准 SOP:
1. 监控告警触发 → 进 incident channel
2. 现场保护 → 不立刻 rollback;先抓 trace/log
3. 缓解措施 → rollback / feature flag / 扩容(不修 root cause)
4. 复现 → 在 staging 或本地复现,确定 root cause
5. 修复 PR → 改代码并加测试
6. 复盘文档 → root cause + action items + preventive
每个案例都按这个模板写。
案例 1:发布后 P99 TTFB 突增 10×
1.1 症状
- 监控(第 34 讲):P99 TTFB 从 200ms 涨到 2.5s,P50 仍 30ms
- 时间起点:deploy commit
7c5a9f...完成后立刻 - 受影响 page:
/products/[slug]全部
1.2 排查路径
第一步:trace(第 34 讲)
打开慢 trace 看 span 树:
BaseServer.handleRequest [2400ms]
└── AppRender.renderToStream [2380ms]
├── AppRender.fetch /api/cms?slug=iphone [2350ms] ← 元凶
└── ...
/api/cms 是新加的 CMS 服务。
第二步:log
{"level":"error","ts":...,"msg":"upstream timeout","url":"/api/cms?slug=iphone","ms":2350}
P99 处都是这个上游慢。
第三步:复现
curl https://staging/api/cms?slug=iphone 在 staging 也是 2-3s。问题不在 Next.js 自己。
1.3 Root cause
新 CMS 接口的 DB 索引没建好,扫全表。
1.4 缓解
- 立刻给 page 加 ISR
revalidate = 60(第 30 讲)→ 用户 99% 命中 cache,免疫上游慢 - 同时把 CMS 接口 fetch 加 timeout(无超时会一直挂着拖死 worker)
const data = await fetch(url, {
signal: AbortSignal.timeout(500),
next: { revalidate: 60, tags: ["products"] },
}).catch(() => null);
1.5 修复
- CMS 团队加索引(DB 侧)
- Next.js 侧保留 ISR + timeout(防御性)
1.6 复盘
| 项 | 内容 |
|---|---|
| Root cause | 上游 CMS DB 无索引,扫表慢 |
| Detect 时间 | 5 分钟(监控告警) |
| Mitigation 时间 | 20 分钟(开 ISR) |
| Resolution 时间 | 2 小时(CMS 加索引) |
| 防御措施 | (1) 所有 fetch 必须有 timeout;(2) ISR 覆盖率纳入 quality gate |
关联章节:第 30 讲(ISR)、第 34 讲(trace)、第 35 讲(性能)。
案例 2:用户能看到别人的订单数据
2.1 症状
客服反馈:用户 A 报告"我登录后看到的订单不是我的"。 监控没告警,但 issue ticket 量异常。
2.2 排查
第一步:log
按 userId 过滤近 24h 的 log,看是否有 cross-user 异常:
{"userId":"u1","viewed_order":"o-belongs-to-u2",...}
不少这种记录。
第二步:复现
打开一个 Server Component 看:
import { unstable_cache } from "next/cache";
const getOrders = unstable_cache(async () => {
const userId = await getCurrentUserId();
return db.orders.find({ userId });
}, ["orders"]);
export default async function Orders() {
const data = await getOrders();
return <List orders={data} />;
}
Root cause 暴露:unstable_cache 的 key 是 ['orders'],没把 userId 放进 key!所有用户共享同一份 cache,A 命中后 B 拿到 A 的数据。
2.3 缓解
立刻部署修复 + 调 revalidateTag 清空所有 orders cache。
const getOrders = (userId: string) =>
unstable_cache(
async () => db.orders.find({ userId }),
['orders', userId], // ← key 带 userId
{ tags: [`user:${userId}:orders`] }
)()
export default async function Orders() {
const userId = await getCurrentUserId()
const data = await getOrders(userId)
return <List orders={data} />
}
2.4 复盘
| 项 | 内容 |
|---|---|
| Root cause | unstable_cache key 漏 userId,导致 cache 跨用户污染 |
| Detect 时间 | 6 小时(客服 ticket) |
| Mitigation | 立刻部署 + 清缓存 |
| 防御措施 | (1) eslint 规则禁止 unstable_cache key 中无 user-scoped 字段;(2) PR review checklist 加"cache key 隔离审查";(3) 自动化测试覆盖 multi-user cache 场景 |
关联章节:第 11 讲(unstable_cache)、第 13 讲(caching 全景)、第 32 讲(安全 review)。
案例 3:构建 OOM,CI 失败
3.1 症状
CI 跑 next build 时 worker 进程被 SIGKILL,CI 红。 本地 build 正常。
3.2 排查
第一步:build log
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
Worker [SIGKILL] killed
CI runner 内存只有 4GB,build 进程超了。
第二步:bundle analyzer(第 35 讲)
ANALYZE=true pnpm build
发现 @radix-ui/* 几十个子包全打进了一个 chunk,bundle 800kB+,导致 webpack 内存峰值 4GB+。
3.3 Root cause
代码里大量 import * as Radix from '@radix-ui/react-icons',barrel import 没 tree-shake 干净;同时 turbopack 还没启用。
3.4 修复
next.config.js:
module.exports = {
experimental: {
optimizePackageImports: ["@radix-ui/react-icons"],
},
};
或加 CI memory:
env:
NODE_OPTIONS: "--max-old-space-size=6144"
3.5 复盘
| 项 | 内容 |
|---|---|
| Root cause | barrel import + 大库 + CI runner 内存不足 |
| 防御措施 | (1) optimizePackageImports 加 ESLint 强制扫描;(2) CI 用更大 runner;(3) 跑 weekly bundle size report |
关联章节:第 23 讲(build pipeline)、第 24 讲(webpack)、第 26 讲(split)、第 35 讲(性能)。
案例 4:CSP 错误导致页面白屏
4.1 症状
新部署上线后某个 page 白屏。控制台:
Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'self'"
4.2 排查
第一步:grep CSP 配置
middleware.ts:
response.headers.set("Content-Security-Policy", `script-src 'self'`);
太严:连 Next.js 自己 inline 的 RSC bootstrap script 都拒了(第 17 讲讲过 Fizz stream 会注入 inline script 推送 client manifest)。
4.3 修复
用 nonce-based CSP(第 32 讲):
import { NextResponse } from "next/server";
export function middleware(req) {
const nonce = crypto.randomUUID();
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`font-src 'self'`,
].join("; ");
const res = NextResponse.next({
request: {
headers: new Headers({
...Object.fromEntries(req.headers),
"x-nonce": nonce,
}),
},
});
res.headers.set("Content-Security-Policy", csp);
return res;
}
Next.js 会自动把 nonce 注入到所有 inline script(包括 hydration script、bootstrap script)。
4.4 复盘
| 项 | 内容 |
|---|---|
| Root cause | CSP 没用 nonce-based 模式,框架 inline script 全部被拒 |
| 防御措施 | (1) Staging 必须有 e2e 测试覆盖 CSP;(2) 文档化模板 CSP,每次改要走 review |
关联章节:第 17 讲(dual rendering inline script)、第 32 讲(安全 / nonce-based CSP)。
案例 5:高 QPS 时 RSS 持续上涨,最终 OOM 重启
5.1 症状
凌晨 4 点告警:节点 OOM 重启。看 grafana RSS 24h 单调上涨。 P99 延迟无异常,错误率 0%。但每天一次自动重启 = 噪音 + 风险。
5.2 排查
第一步:heap snapshot 对比(第 37 讲)
抓 t=0 / t=12h 两个 snapshot,在 Chrome DevTools Memory → Comparison view → 按 size delta 排序。
发现 Array<RequestContext> 增加了 50 万个 + 大量 Buffer。
第二步:grep 源码
找到一处自写的 logger 中间件:
const requestHistory: RequestContext[] = []; // module level
export function logRequest(ctx: RequestContext) {
requestHistory.push(ctx);
// 没有清理!只 push,不 pop / cap
}
QPS 1000 → 一天累积 8000 万对象 → OOM。
5.3 修复
const MAX = 1000;
const requestHistory: RequestContext[] = [];
export function logRequest(ctx: RequestContext) {
requestHistory.push(ctx);
if (requestHistory.length > MAX) {
requestHistory.splice(0, requestHistory.length - MAX);
}
}
或者直接用现成 LRU:
import { LRUCache } from "next/dist/server/lib/lru-cache";
const recent = new LRUCache<RequestContext>(1000);
5.4 复盘
| 项 | 内容 |
|---|---|
| Root cause | 自写"近期请求"功能的 array 无上限 |
| Detect 时间 | 监控发现 RSS 单调;但 P99 没异常 = 容易漏 |
| 防御措施 | (1) 所有 module-level state 必须 bounded;(2) eslint 自定义规则禁止 module-level Array 无显式 cap;(3) 日常 review 注意 module-level state 生命周期 |
关联章节:第 36 讲(V8 / 内存)、第 37 讲(heap snapshot)。
综合:你的事故响应 checklist
每发生一次事故,按这个清单走一遍:
5 分钟内
- 进 incident channel,让 on-call 知情
- 不要立刻 rollback;先保留现场(log / trace / metric 截图)
- 看监控时间线,标记起点和影响范围
30 分钟内
- 抓 trace(至少 3 个慢请求)
- grep log 找异常
- 决定是否 rollback / feature flag 缓解
1 天内
- 本地或 staging 复现
- 写最小复现 case(变成测试用例)
- 修复 PR 合并
1 周内
- 写复盘文档(root cause + timeline + action items)
- 把"为什么没早发现" 转化为监控/告警/test 改进
- 在团队周会同步
工具组合速查(前面所有讲的索引)
| 症状 | 第一步 | 工具 | 章节 |
|---|---|---|---|
| TTFB 突增 | trace | OTel/Datadog APM | 34、35 |
| 数据串号 | log + cache key 审查 | grep + ESLint | 11、13、32 |
| Build OOM | bundle analyzer | ANALYZE=true | 23、24、35 |
| CSP 白屏 | console + middleware grep | DevTools | 17、32 |
| 内存上涨 | heap snapshot 对比 | writeHeapSnapshot | 36、37 |
| CPU 100% | CPU profile | inspector + DevTools | 36、37 |
| Hydration 错 | dev overlay + 源 grep | DevTools | 7、17 |
| 错误堆栈不清晰 | __NEXT_SHOW_IGNORE_LISTED=true | env | 33、37 |
| Server Action 被拒 | 看 host vs origin | csrf-protection.ts | 32 |
| Edge bundle 出错 | trace + dce-edge skill | webpack | 21、24 |
配套 fixture
fixtures/lecture-38/ 把 5 个案例做成可运行的 mini app:
/case-1-slow:故意调慢上游模拟/case-2-cache-leak:unstable_cache 漏 userId/case-3-bundle:大 barrel import(看 build 输出)/case-4-csp:错误 CSP 让 client script 被拒/case-5-leak:module-level array 无 cap
启动:
cd learning/nextjs-40-lectures/fixtures/lecture-38
pnpm install && pnpm build && pnpm start
# 按 README 一步步复现 + 排查
阶段六完结
至此阶段六"性能优化与生产问题排查"完成。下个阶段七只剩 2 讲,聚焦"贡献与扩展":
- 39 讲:给 Next.js 生态做扩展(custom server / cache handler / adapter / codemod)
- 40 讲:给 Next.js 主仓库提 PR(从 clone 到 merge)
是不是觉得 38 讲下来比第 1 讲时看 Next.js 顺眼多了?阶段七会带你正式参与主仓库开发。