跳转到内容

当每一次修改都让整个系统一起疼

两天。

这是一个 Ticket 最初的估算。乍看之下,它确实像两天能完成的工作:用户应该看到一个 Modal Dialog;他的选择要保存进 Settings Object;如果没有做出选择,30 天后再次显示 Dialog。

接手 Ticket 的开发者非常有经验,技术能力很强,对现有系统也有异常深入的理解。如果这套 Codebase 里有谁能提前发现隐藏关系,我最信任的就是他。

大约两周后他去休假,离开前的状态是:

“我做完了,请 QA 测试。”

接下来又用了大约三周做 QA、修复、再次测试。在 Feature 真正可靠工作之前,我们发现了大约 40 个错误或非预期行为。

这是个人经历。40 不是 Big Ball of Mud 的典型指标,五周也不是诊断阈值,一次错误估算本身更不能证明架构有问题。

这个 Ticket 真正有意思的是:五周之后,业务需求与第一天完全相同。Dialog 没有突然变复杂,30 天规则也没有变成高级算法,更没有新业务逻辑不断加入。真正增长的是我们为了完成这件事必须理解的系统范围。

业务变化很小,但技术上没有一个可靠的边界。

真正关键的问题在 Settings 的处理方式。

同一个业务 Settings Concept 被保存到两个不同位置,而且两边都可以独立 Persist。于是 Backend 中可能出现两个 Settings Object,尽管业务上只存在一个 State。

之后读取时,又没有通过业务 Identity 明确决定哪个 Object 才是权威来源。相关 Code 只是拿返回 Collection 的第一个元素:

index 0

但这个顺序从来没有业务 Contract。

预期 Settings 还是另一个 Settings 出现在 index 0,不再是 Business Process 的性质,而取决于一个没有业务保证的技术细节。

而这还解释不了全部问题。State 在多个地方被读写,某些 Write 会触发后续 Reaction,Timing 也会影响结果。在某些流程中甚至出现 Race Condition。一个 Fix 可能消掉一个 Bug,同时改变 Timing,让之前被其他顺序或 Timing 掩盖的问题暴露出来。

Race Condition 本身不是 Big Ball of Mud 的独有特征。优秀架构里也会有 Race Condition。多个 Writer 也不能单独证明系统已经是一团泥。

真正重要的是,我们越来越难回答一个原本应该非常简单的问题:

这次修改到底在哪里结束?

属于 Dialog?Settings Service?Global State?Persistence Model?其他写同一个 Object 的地方?所有 Reader?还是那些因为 Timing 改变而可能表现不同的 Flow?

问题不是 Dialog,而是一个 Ownership、Lifecycle 和 Persistence 都没有清晰边界的 State。

同一个小业务修改在清晰边界与 Big Ball of Mud 中拥有完全不同的技术 Change Radius。

业务范围仍然很小,但技术影响范围可以不断扩大。

一个 Ticket 还不能证明 Big Ball of Mud

Section titled “一个 Ticket 还不能证明 Big Ball of Mud”

单独一个这样的事件不够诊断系统。

即使在结构良好的系统里,两天 Estimate 也可能错。Requirement 可能在实现时被澄清。一个看似简单的 State 可能拥有意外 Constraint。Concurrency 可能只在特定 Timing 下暴露 Bug。Developer 可能做出错误假设。QA 也可能在实现后发现新 Scenario。

架构上真正值得注意的是重复出现的 Pattern:业务上 Local 的需求,经常需要理解远处系统;Responsibility 无法明确;Change Impact 在修改前难以限定;Regression Scope 远远大于 Ticket 的业务范围。

一个事件说明机制,重复出现才让它成为 System Property。

Change Impact Analysis 研究的正是这个问题:一个 Change 可能有哪些 Consequence,因此系统哪些部分必须进入分析。听起来抽象,实际上就是每个 Maintenance Ticket 背后的问题:这次修改还可能影响什么?

Parnas 在 1972 年讨论 Modularization 时,重点也不只是让 Source Code “看起来整齐”,而是通过隐藏可变 Design Decision,让系统更容易理解、更能承受变化。

Big Ball of Mud 会逐渐失去这种能力。不是所有区域、也不是每个 Ticket 都如此,但频率高到业务 Change Radius 与技术 Change Radius 不再可靠对应。

对日常开发来说,一个简单模型很有帮助:Change 不只有“大小”,至少还有三个不同 Radius。

第一个 Radius 描述 Requirement 真正要求什么。

这个例子里非常有限:

  • 显示一个 Dialog;
  • 保存用户选择;
  • 如果没有选择,30 天后再次显示。

