我在 wiki 里见过的每一张架构图都是错的。不是那种惊天动地的错,而是悄无声息、日积月累的错。标着 “Auth” 的服务六个月前就被拆成了三个微服务。标着 “sync call” 的箭头现在已通过队列变成了异步调用。标着 “PostgreSQL” 的数据库在一次紧急故障处理中被迁移到了别的系统,却没人更新那个方框。
这不是疏忽,这是物理规律。代码每周变更数百次。文档只有在有人想起来时才会更新。架构图的半衰期大约只有一个 sprint。过了这段时间,它就变成了小说。
目标不是写出永不过时的文档,而是在它误导下一位工程师之前发现偏差。
为什么文档比代码腐烂得更快
代码自带纠错机制:它要么编译通过、测试通过,要么就不通过。文档没有编译器、没有测试套件、没有 CI 门禁。一张错误的图表渲染出来完美无缺。一份过时的 ADR 读起来和新鲜的一样有说服力。
这种偏差也有社会因素。工程师更新代码是因为构建会挂掉。他们更新文档是因为感到内疚。内疚是一种不可靠的日程提醒。
当新工程师加入并阅读 wiki 时,他们会在脑海中构建系统的思维模型。如果这个模型已经过时六个月,他们做出的决定只会让现有的混乱雪上加霜。本应降低 onboarding 摩擦的文档,反而成了一种隐蔽且代价高昂的误导源。
三大虚构陷阱
架构文档最快沦为虚构的地方有三处。
Wiki 坟场。 放在 Confluence、Notion 或 SharePoint 里的架构文档游离在仓库之外。它们没有与代码变更相关联的提交历史。当一次重构删掉某个服务时,wiki 页面却像数字鬼城一样幸存下来。没有构建失败,没有告警触发。
图表虚构。 从 Lucidchart 或 draw.io 导出并贴进 README 的精美图表看起来权威十足。但它也是一张静态图片。下一位改动架构的开发者必须打开设计工具、找到源文件、更新、重新导出、再贴回去。这套工作流的存活率大约只有 5%。
全知 README。 顶层 README 试图在一个文件里记录每一个服务、每一个依赖、每一条数据流。它变成了一份没人敢碰的巨型文档。最终,某个勇敢的人会加上一句备注:“本节可能已过时。” 然后又是一个。最后整份文档被警示引号包裹,惨遭遗弃。
保持诚实的活文档
解决办法不是写更多文档,而是让文档变得无法回避。
把文档移进仓库。 如果文档和代码变更不在同一个 pull request 里,它就不会被更新。ADR、runbook 和决策日志应该放在 docs/architecture/ 或 docs/adr/ 里,与被描述的代码比邻而居。当审查者看到一次重构却没有配套文档更新时,他们可以阻止合并。
从代码生成图表。 Mermaid、PlantUML 和 Structurizr 允许你在文本文件中定义图表,这些文件就放在仓库里。CI 任务在每次提交时渲染它们。当架构变更时,图表源文件也随之变更。不需要设计工具。
<!-- docs/architecture/data-flow.md -->
## Ingestion Pipeline
```mermaid
graph LR
A[Client SDK] -->|HTTP POST| B[Ingest API]
B -->|Pub/Sub| C[Event Consumer]
C -->|Write| D[(ClickHouse)]
这张图表可以在 diff 中被审查。当流水线新增一个 Kafka topic 时,变更会出现在添加 consumer 的同一个 PR 里。
**把 "做什么" 和 "为什么" 分开。** 代码库本身已经描述了系统在做什么。注释、函数名和类型编码了当前的结构。文档应该聚焦于系统为什么长成这样。真正在静默漂移的是决策背后的理由,而不是决策本身。
Architecture Decision Record(ADR)捕捉的正是这一点。ADR 不是规格说明书,而是一条带时间戳的备忘:"在某日,基于这些原因,我们做了这个选择。以下是我们接受的约束条件。" 当约束条件改变时,旧的 ADR 会被新的取代。旧记录作为历史保留下来。
## 自动化 "做什么",手写 "为什么"
最诚实的架构文档,是那些你不需要手写的文档。
从 controller 代码生成的 OpenAPI 规范准确描述了你的 API 表面。TypeDoc、RustDoc 或 Javadoc 描述了你的内部类型。`cargo tree`、`go mod graph` 或 `webpack-bundle-analyzer` 生成的依赖图展示了真实的模块结构。
对于更高层的架构约束,自动化架构测试充当安全网。在 Java 中,ArchUnit 可以强制执行诸如 "`domain` 包不得依赖 `infrastructure`" 之类的规则。在 Python 中,`import-linter` 也能做同样的事。在 Go 中,你可以写一个小测试遍历 AST,如果出现禁止的 import 就让构建失败。
```java
// This test fails the build if architecture rules break.
@ArchTest
static final ArchRule domain_independence =
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat()
.resideInAPackage("..infrastructure..");
当开发者不小心 import 了错误的包时,构建会立即失败。架构文档不需要警告他们,编译器会代劳。
没人愿意承认的权衡
活文档不是免费的。它消耗审查时间、CI 分钟数,以及偶尔争论某次变更是否值得新建一份 ADR。
不是每个系统都需要图表。 一个只有三层的单体应用不需要 C4 模型。维护生成文档的投入应该与误解系统所带来的痛苦成正比。如果错误的思维模型让你多花一天调试,那就花一小时做张图。如果只多花五分钟,那就写条注释。
生成的文档也会撒谎。 从代码生成的 OpenAPI 规范描述了每一个 endpoint,包括那些你忘了文档化的内部接口。依赖图展示了每一个 import,包括那些违反架构的 import。生成的真相是准确的,但未必总是有用。你仍然需要人工筛选来突出重要内容。
ADR 会累积。 两年后,docs/adr/ 里可能会有三十份 ADR。大多数已经无关紧要。解决办法不是删掉它们,而是清晰标记:status: superseded,并附上替换者的链接。历史有价值,混乱没有。
一套行之有效的最小配置
你不需要一个文档平台。你的仓库里只需要三样东西。
docs/adr/YYYY-MM-DD-title.md用于记录决策。使用轻量级模板:
# ADR 012: Event Storage Backend
- Status: accepted
- Date: 2026-03-15
## Context
We need durable storage for ingestion events with sub-second query latency.
## Decision
Use ClickHouse for hot storage, S3 for cold archival.
## Consequences
- Fast analytical queries on recent data.
- Complex operational footprint compared to PostgreSQL.
- Migration path defined in ADR 013.
-
docs/architecture/*.md包含用于系统边界和数据流的 Mermaid 图表。要求结构性代码变更必须在同一个 PR 中更新文档。 -
一个架构测试,强制执行你最昂贵的那个不变量。如果你只有一条规则,那就让它能阻止上一次痛苦的重构。随着系统成长,再逐步增加。
常见问题
我需要为每一个微服务都画图吗?
不需要。只在团队交接边界、数据跨越信任区域、以及故障级联的地方画图。你内部 CRUD 服务的图通常是浪费。哪些服务调用 payment gateway 的图通常很有价值。
如果我的团队已经在用 Confluence 怎么办?
把关键文档镜像到仓库里,并外链出去。真相来源留在 git 中。wiki 变成一层便利的壳。如果 wiki 出现偏差,工程师知道去哪里找真正的版本。
多少份 ADR 算太多?
没有上限,但可读性有极限。如果新工程师无法在十分钟内扫完 docs/adr/ 并理解系统的演进,那就加一个索引。按子系统分组。清晰标记已被取代的记录。
非技术利益相关者用的 wiki 怎么办?
产品经理和高管需要高层摘要。从 ADR 索引生成这些摘要。不要维护一套单独的叙述。同样的事实应该从源代码出发,流经 ADR,再进入人类可读的摘要。一个真相来源,多种视图。
从一份 ADR 和一个测试开始
你不需要在本周就彻底改造文档文化。挑选最近一次引起困惑的架构决策。写一份 ADR,解释你为什么做了那样的选择。挑选一个你希望能自动强制执行的不变量。写一个架构测试,当有人违反它时就让构建失败。
这两份产物会保持诚实,因为它们住在仓库里,在 PR 中被审查,要么通过 CI,要么就不会上线。其他一切——wiki 页面、幻灯片、会议海报——都是次要的。有固然好。可能有用。但大概率是错的。