设计文档写什么:问题、约束、场景与可检查的目标
一份设计文档有二十页:背景、架构图、模块划分、接口列表、表结构。评审会开了两小时,大家对着架构图讨论了半天用不用消息队列。上线后出了问题,回头翻文档,发现没有一处写着「这个系统每秒要处理多少请求」「下游超时时应该怎样」,也没有人记得当初为什么不用缓存。
很多设计文档写的是设计的结果:选了什么组件、分了哪些模块、画了什么图。这些内容有用,但它们回答不了评审时最该问的问题:这个设计要解决什么问题?在什么约束下?怎样知道它做到了?没有这些,评审只能讨论技术偏好,上线后也无从判断设计是否达成了目标。
本文给出一个设计文档的结构,每一部分都说明它回答什么问题、怎样写才可以被检查。这是一篇方法文,没有配套实验;文中引用的实测都来自站内其他文章。
一、问题与目标:先写「为什么」
第一部分只回答一个问题:要解决的是什么问题,解决到什么程度算成功。
- 问题陈述:用业务语言描述现状和痛点,最好带上可以核实的事实(「每天约有 X 笔订单因超时重复扣款,来自客服工单统计」),而不是「系统性能不好」。
- 目标:做完之后,什么会变得不同。目标要能检查:「重复扣款为零」「下单接口 P99 在 300ms 以内」。
- 非目标:明确不解决什么。「不改造支付渠道的对接」「不处理历史数据」。非目标能防止评审时范围不断扩大,也让读者知道哪些问题是有意留下的。
如果写不出可检查的目标,往往说明问题本身还没想清楚,这时候继续写架构是在浪费时间。
二、约束:设计不能改变的东西
约束是设计必须接受的前提,它们决定了哪些方案根本不在考虑范围内:
| 约束类型 | 例子 |
|---|---|
| 业务规则 | 同一笔订单只能扣款一次;退款不能超过实付金额 |
| 外部系统 | 支付渠道的回调最多重试 3 次;对方接口限流每秒 100 次 |
| 数据规模与增长 | 当前 2000 万订单,每月增长 5% |
| 技术栈与运维 | 只能用公司现有的 MySQL 与 Kafka;不能引入新的有状态组件 |
| 时间与人力 | 两个人、六周 |
| 合规 | 支付数据不出境;日志不能记录卡号 |
约束和目标一样要具体。「高并发」不是约束,「峰值每秒 3000 次下单,来自去年大促的监控」才是。数字的来源要写出来,因为数字会过时,读者需要知道它是测量的、估算的还是猜的。
三、场景:用具体的情况检验设计
架构图描述的是静态结构,场景描述的是系统在具体情况下怎样表现。每个关键场景写成「刺激—环境—响应—度量」:
场景:支付回调重复到达
刺激:支付渠道对同一笔交易发送两次成功回调,间隔 2 秒
环境:正常负载,两次回调落在不同的实例上
响应:订单只被标记为已支付一次,第二次回调返回成功但不产生副作用
度量:重复回调测试中,订单状态变化事件恰好 1 个场景分两类:功能场景(正常下单、取消、退款)和质量场景(峰值负载、下游超时、实例宕机、重复消息、数据迁移中途失败)。质量场景是设计文档最常缺的部分,也是上线后最常出问题的部分。
场景的价值在于它可以被检查:评审时逐个场景追问「按这个设计,这个场景下会发生什么」,比讨论「要不要用消息队列」有效得多。上线前,每个质量场景应该有对应的测试或演练。
用这个例子把后面几节串起来看:一个场景怎样落到视图、决策和验证上。
四、方案与视图:每张图回答一个问题
到这一步才写方案本身。一个系统需要从几个角度看,每个视图回答不同的问题:
| 视图 | 回答的问题 | 常见形式 |
|---|---|---|
| 领域模型 | 有哪些概念,规则由谁守 | 类图、规则表 |
| 模块与依赖 | 代码怎样组织,依赖朝哪个方向 | 包图、依赖规则 |
| 运行时交互 | 一个场景里请求怎样流动、失败时怎样 | 时序图 |
| 部署 | 运行在哪里、几个实例、怎样扩容 | 部署图 |
| 数据 | 存在哪里、一致性怎样保证、怎样迁移 | 表结构、数据流 |
不是每份文档都需要所有视图。原则是:每张图都要能回答第三节里某个场景的问题。一张画满了方框和箭头、却说不出对应哪个场景的架构图,评审时只能用来讨论颜色。读一个已有系统时也可以用同样的视图顺序,见读懂一个系统的设计。
五、备选方案与决策记录
每个关键决策都写出至少一个被放弃的备选方案,以及放弃的理由。理由要关联到前面的目标、约束或场景:
决策:重复回调用「订单号 + 交易号」唯一索引去重,不引入分布式锁
备选:Redis 分布式锁
放弃理由:
- 约束「不能引入新的有状态组件」
- 场景「回调落在不同实例上」中,锁过期与回调处理时间的关系需要额外设计;唯一索引由数据库保证
复评条件:如果回调处理需要调用多个外部系统、无法放在一个数据库事务里,重新评估复评条件说明这个决策在什么情况下应该重新考虑,它让后来的人知道决策的前提是什么。候选比较与决策记录的完整做法,见几个模型都说得通时。
比较方案时一起算的东西
只列每个方案的优点和缺点,比较常常不公平:新方案的代价被低估,维持现状的代价被忽略。下面几项要摆在同一张表里。
维持现状也是一个候选。 它不是零成本:已知的故障会继续发生,绕过问题的人工步骤会继续消耗时间,某些需求会继续做不了。把它和其他方案放在一起写,才能回答「为什么现在要改」。
成本按完整的生命周期算。 构建只是一部分,还有迁移(数据、调用方、双写期)、运维(值班、监控、升级)、学习(团队需要掌握的新东西)和退出(将来不用它时要付出什么)。一个方案省下的机器费用,可能变成另一个团队的迁移工作和值班负担。只在自己的边界里算账,会选出把成本推给别人的方案。
机会成本。 投入这个方案的人和时间,同期就不能做别的事。比较时要问的不只是「这个方案值不值」,而是「它是不是这段时间里最值得做的事」。
收益是假设,要标出依据和敏感性。 「新方案能把延迟降低一半」来自压测、来自别人的经验,还是来自推测?依据不同,可信度不同。更有用的是看结论对假设有多敏感:
| 假设(示意) | 方案甲:渐进改造 | 方案乙:替换 |
|---|---|---|
| 迁移 3 个月,收益按预期 | 次优 | 最优 |
| 迁移拖到 6 个月 | 最优 | 次优 |
| 收益只有预期的一半 | 最优 | 次优 |
表中是示意,不是测量结果。如果两个合理的假设变化都会让排序反转,说明这个决策的风险集中在这些假设上,下一步应该先验证它们(做一个小规模试验、迁移一个切片),而不是继续细化方案。
分阶段,并写下重新评估的条件。 长期方向可以很大,但每个阶段都要交付一个能被使用的结果,并写明在什么信号出现时停下来重新评估:数据量超过某个规模、某个假设被试验推翻、团队里能维护它的人离开、监管要求变化。暂时合适的方案,不应该因为写进了文档就变成永久规则。
变更方案也适用同样的结构:变更前写清目标与代价、怎样验证、怎样回退。事故之后的改进同理:先重现问题,再写改进方案,最后用演练证明改进有效,而不是只写「已修复」。
六、验证计划:怎样知道做到了
最后一部分把第一节的目标和第三节的场景变成具体的检查:
| 目标 / 场景 | 验证方式 | 何时 |
|---|---|---|
| 重复回调不重复扣款 | 集成测试:并发发送两次回调 | 每次构建 |
| 下单 P99 ≤ 300ms | 压测:峰值负载 3000 次/秒,持续 30 分钟 | 上线前 |
| 下游超时不拖垮下单 | 故障注入:支付渠道延迟 10 秒 | 上线前 |
| 数据迁移可回退 | 演练:迁移到一半时回滚 | 上线前 |
| 重复扣款为零 | 监控:每日对账差异报表 | 上线后持续 |
验证计划让设计文档从「一份描述」变成「一份可以被检查的承诺」。压测的负载模型怎样选、结果怎样解读,见开放与封闭负载模型;变更怎样做到可回退,见扩展—收缩式变更。
七、写多少
设计文档的篇幅应该与决策的影响和不确定性相称:
- 影响大、难以回退(数据模型、对外接口、跨团队的依赖):完整的六部分。
- 影响局限在一个模块、容易回退:问题、目标、关键场景和决策记录,一两页就够。
- 常规功能:在任务描述里写清目标和验收场景即可,不需要单独的设计文档。
另一个常见的问题是设计投入与真实变化的错配:花大量精力设计「将来可能的扩展」,而真正发生的变化在另一个方向。应对的办法不是更努力地预测,而是在文档里写明设计承诺了哪些变化方向(第五节的复评条件),并在变化真正发生时回来更新文档。文档是活的,过时的设计文档比没有更糟。
八、检查清单
- 问题陈述带有可核实的事实;目标可检查;写明非目标。
- 约束具体,数字注明来源。
- 关键功能与质量场景写成「刺激—环境—响应—度量」。
- 每个视图都能对应到某个场景的问题。
- 每个关键决策有被放弃的备选、关联到约束或场景的理由、复评条件。
- 验证计划覆盖每个目标和质量场景,写明方式与时机。
- 篇幅与决策的影响和不确定性相称;变化发生时更新文档。
参考资料
- Len Bass、Paul Clements、Rick Kazman,《Software Architecture in Practice》(质量属性场景)
- Simon Brown,The C4 model for visualising software architecture
- Michael Nygard,Documenting Architecture Decisions
- arc42 架构文档模板