这就是最初的业务范围。

它并不告诉你必须改几份 File,而是描述 User View 中哪条 Rule、哪个 State、什么 Behavior 要改变。

第二个 Radius 是真正需要修改的系统部分。

即使 Requirement 很 Local,也完全可能需要同时修改 Component、Application Layer、State、API、Persistence 与 Test。跨多个 File 或 Layer 本身不是架构问题。

健康架构里,这个 Radius 通常可以解释。

这个 State 属于这里。这里拥有它。通过这个 Interface 修改。那里负责 Persist。这些 Consumer 受影响。再往外就结束。

Technical Radius 不必与 Business Radius 完全相同,但应该能够从业务范围有逻辑地推导出来。

第三个 Radius 往往最昂贵。

它包括那些最终未必被修改,但在放心交付之前必须理解、检查、测试的部分。

Developer 最后可能只 Commit 三个 File,却不得不阅读另外二十个。

也许要查还有谁写同一个 State;某个第二流程是否复用同一个 Service;Base Class 中是否藏着 Side Effect;另一个 Team 是否知道 API 背后的隐含假设;QA 是否要扩展多个 User Flow,因为没人能够安全排除它们受到影响。

于是 Patch 很小,Change 仍然很贵。

在 Big Ball of Mud 中,最昂贵的工作可能根本不出现在最终 Patch 里。

业务 Change Radius、技术 Change Radius、理解与 Regression Radius 的对比。

真正的 Patch 可以很小,而理解和 Regression 仍然需要覆盖系统很大范围。

这也解释了为什么 Diff Size 几乎不能直接说明架构质量。

一次 API Migration 可能改一百个 File,却依然非常可控:规则机械、Boundary 清晰、Compiler 与 Test 能可靠告诉你哪里还没完成。

反过来,三行 Change 也可以非常危险。

一个 Boolean 被设置。Global Service 对它作出反应。另一个 Process 之后读取同一个 State。Base Class 中存在隐式 Side Effect。另一个位置依赖特定 Execution Order。

Diff 里可能几乎看不到这些关系。

问题不是 Diff 有多大,而是不知道它真正会影响什么。

这一区分很重要。否则我们会通过错误的表面判断架构:File 多看起来像“大 Change”,File 少看起来像“Local Change”。

但 Visible Change 并不等于 Actual Impact Surface。

Gall、Hajek 与 Jazayeri 在 1990 年代末通过大型 Telecom System 的 Release History 已经展示:Module 之间可能存在重要 Logical Coupling,而这些关系无法完全通过显式 Static Dependency 看出来。反复共同 Change 的历史,可以暴露 Source Code 和 Documentation 都没有清楚表达的关系。

对日常工作来说,这意味着:两处 Code 不需要在同一个 Folder、不需要 Import 同一个 Class,也不需要有明显 Interface,仍然可能在实际 Change 中总是绑在一起。

Repository 看起来 Local,不代表它真的可以 Local Change。

前一篇诊断文章已经把 Locality of Change 作为业务 Boundary 是否仍然有效的指标。

这里更关心它的经济后果。

Locality of Change 并不是:

“一个业务修改只能改一个 File。”

这既不现实,也会给很多架构错误激励。

真正含义是:

业务上 Local 的变化,应拥有技术上可以限定的 Impact Radius。

如果修改 Settings Behavior,也许必须改 UI、Application Layer、State、Persistence 和 Test,这完全健康。关键是知道为什么这些区域属于 Change,以及为什么其他区域不属于。

从这个角度看,架构不只是在创建 Structure,也是在创建可靠的 Exclusion。

它让团队能够说:

这个 State 只在这里被修改。

这是唯一 Persistence Boundary。

这个 Slice 拥有这条业务决策。

这些 Consumer 依赖这个 Contract。

在这条 Boundary 之后,这个 Change 不需要继续往外追。

尤其最后一句很值钱。Maintainability 不只是让相关 Context 容易找到,也意味着能够可靠地把系统大部分区域判定为 not relevant

越难排除,越多范围必须“以防万一”进入分析。

业务是 Local 的。

系统把它变成 Global。

John Ousterhout 使用 Change Amplification 描述相关现象:看似简单的 Change 要求在很多不同位置修改。在他的教学材料里,这个概念与 Cognitive Load 和 “Unknown Unknowns” 并列——也就是 Developer 一开始甚至不知道自己还必须知道哪些关系。

对 Big Ball of Mud 来说,这个概念很有帮助,只要我们不把它伪装成精确 Metric。

