两轮系统性审查:哪些债我修了,哪些我明确决定不修

有一阵子我觉得代码里积了不少问题——文件越来越大、有几处逻辑我自己都记不清了。于是我让一个模型对 develop 做了一轮系统性审查,产出一份问题清单。

0. 我给自己做了一次代码审查,然后否决了它的大部分方案

有一阵子我觉得代码里积了不少问题——文件越来越大、有几处逻辑我自己都记不清了。于是我让一个模型对 develop 做了一轮系统性审查,产出一份问题清单。

清单挺长的,分了 P0/P1/P2,每条都有问题描述和修复建议。

我的第一反应是"那就按清单修"。但真的开始动手之后,我发现这份清单有一个根本问题:

它是在没有读全部代码的情况下写的。

举一个具体的例子。清单里有一条说 domain/ 这个目录"名不副实"——应该叫别的名字,因为里面的东西不像领域模型。

这条看起来有点道理,但它不知道一件事:我在 AGENTS.md 里明确写了 domain/ 是"跨服务共享领域库",并且定义了它的准入标准。 按我的定义,domain/ 里的东西恰恰是对的。

所以这一条不是"要修的问题",而是"审查者和我对某个概念的理解不同"。

于是我做了第二轮:逐条复核,而不逐条执行。

这篇讲这个过程——哪些我认了、哪些我否了、哪些我决定推后、以及哪些我决定什么都不做


1. 复核的结构:四个字段,而不是一个状态

我给每条问题都有四个字段:

字段 含义
原条目 报告里怎么说的
复核结论 我核实之后是否成立(可能推翻、可能降级)
优化后的处理 我要怎么做(可能不等于原建议)
状态 ✅ 已修复 / 🟡 按既有路线图进行 / ⏸ 独立治理

关键在第二个字段。 它允许三种结论:

  1. 成立 —— 直接采纳;
  2. 部分成立 —— 风险真的存在,但描述不准,处理方式要改;
  3. 不成立 —— 我核实后推翻。

而"优化后的处理"这个字段允许我采纳问题但不采纳方案。这是最重要的一点。


2. 三种"不照做"的情况

情况一:问题成立,但原方案会制造新问题

这是最危险的一类——按建议改会引入 bug。

原报告里有一条 P1-1:上游已经成功返回之后,本地的 commit 失败会触发重复调用上游。

问题确实成立,而且很严重(重复调用上游等于付两次钱)。

但原报告给的方案是:

继续返回成功响应,再由 cleanup / reconciliation 补齐。

我的复核结论是:

P1-1 正确识别了"上游成功后不能再次调用上游",但"继续返回成功响应,再由 cleanup / reconciliation 补齐"缺少持久化事实。现有 cleanup 会释放过期 reservation,reconciliation 不能从 reservation 还原实际 usage;照原方案实施可能产生免费调用。

拆开说:

  • cleanup 会释放过期的 reservation —— 它的语义是"这次预扣没结算,把钱退回去"。如果 commit 失败但上游已经成功了,那这笔钱不该退(用户确实消费了);
  • reconciliation 不能从 reservation 还原实际 usage —— 对账能看到"这个 reservation 没结算",但它不知道这次调用实际用了多少 token。它能发现差异,但不能修复它。

所以"交给 cleanup / reconciliation 补齐"这个方案的实际效果是:免费调用被系统地合法化了。

我采用的方案是:

上游响应完成后的本地错误标记为 PostForwardError:不可重试、不可污染渠道健康;仍向客户端返回结算失败。

也就是这次请求失败(返回结算失败),而不是假装成功。宁可让一次调用失败,也不制造一次免费调用——而且这个失败会进指标,会被看见。

这个决定和第 15 篇里 PostForwardError 的设计是同一件事。审查发现的问题是"会重复付费",原方案把它变成了"会免费",两个都不对。

情况二:问题成立,但原方案不完整

另一条 P2-1 说:上游返回 405 时会被当成"协议能力不匹配"而去重试别的渠道,但 405 是"这个端点的方法不允许",重试没意义。

问题成立。原方案是"从通用状态表里移除 405"。

我的复核结论:

只从通用状态表移除 405 不会改变行为,因为原 IsProtocolCapabilityMismatch 把所有 405 都视为能力不匹配。必须先引入 endpoint-aware 类型,再移除通用 405。

