58.1 ADR 实践
一句话
ADR 不是文档工程,而是把“为什么这么选”固化成可追溯、可替代的记录:一篇只记一个决策,写清背景、备选与代价;内容不改,状态可演进。
先破除四个误区
| 误区 | 现实 |
|---|---|
| “ADR = 设计文档” | 设计文档讲“怎么做”,ADR 讲“为什么这么选、放弃了什么、代价是什么”。两者可以互相引用,但不能互相替代 |
| “事后补一份也算 ADR” | 可以补,但要标注“追溯记录”(retro);补写的 ADR 很容易变成对既成事实的合理化,价值大打折扣 |
| “写一次就不用管” | 决策会过期。状态要演进(accepted → superseded / deprecated),内容不改,用新 ADR 替代旧 ADR |
| “只有大决策才值得写” | 判据不是“大不大”,而是难以逆转 + 跨团队 + 有争议。三个里中两个,就该写 |
ADR 和别的文档差在哪
| 文档 | 回答的问题 | 读者 | 生命周期 |
|---|---|---|---|
| ADR | 为什么选 A 不选 B?代价是什么? | 未来的维护者、新人、评审者 | 长期,可被替代但不可篡改 |
| 设计文档 | 具体怎么做?模块、接口、数据流 | 实现者、评审者 | 随实现演进 |
| RFC | 提案是否被接受?需要谁同意? | 跨团队干系人 | 讨论期有效 |
| 复盘 | 事故为什么发生?怎么防? | 团队、管理者 | 事件驱动,长期保留 |
一句话:设计文档会过时,ADR 记录的是“当时的信息与权衡”——它不要求今天仍然正确,只要求今天能看懂当时为什么这么定。
一篇 ADR 的五段
# 12. 消息队列选 Kafka
- 状态:accepted
- 日期:2026-10-20
- 决策者:架构组 / 订单域
- 关联:supersedes ADR-0007
## 背景
订单峰值 8k QPS,现有 RabbitMQ 在积压时恢复慢;需要至少 7 天消息保留、
按分区顺序消费。约束:团队 3 人用过 Kafka,无人有 RocketMQ 生产经验。
## 决策
选 Kafka,3 分区起步,保留 7 天;消费者按订单号分区保证顺序。
## 后果
- 好:吞吐与保留能力满足;生态成熟,招人容易。
- 坏:运维复杂度上升,需要补 Kafka 监控与值班 runbook。
- 代价:迁移期间双写,预计 6 周;回滚点是双写开关。
## 替代方案
- RocketMQ:延迟消息更强,但团队无生产经验,运维风险高。
- RabbitMQ 加集群:改动小,但积压恢复仍不达标。
五段缺一不可:状态、背景、决策、后果、替代方案。没有“后果”的 ADR,等于只写了广告;没有“替代方案”的 ADR,读者无法判断当时是不是没得选。
什么时候写,什么时候不写
| 场景 | 写不写 | 理由 |
|---|---|---|
| 选型(数据库、消息队列、框架) | 写 | 影响长期,换的成本高 |
| 跨团队接口/契约 | 写 | 多方依赖,需要共同记忆 |
| 数据模型/分片键 | 写 | 难逆转,事后修改代价大 |
| 引入新的运行时/平台 | 写 | 影响部署、运维、招聘 |
| 命名、目录结构、代码风格 | 不写 | 可逆、低成本,写进规范即可 |
| 一次性的实现细节 | 不写 | 设计文档或代码注释更合适 |
| 已经由公司标准强制规定的 | 不写 | 记录“遵循标准”没有信息量 |
🔑 判断句式:“如果半年后有人问‘为什么不用 B’,我能不能用 5 分钟说清?” 说不清,就写。
状态机:内容不改,状态演进
proposed ──▶ accepted ──▶ superseded(被新 ADR 替代)
└───▶ deprecated(不再适用,但没被替代)
| 规则 | 做法 |
|---|---|
| 不改旧 ADR 的正文 | 决策变了就写新 ADR,旧的状态改为 superseded,并写 superseded-by |
| 编号单调递增 | 0001、0002……不要复用编号 |
| 索引自动生成 | 用工具生成 TOC,避免手工维护漏项 |
| 与代码同仓 | docs/adr/ 跟着代码走,评审时能一起看 |
一个能跑的 ADR 体检器
程序检查五件事:编号唯一、状态合法、必填段落齐全、superseded 有指向、被替代的编号存在。
package main
import (
"fmt"
"os"
"strings"
)
// ADR 是一篇“决策记录”,不是设计文档:一篇只记一个决策,且写下它的代价。
type adr struct {
num int
title string
status string // proposed / accepted / deprecated / superseded
supersededBy int // 0 表示没有被替代
body string
}
var validStatus = map[string]bool{"proposed": true, "accepted": true, "deprecated": true, "superseded": true}
func hasSection(body, name string) bool {
return strings.Contains(body, "## "+name)
}
type finding struct {
level, detail string
}
func check(a adr, byNum map[int]adr) []finding {
var fs []finding
add := func(level, detail string) { fs = append(fs, finding{level, detail}) }
if !validStatus[a.status] {
add("FAIL", "状态非法:"+a.status)
}
for _, sec := range []string{"背景", "决策", "后果"} {
if !hasSection(a.body, sec) {
add("FAIL", "缺“"+sec+"”段;ADR 必须写清上下文、决策与代价")
}
}
if !hasSection(a.body, "替代方案") {
add("WARN", "没有“替代方案”段;读者无法知道当时还考虑过什么")
}
if a.status == "superseded" {
if a.supersededBy == 0 {
add("FAIL", "状态是 superseded,但没有写被哪一篇替代")
} else if _, ok := byNum[a.supersededBy]; !ok {
add("FAIL", fmt.Sprintf("superseded-by %d 不存在", a.supersededBy))
}
} else if a.supersededBy != 0 {
add("FAIL", "写了 superseded-by,但状态不是 superseded")
}
return fs
}
func main() {
adrs := []adr{
{1, "消息队列选 Kafka", "accepted", 0, "## 背景\n订单量增长。\n## 决策\n选 Kafka。\n## 后果\n运维复杂度上升。"},
{2, "缓存改用 Redis Cluster", "accepted", 0, "## 背景\n单机内存不够。\n## 决策\n上 Redis Cluster。\n## 替代方案\n本地缓存。"},
{3, "旧网关下线", "superseded", 0, "## 背景\n网关重复。\n## 决策\n下线旧网关。\n## 后果\n流量迁到新网关。\n## 替代方案\n并行维护。"},
{4, "数据库分库分表", "accepted", 0, "## 背景\n单库容量到顶。\n## 决策\n按 user_id 分 64 库。\n## 后果\n跨分片查询变复杂。\n## 替代方案\n只读副本 / 归档。"},
{4, "重复编号", "proposed", 0, "## 背景\n测试。\n## 决策\n测试。"},
}
byNum := map[int]adr{}
fails, warns := 0, 0
fmt.Println("== ADR 体检 ==")
for _, a := range adrs {
if prev, ok := byNum[a.num]; ok {
fmt.Printf("ADR-%03d %-24s FAIL 编号重复(与「%s」冲突)\n", a.num, a.title, prev.title)
fails++
continue
}
byNum[a.num] = a
fs := check(a, byNum)
if len(fs) == 0 {
fmt.Printf("ADR-%03d %-24s %-9s ok\n", a.num, a.title, a.status)
continue
}
for i, f := range fs {
if i == 0 {
fmt.Printf("ADR-%03d %-24s %-9s %s %s\n", a.num, a.title, a.status, f.level, f.detail)
} else {
fmt.Printf(" %-28s %s %s\n", "", f.level, f.detail)
}
if f.level == "FAIL" {
fails++
} else {
warns++
}
}
}
fmt.Printf("\n汇总:%d 篇,FAIL %d,WARN %d\n", len(adrs), fails, warns)
if fails > 0 {
fmt.Println("结论:ADR 索引不可发布;先修 FAIL,再合并")
os.Exit(1)
}
}
本机运行结果(退出码 1):
== ADR 体检 ==
ADR-001 消息队列选 Kafka accepted WARN 没有“替代方案”段;读者无法知道当时还考虑过什么
ADR-002 缓存改用 Redis Cluster accepted FAIL 缺“后果”段;ADR 必须写清上下文、决策与代价
ADR-003 旧网关下线 superseded FAIL 状态是 superseded,但没有写被哪一篇替代
ADR-004 数据库分库分表 accepted ok
ADR-004 重复编号 FAIL 编号重复(与「数据库分库分表」冲突)
汇总:5 篇,FAIL 3,WARN 1
结论:ADR 索引不可发布;先修 FAIL,再合并
这四条检查对应四类最常见的“假 ADR”:没写代价、没写备选、状态悬空、编号打架。工具不能判断决策对不对,但能挡住“看起来像 ADR、实际没有信息量”的文档。
团队怎么落地
| 阶段 | 动作 | 成本 |
|---|---|---|
| 第 1 个月 | 建 docs/adr/、模板、索引;只要求“新决策必须写” | 低 |
| 第 2–3 个月 | 评审时检查 ADR;每次架构评审产出 1 篇 | 中 |
| 第 4–6 个月 | 补关键历史决策(标注 retro);每季度回看一次 | 中 |
| 长期 | 决策变更必须 supersede;ADR 进 onboarding 阅读清单 | 低 |
经验值:一个 20 人左右的研发团队,一个季度大约产生 5–15 篇有价值的 ADR;超过 30 篇/季度,大概率是把设计文档也塞进来了。
ADR 与流程的衔接:架构评审怎么产出 ADR 见 58.3 架构评审,选型决策怎么产出 ADR 见 58.5 技术选型流程;ADR 里记下的取舍会变成技术债,见 58.2 技术债管理;路线图上的迁移决策同样用 ADR 记录,见 58.4 演进路线图。决策框架与记录类型的全貌见 4.6 决策框架与决策记录,方案对比的方法见 79.3 方案对比与决策(待补)。
生产落地:指名 → 可抄 → 可验 → 可退
指名:轻量场景用 adr-tools(adr 命令)+ Markdown;需要 Web 展示用 log4brains;想要更完整的模板用 MADR(Markdown Any Decision Records)。不要为 ADR 引入重型文档平台。
可抄(本机未安装 adr-tools,以下命令未验证):
# 初始化目录与模板
adr init docs/adr
# 新建一篇(自动分配编号、生成模板)
adr new "消息队列选 Kafka"
# 建立替代关系:新 ADR 替代旧 ADR
adr link 12 supersedes 7
# 生成索引(放进 docs/adr/README.md)
adr generate toc > docs/adr/README.md
可验:本节体检器真跑过,输出如上;把它接进 CI(go run adr_check.go || exit 1)能挡住编号冲突、状态悬空、缺段。人工验证:随机抽 3 篇 ADR,问“半年后的人能不能看懂当时为什么这么选”。
可退:ADR 写错了不要删,也不要改历史——写一篇新的 ADR 替代它,旧的状态改 superseded。删除历史会让后来者重复踩坑,比写错更糟。
反模式
| 反模式 | 后果 |
|---|---|
| 把设计文档当 ADR | 只有“怎么做”,没有“为什么”和“代价” |
| 事后补写、只写好处 | 变成合理化材料,读者无法判断风险 |
| 一篇 ADR 塞多个决策 | 无法替代、无法索引、无法追溯 |
| 状态永不更新 | 旧决策看起来仍有效,误导新人 |
| 编号复用/手工维护索引 | 链接错乱,检索失效 |
| ADR 放在个人笔记/聊天记录 | 人一走就丢,评审时找不到 |
| 只写“我们选了 X” | 没有备选与后果,等于没写 |
自检
Q1 ADR 和设计文档的核心区别是什么?
▸ 答案:ADR 记录决策与权衡(为什么选 A、放弃 B、代价是什么),设计文档记录实现方案(模块、接口、数据流)。ADR 的读者是未来的维护者,设计文档的读者是实现者。两者可以互相引用,但 ADR 不随实现细节变化,只随决策变化(用新 ADR 替代)。
Q2 为什么 ADR 的内容不能改,只能被替代?
▸ 答案:ADR 记录的是“当时的信息与判断”。如果直接改正文,后来者就看不到“当时为什么这么想、后来为什么变了”,也无法从错误决策中学习。正确做法是保留旧 ADR、状态改 superseded,新 ADR 写清“因为什么变化、替代了什么”。
Q3 哪些决策值得写 ADR?给出三个判据。
▸ 答案:① 难以逆转(数据模型、分片键、运行时);② 跨团队(接口契约、平台选型);③ 有争议(评审时吵过、备选方案接近)。三个里中两个就该写。反过来,命名、目录结构、一次性实现细节不该写。
Q4 一篇合格的 ADR 至少要包含哪几段?缺了会怎样?
▸ 答案:状态、背景、决策、后果、替代方案。缺背景,读者不知道约束;缺后果,只看到好处;缺替代方案,无法判断是否“没得选”;缺状态,无法知道是否仍有效。缺任何一段,ADR 都会退化成“会议纪要”。
Q5 团队刚开始推 ADR,最容易被什么拖垮?
▸ 答案:把 ADR 写成设计文档,导致每篇都很长、没人写。起步应该只要求“新决策必须写、一篇一页、只写五段”,用工具自动编号与索引;先让团队感到“写这个能省未来的沟通”,再逐步补历史决策。重流程、重模板、重审批,是推不动的三大原因。
关联
- 前置:4.6 决策框架与决策记录
- 并列:58.3 架构评审、58.5 技术选型流程
- 下游:58.2 技术债管理、58.4 演进路线图
- 待补:79.3 方案对比与决策