这里的 Change Amplification 不是数 File 然后算 Ratio,超过阈值就宣布架构失败。

Amplification 可以发生在多个层面:

  • 增加分析时间;
  • 要求远处技术修改;
  • 扩大 Test Scope;
  • 增加 Review;
  • 要求与原本 Ticket 业务无关的人协调;
  • 让 Release 看起来更危险,从而要求更多 Safety Work。

重点不是每次 Change 都出现所有这些成本,而是 Local Change 经常触发它们时,就从“难 Ticket”变成 System Property。

这个过程通常一点都不戏剧化。

Requirement 很 Local。分析时发现还要考虑另一个 State。这个 State 又在第二个位置被写。于是新的 Service 进入范围。这个 Service 还被另一个 Flow 使用,因此要检查 Behavior。Regression 扩大。Reviewer 又发现一条 Dependency。QA 增加更多 Scenario。

一个小 Business Change 就这样逐步变成更大的 Safety Task。

业务上小 Change

→ 更大的 Analysis Area

→ 更多 Technical Touchpoint

→ 更大的 Potential Impact Surface

→ 更多 Regression

→ 更多 Review / Coordination

→ 更高 Release Uncertainty

→ 更长 Lead Time

这不是自然法则。

额外 Dependency 不一定产生 Bug,大范围 Regression 也不一定拖慢 Release,Shared Service 更不是自动等于坏架构。

真正的问题是 Technical Boundary 逐渐失去早期终止这条链的能力。

此时没人知道何时已经分析够了。

于是又回到同一个问题:

这个 Change 到底在哪里结束?

那个 Modal Ticket 在两周后并不是因为 Developer 工作差才仍然有问题。

恰恰相反。

他已经发现并处理了相当多隐藏关系。QA 后来仍然不断复现新的 Failure State,是因为实际 Impact Surface 在 Change 开始前无法可靠确定。

例如,一个 Fix 让 State 正确写入,却改变了后续流程 Timing。新的 Timing 暴露 Race Condition。下一个 Fix 又影响其他 Writer 或 Reader,从而出现之前一直被旧 Bug 掩盖的状态。

简化后就是:

Fix → Side Effect → Fix → another Side Effect。

并不是每个 Fix 都错。系统只是让人很难预测它们的 Effect 到哪里结束。

所以今天回头看,那约 40 个问题的数字本身没有 Process 重要。很多原因都能制造大量 Bug。架构上真正值得注意的是:每次我们以为已经理解完整 Impact Surface 后,又有新的部分从同一片区域里被暴露出来。

一个看似简单的 Modal/Settings Ticket 逐步暴露第二 Writer、重复 Settings、无保证顺序、Side Effect 与 Race Condition。

不是 Dialog 变得更大,而是与它真正相关的 System Context 一点点显现出来。

Locality of Reasoning 最终会变成工作时间

Section titled “Locality of Reasoning 最终会变成工作时间”

与 Locality of Change 紧密相连的是 Locality of Reasoning

如果一个业务区域能够主要从自身内部被理解,Developer 为 Local Change 只需要构建一个有限 Mental Model。外部 Dependency 当然仍然存在,但它们通过 Contract 被限制,因此内部实现对于当前 Change 可以保持不相关。

如果这条性质消失,Local Change 就需要越来越多非 Local Behavior 的理解。

Developer 不只是在做 Ticket,而是在重建系统。

这个 State 属于谁?谁还写它?哪个 Execution Order 被默认为真?Service 真的 Stateless 吗?哪一个 Base Class 会介入?其他 Flow 是否使用同一个 Object?哪些 Test 代表真实 Invariant,哪些只是固化今天的 Implementation?

Program Comprehension 的实证研究长期关注这类 Maintenance / Change Task 中的信息需求。Sillito 等人的工作研究了 Developer 在真实 Evolution Task 中必须向现有 Codebase 提出什么问题。真正 Code Change 因此只是 Maintenance 的一部分,在改代码之前,必须先建立足够相关的 Existing-system Knowledge。

Legacy System 中无法完全消除这类工作。

真正危险的是:连一个很小的 Local Change,都经常要求理解系统很大一部分。

小 Ticket 于是需要 System-wide Reasoning Space。

Technical Debt 制造 Feature 本身没有要求的工作

Section titled “Technical Debt 制造 Feature 本身没有要求的工作”

这也连接回前文 Technical Debt 的经济含义。

Ticket 的业务任务是:

Modal Dialog + Setting。

实际工作还包括:重建 State Ownership、寻找多个 Writer、理解 Persistence Behavior、发现隐式 Order Assumption、复现 Race Condition、分析 Regression、执行额外 Test。