因为决定"要不要重试"的不是状态码表,而是那个 IsProtocolCapabilityMismatch 函数。 那里有一句 status != 400 的提前返回,还有对 405 的隐式处理。

所以修法是两步,而且顺序不能反:

  1. 给 Responses 那条路径引入类型化的能力错误(第 3 篇讲过的 ProtocolCapabilityError);
  2. 从通用配置里移除 405。

如果反着做(先移除、后引入),中间会有一个窗口:405 既不在重试表里、也不被类型化——它会变成一个"立刻失败且不可重试"的错误,把本来该重试的场景也堵死了。

"原方案不完整"和"原方案是错的"不一样。 前者是缺了一步,补上就行;后者要换方案。区分它们需要看代码,而不是看清单。

情况三:问题不成立

就是我开头说的 domain/ 那条。复核结论直接写"不成立",理由是指出仓库规范已经定义了它。

还有一条更细的,关于"构造函数冗余":

原条目 复核结论
P2-5 构造函数冗余 原报告数量错误:当前是 3 个公开构造函数,不是 5 个

报告先说"有 5 个构造函数",我数出来是 3 个。 数字错了说明它没有真的数——而一个数字不准的条目,它的其他结论我该不该信?

不过这里有个更值得说的后续:我写这篇的时候又数了一遍,现在是 5 个。

orchestrator.go:158  NewRelayOrchestrator
orchestrator.go:164  NewRelayOrchestratorWithProviderFactory
orchestrator.go:170  NewRelayOrchestratorWithDependencies
orchestrator.go:178  NewRelayExecutorWithDependencies
orchestrator.go:186  NewRelayExecutorWithForwarder

也就是说,当初"是 3 个不是 5 个"这个更正本身,现在也过期了。而这条技术债的状态是"随 executor 退场收敛"——executor 还没退场,构造函数就先从 3 个涨到了 5 个。

这比"报告数错了"更值得记:一份审查报告里的数字,从写下的那一刻就在过期。 这也是我坚持给报告文件名加日期的原因——它提醒读的人"这些数字属于那一天"。

我现在对审查报告的态度是:它给的是线索,不是结论。 每一条都要回到代码里核实,而核实本身经常改变处理方式。


3. 严重程度经常被高估

有一条 P1-2 叫"字符串错误分类"。报告的语气是"用字符串匹配来判断错误类型,很脆弱"。

我核实之后:

P1-2 字符串错误分类:严重程度被高估;provider / adaptor 已返回类型化 UpstreamHTTPError,但 IsRetryable 未复用既有类型化提取,模型权限仍是普通字符串。

问题真实存在,但成因不是"到处都在用字符串",而是"已经有的类型化错误没有被复用"。

这是两种完全不同的修法:

  • 如果满仓库都在匹配字符串,那是一次大重构;
  • 如果是"有类型化对象但某个函数没用它",那改一个函数就行。

我最后做的是:

IsRetryable 统一走 UpstreamStatus;模型权限在 biz 源头改为 ReasonModelForbidden

两处小改动,而不是一次大扫除。

这让我意识到审查报告的一个系统性问题:它会根据"看到的现象"推断"问题的规模",而规模需要数。 看到一处字符串匹配,很容易写成"错误处理依赖字符串匹配"——但可能全仓库只有这一处。


4. 明确写出"不做什么"

这一节是整份报告里我最满意的部分,标题就叫"明确不纳入本轮的工作":

  • 不在生产 7 天观察完成前删除 legacy handler、WebSocket 路径或双 RelayRequest
  • 不为 3 个迁移期构造函数引入 functional options;
  • 不以"大文件行数"为唯一依据拆 channel Repository;拆分必须先标明聚合、事务和测试边界;
  • 不在缺少 CSRF / refresh / logout 契约时把一部分 token 迁入 cookie;
  • 不新增 relay 进程内 Commit 重试队列,它不能提供崩溃恢复,还会与 billing 异步队列形成双写语义。

五条,每条都有理由。我挑三条说。

4.1 不在观察期内动执行模型

第一条和第二条都属于这一类。当时 executor 那条新路径正在做 7 天生产观察(第 2 篇提过),而 RelayRequest 有两个版本(legacy 和 executor 各一个),构造函数有三个。

