发布日期

第 38 讲:5 个真实生产案例复盘

5 个真实生产案例复盘:动态/静态误判、hydration 错误、缓存击穿等

前 37 讲讲了各种工具和原理,本讲是实战合卷。我会用 5 个仿真但极常见的真实事故,演示一遍"症状 → 监控 → trace → log → 复现 → root cause → 修复 → 复盘"的完整流程。每个案例都给出可运行的 fixture 让你亲手重现,并把对应章节交叉引用回去。

读完本讲,你应该能:

  1. 套用统一的事故排查模板(事故响应"金字塔")。
  2. 知道每个症状第一时间该看什么、问什么。
  3. 能写一份事故复盘文档:root cause + action items + preventive measure。
  4. 把前 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 causeunstable_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 causebarrel 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 causeCSP 没用 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 突增traceOTel/Datadog APM34、35
数据串号log + cache key 审查grep + ESLint11、13、32
Build OOMbundle analyzerANALYZE=true23、24、35
CSP 白屏console + middleware grepDevTools17、32
内存上涨heap snapshot 对比writeHeapSnapshot36、37
CPU 100%CPU profileinspector + DevTools36、37
Hydration 错dev overlay + 源 grepDevTools7、17
错误堆栈不清晰__NEXT_SHOW_IGNORE_LISTED=trueenv33、37
Server Action 被拒看 host vs origincsrf-protection.ts32
Edge bundle 出错trace + dce-edge skillwebpack21、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 顺眼多了?阶段七会带你正式参与主仓库开发