这些工作没有任何一项让 Modal Dialog 对用户更有价值。

它们只是为了让团队能够 尽管存在当前结构,仍然可靠交付那个业务价值。

Technical Debt 正是在这里真正具有经济意义。Martini、Bosch 与 Chaudron 对多家大型公司的 Architectural Technical Debt 研究描述了:某些架构决策短期支持 Delivery,长期却持续阻碍 Feature Development。

Besker、Martini 与 Bosch 后续进一步研究 Developer 因 Technical Debt 具体在哪里损失时间。他们的 Longitudinal Study 与 Replication 发现相当多额外工作,尤其 Test 经常成为额外成本的重要来源。具体百分比不能直接套用到每家公司,但机制与这里高度相关:Technical Debt 不只表现为“有一天要 Refactor”,它会在每一天的普通 Feature Work 中持续产生额外劳动。

因此问题应该换一种问法。

不是:

为什么这个 Developer 做一个 Dialog 要五周?

而是:

为什么这个系统需要五周的 Development 与 Safety Work 才能交付一个 Dialog?

这不只是更礼貌。它改变了分析单位。

第一个问题寻找 Person 或 Estimate 的错误;第二个问题研究 System 为什么把 Local Requirement 放大成巨大 Technical Work Space。

这也是为什么这类系统里的 Effort Estimate 越来越困难。

Estimate 总是在假设一个 Expected Change Space。

如果 Modal Ticket 估两天,脑中可能是:添加 Dialog、使用现有 Settings Service、实现 30 天 Rule、写 Test。

在这个假设下,两天完全合理。

真正实现时才出现:

Settings State 有两个 Writer。

Backend 可以为同一个业务目的产生两个 Object。

读取时由 index 0 决定。

Order 没有业务保证。

还有其他 Reader 会对 State 反应。

Timing 会改变 Behavior。

这并不只是“已知工作估少了”。真正错误的是一开始假定的 Impact Space:Visible Structure 暗示这是 Local Change,但技术上这个 Local Boundary 根本不存在。

Big Ball of Mud 不只让 Change 难预测,它让 Change 到底有多大本身都难预测。

当然,没有任何 Developer 能在 Ticket 开始前知道所有问题。好架构里 Estimate 同样有不确定性,Software Development 不是 Deterministic Production Line。

但健康 Boundary 至少减少其中一种不确定性:帮助 Team 在 Change 开始前圈定 Relevant Area。

没有 Boundary 时,不只是在估 已知区域里的工作有多难,还在估 这个区域到底有多大

这里我故意用一个工作术语:Regression Surface

指的是 Change 之后,理性上必须被视为“可能受影响”的系统区域。

它不是严格数学面积,在本文里也不是标准 Software Metric。

这个词只是帮助问一个实用问题:

我没有修改哪些东西,但在这次 Change 后仍然必须检查?

在清晰 Slice 中,Regression Surface 可以很小。Domain State 有已知 Owner,Consumer 通过定义好的 Interface 访问,Test 保护相关 Invariant,其他区域可以以足够信心排除。

Boundary 消失后,Potential Surface 增长。

QA 需要测试更多 Flow;Reviewer 检查更多 Service;Developer 分析历史 Special Case;另一个 Team 被问自己是否依赖 Shared State;Release 更谨慎,因为“哪些东西确定不会受影响”的列表越来越难证明。

Change Impact Analysis 就是在处理这个问题:试图确定 Change 的 Potential Consequence,从而识别 Relevant System Part。几十年来研究与实践不断开发独立分析方法,本身就说明 Visible Patch 与 Possible Impact 是两个不同概念。

架构会直接影响这种分析有多困难。

于是来到本文最简单也最重要的问题:

这个 Change 在哪里结束?

健康架构不需要给完美答案,但应该提供足够可靠的答案。

这个 Domain State 属于这里。

这里拥有它。

只能通过这个 Interface 修改。

通过这条路径 Persist。

这些 Consumer 依赖它。

这些 Test 保护 Relevant Invariant。

再往外,就是这次 Change 不需要继续考虑的 Context。

这不是要求完全 Isolation。真实系统必然存在 Dependency。某次 Change 也可能业务上确实跨多个 Slice、Service 或 App。业务 Radius 本来很大时,大 Technical Radius 完全合理。

真正的问题更窄:

业务是 Local 的,系统却把它变成 Global。

此时三个 Radius 开始分离。

Business Change Radius 仍然小。

Technical Change Radius 增长。

Reasoning / Regression Radius 可能增长得更快。