"清理这些重复"确实该做,但现在不能做。 因为:

观察期的目的是看新路径在生产下的表现。如果我在观察期内改了执行模型,那观察的结论就失效了。 第 2 篇里我写过 executor 那次观察最后判 FAIL 并且窗口作废——如果我在那期间还改过别的东西,我甚至无法归因那 69.7% 的回归是谁造成的。

所以复核结论是"随 executor 退场"——这些技术债和 executor 的退场绑定在一起,不单独处理。

4.2 不以行数为依据拆文件

第三条针对的是一条"channel data 层有 3152 / 1883 行,太大"的问题。

行数是事实(我核实了),但报告的"纯机械、风险低"这个判断:

A2 channel data 上帝文件 | 3152 / 1883 行事实成立;"纯机械、风险低"判断过于乐观 | 在对应聚合发生业务改动时分 PR 拆;每次保持 Repository 与事务边界不变

"拆大文件"看起来是纯机械操作,但它会动事务边界。 而第 1 篇里我讲过那个 ...InTx 的方法群——事务的所有权在 biz,data 只提供"在事务里做这一步"的原子操作。如果拆文件的时候顺手把一个方法挪到另一个 repository,事务的语义可能就变了。

所以我给拆分加了个前置条件:先标明聚合、事务和测试边界。而且触发方式是"在对应聚合发生业务改动时顺手拆",而不是单独开一轮。

"顺手拆"这个策略的价值在于:我不是为了拆而拆,而是在真正需要理解那段代码的时候拆。 那时候我对边界的理解是最准的。

4.3 不做一个"看起来能解决问题"的重试队列

最后一条我觉得最值得讲:

不新增 relay 进程内 Commit 重试队列,它不能提供崩溃恢复,还会与 billing 异步队列形成双写语义。

这条针对的是"commit 失败怎么办"这个问题。一个直觉的方案是:在 relay 里加一个内存重试队列,commit 失败了就排队重试。

我否掉的理由是两句话:

第一,"进程内"等于不能提供崩溃恢复。 如果 relay 进程挂了,队列里所有没提交的结算全部丢失——而它们对应的调用是已经成功返回给用户的。 这是最糟的组合:用户拿到了结果,账上没有记录。

第二,"还会与 billing 异步队列形成双写语义"。 billing 服务自己有一个异步结算队列(第 2 篇提到的 AsyncBillingQueue)。如果 relay 也排队,那么同一次 commit 可能从两条路径到达 billing——一个是 relay 的重试、一个是 billing 自己队列的,而它们之间没有共同的去重键保证。

一个方案如果解决不了根本问题(崩溃恢复),还引入了新的问题(双写语义),那它不该做,即使它能"让指标看起来变好"。


5. 完成条件也写成了清单

报告的最后一节是"完成条件":

本轮只有在下列条件全部满足后才能标记完成:

  1. internal/bizinternal/serverplatform/middleware 和 billing biz 相关测试通过;
  2. ./scripts/check-architecture.sh 通过;
  3. Markdown 本地链接和 git diff --check 通过;
  4. executor 观察手册的用户现有记录保持不变;
  5. 本文状态和验证表更新为最终事实,不把尚未结束的生产观察误报为完成

第 5 条是我特意加的。因为那轮修复的时候,executor 的 7 天观察还在跑——它不属于这轮"完成"的范围。

如果我在报告头部写"全部完成",读者会以为整个系统状态是干净的,而实际上有一条观察还在进行。所以头部写的是:

状态:✅ 本轮方案复核、修复与验证已完成。executor 生产 7 天观察仍在进行,不属于本轮完成状态。

"完成"必须有明确的边界,否则它会膨胀成"我觉得差不多好了"。

第 4 条也很具体:"executor 观察手册的用户现有记录保持不变"。意思是这轮修复不许去改那份观察记录里的历史数据——因为那是我用来做判断的原始事实,不能被后续的修复覆盖掉。


6. 验证记录:交叉的,不只是跑测试

这一节列了八项验证:

