2017 年,某大型电商平台需要在商品详情页增加一个”预计送达时间”的展示字段。需求本身不复杂:根据用户地址、库存位置和物流模板计算一个时间区间,然后渲染到页面上。产品经理估计三天完成。
实际开发用了六周。
工程师需要改动 15 个服务:商品服务要透传物流属性,库存服务要暴露仓库地理信息,用户服务要提供默认地址,物流服务要新增计算接口,网关层要聚合数据,BFF 层要组装字段,前端要适配新的数据结构,还有缓存策略、灰度开关、监控埋点、数据同步任务……每个服务的改动本身都不大,但它们之间的协调成本吞噬了全部时间。上线后又因为一个服务的超时配置不一致引发了级联故障。
这个场景不是个例。几乎每个存活超过三年的系统都会经历类似的时刻:单个功能的实现成本与系统规模之间不再是线性关系,而是呈现出某种加速增长的趋势。这种加速增长的背后,就是复杂性(Complexity)在起作用。
本文要回答的核心问题是:系统复杂性从何而来?架构师能做什么来控制它的增长?哪些手段有实证支撑,哪些只是纸上谈兵?
一、Brooks 的划分:本质复杂性与偶然复杂性
“没有银弹”的核心论点
1986 年,Fred Brooks 在《No Silver Bullet — Essence and Accident in Software Engineering》中提出了一个至今仍然有效的划分框架。他把软件开发中的困难分为两类:
- 本质复杂性(Essential Complexity):由问题域本身决定的复杂性。税务系统之所以复杂,是因为税法本身就复杂;航空管制系统之所以困难,是因为空域冲突检测本身就是一个高维约束满足问题。这部分复杂性不会因为换一种编程语言或框架而消失。
- 偶然复杂性(Accidental Complexity):由我们选择的解决方案引入的复杂性。手动内存管理、跨服务的数据一致性协调、构建工具链的配置、重复的样板代码——这些都不是问题域要求的,而是技术实现带来的副产品。
Brooks 论文中”Accidental”一词容易被误解为”意外的”。实际上 Brooks 取的是哲学术语含义——“附属的、非内在的”(与 Essential 对应),而非日常用语中的”偶然发生的”。一个设计决策引入的偶然复杂性可能是有意识的权衡结果,而不是”失误”。
Brooks 的关键论断是:软件工程中没有任何单一技术或方法能在十年内将生产力提高一个数量级。他的推理逻辑是这样的:
- 软件开发的困难分为本质困难和偶然困难。
- 过去几十年的工具进步(高级语言、分时系统、集成开发环境)主要解决的是偶然困难。
- 随着偶然困难被逐步消除,本质困难在总困难中的占比越来越高。
- 本质困难不可能通过工具消除——你不能靠更好的编译器简化税法本身。
- 因此,能被进一步压缩的空间有限,不足以支撑一个数量级的提升。
Brooks 特别讨论了他认为的四个本质困难:
- 复杂性(Complexity):软件实体的规模使得没有两个部分是相同的(不同于建筑中的重复构件)。
- 一致性(Conformity):软件必须与外部世界(已有接口、法规、硬件)保持一致,而这些外部约束本身缺乏逻辑一致性。
- 可变性(Changeability):软件比建筑更容易被要求修改,因为”修改成本低”的感知导致变更请求不断涌入。
- 不可见性(Invisibility):软件没有空间上的直觉表示,难以用图形完整呈现。
这个划分对架构师意味着什么
Brooks 的论文发表近四十年后,偶然复杂性的比例到底有多高,仍然没有定论。但架构师可以从这个框架中提取出一条可操作的原则:
区分”必须承受的复杂性”和”可以消除的复杂性”,然后把精力集中在后者上。
举例来说,一个跨境支付系统必须处理多币种汇率转换、反洗钱合规检查、跨时区对账——这些是本质复杂性,架构师不应该试图绕过它们,而应该为它们提供清晰的建模空间。但如果这个系统因为历史原因把汇率服务和用户认证服务耦合在同一个模块里,导致每次汇率规则变更都要重新测试认证逻辑,那就是偶然复杂性——架构师应该动手消除它。
John Ousterhout 在《A Philosophy of Software Design》(2018)中提出了一个与 Brooks 互补的视角:复杂性不是某个单一决策造成的,而是成百上千个小决策累积的结果。他用”complexity is incremental”来描述这个过程——每一次”先这样写,以后再改”的妥协,每一次”加个 flag 就能解决”的快捷方案,单独来看都无伤大雅,但它们的总和构成了系统的复杂性负担。
Ousterhout 进一步给出了复杂性的三个症状:
- 变更放大(Change Amplification):一个看似简单的变更需要在很多地方修改代码。开头那个电商案例中,增加一个字段需要改动 15 个服务,就是变更放大的典型表现。
- 认知负荷(Cognitive Load):开发者需要了解多少信息才能完成一项任务。如果修改一个函数之前要先理解它调用的五个其他函数的副作用,认知负荷就很高。
- 未知的未知(Unknown Unknowns):开发者不知道完成一项任务需要了解什么信息。这是最危险的一种——前两种你至少知道问题存在,第三种是你以为自己改完了,实际上漏掉了一个关键的地方。
这意味着复杂性管理不是一次性的架构决策,而是一个持续的纪律。
复杂性增长的两种模式
在实际项目中,复杂性的增长大致呈现两种模式:
模式一:线性增长。系统的功能增加时,复杂性按比例增长。增加一个新功能模块,只需要理解和修改这个模块本身,不需要触碰已有模块。这种情况通常发生在模块划分合理、边界清晰的系统中——每个新模块通过已有的稳定接口与系统集成,不引入额外的耦合。
模式二:超线性增长。系统的功能增加时,复杂性的增长速度快于功能的增长。增加第 n 个功能模块时,不仅要实现这个模块本身,还要处理它与已有 n-1 个模块之间的潜在交互。如果模块之间的耦合没有被有效控制,交互数量以 O(n^2) 增长——这就是前面那个电商案例的根本原因。
下面这张 Mermaid 图描述了这两种增长模式,以及架构干预的效果:
graph TD
subgraph 复杂性增长模式
direction LR
A["阶段 1<br/>3 个模块<br/>3 条依赖"] -->|增加功能| B["阶段 2<br/>6 个模块<br/>15 条依赖"]
B -->|继续增加| C["阶段 3<br/>10 个模块<br/>45 条依赖"]
end
subgraph 架构干预后
direction LR
D["阶段 1<br/>3 个模块<br/>3 条依赖"] -->|增加功能| E["阶段 2<br/>6 个模块<br/>8 条依赖<br/>限界上下文隔离"]
E -->|继续增加| F["阶段 3<br/>10 个模块<br/>14 条依赖<br/>契约 + 事件解耦"]
end
上方路径展示的是无架构干预的情况:模块数从 3 增长到 10 时,如果每个模块都可能和其他模块直接交互,依赖数量从 3 增长到 45(接近 n*(n-1)/2 的上界)。下方路径展示的是通过限界上下文、API 契约和事件机制进行架构干预后的情况:模块数同样增长到 10,但因为模块被隔离在不同的上下文中、通过契约交互,依赖数量只从 3 增长到 14。
这张图简化了真实情况——实际系统的依赖增长不会精确地按公式变化。但它传达了一个核心观点:架构手段的本质作用是把复杂性增长从超线性拉回到接近线性。不是消除复杂性,而是控制增长速率。
Brooks 所说的”没有银弹”可以用这个模型重新理解:本质复杂性对应的是每个模块内部的功能实现成本,它随功能数量线性增长,不可压缩。偶然复杂性对应的是模块之间不必要的交互成本——这部分可以通过架构手段压缩。银弹不存在,是因为线性增长的本质复杂性始终存在;但架构很重要,是因为超线性增长的偶然复杂性是可以控制的。
二、复杂性的四个来源
系统复杂性不会凭空出现。从工程实践中归纳,复杂性主要来自四个方向:依赖与耦合、状态管理、边界模糊、技术债务累积。下面逐一展开。
依赖与耦合
依赖是复杂性最直观的来源。当模块 A 的变更要求模块 B 也必须变更时,这两个模块之间就存在耦合。耦合本身不是坏事——零耦合的系统什么也做不了。问题在于耦合的方向、强度和可见性。
Robert C. Martin 在《Agile Software Development: Principles, Patterns, and Practices》(2002)中引入了两个度量指标来量化模块的耦合特征:
- 传入耦合(Afferent Coupling, Ca):依赖该模块的外部模块数量。Ca 高意味着这个模块被广泛使用,它的变更影响面大。
- 传出耦合(Efferent Coupling, Ce):该模块依赖的外部模块数量。Ce 高意味着这个模块对外部变化敏感,容易因为依赖变更而被迫修改。
由此衍生出不稳定性指标(Instability, I):
I = Ce / (Ca + Ce)
I 的取值范围是 [0, 1]。I = 0 表示完全稳定(只被别人依赖,不依赖别人),I = 1 表示完全不稳定(只依赖别人,不被别人依赖)。
稳定性本身不是好坏之分。Martin 提出的稳定依赖原则(Stable Dependencies Principle, SDP) 要求:模块的依赖方向应该朝着稳定性递增的方向。也就是说,不稳定的模块可以依赖稳定的模块,但反过来不行。如果一个被大量模块依赖的核心模块反过来又依赖了一个频繁变更的边缘模块,整个依赖链条的稳定性就会被最薄弱的环节拉低。
为什么要用两个独立的指标(Ca 和 Ce)而不只用一个?因为同一个 I 值可以对应不同的耦合特征。Ca=10, Ce=10 和 Ca=1, Ce=1 的 I 值都是 0.5,但前者是一个处于系统枢纽位置的高流量模块,后者只是一个普通的中间层组件。区分这两种情况对治理策略很重要——前者需要严格的接口稳定性保证,后者的改动相对安全。
Martin 还提出了抽象度指标(Abstractness, A),定义为模块中抽象类和接口占总类数的比例。结合不稳定性指标,他定义了主序列(Main Sequence)——一条从 (1, 0) 到 (0, 1) 的线段,表示”越稳定的模块应该越抽象,越不稳定的模块可以越具体”。偏离主序列的模块可能有问题:
- 远离主序列、靠近 (0, 0) 的模块落在”痛苦区(Zone of Pain)“——高度稳定且高度具体,改起来费劲但又无法避免。
- 远离主序列、靠近 (1, 1) 的模块落在”无用区(Zone of Uselessness)“——高度不稳定且高度抽象,没人依赖的抽象层。
用一个具体的例子说明。假设一个系统有以下模块和依赖关系:
模块 Ca Ce I A D(到主序列的距离)
--------------------------------------------------------------
domain-model 8 1 0.11 0.75 0.14 (健康:稳定且抽象)
user-service 3 4 0.57 0.40 0.03 (健康:接近主序列)
payment-gateway 0 6 1.00 0.10 0.10 (健康:不稳定但具体)
utils-common 12 0 0.00 0.05 0.95 (痛苦区:稳定但太具体)
abstract-base 0 0 — 1.00 — (无用区:没人用的抽象)
这张表里最值得关注的是 utils-common:Ca=12
意味着 12 个模块都依赖它,I=0 意味着它必须高度稳定,但
A=0.05 意味着它几乎全是具体实现、没有抽象层。距离主序列
D=0.95,深陷痛苦区。任何对它的修改都有极高的风险——这就是前面案例中那个
2,000 行 utils.py 的数学描述。
状态管理
状态使系统的行为从”给定输入,输出确定”变成了”给定输入,输出取决于系统当前处于哪个状态”。状态空间的膨胀速度是指数级的:如果一个系统有 n 个独立的布尔状态变量,理论上就有 2^n 个可能的状态组合。
状态导致复杂性的方式有三种:
第一,隐式状态。状态散布在多个地方,没有一个统一的视图。数据库里一份、缓存里一份、内存里一份、消息队列里还有一份正在传播中的旧值。开发者修改了数据库中的状态,但忘记失效缓存,或者没有考虑到消息队列中还有基于旧状态的事件正在被消费。这种不一致不是缺陷(bug),而是架构层面没有建立状态一致性的保障机制。
第二,状态耦合。一个状态变量的合法取值依赖于另一个状态变量的当前值。订单状态只有在支付状态为”已支付”时才能转为”已发货”——这种约束如果没有被显式建模,就会变成散落在代码各处的 if-else 条件。更危险的是跨服务的状态耦合:支付状态在支付服务中管理,订单状态在订单服务中管理,两者之间的一致性需要分布式协调。
第三,并发状态。多个线程或进程同时读写共享状态,引入竞态条件(Race Condition)和一致性问题。并发状态之所以特别危险,是因为它产生的问题是非确定性的——同样的输入在不同的时序下可能产生不同的结果,而且这些时序条件在测试环境中往往难以复现。
控制状态复杂性的常见架构手段包括:将状态集中到明确的”状态所有者”(单一数据源原则);用显式的状态机替代散落的条件判断;在可能的范围内使用不可变数据结构;为跨服务的状态一致性选择明确的一致性模型(强一致还是最终一致),并在架构文档中记录这个决策。
边界模糊
当一个系统的模块之间没有清晰的职责边界时,每个模块都知道太多关于其他模块的事情。这导致两个后果:
第一,变更无法被局部化。修改一个功能需要理解多个模块的内部实现,因为它们的职责交织在一起。开头那个电商案例就是典型的边界模糊——“预计送达时间”这个功能的逻辑被分散到十几个服务中,没有一个服务拥有完整的上下文。
边界模糊的常见信号包括:
- 一个变更请求需要多个团队协调才能完成。
- 代码审查时频繁出现”这个改动需要同时修改 X 模块”的评论。
- 服务之间存在循环依赖。
- 同一个业务概念在多个模块中有不同的定义,但没有显式的映射关系。
第二,团队协作效率下降。如果两个团队维护的模块在职责上有重叠,他们就不得不频繁协调。Conway 定律在这里起作用:组织边界和系统边界不一致时,沟通成本会急剧上升。Mel Conway 在 1968 年的原始论文中指出:“设计系统的组织,其产生的设计等同于组织之间的沟通结构。”反过来说,如果你想要一个模块边界清晰的系统,你的团队边界也需要与之对齐。
技术债务累积
Ward Cunningham 在 1992 年的 OOPSLA 报告中首次使用”技术债务(Technical Debt)“这个比喻来描述为了短期速度而牺牲长期代码质量的决策。和金融债务一样,技术债务本身不一定是坏事——有时候为了快速验证市场假设,有意识地走捷径是合理的商业决策。问题在于:技术债务有利息。
不同于金融债务,技术债务的利息不是固定利率,而是递增的。原因有两个:
- 复合效应:新代码不得不绕过已有的变通方案(Workaround),在变通方案之上再叠加变通方案。第一笔债务的利息是一次绕行,第二笔债务可能要绕行两次,第 n 笔债务的绕行成本取决于前 n-1 笔债务的总和。
- 知识损耗:当初选择走捷径的人离开团队后,新人不知道”为什么代码写成这样”,只能小心翼翼地在现有结构上添加逻辑,不敢重构——因为他们不知道哪些”奇怪的代码”是在应对某个边界情况,哪些只是历史遗留。
Martin Fowler 提出了技术债务的四象限分类,帮助团队区分不同性质的债务:
| 有意识(Deliberate) | 无意识(Inadvertent) | |
|---|---|---|
| 审慎的(Prudent) | “我们知道这样做不理想,但为了赶上发布窗口,先用这个方案,下个迭代重构” | “现在回头看,当时如果用事件驱动而不是同步调用会更好” |
| 鲁莽的(Reckless) | “我们没时间做设计” | “分层是什么?” |
只有左上角的债务——有意识、审慎的债务——是架构师应该主动承担的。右上角的债务是学习的结果,不可避免但应该尽快偿还。下面两种——鲁莽的债务——应该通过更好的流程、培训和架构约束来预防。
在架构层面管理技术债务,有几条可操作的建议:
- 记录债务。每次有意识地引入技术债务时,在决策记录(ADR)或专门的债务登记簿中记下:做了什么妥协、为什么、影响范围、预计何时偿还。不记录的债务会被遗忘,直到它以生产事故的形式被想起来。
- 量化利息。用变更耦合分析和代码变动率来评估哪些债务正在产生最高的”利息”——哪些技术债务导致了最多的额外修改、最多的关联变更。优先偿还高利息的债务,而不是最容易偿还的债务。
- 设定上限。在迭代规划中为债务偿还预留固定比例的时间(例如每个迭代 20%)。不需要每次都偿还最大的债务——持续的小额偿还比偶尔的大规模重构更可持续,也更安全。
一个关键观察:四种复杂性来源之间不是独立的,而是相互强化的。边界模糊导致依赖增多,依赖增多导致变更成本上升,变更成本上升导致团队倾向于走捷径(积累技术债务),技术债务进一步模糊模块边界。这是一个正反馈循环——如果不主动干预,系统的复杂性会加速增长。
认识到这个循环的存在,就能理解为什么复杂性治理不能只靠一次性的重构:即使把当前的债务全部偿还,如果没有建立防止债务重新累积的机制(架构约束、代码审查、度量监控),循环会重新启动。
三、认知负荷:复杂性的人因维度
复杂性不仅是系统的属性,也是人对系统的感知。两个在代码度量上同样复杂的系统,如果一个的代码组织方式更符合开发者的心智模型,它在实践中就更容易维护。这把我们引向认知负荷(Cognitive Load)理论。
Sweller 的认知负荷理论
John Sweller 在 1988 年提出认知负荷理论,最初用于教育领域。该理论将人在处理信息时承受的认知负荷分为三类:
- 内在认知负荷(Intrinsic Cognitive Load):由任务本身的内在难度决定。理解一个递归算法比理解一个顺序循环需要更多的工作记忆资源,这不取决于代码写得好不好,而取决于算法本身的性质。内在认知负荷取决于”元素交互性”(Element Interactivity)——需要同时处理的信息元素数量越多,内在负荷越高。
- 外在认知负荷(Extraneous Cognitive Load):由信息的呈现方式引入的额外负担。不一致的命名规范、过深的代码嵌套、缺乏注释的复杂表达式、需要在多个文件之间跳转才能理解一个完整的流程——这些都会消耗开发者的工作记忆,但并不帮助他们理解问题本身。外在认知负荷是”可以被设计消除”的部分,这和 Brooks 的”偶然复杂性”在概念上是对应的。
- 相关认知负荷(Germane Cognitive Load):用于建立和巩固心智模型(Schema)的认知投入。开发者花时间理解系统的整体架构、建立模块间关系的心智地图——这种投入是有价值的,因为它会形成长期记忆中的知识结构,降低未来任务的认知负荷。
认知科学的一个基本发现是:人的工作记忆容量非常有限。George Miller 在 1956 年的经典论文中提出”7 +/- 2”规则,后续研究(Cowan,《The Magical Number 4 in Short-Term Memory》, 2001)将这个估计修正为更接近 4 个信息块(Chunk)。这意味着开发者在任一时刻能同时持有和操作的独立概念数量大约只有 4 个。
这个限制有一个重要推论:模块的接口复杂性不能超过开发者工作记忆的容量。一个需要同时理解 8 个参数的函数调用、一个有 12 个方法的接口、一个涉及 6 个服务协调的业务流程——这些都超出了工作记忆的舒适区,开发者只能通过反复查看文档和代码来弥补,效率急剧下降,出错概率显著上升。
Team Topologies 的认知负荷分类
Matthew Skelton 和 Manuel Pais 在《Team Topologies》(2019)中将 Sweller 的认知负荷理论应用到了软件团队的组织设计中,提出了对应的三种团队认知负荷:
- 内在认知负荷(Intrinsic):与业务域本身的复杂性相关。金融衍生品定价团队面临的内在认知负荷就比一个 CRUD 管理后台团队更高。这部分负荷不可压缩,只能通过招聘领域专家、提供培训来应对。
- 外在认知负荷(Extraneous):与基础设施、工具链、跨团队协调相关。如果一个团队在写业务逻辑之前要先花半天配置 CI/CD 流水线、调试 Kubernetes 部署文件、理解三层服务网格的路由规则,这些都是外在认知负荷。它们不帮助团队交付业务价值,但消耗了工作记忆和注意力。
- 相关认知负荷(Germane):团队用于深入理解业务域、改进解决方案质量的投入。这是应该被最大化的部分——团队理解域知识越深入,做出的设计决策质量越高。
Skelton 和 Pais 的核心主张是:架构和平台的目标应该是最小化外在认知负荷,从而释放团队的工作记忆用于内在和相关认知负荷。他们建议用一个简单的试金石来检验架构方案:如果一个团队的成员普遍反映”我们把太多时间花在了和业务无关的事情上”,那就是外在认知负荷过高的信号。
架构决策如何影响认知负荷
下面这张表给出了常见架构决策对三类认知负荷的影响方向和机制:
| 架构决策 | 对内在负荷的影响 | 对外在负荷的影响 | 对相关负荷的影响 |
|---|---|---|---|
| 清晰的模块边界 | 不变 | 降低:开发者只需理解当前模块 | 提升:更容易建立局部心智模型 |
| 统一的错误处理范式 | 不变 | 降低:减少记忆不同模块的错误约定 | 不变 |
| 过度抽象(例如五层继承) | 不变 | 升高:理解一个行为要追踪多层 | 降低:心智模型变得不可靠 |
| 共享可变状态 | 不变 | 升高:需要时刻考虑并发影响 | 降低:难以推理系统行为 |
| 平台团队提供标准化部署模板 | 不变 | 降低:部署成为标准流程 | 释放精力用于业务理解 |
| 没有文档的微服务拆分 | 不变 | 升高:需要逆向工程理解服务职责 | 降低:无法建立全局心智模型 |
| 领域特定语言(DSL) | 可能降低:用域概念替代通用编程概念 | 升高或降低:取决于 DSL 的设计质量 | 提升:强制用业务语言思考 |
| 事件驱动替代同步调用 | 不变 | 升高:需要理解最终一致性模型 | 提升:解耦促进独立推理 |
这张表的一个关键观察是:架构决策几乎无法降低内在认知负荷——那是问题域决定的——但可以显著影响外在和相关认知负荷。这与 Brooks 的本质/偶然复杂性划分是一致的。
还有一个容易被忽略的问题:某些降低外在认知负荷的决策会增加另一种外在认知负荷。例如,引入事件驱动架构消除了同步调用链的理解负担,但引入了事件流追踪、最终一致性和幂等性处理的理解负担。这不是说事件驱动不好,而是说每个架构决策的认知负荷影响需要整体评估,不能只看一个维度。
我认为认知负荷视角对架构师的最大启发在于:一个架构方案的好坏,不能只从技术维度评估,还要从”开发者需要在脑子里同时持有多少概念才能完成一项典型任务”这个维度来评估。如果一个架构方案在技术上很优雅,但要求开发者同时理解分布式事务、事件溯源、CQRS 和 Saga 编排才能修改一个简单的业务规则,那么这个方案引入的外在认知负荷很可能会抵消它在技术上的优势。
降低认知负荷的架构手段
基于上面的分析,降低认知负荷的架构手段可以归纳为几条原则:
原则一:缩小工作范围。让开发者完成一项典型任务时只需要理解系统的一个局部,而不是全局。这要求模块有清晰的边界和最小化的接口——开发者在一个模块内部工作时,只需要理解这个模块的内部逻辑和它的公共接口,不需要知道其他模块的实现细节。
原则二:减少意外因素。代码的行为应该符合开发者的预期。函数应该做它的名字所暗示的事情,不多也不少。如果一个名为
getUser()
的函数在获取用户信息的同时还更新了最近访问时间、记录了审计日志、触发了一个异步事件——它的行为就超出了名字的暗示范围。Ousterhout
把这种现象称为”浅层接口背后的深层副作用”,它制造了”未知的未知”。
原则三:保持一致性。在整个系统中使用一致的命名规范、错误处理方式、代码组织结构和设计模式。一致性降低外在认知负荷的原因是:开发者在一个模块中建立的心智模型可以直接迁移到另一个模块。如果每个模块的错误处理方式都不同——一个用异常、一个用错误码、一个用 Result 类型——开发者每次进入一个新模块都要重新学习约定。
原则四:显式优于隐式。依赖关系、配置来源、状态转换规则应该在代码中显式表达,而不是隐藏在文档里或者依赖开发者的口口相传。显式的代价是代码量增加,但它降低了”未知的未知”的风险——开发者至少能通过阅读代码发现所有相关的信息,而不需要猜测或者去问”那个当年做这个功能的人”。
四、模块化与信息隐藏:对抗复杂性的基本武器
Parnas 的分解准则
1972 年,David Parnas 发表了《On the Criteria To Be Used in Decomposing Systems into Modules》,这篇论文在软件工程史上的地位怎么强调都不过分。Parnas 用一个具体的系统——KWIC(Key Word in Context)索引系统——展示了两种不同的模块分解方式,以及它们在面对变更时的不同表现。
KWIC 系统的功能是:输入一组标题,对每个标题生成所有可能的循环移位(Circular Shift),按字母序排序后输出。例如,“The Quick Brown Fox”会生成四个移位版本:“The Quick Brown Fox”“Quick Brown Fox The”“Brown Fox The Quick”“Fox The Quick Brown”。
Parnas 给出了两种分解方式:
方式一(流程图分解)。按照处理步骤划分模块——输入模块负责读取数据并存入共享数组、循环移位模块负责生成所有移位并存入第二个共享数组、排序模块负责对移位结果排序、输出模块负责格式化输出。每个模块对应数据处理流水线上的一个阶段。模块之间通过共享数据结构通信。
方式二(信息隐藏分解)。按照”可能变化的设计决策”划分模块——行存储模块封装数据的内部表示方式(是用数组、链表还是其他结构来存储行?外部不需要知道)、循环移位模块封装移位的实现算法(是预先生成所有移位还是按需计算?外部不需要知道)、排序模块封装排序算法和排序依据、输入模块封装输入格式的解析逻辑、输出模块封装输出格式。
两种方式产生的模块数量相同,但在面对变更时表现截然不同:
- 当数据存储格式从数组改为链表时,方式一需要修改几乎所有模块(因为它们都直接操作共享的数组),而方式二只需要修改行存储模块。
- 当排序算法从插入排序改为快速排序时,方式一需要修改排序模块以及所有依赖排序结果索引的模块,而方式二只需要修改排序模块。
- 当输入格式从文本文件改为网络流时,方式一和方式二都只需要修改输入模块——但在方式一中,如果输入模块同时改变了共享数据结构的写入方式,其他模块也要跟着改。
用一张表来对比两种方式面对不同变更时的影响范围:
| 变更类型 | 方式一(流程图分解)需修改的模块 | 方式二(信息隐藏分解)需修改的模块 |
|---|---|---|
| 数据存储格式从数组改为链表 | 输入、循环移位、排序、输出(4 个) | 行存储(1 个) |
| 排序算法从插入排序改为归并排序 | 排序 + 依赖排序索引的模块(2-3 个) | 排序(1 个) |
| 输入格式从文本文件改为 JSON | 输入 + 可能影响共享数据结构的模块(1-2 个) | 输入(1 个) |
| 输出格式从纯文本改为 HTML | 输出(1 个) | 输出(1 个) |
这张表清楚地显示了信息隐藏的价值:在四种变更场景中,方式二的变更影响范围要么与方式一相同(最后一行),要么显著更小(前三行)。没有任何场景下方式二比方式一更差。这不是巧合——信息隐藏通过将设计决策封装在模块内部,从结构上保证了变更的局部性。
信息隐藏原则
Parnas 从这个案例中提炼出的原则是:模块的划分依据不应该是处理流程,而应该是”设计决策的隐藏”。每个模块应该封装一个可能变化的设计决策,对外只暴露稳定的接口。模块的用户不需要知道这个决策是什么,也不需要知道它是怎么实现的。
这就是信息隐藏(Information Hiding)原则。它的核心洞察是:模块的价值不在于它做了什么(每种分解方式的模块都能完成任务),而在于它隐藏了什么。隐藏得越好,变更时的波及范围越小。
信息隐藏和封装(Encapsulation)经常被混为一谈,但它们的层次不同。封装是一种语言机制(通过
private/public
等访问控制实现),信息隐藏是一种设计原则。用 Parnas
自己的话说,信息隐藏关注的是”什么信息应该对模块的用户不可见”,而不是”用什么语法来实现不可见”。一个模块可以把所有字段都设为
private,但如果它的接口设计隐含了内部实现细节(例如方法名
getFromRedis()
暴露了缓存引擎类型),它就没有做到信息隐藏。
反过来,一个用 C 语言编写的模块没有 private
关键字,但如果它只通过头文件暴露函数声明,把实现细节留在
.c 文件中,并通过 opaque pointer
隐藏数据结构定义,它就做到了信息隐藏——尽管没有语言级别的封装机制。
量化模块性:耦合、内聚与共生性
直觉上我们都知道”高内聚、低耦合”是好的模块划分。但这种直觉需要更精确的度量工具来支撑。
内聚(Cohesion) 衡量的是模块内部元素之间的关联强度。Larry Constantine 和 Edward Yourdon 在《Structured Design》(1979)中提出了内聚的七级分类(从低到高):
- 偶然内聚(Coincidental):模块内的元素没有有意义的关系,只是碰巧被放在一起。典型的
Utils类或Common包——什么都往里扔。 - 逻辑内聚(Logical):元素执行逻辑上相似的功能,但实际上互不相关。例如一个模块同时处理日志输出、控制台输出和文件输出,只因为它们都是”输出”。
- 时间内聚(Temporal):元素因为在同一时间点执行而被放在一起。例如”系统初始化模块”把数据库连接池初始化、缓存预热、配置加载放在一起。
- 过程内聚(Procedural):元素必须按特定顺序执行,且存在控制流关系。
- 通信内聚(Communicational):元素操作相同的数据,但各自独立。
- 顺序内聚(Sequential):一个元素的输出是下一个元素的输入,形成数据流。
- 功能内聚(Functional):所有元素共同完成一个单一、明确定义的功能,缺一不可。
在实际代码中,纯粹的功能内聚不总是可行的。但有一个简单的检验方法:能否用一个不包含”和”字的短句描述模块的职责? 如果需要说”这个模块负责用户认证和权限管理和审计日志”,那它的内聚度可能不够高。
耦合(Coupling) 衡量的是模块之间的依赖强度。从高(有害)到低(良好):
- 内容耦合(Content Coupling):一个模块直接修改另一个模块的内部数据或跳转到另一个模块的内部代码。
- 公共耦合(Common Coupling):多个模块共享全局变量或全局状态。
- 控制耦合(Control Coupling):一个模块通过传递控制标志(布尔参数、枚举)来影响另一个模块的执行路径。
- 印记耦合(Stamp Coupling):模块之间传递数据结构,但接收方只使用其中一部分字段。
- 数据耦合(Data Coupling):模块之间只通过参数传递简单数据值。这是最弱、最理想的耦合形式。
Meilir Page-Jones 在《What Every Programmer Should Know About Object-Oriented Design》(1996)中提出了共生性(Connascence),作为耦合和内聚的统一度量框架。共生性的定义是:如果改变组件 A 要求组件 B 也必须相应改变才能保持系统正确性,则 A 和 B 之间存在共生性。
共生性按强度从弱到强排列:
| 共生性类型 | 英文名 | 描述 | 示例 |
|---|---|---|---|
| 名称共生 | Connascence of Name | 多个组件必须就名称达成一致 | 函数名、参数名、类名 |
| 类型共生 | Connascence of Type | 多个组件必须就类型达成一致 | 参数类型、返回值类型 |
| 含义共生 | Connascence of Meaning | 多个组件必须就值的含义达成一致 | 状态码 200 表示成功、true 表示启用 |
| 位置共生 | Connascence of Position | 多个组件必须就元素的位置达成一致 | 函数参数的顺序 |
| 算法共生 | Connascence of Algorithm | 多个组件必须就算法达成一致 | 哈希算法、加密算法 |
| 执行顺序共生 | Connascence of Execution | 多个组件的执行顺序必须特定 | 先初始化再使用 |
| 时序共生 | Connascence of Timing | 多个组件的执行时机必须协调 | 超时设置、竞态条件 |
| 值共生 | Connascence of Value | 多个组件的值必须满足特定约束 | 数据库冗余字段保持同步 |
| 身份共生 | Connascence of Identity | 多个组件必须引用同一个对象实例 | 单例依赖 |
共生性有三个重要的分析维度:
- 强度(Strength):上表从上到下强度递增。弱共生性(如名称共生)容易发现和修复,强共生性(如时序共生)难以检测和处理。
- 局部性(Locality):共生性发生在模块内部还是模块之间?模块内部的强共生性是可接受的(甚至是好的——意味着高内聚),但模块之间的强共生性是危险信号。
- 程度(Degree):共生性涉及多少个元素?两个组件之间的名称共生问题不大,但如果 50 个组件都依赖同一个名称,影响面就大得多。
Page-Jones 给出的三条指导原则:
- 跨模块边界的共生性应该尽量弱。模块之间尽量只保留名称共生和类型共生。
- 模块内部的共生性可以更强。函数内部的变量位置依赖、算法依赖都是正常的。
- 随着共生性强度增加,涉及的元素数量应该减少。如果你不得不在模块之间引入算法共生(例如序列化/反序列化必须用同一种算法),至少应该把它限制在尽可能少的组件之间。
共生性在微服务架构中的表现尤为突出。考虑一个场景:订单服务发布一个事件到消息队列,支付服务消费这个事件。两个服务之间至少存在以下共生性:
- 名称共生:事件的 topic 名称必须一致。
- 类型共生:事件的数据类型必须匹配。
- 含义共生:事件中某些字段的值含义必须双方理解一致(例如金额的单位是分还是元)。
- 算法共生:如果事件体经过序列化,双方必须使用同一种序列化格式。
这四种都是跨模块边界的共生性。按照 Page-Jones 的原则,应该尽量只保留弱共生性(名称和类型),把强共生性(含义和算法)降级——例如通过 schema registry(模式注册表)来统一管理事件的类型定义和序列化格式,减少含义共生和算法共生的隐患。
不稳定性指标与信息隐藏的关系
回到 Robert C. Martin 的不稳定性指标 I = Ce / (Ca + Ce)。这个指标与信息隐藏原则之间有直接的联系。一个信息隐藏做得好的模块,其接口稳定、实现可变,因此:
- 如果它是底层基础模块(如领域模型、核心算法库):Ca 高(很多模块依赖它),Ce 低(它不依赖别人),I 接近 0,高度稳定。这类模块的接口变更成本极高,需要格外谨慎。
- 如果它是上层业务模块(如 API 控制器、视图层):Ca 低(很少有模块依赖它),Ce 较高(它调用底层服务),I 接近 1,不稳定但可以自由变化。这类模块的变更不会波及其他模块。
下面这张 Mermaid 图展示一个健康的依赖结构和一个存在问题的依赖结构对比:
graph TB
subgraph 健康结构:依赖方向朝着稳定性递增
direction TB
UI1["UI 层 I≈1.0"] --> BIZ1["业务逻辑层 I≈0.5"]
BIZ1 --> CORE1["核心域模型 I≈0.0"]
BIZ1 --> INFRA1["基础设施抽象 I≈0.1"]
end
subgraph 问题结构:稳定模块依赖了不稳定模块
direction TB
UI2["UI 层 I≈1.0"] --> BIZ2["业务逻辑层 I≈0.5"]
BIZ2 --> CORE2["核心域模型 I≈0.0"]
CORE2 -.->|"违反 SDP"| UTIL2["工具模块 I≈0.8"]
UTIL2 --> EXT2["外部 SDK I≈1.0"]
end
右侧结构中,核心域模型(I≈0.0,高度稳定)依赖了工具模块(I≈0.8,高度不稳定),违反了稳定依赖原则。工具模块一旦因外部 SDK 变化而修改接口,核心域模型就被迫跟着改——而核心域模型的变更会波及所有依赖它的业务逻辑层。修复方案是引入一个抽象接口:核心域模型依赖抽象接口(稳定),工具模块实现这个接口(不稳定模块依赖稳定抽象),这就是依赖倒置原则(Dependency Inversion Principle)的应用。
五、复杂性度量:能测量什么,不能测量什么
度量复杂性是管理复杂性的前提。但没有任何单一指标能完整描述一个系统的复杂性——就像不能只靠体温判断一个人的健康状况。下面讨论几种主要的度量方法,以及它们各自的适用边界。
圈复杂度的价值与局限
Thomas McCabe 在 1976 年提出的圈复杂度(Cyclomatic Complexity)是最广泛使用的代码复杂度度量。它计算程序控制流图中的独立路径数量,公式为:
M = E - N + 2P
其中 E 是边数,N 是节点数,P
是连通分量数。对于单个函数,简化为分支语句(if/else、switch/case、循环、catch)的数量加一。
圈复杂度的优势在于简单、可自动化、有明确的阈值建议。McCabe 原始论文建议单个函数不超过 10。NIST(美国国家标准与技术研究院)的建议更宽松一些,认为 15 以内都是可管理的。
但圈复杂度有几个重要局限:
第一,不考虑数据复杂性。一个函数可能只有一个
if 分支(圈复杂度 = 2),但如果它操作一个包含
20
个字段的数据结构,每个字段有复杂的业务规则和验证逻辑,实际理解难度远超数值显示。
第二,不考虑认知距离。圈复杂度把所有分支平等对待,但在实际阅读中,一个嵌套五层的
if-else 和五个平铺的 if
语句的理解难度完全不同。前者要求开发者在脑中维护五层上下文栈,后者只需要依次判断五个独立条件。
第三,不捕捉模块间复杂性。圈复杂度是函数级别的度量,无法反映系统级别的耦合和依赖问题。一个系统可能每个函数的圈复杂度都在 5 以内,但 200 个函数之间形成了蛛网式的依赖关系,系统级复杂度极高。
第四,不区分”简单的复杂”和”复杂的简单”。一个
switch 语句处理 20
种消息类型,圈复杂度很高(21),但如果每个 case
只是调用对应的处理器,逻辑可能非常清晰。反过来,一个只有两个
if
的函数如果涉及微妙的竞态条件或状态依赖,实际复杂度远高于数值显示。
SonarSource 在 2017 年提出的认知复杂度(Cognitive Complexity)是对圈复杂度的有针对性的改进。它做了几个调整:
- 对嵌套结构施加额外惩罚——嵌套每深一层,当前分支的权重加一。这反映了嵌套对工作记忆的额外消耗。
- 对
switch语句整体只计一次(而非每个case都计),因为switch的心智模型是”根据值选择分支”,不是”逐个判断条件”。 - 对
&&和||的混合使用计入复杂度(a && b && c只计一次,但a && b || c计两次),因为逻辑运算符的混合需要更多的心智努力。 - 对
break、continue等打断正常控制流的语句计入额外复杂度。
下面用一个具体的对比来说明两种度量的差异:
示例 A:平铺的条件分支(圈复杂度 = 5,认知复杂度 = 4)
if (condition1) { doA(); }
if (condition2) { doB(); }
if (condition3) { doC(); }
if (condition4) { doD(); }
示例 B:嵌套的条件分支(圈复杂度 = 5,认知复杂度 = 10)
if (condition1) {
if (condition2) {
if (condition3) {
if (condition4) { doX(); }
}
}
}
两个示例的圈复杂度相同(都是 5),但认知复杂度差异很大。示例 B 的认知复杂度几乎是 A 的三倍,因为嵌套要求开发者在脑中维持更深的上下文栈。这更贴近实际的阅读体验——大多数开发者都会同意示例 B 比示例 A 更难理解。
变更耦合分析
变更耦合(Change Coupling)或逻辑耦合(Logical Coupling)是一种基于版本控制历史的度量方法。它的核心思想来自 Adam Tornhill 在《Your Code as a Crime Scene》(2015)中的实践:如果两个文件经常在同一次提交中一起被修改,它们之间很可能存在隐含的耦合关系,即使代码中没有显式的依赖。
变更耦合的计算方法是:
变更耦合度(A, B) = 同时修改次数 / max(A 的修改次数, B 的修改次数)
取值范围是 [0, 1]。值越高,说明 A 和 B 越经常一起变更。
这种分析的价值在于它能发现代码结构分析找不到的耦合。例如:
- 一个配置文件和一个验证模块之间在代码层面没有任何
import或require关系,但如果每次修改配置格式都需要同步修改验证逻辑,变更耦合分析就能捕捉到这个隐含的依赖。 - 两个微服务之间通过消息队列异步通信,代码层面看不到直接调用关系,但如果消息格式的变更总是导致两个服务同时被修改,变更耦合分析能揭示这种隐式契约。
变更耦合分析的局限在于:它需要足够的提交历史(通常至少几个月),而且结果受提交粒度影响——如果团队习惯在一次提交中把多个不相关的修改混在一起,分析结果就会有噪声。
代码变动率
代码变动率(Code Churn)衡量的是代码在一段时间内被修改的频率和幅度。常见的度量方式包括:文件的增加行数、删除行数、修改频次、以及修改的时间分布。
单独看变动率意义不大——一个正在被积极开发的新模块自然有高变动率,这不代表它有问题。但当变动率和其他指标组合时,能提供有价值的信号:
- 高变动率 + 高圈复杂度 = 高风险热点。复杂的代码频繁变更,缺陷概率最高。
- 高变动率 + 高变更耦合 = 架构腐化信号。频繁变更且总是牵连其他模块,说明模块边界划分有问题。
- 高变动率 + 低测试覆盖 = 定时炸弹。频繁修改的代码缺乏自动化回归保护。
- 高变动率 + 多作者 = 知识冲突区域。多人频繁修改同一区域,可能反映职责不清。
Microsoft Research 在 2005 年的一项研究(Nagappan, Ball & Zeller,《Use of Relative Code Churn Measures to Predict System Defect Density》, ICSE 2005)发现,相对代码变动率(文件在一段时间内的变动量相对于文件总行数的比例)是缺陷密度的强预测指标,其预测准确度优于代码行数和圈复杂度。这项研究基于 Windows Server 2003 的代码库,涵盖了约 300 万行代码。
Tornhill 在《Your Code as a Crime Scene》中进一步发展了这种方法,提出了”热点分析(Hotspot Analysis)“——将代码变动率与代码复杂度叠加,优先关注”既复杂又经常变更”的文件。这种方法的优势在于它直接指向了最需要重构的位置:一个复杂度很高但几年没人动的文件优先级低(它虽然复杂但稳定),一个简单但经常变更的文件优先级也不高(它虽然频繁变动但修改成本低),只有又复杂又频繁变更的文件才是真正的高优先级重构目标。
复杂性度量对比
| 度量指标 | 度量对象 | 数据来源 | 优势 | 局限 | 适用场景 |
|---|---|---|---|---|---|
| 圈复杂度 | 函数/方法级控制流 | 源代码静态分析 | 简单直观,工具广泛 | 不反映数据复杂性、嵌套影响和模块间耦合 | 代码审查阈值、识别需要拆分的函数 |
| 认知复杂度 | 函数/方法级理解难度 | 源代码静态分析 | 比圈复杂度更贴近实际理解体验 | 仍然是局部度量,不捕捉系统级问题 | 替代圈复杂度用于代码审查和 CI 门禁 |
| Ca/Ce | 模块级依赖方向 | 源代码依赖分析 | 量化模块的依赖角色 | 不区分依赖的强度和类型 | 评估模块稳定性、发现 SDP 违反 |
| 不稳定性(I) | 模块级稳定性 | Ca 和 Ce 计算 | 提供依赖方向的指导 | 需要结合抽象度才有完整意义 | 架构层次设计、依赖方向治理 |
| 变更耦合度 | 文件/模块间隐含耦合 | 版本控制历史 | 发现代码结构分析看不到的耦合 | 需要充足提交历史,受提交粒度影响 | 发现架构腐化、指导重构优先级 |
| 代码变动率 | 文件/模块变更频率 | 版本控制历史 | 识别热点区域 | 单独使用意义有限 | 与其他指标组合识别高风险区域 |
我认为没有单一的复杂度指标能完整描述一个系统的复杂性状况。在实践中,组合使用多个指标、结合代码结构和版本历史做交叉分析,比依赖任何一个数值都更有价值。具体建议是:先用变更耦合和代码变动率识别热点区域(“哪里变得最频繁?哪些文件总是一起变?”),再对这些热点区域用圈复杂度、认知复杂度和 Ca/Ce 做局部深入分析。
实施度量的注意事项
在实施复杂性度量时,有几个容易踩的坑:
第一,不要把度量变成目标。Goodhart 定律在这里完全适用——“当一个度量成为目标时,它就不再是一个好的度量。”如果团队的 KPI 是”把所有函数的圈复杂度降到 10 以下”,开发者会通过机械地拆分函数来满足数值要求,但拆分后的函数可能在逻辑上比拆分前更难理解(因为一个完整的逻辑流程被打碎到了五个函数中,读者需要在五个函数之间跳转)。度量的正确用法是作为”发现问题的线索”,而不是”证明问题已解决的证据”。
第二,关注趋势而非绝对值。一个模块的圈复杂度是 15 还是 20 不重要,重要的是它过去三个月从 10 涨到了 20——这说明复杂性正在快速增长,需要关注。同样,变更耦合度的绝对值不重要,重要的是哪些模块对的变更耦合度在上升。
第三,结合业务上下文解读数据。一个处理 20 种税种的税务计算模块圈复杂度高达 30,这可能完全合理——因为税法本身就有 20 种分支。强行把它拆成 20 个子函数可能反而增加理解难度。度量数据需要结合业务域知识来解读,不能脱离上下文机械地应用阈值。
六、实战策略:在架构层面控制复杂性
理论框架和度量工具的价值最终要体现在架构决策上。下面讨论三种在工程实践中验证过的复杂性控制策略。
限界上下文:在业务语义层面划界
Eric Evans 在《Domain-Driven Design: Tackling Complexity in the Heart of Software》(2003)中提出的限界上下文(Bounded Context)是在业务域层面管理复杂性的核心工具。书名本身就点明了主题——“在软件的核心攻克复杂性”。
限界上下文的核心思想是:同一个业务概念在不同的上下文中有不同的含义,不应该被强制统一到一个模型中。
以”用户”为例:
- 在认证上下文中,“用户”是一组凭证——用户名、密码哈希、MFA 配置、登录历史。核心行为是登录、注销、密码重置。
- 在电商上下文中,“用户”是一个购买者——收货地址、订单历史、会员等级、积分余额。核心行为是下单、退款、申请售后。
- 在客服上下文中,“用户”是一个工单发起者——联系方式、历史工单、满意度评分、服务等级。核心行为是提交工单、评价服务。
试图用一个”统一用户模型”来满足所有上下文,只会制造一个包含几十个字段、几十种行为的庞然大物。这个模型在任何一个上下文中都会暴露大量与当前操作无关的字段和行为,增加开发者的外在认知负荷。
限界上下文通过以下方式降低复杂性:
第一,缩小每个模型的作用域。开发者在一个上下文内工作时,只需要理解这个上下文中的模型定义,不需要关心同一概念在其他上下文中的含义。用认知负荷的术语说,每个限界上下文将系统的内在认知负荷分解为若干可独立理解的子集。
第二,显式化上下文之间的关系。Evans 定义了多种上下文映射(Context Mapping)模式来描述不同上下文之间的集成策略:
- 共享内核(Shared Kernel):两个上下文共享一部分模型,修改需双方同意。适用于紧密协作的团队。
- 客户-供应商(Customer-Supplier):下游上下文(客户)依赖上游上下文(供应商)提供的模型,但可以对上游提需求。
- 防腐层(Anti-Corruption Layer, ACL):下游上下文不直接使用上游的模型,而是通过一个翻译层将上游概念转换为自己的域语言。这是隔离性最强的模式。
- 开放主机服务(Open Host Service):上游上下文提供一个标准化的协议或 API 供多个下游使用。
- 各行其是(Separate Ways):两个上下文完全独立,不集成。
这些模式迫使架构师在设计时就明确回答”上下文 A 和上下文 B 之间的关系是什么?谁依赖谁?依赖的内容是什么?变更时的协调机制是什么?“,而不是让这些问题在运行时隐式地暴露。
第三,为团队自治提供基础。每个限界上下文可以由一个独立的团队拥有和演进,只要上下文之间的契约保持稳定。这与 Conway 定律的”逆向应用”一致——先定义好系统边界,再让团队边界跟随。
平台抽象层:将外在认知负荷下沉
平台抽象层(Platform Abstraction Layer)的思路是:把基础设施的复杂性从应用团队的视野中移除,封装到一个专门的平台层中。
具体来说,平台抽象层提供的不是底层基础设施本身(Kubernetes 集群、消息队列集群、可观测性基础设施),而是面向应用开发者的简化抽象——一套标准化的部署描述符、一组封装好的 SDK、一个自助式的服务目录。应用开发者不需要知道 Kafka 的分区再均衡机制或 Prometheus 的 PromQL 语法,他们只需要调用平台提供的接口:“发送一条领域事件”或”注册一个业务指标”。
用认知负荷的术语说,平台抽象层的作用是将基础设施相关的外在认知负荷从应用团队转移到平台团队。应用团队释放出来的工作记忆可以用于理解业务域(内在认知负荷)和改进解决方案(相关认知负荷)。
一个运作良好的平台抽象层应该满足 Parnas 的信息隐藏原则——它隐藏了基础设施选型这个”可能变化的设计决策”。今天底层用 Kafka,明天换成 Pulsar,只要平台接口不变,应用代码不需要修改。
但平台抽象层不是没有代价的。它引入了一个新的依赖:应用团队依赖平台团队提供的抽象。代价至少包括:
- 抽象泄漏风险。Joel Spolsky 在 2002 年提出的观察——所有非平凡的抽象在某种程度上都是泄漏的——在这里同样适用。当平台抽象无法满足某个业务场景的特殊需求时(例如需要精确控制消息分区策略),应用团队要么绕过抽象直接操作底层(这破坏了信息隐藏),要么等待平台团队扩展抽象(这引入了协调延迟)。
- 调试困难。出问题时,应用团队看到的是平台接口返回的错误,而不是底层基础设施的原始错误。中间的转译过程可能丢失关键的诊断信息。
- 演进协调。平台接口的变更需要所有应用团队配合升级,这在大规模组织中是一个不小的协调成本。
API 契约:复杂性的防火墙
API 契约(API Contract)作为复杂性防火墙的核心机制是:在模块或服务之间建立一层显式的、可版本化的、可独立验证的接口协议,使得一侧的实现变更不需要另一侧感知。
这与 Parnas 的信息隐藏原则一脉相承——API 契约就是模块接口的形式化表达。但契约比一般的接口定义走得更远,它不仅定义”能调什么”,还定义”调用后会发生什么”。
有效的 API 契约需要满足几个条件:
- 完备性:契约包含调用方需要知道的所有信息——输入参数、输出格式、错误码及其含义、超时预期、幂等性保证、并发行为。调用方不需要阅读实现代码就能正确使用接口。
- 最小性:契约不暴露实现细节。返回字段不应该包含数据库自增
ID,接口命名不应该暴露底层存储引擎(
getFromRedis就是一个反例),错误消息不应该泄漏内部堆栈。 - 可独立演进:契约支持版本化,旧版本的调用方可以继续工作(向后兼容),新版本的功能可以通过新的契约版本引入。
契约测试(Contract Testing) 是验证 API 契约有效性的实践手段。以 Pact 框架为例,它的工作流程是:
- 消费者(调用方)编写测试,定义它对提供者(被调用方)的期望——请求什么输入、期望什么输出。这些期望被记录为一个”契约文件”。
- 契约文件被共享给提供者。
- 提供者运行契约验证测试,确认自己的实现满足所有消费者的期望。
这种方式比端到端集成测试更轻量(不需要启动整个系统)、更快速(只验证接口层)、反馈更精确(明确告诉你哪个消费者的哪个期望被破坏了)。
下面的 Mermaid 图展示了 API 契约如何在服务之间建立复杂性隔离:
graph LR
subgraph 订单上下文
OS[订单服务<br/>内部实现自由变更] --> OC[/"订单契约 v2.1<br/>对外的稳定接口"/]
end
subgraph 库存上下文
IC[/"库存契约 v1.3<br/>对外的稳定接口"/] --> IS[库存服务<br/>内部实现自由变更]
end
subgraph 物流上下文
LC[/"物流契约 v3.0<br/>对外的稳定接口"/] --> LS[物流服务<br/>内部实现自由变更]
end
OC -.->|通过契约调用| IC
OC -.->|通过契约调用| LC
图中虚线箭头表示跨上下文的调用都通过契约进行。订单服务不需要知道库存服务内部是用数据库锁还是乐观并发控制来管理库存,它只需要知道”调用库存契约 v1.3 的预扣接口,传入商品 ID 和数量,返回预扣结果”。库存服务内部的重构、性能优化甚至存储引擎迁移,只要不破坏契约,对订单服务完全透明。
这三种策略——限界上下文、平台抽象层、API 契约——不是互斥的,而是在不同层次上发挥作用。限界上下文在业务语义层面划分模块边界,平台抽象层在基础设施层面封装技术复杂性,API 契约在模块接口层面建立变更隔离。三者组合使用时,每种策略负责隔离一个维度的复杂性。
策略选择的判断框架
面对一个具体的复杂性问题,应该选择哪种策略?下面是一个基于复杂性来源的判断框架:
如果复杂性的主要来源是业务概念的纠缠——同一个数据实体在不同场景下有不同的含义,业务规则散布在多个模块中——优先考虑限界上下文划分。判断信号是:团队讨论需求时经常出现”这里的’用户’是指哪种用户?“或者”这个’订单状态’是指支付状态还是物流状态?“这类语义歧义。
如果复杂性的主要来源是基础设施的侵入——业务代码中混杂着大量与业务无关的技术细节,开发者把过多精力花在”怎么部署”“怎么监控”“怎么连接中间件”上——优先考虑平台抽象层。判断信号是:团队中的大量代码审查评论与业务逻辑无关,而是关于配置文件格式、部署脚本和中间件用法。
如果复杂性的主要来源是模块之间的隐式依赖——一个模块的变更经常意外地破坏另一个模块的行为,但从代码结构上看不出为什么——优先考虑 API 契约和契约测试。判断信号是:集成测试频繁失败,而且失败原因往往是”上游服务改了返回格式但没通知我们”。
这三个判断不是排他的。实际系统通常同时面临多种复杂性来源,需要组合使用多种策略。但优先级很重要——在资源有限的情况下,先解决最突出的复杂性来源,再逐步扩展到其他维度。
七、案例分析:一个系统的复杂性演化
为了将上述概念串联起来,这里构造一个完整的案例来展示复杂性如何在一个系统中生长,以及架构手段如何控制它。
初始阶段:简单的单体
一个内容管理系统(CMS)最初是一个 Django 单体应用。它有三个核心功能:文章管理、用户管理、评论管理。所有代码在一个代码库中,共享一个 PostgreSQL 数据库。
此时系统的复杂度指标大致是:
模块数:3
模块间依赖:3(文章→用户、评论→用户、评论→文章)
依赖方向:全部朝向稳定方向(用户模块最稳定,Ca=2, Ce=0, I=0)
最大圈复杂度:12(文章发布函数,包含权限检查、格式校验、SEO 处理)
总代码行数:约 8,000
变更耦合热点:0 对
这个阶段的复杂性是可控的。单个开发者可以理解整个系统,变更的影响范围容易预判。所有状态都在一个数据库中,不存在分布式一致性问题。
增长阶段:功能扩展与复杂性滋生
两年后,系统增加了标签系统、全文搜索(接入 Elasticsearch)、多语言支持、审核工作流、通知系统、数据分析仪表板、第三方登录集成。代码行数增长到 80,000 行,模块数增长到 15 个,开发团队从 2 人扩展到 8 人。
问题开始出现:
隐式依赖。通知模块直接查询文章表来获取文章标题用于通知文本,但文章模块修改了标题字段的编码方式(从纯文本改为
Markdown)后,通知模块显示出了未渲染的 Markdown
语法符号。这两个模块之间在代码层面只有数据库级别的依赖(共同读写同一张表),没有
import 关系——用 Ca/Ce
分析找不到这个耦合,但变更耦合分析会发现它们频繁同时被修改。
状态爆炸。审核工作流引入了文章状态机(草稿、待审核、审核中、已发布、已下架、已归档),但多语言支持要求每个语言版本独立管理状态。6 个状态 x 5 种语言 = 30 种状态组合。实际代码中出现了大量针对特定组合的特殊处理——“中文版已发布但英文版还在审核中时怎么处理?”“日文版下架但其他版本不受影响怎么实现?”这些逻辑散落在多个模块中,形成了 Ousterhout 所说的”未知的未知”——开发者修改一种语言版本的状态逻辑时,不知道还有其他地方也在处理状态转换。
工具类膨胀。一个 utils.py
文件累积了 2,000 行代码,从日期格式化到 HTML 清洗到 URL
生成到邮件模板渲染,内聚度极低(偶然内聚),但被大量模块引用(Ca
= 12),成为一个脆弱的核心节点。任何对 utils.py
的修改都可能影响 12
个模块,但修改者很难判断影响范围——这就是高 Ca
与低内聚的危险组合。
循环依赖。搜索模块在建立索引时需要文章的标签信息(搜索→标签),标签管理界面需要显示每个标签下的文章数量(标签→搜索的聚合查询)。这形成了循环依赖,任何一方的接口变更都会影响对方。
重构阶段:应用复杂性管理策略
团队决定进行架构重构,策略如下:
第一,划分限界上下文。将系统分为四个上下文:
- 内容管理上下文:文章、标签、多语言版本管理。
- 用户身份上下文:认证、授权、第三方登录。
- 社交互动上下文:评论、通知。
- 分析上下文:数据统计、仪表板。
每个上下文拥有独立的领域模型。搜索功能横跨多个上下文,作为一个独立的技术服务存在,通过订阅领域事件来建立和维护索引。
第二,拆分工具类。将
utils.py 按功能域拆分:
- 日期格式化逻辑归入各自使用的上下文(不同上下文的日期格式可能不同,不必强制统一)。
- HTML 清洗逻辑归入内容管理上下文(只有这个上下文需要处理富文本)。
- 通用的 URL 工具提取为独立的基础库(Ca 高、Ce 低、I 接近 0),接口严格稳定。
- 邮件模板渲染归入社交互动上下文。
第三,引入事件机制替代直接查询。文章发布时发出领域事件(包含文章 ID、标题、摘要等通知所需的最小信息),通知模块订阅事件获取所需信息,而不是直接查询文章表。这把内容耦合降低为数据耦合——通知模块只依赖事件的数据结构(一种契约),不依赖文章模块的内部数据表结构。
同样,标签与搜索之间的循环依赖通过事件解耦:标签变更时发出事件,搜索模块异步更新索引,不再需要直接查询标签模块。
第四,显式建模状态机。将文章状态机从散落在代码各处的
if-else
集中到一个独立的状态机定义中。每个语言版本作为独立的状态机实例运行,共享同一套状态转换规则,但状态是独立的。跨版本的业务规则(“至少有一个语言版本已发布,文章才显示在站点地图中”)被提取为一个独立的聚合查询,而不是分散在各个状态转换的回调中。
重构后的复杂度变化:
模块数:22(增加了,但每个模块更小更聚焦)
跨上下文依赖:6(通过契约或事件,而非直接数据库查询)
循环依赖:0(事件解耦消除了所有循环依赖)
最大圈复杂度:8(之前的 12 通过提取子函数降低)
工具类大文件:0 个(原来的 2,000 行 utils.py 被拆分)
变更耦合热点:从 8 对降低到 2 对
这个案例展示了一个关键现象:复杂性管理不是减少系统的功能或代码量,而是重新组织系统的结构,使得复杂性被隔离在清晰的边界内。重构后的系统功能完全不变,代码总量甚至略有增加(因为引入了事件定义、契约定义、上下文映射层等”胶水代码”),但每个开发者在完成一项任务时需要理解的范围显著缩小了。用认知负荷的术语说:内在认知负荷不变(业务逻辑没有减少),外在认知负荷降低(不需要理解不相关模块的内部实现),相关认知负荷提升(更容易建立局部心智模型)。
三个阶段的指标对比
把三个阶段的关键指标放在一起比较,更容易看出复杂性治理的效果:
| 指标 | 初始阶段 | 增长阶段(重构前) | 重构后 |
|---|---|---|---|
| 功能模块数 | 3 | 15 | 22 |
| 代码行数 | 8,000 | 80,000 | 85,000 |
| 模块间依赖数 | 3 | 约 40(含隐式) | 6(跨上下文) |
| 循环依赖 | 0 | 2 组 | 0 |
| 最大圈复杂度 | 12 | 25 | 8 |
| 变更耦合热点 | 0 对 | 8 对 | 2 对 |
| 单次功能变更涉及模块数(中位数) | 1 | 4 | 1-2 |
| 新成员上手时间(估计) | 1 周 | 4 周 | 2 周(仅限所负责的上下文) |
注意”功能模块数”从 15 增长到 22——模块变多了,但每个模块的职责范围变小了,模块之间的依赖关系从网状变成了树状。代码总量略有增加(从 80,000 到 85,000),增加的部分主要是事件定义、契约接口和上下文间的映射代码。这些”胶水代码”本身是偶然复杂性——但它们换来的是模块间耦合的大幅降低,这笔交易在这个案例中是值得的。
这也说明了一个容易被忽略的观点:消除一种偶然复杂性的手段本身可能引入另一种偶然复杂性。关键不是把偶然复杂性降到零(那不可能),而是用可控的、结构化的偶然复杂性(如契约定义)替换不可控的、弥散的偶然复杂性(如隐式数据库依赖)。
八、简洁性与灵活性的权衡
复杂性管理中最微妙的权衡是:简洁性和灵活性往往是矛盾的。
一个简洁的系统直接解决当前的问题,没有多余的抽象层和扩展点。一个灵活的系统预留了变化空间,可以适应未来的需求——但这些抽象层和扩展点本身就是复杂性的来源,是 Brooks 所说的偶然复杂性。
下面这张表从多个维度比较这两种取向:
| 维度 | 偏向简洁 | 偏向灵活 |
|---|---|---|
| 模块接口 | 具体类型、直接调用 | 抽象接口、依赖注入 |
| 数据模型 | 针对当前需求的专用 schema | 通用的 EAV 模型或 JSON 列 |
| 配置策略 | 硬编码合理默认值 | 外部化所有可配置项 |
| 架构分层 | 最少层数、允许直接穿透 | 每层有独立的抽象和转换 |
| 错误处理 | 针对已知错误的具体处理 | 通用错误处理框架 + 插件 |
| 典型代价 | 需求变化时修改成本高 | 当前开发和理解成本高 |
| 适用场景 | 需求稳定、变化方向不确定 | 变化方向明确、变化频率高 |
| 风险 | 变更引发大范围重构 | YAGNI 违反、过度工程 |
这个权衡没有通用的最优解——它取决于具体项目的需求稳定性、团队规模、系统生命周期预期。但有一些可操作的启发式规则帮助做判断:
启发式一:Parnas 的变化轴原则。只在你有理由相信某个设计决策会变化的方向上引入灵活性。“有理由相信”不是”理论上可能”——它需要来自领域知识(“监管合规要求每两年更新一次”)、历史变更数据(“过去 12 个月这个模块的接口改了 5 次”)或明确的产品路线图(“下个季度要支持三种新的支付方式”)。
启发式二:三次法则(Rule of Three)。当你第一次遇到一个问题时直接解决它,第二次遇到类似问题时容忍重复,第三次遇到时再提取抽象。这条规则来自 Don Roberts 的建议,被 Martin Fowler 在《Refactoring》中推广。它的合理性在于:两次重复可能是巧合,三次重复才构成模式。过早抽象的风险是你可能错误地概括了共性,创造出一个既不好用又不好改的抽象。
启发式三:可逆性优先。如果一个架构决策容易撤销(例如在单体内部重新组织模块结构),倾向于选择更简洁的方案——即使后来发现需要灵活性,修改成本也不高。如果一个决策难以撤销(例如数据库选型、服务拆分粒度、公共 API 的格式),则需要更谨慎地评估灵活性需求,因为”后来再改”的成本可能非常高。
启发式四:认知预算。每一层抽象都消耗开发者的工作记忆。在引入抽象之前,估算一下团队中的普通开发者是否有足够的”认知预算”来同时理解业务逻辑和这些抽象机制。如果一个初级工程师需要花两周才能理解框架的抽象层次才能开始写业务代码,这个抽象的认知成本可能已经超过了它带来的灵活性收益。
我认为在实践中,大多数系统的问题不是缺乏灵活性,而是过度灵活性带来的理解负担。一个常见的反模式是:在项目初期就引入策略模式、插件架构、事件总线等灵活性机制来应对”将来可能的需求”,结果这些机制在整个项目生命周期中只有一种实现——但每个新加入的开发者都要花时间理解”这里为什么有一个接口?其他实现在哪里?“才发现答案是”没有其他实现,只是当初觉得以后可能会有”。这种预防性的灵活性不是在降低复杂性,而是在增加偶然复杂性。
反过来,对于已经被验证需要变化的方向,投入灵活性是值得的。如果过去一年的变更历史显示支付方式模块被修改了 20 次,每次都是增加一种新的支付渠道,那么为支付方式引入策略模式就是一个有数据支撑的决策,不是过度工程。
一个实用的决策检查清单
面对”要不要引入这个抽象层”或”要不要现在就把这个模块拆出去”的决策时,可以用下面这组问题做快速评估:
- 变化频率:过去 6 个月,这个方向上发生了几次变更?零次或一次,倾向简洁;三次以上,倾向灵活。
- 变化可预测性:未来的变化是否沿着已知的维度扩展(例如增加支付渠道),还是完全不确定?可预测的变化更适合提前设计灵活性,不确定的变化更适合等待。
- 可逆成本:如果现在选择简洁方案,未来发现需要灵活性时,修改成本有多高?如果只是”重构几个类”,那选择简洁;如果是”重新设计数据库 schema 并迁移十亿行数据”,那需要更慎重。
- 团队规模:团队有多少人?10 人以下的团队,口头约定就能保持一致性,抽象层的协调收益有限。50 人以上的团队,没有显式的抽象层和契约,协调成本会失控。
- 系统寿命预期:这个系统预计运行多久?一个为期三个月的实验项目和一个要运行十年的核心业务系统,对灵活性的需求完全不同。
这些问题没有标准答案,但它们迫使决策者从具体的项目约束出发做判断,而不是套用通用的”最佳实践”。
九、结论
系统复杂性的增长有一个不对称性:增加复杂性很容易(每一次”加个 flag”、“多传一个参数”、“先不拆了直接写在这里”),消除复杂性很难(需要理解全局影响、协调多方、重新设计接口)。这种不对称性意味着如果不持续投入治理精力,系统的复杂性会自然增长。
本文讨论的框架和工具可以归纳为几个层次:
- 理解层:Brooks 的本质/偶然复杂性划分告诉我们能战斗的战场在哪里;Ousterhout 的三个复杂性症状告诉我们复杂性如何表现自己。
- 人因层:认知负荷理论告诉我们为什么人在复杂系统面前会失败——不是因为不够聪明,而是因为工作记忆容量有硬上限。
- 度量层:圈复杂度、Ca/Ce、变更耦合、代码变动率提供了可量化的信号,但每一种都只照亮复杂性的一个侧面,需要组合使用。用趋势而非绝对值来判断,用热点分析来确定优先级。
- 策略层:Parnas 的信息隐藏原则、限界上下文、平台抽象层、API 契约提供了具体的架构手段,但每一种都有其适用条件和代价。
- 权衡层:简洁性与灵活性的权衡没有通用最优解,需要基于变化频率、可逆成本、团队规模和系统寿命来做具体判断。
没有任何度量指标或架构原则能替代持续的判断。圈复杂度高不一定意味着代码有问题(可能只是处理了很多合理的分支),低耦合不一定意味着模块划分合理(可能只是把问题推到了运行时),限界上下文划分不当反而会增加集成复杂性。这些工具和原则的价值在于它们提供了思考的框架和团队沟通的语言,而不是可以机械执行的规则。
复杂性管理最终是一个关于取舍的实践:在简洁和灵活之间取舍,在局部优化和全局一致之间取舍,在当前效率和未来可维护性之间取舍。好的架构师不是消除复杂性的人——本质复杂性不可消除——而是把复杂性放到正确位置的人:让每个模块承担它应该承担的复杂性,不多也不少。
最后一个观察:复杂性管理不是架构师一个人的事。代码审查中对”这里加个 flag 就行了”的质疑、重构计划中对技术债务的定期清理、团队规范中对模块边界的约束——这些日常实践的效果远大于一次性的架构重构。复杂性是在每一天的每一个决策中累积的,控制它的手段也必须嵌入到每一天的工作流程中。正如 Ousterhout 所说:“好的设计是免费的”——不是因为它不需要时间,而是因为它节省的时间远超投入的时间。但只有当团队把”控制复杂性”当作与”交付功能”同等重要的目标时,这句话才能成立。
上一篇:架构评估
下一篇:架构文档
参考资料
论文
- Fred Brooks,《No Silver Bullet — Essence and Accident in Software Engineering》, 1986. 本质复杂性与偶然复杂性的经典划分,软件工程四个本质困难的论述。
- David Parnas,《On the Criteria To Be Used in Decomposing Systems into Modules》, Communications of the ACM, 1972. 信息隐藏原则的奠基论文,KWIC 系统的两种分解对比。
- Thomas McCabe,《A Complexity Measure》, IEEE Transactions on Software Engineering, 1976. 圈复杂度的定义与阈值建议。
- George Miller,《The Magical Number Seven, Plus or Minus Two》, Psychological Review, 1956. 工作记忆容量的经典研究。
- Nelson Cowan,《The Magical Number 4 in Short-Term Memory》, Behavioral and Brain Sciences, 2001. 对 Miller 工作记忆容量估计的修正。
- Nagappan, Ball & Zeller,《Use of Relative Code Churn Measures to Predict System Defect Density》, ICSE, 2005. 代码变动率与缺陷密度的预测关系。
- John Sweller,《Cognitive Load During Problem Solving: Effects on Learning》, Cognitive Science, 1988. 认知负荷理论三分类的提出。
- Mel Conway,《How Do Committees Invent?》, 1968. Conway 定律的原始表述。
- G. Ann Campbell,《Cognitive Complexity: An Overview and Evaluation》, SonarSource, 2017. 认知复杂度指标定义,对圈复杂度的改进。
书籍
- John Ousterhout,《A Philosophy of Software Design》, 2018. 复杂性增量累积、变更放大、认知负荷、未知的未知。
- Robert C. Martin,《Agile Software Development: Principles, Patterns, and Practices》, 2002. 传入/传出耦合、不稳定性指标、抽象度、稳定依赖原则。
- Eric Evans,《Domain-Driven Design: Tackling Complexity in the Heart of Software》, 2003. 限界上下文、上下文映射模式。
- Matthew Skelton & Manuel Pais,《Team Topologies》, 2019. 团队认知负荷三分类。
- Martin Fowler,《Refactoring: Improving the Design of Existing Code》, 1999. 三次法则、技术债务四象限。
- Adam Tornhill,《Your Code as a Crime Scene》, 2015. 变更耦合分析、基于版本控制历史的代码分析方法。
- Larry Constantine & Edward Yourdon,《Structured Design》, 1979. 内聚七级分类与耦合分级。
- Meilir Page-Jones,《What Every Programmer Should Know About Object-Oriented Design》, 1996. 共生性(Connascence)概念及其三个分析维度。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】DDD 战略设计:限界上下文与上下文映射
一个中型电商系统里,"订单"在交易团队意味着"待支付的购物车快照",在物流团队意味着"等待拣货的配送单",在财务团队意味着"一条应收账款记录"。三个团队共用同一张 torder 表、同一个 OrderService 类,每次迭代都互相踩脚。这种混乱的根源不是代码质量,而是缺少一项最基本的架构决策——限界上下文(Boun…
【系统架构设计百科】DDD 战术模式:聚合、实体与值对象
某团队在实施领域驱动设计时,把整个"订单"建模为一个聚合根(Aggregate Root),其中包含订单基本信息、所有订单行、配送信息、支付记录、物流轨迹、评价数据。结果这个聚合加载一次需要从 7 张表联查,保存一次需要锁定整个订单树。并发下单高峰期,数据库锁等待飙升至秒级。这就是典型的"大聚合"反模式——聚合的边界画…
【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案
某金融科技公司正在构建新一代交易系统。新系统使用领域驱动设计,模型清晰、代码整洁。然而它必须对接一套运行了 15 年的核心银行系统(Core Banking System)——这套系统的接口返回 COBOL 风格的定长字段,状态码用两位数字表示("01"正常、"02"冻结、"99"未知),金额用"分"而非"元"为单位。…
【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界
某电商团队按数据库表拆分微服务——用户服务管 tuser,商品服务管 tproduct,订单服务管 torder。看起来边界清晰,实际运行中却发现:下单需要同步调用商品服务查价格、调用库存服务检查库存、调用优惠服务算折扣、调用用户服务查地址,一个下单请求扇出 4 次 RPC,任意一个服务超时整条链路就失败。这种"一实体…