有清晰 Boundary 的 Change Path 与没有 End Point 的系统对比。

架构最有价值的能力之一,是可靠告诉我们哪些系统部分不需要再继续看。

上一篇文章已经讨论过:Coding Agent 今天可以比人类单独工作时更经济地处理大型 Analysis Surface。

这会改变成本,但不会改变底层机制。

Agent 可以更快搜索 Settings Object 的所有 Writer,可以追 Reader、打开 Service、重建 Call Chain、执行 Test。于是巨大的 Reasoning Space 变得更可处理。

但它仍然没有回答真正架构问题:为什么一个 Local Change 原本需要这么大的 Reasoning Space?

因此即使 Tool 变化,Locality of Change 仍然重要。

今天再回头看那个 Ticket,最重要的就是这一点。

Modal Dialog 本身从来没有复杂到需要五周。

业务 Requirement 从头到尾都很小:显示 Dialog、保存选择、如果没有选择,30 天后再次询问。

真正昂贵的是穿过一个 System:同一个 Domain State 有多个 Writer,Persistence 没有唯一 Truth,连“哪个 Settings 才是有效 Settings”都依赖没有保证的 Collection Order。

Developer 不是五周都在挣扎着“如何写 Dialog”。

我们花几周时间才真正搞清楚,这个 Dialog 在现有系统里到底会触碰什么,并把这些 Effect 保护到足以 Release。

所以不应该通过“每次 Change 是否都很大”来识别 Big Ball of Mud。很多 Change 仍然小,很多可以一次成功,一些区域甚至可能非常稳定。

真正的问题是不可依赖。健康架构中,Local Change 通常拥有一个可以限定的 Technical Impact Space。在 Big Ball of Mud 中,Business Radius、Technical Radius 与 Required Reasoning Space 会越来越脱离彼此。

因此 Ticket 结束后真正值得问的不是:

改了多少 File?

而是:

为了负责任地完成这个 Local Change,我们到底必须理解和保护系统多大范围?

更一般地:

我在哪里可以有足够信心停止继续思考这次 Change?

如果答案经常是“没有地方”,那么最后确实会变成每次修改都让整个系统一起疼。

  • David L. Parnas: On the Criteria To Be Used in Decomposing Systems into Modules. Communications of the ACM 15(12), 1972, pp. 1053–1058. DOI: 10.1145/361598.361623. 该文从可理解性、可变更性以及把易变 Design Decision 隐藏在 Module Boundary 后面等角度论证 Modularization。
  • Harald Gall, Karin Hajek, Mehdi Jazayeri: Detection of Logical Coupling Based on Product Release History. ICSM 1998, pp. 190–198. DOI: 10.1109/ICSM.1998.738508. 作者分析大型 Telecom System 的 Release History,说明共同 Change Pattern 可以暴露 Static Coupling 之外的 Logical Dependency。
  • Cleidson R. B. de Souza, David F. Redmiles: An Empirical Study of Software Developers’ Management of Dependencies and Changes. ICSE 2008, pp. 241–250. DOI: 10.1145/1368088.1368122. 研究把 Change Impact Analysis 描述为识别 Change Potential Consequence,并分析 Developer 在实践中如何管理 Dependency 与 Change。
  • Jonathan Sillito, Gail C. Murphy, Kris De Volder: Questions Programmers Ask During Software Evolution Tasks. SIGSOFT FSE 2006, pp. 23–34. DOI: 10.1145/1181775.1181779. 研究 Developer 在真实 Software Evolution Task 中需要从 Codebase 获得哪些信息。
  • Antonio Martini, Jan Bosch, Michel Chaudron: Investigating Architectural Technical Debt Accumulation and Refactoring over Time: A Multiple-Case Study. Information and Software Technology 67, 2015, pp. 237–253. DOI: 10.1016/j.infsof.2015.07.005. 多案例研究分析 Architectural Technical Debt 的形成与长期影响。
  • Terese Besker, Antonio Martini, Jan Bosch: Software Developer Productivity Loss Due to Technical Debt – A Replication and Extension Study Examining Developers’ Development Work. Journal of Systems and Software 156, 2019, pp. 41–61. DOI: 10.1016/j.jss.2019.06.004. 研究 Technical Debt 导致的额外 Development Work,并把额外 Analysis/Test Work 识别为 Productivity Loss 的重要来源。
  • John Ousterhout: The Nature of Complexity. Stanford CS 190, Lecture Notes, 2018. Ousterhout 使用 “Change Amplification” 描述简单修改要求多处变化,并将其与 Cognitive Load 与 “Unknown Unknowns” 并列。