验证 结果
新增用例的定向回归
go test 相关包
go test -race 相关包
go test ./cmd/relay-gateway ./internal/conf(验证真实配置能加载)
./scripts/check-architecture.sh
./scripts/check-deployment-docs.sh
python3 scripts/check-markdown-links.py(142 份文档)
git diff --check

有意思的是后面几项——它们和"我改的那几行代码"没有直接关系

  • 架构检查(第 1 篇那十条规则);
  • 部署文档检查(Compose / K8s 资源和文档链接有效);
  • 142 份 Markdown 文档的本地链接检查
  • git diff --check(空白字符错误)。

为什么改几行 Go 代码要跑这些? 因为我的改动会顺带改文档(每轮修复都要更新报告),而文档里的链接会断。而 check-deployment-docs.sh 是因为有一处修复动了环境变量(CORS 的默认值),配置模板里引用了它。

"验证"不是"跑一遍相关测试",而是"把所有可能被我这次改动破坏的东西都检查一遍"。 而"可能被破坏的东西"包括文档和部署模板——只要我动过它们。


7. 我现在对"审查清单"的态度

做完这两轮,我的看法变了。几点:

第一,清单给的是线索,不是任务。 每一条都要回到代码里核实,而核实会改变处理方式——有时候是降级,有时候是换方案,有时候是推翻。

第二,允许"采纳问题但不采纳方案"。 这是最有用的一条。审查者看到的现象通常是真的,但他根据现象给的修法不一定对——因为他没有全部上下文。

第三,"明确不做什么"要写下来。

这一条我认为是整份报告最重要的部分。原因很实际:如果不写,同一个问题会在下一轮审查里被重新提出来。 而写下来之后,它就变成了一个有理由的决定,下一个人(包括未来的我)能看到"这个我考虑过,当时决定不做,因为……"。

第四,修复的报告要带日期。 文件名是 systematic-code-review-remediation-2026-08-25.md。因为这类报告的结论是有时效的——不仅"已修复"的部分可能被改回去,连里面用来论证的数字本身都会过期(本文第 2 节那个构造函数数量就是现成的例子:报告写 5 个、复核时是 3 个、现在又是 5 个)。

第五,数字要自己数。 "3 个构造函数不是 5 个"这种错误说明报告在估。而估出来的数字一旦进了清单,就会被当成事实用来做决策。


8. 现在的状态和欠账

已经能用的:

  • 一份带日期的复核报告,逐条记录"原条目 / 复核结论 / 优化后的处理 / 状态"
  • 修了可复现的正确性问题(PostForwardError、流取消、类型化 405、CORS 默认值、共享契约常量)
  • 明确列出五条"不纳入本轮",每条带理由
  • 完成条件包含"不把未结束的生产观察误报为完成"
  • 验证覆盖代码、架构、部署模板、142 份文档链接、diff 空白

还没解决的:

第一,延期项没有独立的跟踪位置。 ⏸ 独立治理 / ⏸ 独立设计 这些条目现在只存在于这份报告里。没有 issue 或者 TODO 列表把它们聚合起来,所以"以后要做"实际上等于"可能会忘"。

第二,只有两轮,没有常态化。 我做的是"攒一阵子然后集中审查一次",而不是持续的小步复核。这导致每轮的清单都很长,而且有些条目在等待期间会继续恶化。

第三,审查是模型做的,我复核。 这个分工里我承担的是"判断",而模型承担的是"发现"。发现率取决于它的上下文完整性——它没有读全部代码,所以会有"数量错误"和"概念误解"这类问题。如果需要更准的发现,得让它读更多,而那是另一个成本。

第四,A2 channel data 那条只是定了策略(按聚合分 PR 拆),没有实际执行。 3152 行的文件还在。策略定了不等于债还了,这是这份报告里最容易产生错觉的一条。

第五,P2-3 localStorage token 我还是保持现状。 复核结论是"迁 cookie 需要 CSRF 与身份接口联合设计,不在本轮半迁移"。这是对的判断,但"半迁移"的替代方案是"不迁移"——而风险还在。


9. 下一篇

下一篇讲性能决策:为什么包一层 jsonx 而不是直接调 sonic、一个 24 小时 P95 回归 69.7% 的方案我是怎么决定回滚的、以及"准入线不降"这条规则怎么执行。

《性能决策:一个“变慢了”的方案我怎么判断,以及一条不降的准入线》