ADR 不是事后总结
ADR(Architecture Decision Record)不是项目结束后的复盘文档,而是决策之前的思考记录。如果在代码已经写完之后才补 ADR,它的价值就消失了——ADR 的核心作用是记录「为什么这么做」而不是「做了什么」。
本附录提供五份模板,覆盖技术选型、事件契约、一致性方案、迁移方案、治理方案五个场景。每份模板包含七个必填字段:标题、状态、背景、决策因素、选项对比、结论、后果、验证条件。缺失任何一个字段,ADR 就不可接受。
场景一:技术选型
标题:ADR-001 消息中间件选型
状态:已接受
背景:
系统需要引入一套消息中间件支持事件驱动架构,当前候选方案有 Kafka、RabbitMQ、Pulsar 三个。选型需要在吞吐、延迟、运维复杂度、生态成熟度之间做权衡。
决策因素:
- 吞吐量:峰值 10K msg/s,未来三年增长到 50K msg/s
- 延迟:事件从生产到消费的 p99 延迟 < 100ms
- 生态:需要成熟的客户端库、监控工具、云服务托管
- 运维:团队没有专职运维,需要低运维复杂度的方案
选项对比:
| 维度 | Kafka | RabbitMQ | Pulsar |
|---|---|---|---|
| 吞吐量 | 10K–1M msg/s | 1K–20K msg/s | 10K–500K msg/s |
| p99 延迟 | 10–50ms | 1–10ms | 10–100ms |
| 运维复杂度 | 中(需 ZooKeeper 或 KRaft) | 低 | 中(需 BookKeeper) |
| 生态成熟度 | 高(Confluent、云托管) | 中 | 低(较新) |
| 适合场景 | 日志、事件流 | RPC、任务队列 | 多租户、跨地域 |
结论:选择 Kafka。吞吐量和延迟满足当前及未来三年需求,生态最成熟,云托管选项(MSK、Confluent Cloud)降低运维压力。RabbitMQ 吞吐不足,Pulsar 生态不成熟且团队无运维经验。
后果:
- 好处:吞吐和延迟达标,生态成熟,迁移路径清晰
- 风险:Kafka 运维需要专门知识,前期需要培训
- 抵消措施:选择云托管版本,减少自运维需求
验证条件:
- 生产环境压测吞吐达到 10K msg/s,p99 延迟 < 50ms
- 消费者 lag 在可接受范围内(< 1000 条)
- 运维团队完成 Kafka 基础知识培训
场景二:事件契约
标题:ADR-002 事件序列化格式
状态:已接受
背景:
事件需要在生产者和消费者之间序列化传输,候选格式有 JSON、Protobuf、Avro 三种。选型需要在可读性、性能、兼容性、Schema 治理之间做权衡。
决策因素:
- 可读性:开发调试需要人类可读的事件格式
- 性能:序列化/反序列化延迟 < 1ms
- 兼容性:支持字段增删不改,老消费者不崩溃
- Schema 治理:有工具支持 Schema 版本管理和兼容性校验
选项对比:
| 维度 | JSON | Protobuf | Avro |
|---|---|---|---|
| 可读性 | 高 | 低(二进制) | 低(二进制) |
| 序列化延迟 | 高(文本解析) | 低 | 低 |
| 包大小 | 大(字段名重复) | 小(字段编号) | 小(Schema 共享) |
| Schema 演化 | 手动管理 | Protobuf 支持 | Avro 原生支持 |
| 工具生态 | 通用 | Google 生态 | Hadoop 生态 |
结论:选择 JSON + 手动 Schema 管理(v1/v2 版本号)。团队当前规模小(< 5 个服务),JSON 的可读性带来的开发效率提升大于性能损失。Schema 管理通过事件目录人工维护,不引入 Registry 降低复杂度。
后果:
- 好处:开发调试效率高,团队学习成本低
- 风险:包大小增加网络开销,Schema 变更依赖人工沟通
- 抵消措施:监控消息大小,超过 10KB 时告警;Schema 变更走审批流程
验证条件:
- 单条消息序列化/反序列化延迟 < 5ms(JSON 可接受)
- Schema 变更后所有消费者验证通过
- 消息大小分布 95% < 5KB
场景三:一致性方案
标题:ADR-003 跨服务事务一致性
状态:已接受
背景:
订单服务和库存服务需要协同完成「下单扣库存」操作,两个服务有独立数据库,无法使用单机事务。需要在 2PC、TCC、Saga 三种方案中选择。
决策因素:
- 一致性强弱:允许最终一致,不强求实时一致
- 业务场景:订单创建 + 库存扣减,扣减失败需要释放库存
- 回滚复杂度:库存扣减的回滚操作是释放库存,简单可逆
- 外部依赖:库存服务是内部服务,可控制 API
选项对比:
| 维度 | 2PC | TCC | Saga(协同式) |
|---|---|---|---|
| 一致性强弱 | 强一致 | 最终一致 | 最终一致 |
| 业务侵入 | 低(协议层) | 高(需 Try/Confirm/Cancel) | 中(需定义补偿事件) |
| 回滚复杂度 | 低(自动回滚) | 中(需实现 Cancel) | 中(需实现补偿消费者) |
| 外部依赖 | 需支持 XA | 需服务实现 TCC 接口 | 需事件总线 |
| 性能影响 | 高(两阶段锁) | 中 | 低 |
结论:选择 Saga(协同式)。一致性强弱满足业务需求(最终一致),业务侵入低于 TCC,性能优于 2PC。回滚操作(释放库存)简单可逆,适合 Saga 模式。
后果:
- 好处:性能高,业务侵入适中,回滚操作简单
- 风险:补偿可能失败,需要人工出口;最终一致窗口期内数据不一致
- 抵消措施:补偿操作设置超时和重试,失败时进人工处理队列;一致性窗口承诺 30 秒内
验证条件:
- 正常流程 100% 成功
- 库存不足场景补偿成功,无库存泄漏
- 补偿超时场景进入人工处理,15 分钟内处理完成
场景四:迁移方案
标题:ADR-004 单体到事件驱动迁移策略
状态:已接受
背景:
现有单体系统需要拆分为事件驱动微服务,需要在「大爆炸重写」和「绞杀者模式」之间做选择。团队 8 人,业务不能停服超过 1 小时。
决策因素:
- 停机窗口:最多 1 小时,实际要求 0 停机
- 团队规模:8 人,无法同时维护两套系统超过 3 个月
- 回滚能力:迁移失败需要能回滚到单体状态
- 验证周期:每个迁移阶段需要 2–4 周观察期
选项对比:
| 维度 | 大爆炸 | 绞杀者模式 |
|---|---|---|
| 停机窗口 | 需要(数小时–数天) | 0(渐进切换) |
| 团队压力 | 高(一次性全量) | 中(分阶段) |
| 回滚能力 | 差(数据已迁移) | 好(双写期间可回滚) |
| 验证周期 | 一次验证 | 分阶段多次验证 |
| 技术风险 | 高 | 低 |
结论:选择绞杀者模式。满足 0 停机要求,回滚能力好,分阶段验证降低风险。虽然周期更长(预计 6 个月),但团队无法承受大爆炸的风险。
后果:
- 好处:0 停机,回滚能力强,风险可控
- 风险:周期延长,双写阶段需要维护两套写入逻辑
- 抵消措施:第一阶段选最独立的模块(用户通知),双写阶段使用 outbox 模式保证一致性
验证条件:
- 每个阶段完成后回滚演练成功
- 双写阶段数据一致性检测零差异
- 全部切换后单体代码库可以冻结(保留 1 个月观察)
场景五:治理方案
标题:ADR-005 事件 Schema 治理模式
状态:草稿
背景:
团队有 15 个微服务、50+ 个事件类型,Schema 变更频繁。需要在集中式 Schema Registry 和去中心化治理之间做选择。
决策因素:
- 变更频率:每月 5–10 次 Schema 变更
- 消费者数量:平均每事件 3–5 个消费者
- 团队分布:3 个业务线,各自维护自己的服务
- 兼容性要求:90% 的变更为向后兼容
选项对比:
| 维度 | 集中式 Registry | 去中心化治理 |
|---|---|---|
| Schema 变更审批 | 中心化审批,流程规范 | 各团队自主,快速灵活 |
| 兼容性校验 | 自动(Registry 内置) | 手动(CI 脚本) |
| 组织成本 | 高(需推广和培训) | 低(各管各的) |
| 一致性 | 高(全局统一) | 低(可能各自为政) |
| 适合规模 | 中大型团队(>20 服务) | 小型团队(<10 服务) |
结论:待定。当前规模(15 服务)处于中间地带,两种方案各有利弊。计划在 Q2 之前小规模试点集中式 Registry(选 3 个高频变更事件),Q3 评估后决策。
后果:
- 试点好处:验证 Registry 在团队内的接受度和实际效果
- 试点风险:增加短期复杂度,可能影响开发效率
- 抵消措施:Registry 只管理高频事件(Top 10),其他事件保持现状
验证条件:
- 试点 3 个事件的 Schema 变更审批时间 < 2 天
- 消费者兼容性验证自动化率 100%
- 团队满意度调研得分 > 4/5
ADR 完整性校验器
以下 Java 程序检查 ADR 是否包含所有必填字段,以及状态转换是否合法。
import java.util.*;
public class ADRValidator {
record ADR(String id, String title, String status, String background,
String factors, String options, String conclusion,
String consequences, String validation) {}
static final Set<String> VALID_STATUSES = Set.of("草稿", "已接受", "已废弃");
static final Map<String, List<String>> VALID_TRANSITIONS = Map.of(
"草稿", List.of("已接受", "已废弃"),
"已接受", List.of("已废弃"),
"已废弃", List.of()
);
static List<String> validate(ADR adr) {
List<String> errors = new ArrayList<>();
if (isBlank(adr.title())) errors.add("标题缺失");
if (isBlank(adr.status())) errors.add("状态缺失");
if (!VALID_STATUSES.contains(adr.status())) errors.add("状态非法: " + adr.status());
if (isBlank(adr.background())) errors.add("背景缺失");
if (isBlank(adr.factors())) errors.add("决策因素缺失");
if (isBlank(adr.options())) errors.add("选项对比缺失");
if (isBlank(adr.conclusion())) errors.add("结论缺失");
if (isBlank(adr.consequences())) errors.add("后果缺失");
if (isBlank(adr.validation())) errors.add("验证条件缺失");
return errors;
}
static boolean isBlank(String s) { return s == null || s.trim().isEmpty(); }
static boolean canTransition(String from, String to) {
List<String> allowed = VALID_TRANSITIONS.getOrDefault(from, List.of());
return allowed.contains(to);
}
public static void main(String[] args) {
ADR good = new ADR("ADR-001", "消息中间件选型", "已接受",
"需要引入事件驱动架构", "吞吐、延迟、运维",
"Kafka vs RabbitMQ vs Pulsar", "选 Kafka",
"好处:吞吐达标;风险:运维复杂", "压测通过");
ADR bad = new ADR("ADR-002", "", "草稿",
"需要选序列化格式", "", "JSON vs Protobuf", "选 JSON",
"好处:可读性高", "");
ADR invalidTransition = new ADR("ADR-003", "一致性方案", "已废弃",
"跨服务事务", "一致性强弱", "2PC vs Saga", "选 Saga",
"性能好", "验证通过");
System.out.println("=== ADR 完整性校验 ===\n");
System.out.println("[ADR-001] 完整案例:");
List<String> e1 = validate(good);
System.out.println(e1.isEmpty() ? "✓ 通过" : "✗ " + e1);
System.out.println("\n[ADR-002] 缺失字段案例:");
List<String> e2 = validate(bad);
e2.forEach(err -> System.out.println(" ✗ " + err));
System.out.println("\n[ADR-003] 状态转换检查:");
boolean ok = canTransition("已接受", "已废弃");
System.out.println("已接受 → 已废弃: " + (ok ? "✓ 合法" : "✗ 非法"));
boolean badTrans = canTransition("已废弃", "已接受");
System.out.println("已废弃 → 已接受: " + (badTrans ? "✓ 合法" : "✗ 非法"));
System.out.println("\n校验规则:");
System.out.println("- 七个必填字段: 标题、状态、背景、因素、对比、结论、后果、验证");
System.out.println("- 合法状态: 草稿、已接受、已废弃");
System.out.println("- 合法转换: 草稿→已接受/已废弃, 已接受→已废弃");
}
}
本机实测:
javac --release 21 ADRValidator.java && java ADRValidator
预期输出:
=== ADR 完整性校验 ===
[ADR-001] 完整案例:
✓ 通过
[ADR-002] 缺失字段案例:
✗ 标题缺失
✗ 决策因素缺失
✗ 验证条件缺失
[ADR-003] 状态转换检查:
已接受 → 已废弃: ✓ 合法
已废弃 → 已接受: ✗ 非法
校验规则:
- 七个必填字段: 标题、状态、背景、因素、对比、结论、后果、验证
- 合法状态: 草稿、已接受、已废弃
- 合法转换: 草稿→已接受/已废弃, 已接受→已废弃
ADR 的使用流程
五份模板的实际使用流程:
| 步骤 | 做什么 | 责任人 | 产出物 |
|---|---|---|---|
| 1. 识别决策点 | 在架构设计中发现需要记录的技术选择 | 架构师 | 决策候选列表 |
| 2. 填写 ADR | 用模板填写七个字段,状态设为「草稿」 | 架构师 | ADR 文档 |
| 3. 评审 | 技术评审会讨论 ADR,确认选项对比是否完整 | 评审组 | 评审意见 |
| 4. 状态更新 | 根据评审意见更新为「已接受」或「已废弃」 | 架构师 | 更新后的 ADR |
| 5. 验证 | 按验证条件执行检查,结果写入 ADR | 开发团队 | 验证报告 |
| 6. 归档 | 已接受的 ADR 归档到版本仓库 | 架构师 | 归档文件 |
关键原则:ADR 在代码变更之前写,变更之后只补验证结果。如果代码已经写完了才补 ADR,就是事后总结,不是事前决策。
模板选型的指导
五个场景对应书稿的不同章节:
| ADR 场景 | 书稿章节 | 核心决策点 |
|---|---|---|
| 技术选型 | 卷一第 1 章 | Kafka vs 其他消息队列 |
| 事件契约 | 卷三第 2.3、6 章 | Schema 格式、兼容性策略 |
| 一致性方案 | 卷三第 4 章 | 2PC vs TCC vs Saga |
| 迁移方案 | 卷三第 12.2 章 | 大爆炸 vs 绞杀者 |
| 治理方案 | 卷三第 11 章 | 集中式 vs 去中心化 |
ADR 不是每章都需要的,只在出现技术分歧时才写。写 ADR 的目的是记录决策上下文,让半年后回顾时知道「为什么当时这么选」。
本节小结
- ADR 是事前决策记录,不是事后总结,必须在代码变更之前写。
- 五份模板覆盖技术选型、事件契约、一致性、迁移、治理五个场景。
- 每个 ADR 必须包含七个字段:标题、状态、背景、因素、对比、结论、后果、验证。
- 状态转换规则:草稿→已接受/已废弃,已接受→已废弃,已废弃不可恢复。
- 本机实测:ADRValidator 校验完整性,缺失字段和非法状态转换均被拦截。
- ADR 的使用时机:出现技术分歧、选型犹豫、团队意见不统一时。
阅读导航:上一节 附录 A 术语表
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。