这一章不讲方法论,讲事故。
227 张卡片里,至少有一半是踩坑记录。每一条背后都是真实的时间损失——从 20 分钟到 2 天不等。我把其中最具普适性的教训整理出来,按主题分成四组。
这些教训有一个共同特点:表面症状和根因之间隔着好几层。Port 冲突表现为 OAuth 失败,regex 的 stateful 行为表现为”随机”的 match 丢失,build 缓存表现为”部署了但没生效”。
如果你只记住一件事:永远不要相信第一个报错信息。 往下挖一层,再挖一层。
Git 与部署陷阱
这组教训的核心主题:你以为 Git 和部署是确定性操作,但环境差异、hook 副作用、缓存状态会让它们退化成概率性行为。
端口冲突导致 OAuth 静默失败
发生了什么:OAuth 登录流程突然不工作了。没有报错,callback 就是不触发。花了将近两个小时排查 OAuth provider 配置、redirect URI、cookie 设置,全部正确。最后发现是本地另一个进程占用了同一个端口,dev server 静默 fallback 到了另一个端口,而 OAuth redirect URI 是硬编码的原端口。
为什么:OAuth 的 redirect URI 验证是严格匹配的——端口号是 URL 的一部分。大多数 dev server(Vite、Next.js dev)在端口被占用时会自动 +1,但不会告诉你这件事对 OAuth 意味着什么。更糟糕的是,OAuth 失败的表现不是报错,而是 redirect 到一个没人监听的端口,然后——什么都不发生。静默失败是最贵的失败。
规则:Dev server 启动后,永远验证实际端口号。OAuth 项目必须在启动脚本中做 port assertion——端口不对,直接 crash,不要 fallback。
// 启动脚本里加这个,不要等到 OAuth 回调时才发现端口不对
const EXPECTED_PORT = parseInt(process.env.PORT || '3000');
server.listen(EXPECTED_PORT, () => {
const actualPort = server.address().port;
if (actualPort !== EXPECTED_PORT) {
console.error(`FATAL: Expected port ${EXPECTED_PORT}, got ${actualPort}. OAuth will break.`);
process.exit(1);
}
});
Pre-push hooks 导致 SSH timeout
发生了什么:git push 到 remote 时随机超时。有时成功,有时挂住 30 秒后断开。直觉是网络问题,但 ssh -T git@github.com 每次都通。最后发现是 pre-push hook 里跑了完整的 test suite,耗时超过了 SSH 的 idle timeout。
为什么:Git hooks 在 push 的 SSH 连接建立之后、数据传输之前执行。如果 hook 执行时间过长,连接可能被多个环节 kill:SSH server 端的 ClientAliveInterval、中间网络设备(路由器/防火墙)的 idle connection cleanup、或客户端自身的超时设置。本地测试不会触发这个问题,因为没有网络层的介入。
规则:Pre-push hooks 必须有时间上限。超过 10 秒的检查放到 CI 里做,不要放在 hook 里。如果一定要在 hook 里跑测试,客户端配置 ServerAliveInterval 和 ServerAliveCountMax 来保活连接。
Worktree 里误切 main 的隐患
发生了什么:在一个 Git worktree 里尝试 git checkout main。Git 2.5+ 默认会拒绝这个操作(fatal: 'main' is already checked out),但如果用了 --force 或者通过 git checkout <main的commit hash>(detached HEAD)绕过了这个保护,主仓库的 working directory 状态就会被污染。两个 worktree 指向同一个 ref 时,文件锁冲突、index 不一致接踵而至。恢复需要手动编辑 .git/worktrees/ 下的文件。
为什么:Git worktree 的设计假设是每个 worktree 对应一个独立的 branch。Git 有保护机制来阻止同一个 branch 被两个 worktree checkout,但这个保护可以被 --force 绕过,也不覆盖 detached HEAD 的场景。一旦两个 worktree 共享同一个 ref,任何一边的 commit 都会移动 ref,导致另一边的状态不一致。
规则:Worktree 里永远只用 feature branch。如果需要看 main 的代码,用 git show main:path/to/file 或者回到主仓库。不要用 --force 绕过 checkout 保护。可以写一个 post-checkout hook 做二次检测。
生产 build 缓存陷阱
发生了什么:部署了新版本,但线上行为没变。确认了 deploy 脚本跑完了,确认了新代码在服务器上,确认了 process restart 了。但用户看到的还是旧版本。原因:build 工具(这次是 Vite)的缓存没被清理,输出的 bundle hash 和上一次相同,CDN 认为内容没变,没有 purge。
为什么:现代 build 工具为了加速,会缓存中间产物。大多数时候这是正确的优化。但当你改的是配置文件、环境变量、或者 build 工具自身无法追踪的依赖时,缓存不会失效。更隐蔽的情况是:代码确实变了,但 tree-shaking 之后,实际输出的 chunk 内容没变(因为改动的代码路径没被 import),hash 不变,CDN 不更新。
规则:部署脚本必须包含 rm -rf dist/ .cache/ node_modules/.vite/ 或等价操作。CI/CD pipeline 里的 build 步骤应该是 clean build——每次从零开始。在速度和正确性之间,生产环境永远选正确性。(例外:如果你的 build 时间超过 10 分钟,可以用 content-addressable cache 如 Turborepo,但前提是你理解它的 cache key 包含什么、不包含什么。)
npm scoped package 的 scope 必须精确匹配
发生了什么:创建了一个 npm scoped package @myorg/tool-name,本地开发一切正常。npm publish 的时候报 403。检查了 npm token、2FA、package.json 的 publishConfig,全部正确。最后发现 scope name 和 npm 上的实际 organization name 不匹配。
为什么:npm 的 scope 系统把 @scope/package 映射到 registry 上的 organization 或 user namespace。这个映射是精确匹配的。注意:npm v7+ 对 scope name 做了 lowercase normalization,所以大小写问题在新版本中有所缓解,但 scope 名称的拼写仍然必须和 registry 上的一致。你在本地 npm install 时不会触发权限检查(因为是读操作),只有 npm publish 才会验证你对这个 scope 的写权限。问题在开发阶段完全不可见。
规则:创建 scoped package 之前,先用 npm org ls 或 npm whoami 确认你的 scope name 的精确拼写。package.json 里的 scope 必须和 registry 上的一字不差。
测试与验证陷阱
这组的核心主题:测试通过不等于代码正确。测试本身可以骗你。
Smoke test 的 false positive
发生了什么:部署后跑 smoke test,全绿。但用户报告核心功能不可用。排查发现 smoke test 验证的是”页面返回 200”和”页面包含某个关键词”,但实际的功能依赖一个后端 API,而那个 API 挂了。页面本身是 SSR 的,200 和关键词都存在于 SSR 输出里,但客户端 hydration 之后尝试调用 API 时失败了。Smoke test 在 SSR 层面通过,在客户端层面失败。
为什么:Smoke test 的目的是”快速确认系统没有完全挂掉”。但”没有完全挂掉”和”功能正常”之间的差距巨大。HTTP 200 只意味着 server 没 crash。页面包含关键词只意味着 SSR template 渲染了。真正的功能验证需要执行用户的关键路径——点击按钮、提交表单、看到结果。
规则:Smoke test 必须包含至少一个端到端的功能验证,不能只检查 HTTP status 和静态内容。如果做不到完整的 E2E,至少要 hit 一下关键 API endpoint 并验证 response shape。
Mock 会撒谎:从单元测试到集成测试的断层
发生了什么:重构了一个数据处理 pipeline。每个 stage 的单元测试都通过了。部署后,整个 pipeline 输出是错的。原因:stage A 的输出 schema 变了,stage B 的单元测试 mock 了 stage A 的输出,mock 还是旧 schema。每个 stage 独立看都是”正确的”,但串起来就碎了。
为什么:单元测试的核心假设是”如果每个部分都正确,整体就正确”。这个假设在部分之间的接口契约没有被显式验证时就是错的。Mock 是冻结的假设——它说”我假设上游是这样的”。但上游一旦变了,mock 不会自动更新,测试依然绿,而真实系统已经碎了。
最终方案:我们后来采用的策略是”只 mock 存储层,其他跑真的”。DB 用 in-memory 实现(如 SQLite in-memory 替代 PostgreSQL),但 API handler、middleware、validation、serialization 全部真实运行。这个方案抓到了 3 个单元测试遗漏的 bug:middleware 顺序问题、validation schema 不匹配、serialization 丢字段。
规则:任何 pipeline 式的架构,必须有至少一个 integration test 用真实数据跑完全程。Mock 只用在单元测试里隔离最重的外部依赖(DB、第三方 API),不用在组件间接口上。如果你 mock 了上游组件的输出,你就有责任在上游变化时更新 mock——但更好的做法是别 mock 它。
Runtime spy 验证测试诚实度
发生了什么:发现一组测试通过了,但覆盖率报告显示对应的代码行从未被执行。深入看,测试在验证 mock 的返回值——它没有测任何真实代码,只是在测自己的 setup。100% 的测试通过率,0% 的实际覆盖。
// 这种测试是假的——它在测 mock,不在测代码
jest.mock('./userService');
test('getUser returns user', async () => {
getUserById.mockResolvedValue({ id: 1, name: 'Alice' });
const user = await getUserById(1); // 调用的是 mock,不是真实函数
expect(user.name).toBe('Alice'); // 永远通过,没测任何东西
});
为什么:这是 mock-heavy 测试的经典退化。开发者先写了 mock,然后写 assertion 验证 mock 的行为,完全绕过了被测代码。这种测试不会失败(因为 mock 永远按设定返回),给人一种安全感,但实际上什么都没验证。
规则:定期用 runtime coverage 工具(如 jest --coverage 或 c8)检查测试是否真的执行了被测代码。如果一个测试文件的覆盖率接近 0%,要么测试是假的,要么被测代码已经被删了。两种情况都需要处理。
删组件必须删测试
发生了什么:删除了一个废弃的 React 组件。CI 报错:测试文件 import 了一个不存在的模块。修复很简单——删掉测试文件。但这暴露了一个更深的问题:代码库里有几十个测试文件对应的组件已经被删除或重命名了,这些测试一直被静默跳过。
为什么:大多数 test runner 根据配置不同,对 compile 失败的 test file 有不同行为。在某些配置下(如 test file pattern 不再匹配重命名后的文件),测试会被静默排除在执行列表外。结果是:你以为测试在跑,实际上测试早就不存在了。Jest 的 --passWithNoTests flag 解决的是”没有匹配到任何测试文件时不报错”的问题,但更常见的陷阱是测试文件匹配规则和源文件命名规则之间的 drift。
规则:删组件时,必须同时删对应的测试文件和 story 文件。在 CI 中,定期跑一次”test 文件和源文件的对应检查”——每个 .test.ts 都应该有对应的源文件存在。
前端陷阱
这组的核心主题:前端的 bug 经常是”规范行为”——它不是坏了,它就是这么设计的,但你不知道。
Astro slot 内容放到 body 外面
发生了什么:用 Astro 写了一个 layout 组件,通过 slot 传入页面内容。页面渲染后,部分内容出现在 </body> 标签之后。浏览器勉强渲染了它(浏览器对 malformed HTML 的容错能力惊人),但 JS hydration 失败了,因为 DOM 结构和预期不一致。
为什么:Astro 的 slot 在 build time 做的是字符串插入,不是 DOM 操作。如果你的 layout 组件的 HTML 结构不完整——比如一个 <div> 没有闭合——slot 内容会被插入到错误的位置。更隐蔽的情况:如果 slot 内容本身包含某些 HTML 元素(如 <tr> 没有被 <table> 包裹),浏览器的 HTML parser 会主动”修正”DOM 结构,把元素移到它认为合法的位置——可能是 <body> 外面。
规则:Astro layout 的 HTML 必须是合法的、完整闭合的。Slot 内容不能包含”裸”的 table 元素。上线前用 HTML validator 检查一次 build output。如果发现内容出现在 <body> 外,先检查 HTML 结构,不要去 debug Astro 框架。
JS global regex lastIndex 陷阱
发生了什么:一个带 g flag 的 regex 在循环中使用,第一次 match 成功,第二次同样的输入却返回 null。第三次又成功了。行为看起来完全随机,但实际上是确定性的——只是你不知道它的状态。
const pattern = /foo/g;
pattern.test('foobar'); // true — lastIndex 变成 3
pattern.test('foobar'); // false — 从 index 3 开始找,没找到,lastIndex 重置为 0
pattern.test('foobar'); // true — 又从 0 开始,循环往复
为什么:JavaScript 的 RegExp 对象在带 g(global)或 y(sticky)flag 时是 stateful 的。每次 exec() 或 test() 调用后,regex 对象会更新自己的 lastIndex 属性,下次调用会从 lastIndex 开始匹配。这是 ECMAScript 规范的正确行为。
规则:如果 regex 不需要在同一字符串上连续匹配多个结果,不要用 g flag。如果必须用 g,每次使用前手动重置 lastIndex = 0,或者每次创建新的 RegExp 实例。
Vite proxy URL matching 必须用 parsed pathname
发生了什么:配置 Vite 的 dev server proxy,用字符串 startsWith 匹配 URL path 来决定是否转发请求。大多数请求工作正常,但有一个请求的 URL 包含 query parameter,parameter 的值里有被编码的 /(%2F),导致字符串匹配失败,请求没被转发。
// ❌ 错误:对 raw URL string 做字符串匹配
if (req.url.startsWith('/api')) { proxy(req); }
// ✅ 正确:先 parse,再在 pathname 上匹配
const { pathname } = new URL(req.url, 'http://localhost');
if (pathname.startsWith('/api')) { proxy(req); }
为什么:URL 是结构化数据,不是纯字符串。用字符串操作(startsWith、includes、regex)去匹配 URL 的 pathname 部分,会被 query string、fragment、encoded characters 干扰。正确做法是先 parse URL,再在 parsed 的 pathname 上做匹配。这不只是正确性问题,也是安全问题——?path=%2Fapi%2Fhack 可以骗过字符串匹配。
规则:任何 URL matching 逻辑,必须先用 new URL() 或等价的 parser 解析,然后在 .pathname 上做匹配。永远不要对 raw URL string 做 startsWith 或 regex match。
架构与工程规范陷阱
这组的核心主题:架构问题的症状总是出现在离根因最远的地方。
Auth 和 Data Access 必须分离
发生了什么:在一个 API handler 里,auth 检查和数据查询混在一起。代码大概是:查询数据 → 检查当前用户是否有权限访问这条数据 → 返回。看起来合理。问题是:当数据不存在时,代码返回 403(因为走到了”无权限”分支),而不是 404。攻击者可以通过 403 vs 404 的差异来探测哪些资源 ID 是存在的。
// ❌ auth 和 data access 混合——信息泄露
async function getResource(userId, resourceId) {
const resource = await db.find(resourceId);
if (!resource || resource.ownerId !== userId) return 403; // 不存在也返回 403
}
// ✅ 分离后——行为一致
async function getResource(userId, resourceId) {
const resource = await db.find(resourceId);
if (!resource) return 404; // 不存在就是不存在
if (resource.ownerId !== userId) return 403; // 无权限就是无权限
}
为什么:当 auth 和 data access 混合在一起时,错误处理的语义会纠缠。“用户无权限”和”资源不存在”变成了同一段代码里的不同分支,而分支的顺序决定了外部可观察的行为。这是一个 information leakage 问题(OWASP 有专门条目)。更深层的原因是职责混合——auth 是”谁可以做什么”,data access 是”东西在哪里”,这是两个独立的问题。
规则:Auth 检查必须在 data access 之前,作为独立的 middleware 或 guard。如果需要基于数据内容做 auth(“只有所有者可以编辑”),先查数据,然后用专门的 auth function 检查,不要在同一个 if-else 里混合。(例外:某些安全场景下,为了避免 timing attack,需要故意统一返回 403/404——但这应该是有意识的安全设计,而不是偶然的代码结构。)
Sandbox fallback 不一致
发生了什么:系统有一个 sandbox 模式(用于 dev/test),当外部服务不可用时 fallback 到本地 mock。问题是:不同的模块对”sandbox 模式”的理解不一样。模块 A 在 NODE_ENV=development 时启用 sandbox,模块 B 在 SANDBOX=true 时启用,模块 C 在外部服务连接失败时自动 fallback。结果是同一个环境里,有的模块用真实服务,有的用 mock,行为不可预测。
为什么:Sandbox/fallback 是一个横切关注点,但通常由各个模块独立实现。没有统一的”系统级 sandbox 状态”,每个模块自己决定什么时候 fallback。这导致了不一致:你以为在测试真实集成,实际上一半是真的一半是假的。更糟糕的是,这个不一致是隐性的——没有日志告诉你”模块 C 正在用 mock”。
规则:Sandbox 状态必须是全局的、显式的、由单一来源控制。一个环境变量(如 SANDBOX_MODE=full|partial|off),所有模块读同一个变量。每个模块在启动时 log 自己的 sandbox 状态。不允许”自动 fallback”——要么用真的,要么用假的,但必须是有意识的选择。
错误表面远离根因
发生了什么:用户报告”保存按钮点击没反应”。前端没有报错(因为 catch block 吞了错误),网络请求返回 500。后端日志显示 database constraint violation。最终发现是两周前的一次 migration 没有在生产环境跑——一个新的 NOT NULL column 被加到了代码里但没加到数据库里。
用户看到的:按钮不工作。 根因:migration 没跑。 中间经过了:前端 error handling → API response → 后端 exception → ORM error → database constraint。 五层。
为什么:现代应用的抽象层数太多了。每一层都有自己的 error handling 策略,而大多数的策略是”catch 住,log 一下,返回 generic error”。每过一层,根因信息就损失一些。到了用户面前,就只剩”不工作”了。
规则:错误信息必须携带 correlation ID,贯穿所有层。前端的 error toast 必须包含请求 ID。后端的 error response 必须包含 error code(不是 message,是 machine-readable code)。每一层要做的不是吞掉错误,而是翻译并传递。
Dev 和 Prod API handler drift
发生了什么:本地开发环境一切正常,部署到生产后 API 行为不同。不是 bug——是 dev 和 prod 跑的是不同版本的 handler。Dev 环境用的是 src/api/handler.dev.ts,prod 用的是 src/api/handler.ts。半年前有人为了 debug 创建了 dev 版本的 handler,加了一些 logging 和 mock,然后 build 配置把 .dev.ts 文件排除在 production build 外。半年后,dev handler 有了十几个 feature patch,prod handler 还是半年前的版本。
为什么:任何”针对环境的代码分支”都会 drift。这是不可避免的,因为开发者在 dev 环境测试,自然会修 dev 版本的 bug 和加 dev 版本的 feature。只有在部署时才会发现 prod 版本落后了——如果你运气好的话。
规则:不允许 .dev.ts / .prod.ts 的文件命名约定。环境差异通过配置(环境变量)注入,不通过不同的代码文件。如果某段逻辑只在 dev 需要(如 debug logging),用 if (isDev) 保护,但代码必须在同一个文件里,经过同一个 review 流程。
CLI stdout 必须干净
发生了什么:写了一个 CLI 工具,输出 JSON 到 stdout,供其他脚本通过 pipe 消费。某天 downstream 脚本报错:Unexpected token in JSON。原因:CLI 工具在启动时打印了一行 warning(来自一个依赖库),这行 warning 和 JSON 混在了 stdout 里,破坏了 JSON 结构。
为什么:Unix 的设计哲学是 stdout 给数据,stderr 给信息。但太多工具和库不遵守这个约定——它们往 stdout 打 warning、progress bar、deprecation notice。当你的 CLI 输出结构化数据时,任何意外的 stdout 内容都会污染输出。而且这个问题是间歇性的——只有特定条件触发 warning 时才出现。
规则:CLI 的 stdout 只输出业务数据,所有 diagnostic 信息(warning、log、progress)走 stderr。默认行为就应该是 stdout 干净——--quiet flag 不够,因为它要求调用者知道并记得加这个 flag。如果你依赖的库往 stdout 打 garbage,在启动时 redirect 它的 output 到 stderr。
总结:教训的结构
回看这些教训,它们不是 17 个孤立的坑,而是四种失败模式的不同实例:
- 静默失败是最贵的。 Port 冲突不报错,test 静默跳过,error 被 catch 吞掉——你不知道它坏了,所以不会去修。
- Stateful 是 bug 的温床。 Regex 的 lastIndex,build 工具的缓存,SSH 连接的 timeout 计时器——任何隐式的状态都会在某一天咬你。
- 表面症状和根因之间的距离和系统复杂度成正比。 层数越多,debug 越痛苦。
- “不同环境不同行为”是所有问题类别里最难 debug 的。 因为你无法在一个环境里 reproduce 另一个环境的 bug。
有一个值得反思的 paradox:AI 擅长消除已知类别的错误——给它 linting rule、给它 checklist,它比人类可靠十倍。但上面这些教训,几乎每一条都是第一次遇到时才疼的。这种”创造性的摩擦”——在未知领域里碰壁、形成直觉、长出判断力——恰恰是 AI 替代不了的。效率和摩擦不是对立面:没有摩擦的效率只会更快地跑向错误的方向。
227 张卡片教会我的,归结为一句话:让系统在出错时尖叫,而不是沉默。 Fail loud, fail fast, fail with context。每一次你选择”catch 住,返回默认值”而不是”throw 并附上上下文”,你就在给未来的自己埋一颗地雷。
地雷不会消失。它只会等到最糟糕的时刻爆炸。