三个月后有人问"为什么网关鉴权放在 Filter 而不是 Service 里",翻遍代码只能看到结果,看不到原因。Slack 里搜不到,开会的人已经走了两个。最后要么重做一遍已经否定过的方案,要么在错误的路上继续加固。
架构决策记录(ADR)就是为这个问题设计的:一条决策一个文件,写在仓库里,跟代码一起 review、一起演进。
1. ADR 解决什么问题
| 症状 | 没有 ADR 的结果 |
|---|---|
| 新人问"为什么不用 X" | 靠老人记忆,老人走了答案就没了 |
| 有人提"我们换成 Y 吧" | 无法判断半年前是否评估过 Y、为什么否掉 |
| 代码里两种风格并存 | 不知道哪一种是"当时决定的",哪一种是"遗留的" |
| 上线后出问题复盘 | 只知道"这样做会出问题",不知道"当时为什么接受这个风险" |
ADR 不替代设计文档、不替代注释。它只记录有替代方案、且影响面大的选择——以及被否掉的那些。
2. 一份 ADR 的结构
模板本身要短到有人愿意写:
# 0007 订单状态回写改用 outbox,不再依赖事务内双写
- 状态:已接受
- 日期:2025-03-14
- 决策者:后端组
- 影响范围:order-service、payment-service
## 背景
支付回调需要在同一逻辑里更新订单状态并发送通知。目前两者都在本地事务内,
消息发送失败会让整个事务回滚,导致第 3 方回调重试,重复回调需要人工核对。
## 决策
订单状态变更后写入 outbox 表,由独立投递器读取并发送消息,发送成功再标记已投递。
## 后果
- 正面:消息发送失败不再回滚业务事务;重试集中在一处,便于观测。
- 负面:通知延迟从"毫秒级"变成"最长 1s";需要新增 outbox 表与清理任务。
- 需要接受:投递器故障时通知会积压,属于可接受范围。
## 被否决的替代方案
1. 分布式事务(Seata):引入新组件与运维成本,延迟更高,收益不匹配当前量级。
2. 事务提交后直接发消息,失败只记日志:会丢消息,且没有任何补偿路径。
3. 把通知改成定时全量扫描:对 DB 压力大,且难以判定"该发没发"。
每个字段都有必须回答的问题:
| 字段 | 必须回答 | 不写会怎样 |
|---|---|---|
| 标题 | 一句话说清"决定了什么" | 文件名 decision-2025.md,半年后没人想点开 |
| 状态 | 现在还有效吗 | 读者把废弃决策当现状 |
| 背景 | 什么约束、什么数据促成了这次选择 | 决策像是拍脑袋 |
| 决策 | 具体做什么,可执行 | 写成一堆原则,没有动作 |
| 后果 | 好消息 + 坏消息 + 需要接受什么 | 变成宣传材料,没人信 |
| 替代方案 | 每个方案被否的具体理由 | 下次有人重新提一遍 |
"被否决的替代方案"是 ADR 里最值钱的一段。 只写结论的 ADR,等于告诉未来的读者"别问,就这么办"。
3. 编号与存放位置
放在代码仓库里,跟代码一起走:
order-service/
├── docs/
│ └── adr/
│ ├── README.md # 索引 + 状态一览
│ ├── 0001-use-outbox-for-notify.md
│ ├── 0002-jwt-short-lived-token.md
│ └── 0007-order-state-outbox.md
└── src/
| 项目 | 约定 | 理由 |
|---|---|---|
| 目录 | docs/adr/(单服务)或 docs/adr/{服务名}/(多服务) |
离代码近,review 时一起看 |
| 文件名 | NNNN-kebab-case-title.md,4 位补零 |
排序即时间序,改动标题不改编号 |
| 编号 | 全仓库单调递增,不复用、不回收 | 被取代的 ADR 仍要能按号找到 |
| 索引 | README.md 列出编号、标题、状态 |
不用翻目录就能看全貌 |
| 语言 | 团队内部语言 | 决策是给人看的,不是给机器 |
编号原则只有一条:一旦发布不再改号。0007 被 0012 取代后,文件名不动,只在状态里写"被 0012 取代"。
git mv docs/adr/0007-order-state-double-write.md docs/adr/0007-order-state-outbox.md
git commit -m "docs(adr): 补充 0007 的替代方案与被否决理由"
改名也要留痕:只允许改标题部分,编号前缀保持。用 git mv 而不是删了重建,git log --follow 才能追到全部讨论。
4. 状态流转
| 状态 | 含义 | 谁能改 | 文件是否还改内容 |
|---|---|---|---|
| 提议(proposed) | 正在评审,未定 | 作者 | 可以,改动直接提交 |
| 已接受(accepted) | 是当前的实现依据 | 作者 + 一位复核者 | 只能勘误,观点改动要开新 ADR |
| 已废弃(deprecated) | 不再推荐,但代码可能还在 | 任何维护者 | 不再改内容,只改状态 |
| 被取代(superseded by 00XX) | 有新决策替代它 | 新 ADR 的作者 | 冻结,只保留链接 |
状态本身可以在代码里建模,省得各处拼字符串:
public enum AdrStatus { PROPOSED, ACCEPTED, DEPRECATED, SUPERSEDED }
关键约束:已接受的 ADR 不改内容,改状态或开新条目。
- 状态:被取代(by 0012)
- 取代日期:2025-09-02
理由很实际:允许改正文,文档就变成随波逐流的说明,读者无法判断"当时是怎么想的"。表达"改主意了"的正确做法是新开一条并在背景里引用旧号——决策历史因此是一条链,而不是不断被覆盖的当前值。
5. 什么该写,什么不值得写
判断标准三条同时成立才写:有多个可行替代方案、影响面超过单个模块、三个月后有人会问为什么。
| 该写 | 不值得写 |
|---|---|
| 引入新的中间件/存储(Redis、MQ、ES) | 加一个 Controller 接口 |
| 改变数据一致性策略(双写 → outbox) | 变量重命名、格式化 |
| 接口协议与版本策略(REST vs gRPC、版本号方案) | 单元测试怎么写 |
| 认证鉴权方案(会话 vs JWT、网关校验 vs 服务内) | 依赖包小版本升级 |
| 跨服务的边界划分与职责归属 | 单个类内部的重构 |
| 明确"我们决定接受某个已知缺陷" | 遵循框架默认约定的普通实现 |
最后一条常被忽略:接受一个已知问题也是决策。比如"先不做幂等,接受少量重复,因为业务侧可人工撤销",这个判断如果不写下来,半年后有人修好了,反而可能破坏当时的业务假设。
写 ADR 的成本控制在 20–40 分钟;要半天的 ADR,团队写到第三篇就停了。
6. 与设计文档、commit message 的分工
| 载体 | 回答什么 | 生命周期 | 典型长度 |
|---|---|---|---|
| ADR | 为什么这么选、否了什么 | 长期,冻结后不改 | 半页到一页 |
| 设计文档 | 完整方案怎么做(表结构、接口、时序) | 到实现完成 | 数页 |
| commit message | 这次改动做了什么 | 永久但零散 | 一行标题 + 若干正文 |
| 代码注释 | 这段代码为什么这么写(局部技巧) | 跟代码一起改 | 几行 |
分工的实操口径:
- 决定"用 outbox 而不是分布式事务" → ADR;
- outbox 表的字段、投递器的重试曲线 → 设计文档;
- "补投递器的指数退避" → commit message;
- "这里 sleep 50ms 是为了等索引可见" → 代码注释。
同一个内容不要写三遍:ADR 链接设计文档、设计文档引用 ADR 编号,但都不复述。
7. 评审流程:轻量到有人愿意做
1. 起草:作者开 PR,只加一个 ADR 文件,状态=提议
2. 复核:指定 1 位熟悉该模块的人复核(不是开会,是 PR comment)
3. 定稿:复核通过后改状态为已接受,合并
4. 索引:同一 PR 里更新 docs/adr/README.md 的状态列
PR 上挂一个极简检查清单:
# .github/pull_request_template.md 片段
- [ ] 背景里写清了触发这次决策的具体问题或数据
- [ ] 决策是可执行的(不是"我们要重视可维护性")
- [ ] 后果里包含负面与需要接受的部分
- [ ] 至少列出一个被否决的替代方案及其理由
- [ ] 已更新 docs/adr/README.md 索引
| 团队规模 | 合适的评审方式 |
|---|---|
| 3–8 人 | 一人起草、一人复核,PR 合并即生效 |
| 8–20 人 | 起草 + 两位领域相关者复核,超过 3 天未表态视为默认通过 |
| 跨团队接口相关 | 强制加一位对端团队复核者 |
| 20 人以上 | 不要全员评审单篇 ADR,改为按季度汇总状态回顾 |
不要做成"架构委员会审批制"。ADR 的作用是留痕,不是把关;把关交给代码评审与发布流程。
8. 常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| ADR 写成事后总结 | 决策做完了才补文档,只写结论 | 决策前起草、决策后改状态 |
| 只有结论没有替代方案 | 作者觉得"显然" | 强制填"被否决的替代方案"字段 |
| 状态永远停在"提议" | 忘了改状态 | 索引加过期提醒,或复核通过即由复核者改 |
| ADR 与代码脱节 | 实现时走了另一条路,没回写 | PR 模板加"是否需要新增/更新 ADR" |
| 目录里 60 条,全是同一类 | 粒度太细,把每个依赖升级都写成 ADR | 只写影响面大的,聚合同类记录 |
| 内容重复三处 | ADR、设计文档、README 各抄一遍 | ADR 只留决策与理由,其余链接 |
第四条最常见的表现是:ADR 说"用 outbox",代码里其实还是双写,因为实现时发现 outbox 投递器没排期。这时要么补一条新 ADR 说明临时偏差,要么改回原 ADR 的状态为"已废弃"——不能放着不管,一份与代码不符的 ADR 比没有 ADR 更坏。
9. 边界:什么情况不该用 ADR
| 场景 | 替代做法 | 理由 |
|---|---|---|
| 一次性原型、验证性编码 | 不留档,直接删 | 决策寿命短于文档寿命 |
| 合规/审计要求的正式评审 | 正式设计评审记录 | 需要签字与流程,ADR 承载不了 |
| 纯运维操作(扩容、参数调整) | 变更单 / Runbook | 不是架构决策 |
| 团队还没有代码仓库规范 | 先建立 PR 流程 | ADR 依赖 review 文化 |
| 需要对外承诺的接口变更 | 接口变更公告 + 版本策略文档 | 受众是外部方,不是本团队 |
务实起点:只写"引入新组件"和"改变一致性/鉴权策略"两类,攒到 10 条再扩范围,空目录里立一份 8 页规范等于不写。
10. 自测
- ADR 与设计文档的分工边界在哪?
- 一份 ADR 里最容易被省略、却最有价值的是哪一段?
- 已经"已接受"的 ADR 为什么不允许改正文?
- 判断"这次决策要不要写 ADR"的三条标准?
- 写完 ADR 但实现走了另一条路,该怎么处理?
- 为什么"接受某个已知缺陷"也值得写成 ADR?