系统设计文档怎么写:让评审者看见边界和风险
从一次翻车说起
去年下半年,我参与了一个支付渠道切换的项目。技术方案写了四十多页,架构图画了三张,接口定义详细到每个字段的类型。评审会上大家都说「写得挺全的」,顺利通过。
上线第二周,第三方支付通道出现了一次 30 秒的超时抖动。我们的系统在超时后重复发起了扣款请求,对端没有做幂等校验,导致同一笔订单被扣了两次。用户在群里炸了锅,客服工单堆了 200 多条。
事后复盘,我发现那份四十页的技术方案里,「失败路径」只有一个自然段,写的是「异常情况做重试处理」。重试几次?间隔多久?超时后怎么办?对端不幂等怎么兜底?全都没有。
这件事让我重新审视系统设计文档到底该写什么。不是因为写得不够多,而是写的内容没有帮评审者看见真正的风险。
文档的读者决定了写法
在动手写之前,我会先问自己一个问题:这份文档的读者是谁?
Google 内部的设计文档有一个明确的原则——文档的受众不是作者自己,而是评审者。Tanya Reilly 在《The Staff Engineer's Path》里也强调,RFC 的核心目的是让决策过程透明化,让团队能在「信息充分」的前提下给出判断,而不是走过场。
Uber 的工程实践也印证了这一点。他们在 Scaling Engineering Teams via Writing Things Down 一文中提到,RFC 流程的核心价值不在于写出一篇完美的文档,而在于「把假设和计划提前暴露出来」,让团队能在动手之前发现那些藏在脑子里的分歧。
不同的读者关心的东西不一样:
| 读者角色 | 核心关注点 | 文档中应突出的部分 |
|---|---|---|
| 技术负责人 | 方案是否合理、有无遗漏关键风险 | 架构图、备选方案对比、非功能性约束 |
| 下游团队 | 接口契约、变更兼容性 | 数据流、接口定义、发布时序 |
| SRE / 运维 | 可观测性、降级手段、回滚方案 | 失败路径、监控指标、回滚策略 |
| 产品经理 | 是否解决业务问题、上线时间 | 目标与非目标、里程碑、验收标准 |
| 未来的维护者 | 为什么这样设计、取舍依据 | 备选方案及淘汰原因、ADR 记录 |
理解读者之后,文档的详略取舍就有了判断标准。给技术负责人看的东西,不需要把每个字段的类型都列出来;给下游团队看的东西,不能只放一张架构图什么都不解释。
系统设计文档的核心章节
我总结的模板包含以下章节。不是每一篇都需要全部展开——简单功能压缩到半页也行,但「目标」「非目标」「失败路径」「验证计划」这四个,几乎不能跳过。
| 章节 | 核心问题 | 常见缺失后果 |
|---|---|---|
| 背景和目标 | 为什么要做?做到什么程度算成? | 评审者无法判断方案的合理性 |
| 非目标和约束 | 什么不做?有什么限制? | 范围蔓延,项目延期 |
| 架构图和数据流 | 系统怎么组织?数据怎么走? | 评审者无法理解全局 |
| 接口契约 | 上下游怎么对接?字段含义是什么? | 联调时反复扯皮 |
| 失败路径和降级 | 出了问题怎么办? | 上线后故障扩大 |
| 备选方案 | 为什么不选其他方案? | 重复讨论,决策不透明 |
| 验证和发布计划 | 怎么证明做对了?怎么上线? | 上线后无法验证效果 |
| 开放问题 | 还有什么没想清楚的? | 伪装成已决策,留下隐患 |
背景和目标
背景不是走过场。它要做的事情是:一个不了解上下文的工程师,读完这段就能理解为什么要做这件事。
腾讯云技术社区的一篇文章提到,背景部分最常见的毛病是「知识的诅咒」——作者默认读者知道所有的历史包袱和业务约束,写出来的背景对局外人来说毫无信息量。
好的背景段应该包含:当前系统是什么样的、哪里出了问题、为什么要现在改。
# 坏例子:模糊的背景描述
## 背景
随着业务的快速发展,现有支付系统已经无法满足日益增长的需求,
需要进行升级优化,以提升系统的稳定性和性能。
# 好例子:具体的背景描述
## 背景
当前支付系统通过单一通道(XX 支付)处理所有扣款请求,日均交易量
120 万笔。近三个月该通道出现 4 次 P1 故障,累计影响时长 6 小时,
直接导致资损 ¥38 万。业务侧要求在 Q3 完成多通道接入,主通道故障时
能在 30 秒内自动切换到备用通道。
目标要可验证。「提升系统性能」不是目标,「P99 延迟从 800ms 降到 200ms 以内」才是目标。
# 坏例子:不可验证的目标
## 目标
- 提升系统性能
- 增强系统稳定性
- 优化用户体验
# 好例子:可验证的目标
## 目标
- 支付接口 P99 延迟从 800ms 降至 200ms 以内
- 通道切换时间 < 30s,切换过程零丢单
- 系统可用性从 99.9% 提升至 99.95%(月度)
非目标和约束
非目标(Non-Goals)是我认为最容易被忽略、也最有价值的章节。
它的作用是在评审时挡住范围蔓延。当你明确写出「本期不做国际化」「不改造底层存储引擎」,评审者就不会在会议上提出「你们是不是也应该考虑一下多语言」这类发散性问题。
# 非目标示例
## 非目标
- 本期不做多币种支持,仅在人民币通道内做多通道容灾
- 不改造现有对账系统,对账逻辑保持 T+1 不变
- 不替换数据库,继续使用 MySQL 5.7
## 约束
- 必须在现有 K8s 集群内完成,不申请新机器
- 不能使用公司尚未引入的中间件
- 发布窗口限于每周四凌晨,单次发布不超过 30 分钟
架构图和数据流
架构图是设计文档里被看得最多的部分。但很多人犯的错误是只放一张图,不做任何文字说明。
我见过的最差情况:一张包含 15 个方框和无数箭头的架构图,没有任何图例,没有说明哪个是新增的、哪个是已有的。评审者花了 10 分钟试图理解图中哪个方框代表「新增的路由服务」。
画架构图的原则:
- 先画高层关系,再展开内部细节,不要一张图塞下所有信息
- 用不同颜色或线型标注「新增」「修改」「已有」
- 每张图配一段文字,解释核心数据流走向
- 说清楚关键节点的容错方式
这张图展示了多通道支付的核心链路。新增的路由服务和熔断器用虚线标注,数据流从客户端进入网关后,由路由服务根据通道健康状态决定走主通道还是备用通道。当通道超时或返回失败时,熔断器会触发降级,请求进入重试队列等待后续调度。
数据流和接口契约
数据流图和架构图是两个维度的东西。架构图说「有哪些组件、它们是什么关系」,数据流图说「一个请求从进来到出去,经过了哪些处理步骤、每个步骤做了什么」。
对于涉及多个系统交互的设计,我会用时序图把关键流程画出来。这不仅帮助评审者理解逻辑,也能提前暴露出接口定义中的歧义。
这张时序图展示了正常流程和通道切换的时序。有几个关键设计决策藏在里面:
- 路由服务在调用通道之前先查熔断器状态,而不是直接调用——这是为了防止在已知通道异常时继续浪费请求
- 主通道超时后不重试主通道,直接切换——因为超时通常意味着通道侧出了问题,重试大概率还是超时
- 写入数据库在拿到成功响应之后——保证数据一致性,不会出现「通道没扣成功但本地记录了成功」的情况
接口契约部分,我通常会列出关键的请求和响应结构,重点标注:
- 哪些字段是必选的,哪些是可选的
- 枚举值的具体含义
- 向后兼容的承诺(新增字段可以,删除或修改语义不行)
# 坏例子:模糊的接口定义
## 接口:查询用户权限
- 请求:userId(用户 ID)
- 响应:权限列表
# 好例子:明确的接口定义
## 接口:查询用户权限
- 路径:GET /api/v1/users/{userId}/permissions
- 请求参数:
- userId(路径参数,必填,string):用户唯一标识
- 响应字段:
- roles(array<string>,必填):用户角色列表,如 ["admin", "editor"]
- permissions(array<string>,必填):权限点列表,如 ["user:read", "user:write"]
- source(enum,可选,v1.2 新增):权限来源
- "role_inherited":通过角色继承
- "direct_granted":直接授权
- 注意:此字段为只读,调用方不可依赖其做业务逻辑判断
接口定义中最容易被忽视的是「版本兼容性」。一个字段从 string 改成 enum、一个返回码从 200 改成 202、一个可选字段变成必填——这些看似微小的变更,都可能让下游系统崩溃。
我会在接口契约部分明确标注:本次变更中哪些是新增字段、哪些是修改字段、哪些是废弃字段。对于 breaking change,必须单独列出影响范围和迁移方案。
失败路径和降级策略
回到开头那个翻车的故事。失败路径是我最重视的章节,也是评审者最容易挑战的部分。
对于每一个外部依赖,我都会问:如果它挂了会怎样?如果它响应慢会怎样?如果它返回错误数据会怎样?
# 坏例子:一句话带过失败处理
## 异常处理
- 网络异常时自动重试
- 记录错误日志
- 必要时报警
# 好例子:逐条分析失败场景
## 失败路径分析
### 场景 1:主通道超时(> 5s)
- 触发条件:单次请求耗时超过 5 秒
- 处理方式:立即切换到备用通道发起请求
- 超时请求不自动重试,由对账系统 T+1 补偿
- 报警级别:P2,连续 3 次超时升级为 P1
### 场景 2:备用通道不可用
- 触发条件:备用通道健康检查连续失败 3 次
- 处理方式:熔断备用通道,所有请求走主通道
- 同时暂停新订单,进入「排队等待」状态
- 报警级别:P1,通知 oncall 人工介入
### 场景 3:双通道同时故障
- 处理方式:所有支付请求写入本地消息队列
- 客户端返回「支付处理中」,不做最终失败判定
- 每 5 分钟探测通道恢复情况,恢复后按顺序消费队列
- 超过 30 分钟未恢复,升级为 P0,启动人工兜底
备选方案
只写最终方案,评审者看不到你的取舍过程。至少列一个被放弃的方案,说明为什么没选。
这个章节的价值不仅在评审时,更在半年后——当有人问「当初为什么不用方案 B」的时候,你可以直接指向文档,而不用靠记忆还原当时的上下文。
## 备选方案
### 方案 A(采纳):应用层路由 + 熔断降级
- 在支付服务内部实现路由逻辑
- 优点:改动范围小,不依赖新中间件
- 缺点:路由逻辑和业务代码耦合
### 方案 B(放弃):独立路由微服务
- 新建一个支付路由服务,所有请求先到路由再到通道
- 放弃原因:引入新服务增加运维成本,当前流量
级别(120 万 / 天)不需要独立部署
- 预期 QPS 峰值 200,独立服务的资源利用率过低
### 方案 C(放弃):Service Mesh 层面路由
- 在 Sidecar 层面做通道切换
- 放弃原因:公司 Service Mesh 尚在灰度阶段,
支付核心链路不能依赖未稳定的基础设施
验证和发布计划
没有验证计划的设计文档等于一份建议书。我会把验证拆成三层:
| 验证层级 | 验证内容 | 执行时机 |
|---|---|---|
| 开发自测 | 单元测试覆盖核心路由逻辑和熔断状态机 | 代码提交前 |
| 集成测试 | 模拟通道超时、失败、恢复场景 | 预发布环境 |
| 灰度验证 | 1% 流量切到新方案,观察 24 小时 | 生产环境 |
| 全量切换 | 确认灰度指标达标后全量放开 | 生产环境 |
| 回滚演练 | 关闭新路由,流量回到原通道,验证无损 | 上线后 1 周内 |
发布计划要具体到步骤和检查点:
## 发布计划
### Step 1:部署路由服务和熔断器(周四 00:00)
- 仅部署,不切流量
- 检查:服务健康检查通过,日志无异常
### Step 2:1% 灰度(周四 00:30)
- 按用户 ID 尾号取模,1% 流量走新路由
- 观察指标:成功率、P99 延迟、通道切换次数
- 检查:无报警,指标波动 < 5%
### Step 3:全量切换(周五 00:00)
- 100% 流量切到新路由
- 保留旧通道配置 72 小时,不回滚
### Step 4:清理(下周三)
- 移除旧通道直连代码
- 更新运维文档
真实案例拆解
案例一:订单系统的「非目标」缺失
一个电商平台要重构订单系统。技术方案写得很详细,包括分库分表方案、读写分离架构、订单状态机改造。
问题出在没有写非目标。评审会上,有人问:「退款系统一起改吗?」作者说「这次不改退款」。又有人问:「商家的发货通知逻辑变不变?」作者说「不变」。整场评审花了 40 分钟在讨论「到底改什么」,而不是「方案本身有没有风险」。
更严重的是,因为非目标没有明确写下来,开发过程中不断有人把相关需求「顺便」塞进去。最后项目延期了两个月,超出原始范围 60%。
修复方法:在文档开头用一节明确列出「本期改什么」和「本期不改什么」,每个「不改」的项标注原因(是技术约束、业务优先级还是风险控制)。后续评审和开发中如果有人提出范围内的需求,直接指向这个列表。
案例二:数据同步的失败路径盲区
一个数据团队要做用户行为数据从 Kafka 到数据仓库的实时同步。文档里详细描述了 Flink 作业的并行度配置、Checkpoint 策略、数据 schema 设计。
但对「数据延迟」这个关键失败模式,只写了一句「正常情况下延迟在秒级」。
上线后发现:上游 Kafka 分区扩缩容时,Flink 消费组 rebalance 会导致 3-5 分钟的消费暂停。这段时间内数据仓库的数据是过期的,但下游的实时报表没有标记「数据可能不完整」,运营基于过期数据做了决策,导致一次严重的投放失误。
修复方法:在失败路径章节增加「消费延迟」场景,定义延迟告警阈值(30 秒、3 分钟、10 分钟三级),下游报表在延迟超过 1 分钟时自动打上「数据延迟」水印。同时增加 Flink rebalance 事件的监控,在 rebalance 期间自动暂停下游数据消费。
案例三:接口文档和实现不同步
一个中台团队对外提供用户权限查询接口。设计文档里写的是:「返回用户的角色列表和权限点列表」。
实现时,开发同学加了一个「权限来源」字段,标注权限是通过角色继承还是直接授权的。这个字段没有在文档中说明,也没有在评审中提及。
三个月后,接入方基于这个「隐藏字段」做了大量业务逻辑。当中台想修改这个字段的语义时,发现有 5 个下游系统依赖它,改动影响面远超预期。
修复方法:接口变更必须在设计文档中同步,并经过评审。即使只是「加了一个字段」,也要走文档更新流程。长期来看,接口契约应该用 OpenAPI Schema 管理,CI 流程中自动检查实现和文档的一致性。
文档质量自检清单
写完文档之后,我会按这个清单过一遍。不是每一条都必须满足,但如果某一条明显缺失,通常意味着文档还需要补充。
写之前
- 明确了文档的读者是谁,不同读者关心的重点不同
- 确认了问题的背景和影响范围,不是凭感觉觉得「该做」
- 和至少一个相关方(产品 / SRE / 下游团队)对齐了需求理解
写的时候
- 目标可验证——每个目标都有具体的数字或可判断的标准
- 非目标至少列出 3 条,明确标注「不改」的原因
- 架构图有文字说明,不是一张裸图
- 每个外部依赖都分析了「如果它挂了会怎样」
- 备选方案至少列了一个,写清楚为什么放弃
- 接口变更考虑了向后兼容,标注了 breaking change
- 发布计划具体到步骤、时间窗口和回滚操作
写完之后
- 找一个不了解项目的人读一遍,看能否理解核心决策
- 检查数据引用是否有来源,避免「性能提升明显」这类模糊表述
- 确认所有开放问题都有标注,不伪装成已决策
- 文档长度适中——如果超过 20 页,考虑是否需要拆分
一些实操建议
关于详略
不是每个系统都需要二十页设计文档。一个内部工具的 CRUD 改造,半页就够。一个涉及资金链路的核心系统改造,可能五十页都嫌少。
判断标准不是系统复杂度本身,而是「做错了之后代价有多大」。代价越大,文档越详细。
| 变更影响 | 建议文档量 | 必写章节 | 可选章节 |
|---|---|---|---|
| 单服务内部重构 | 1-2 页 | 目标、方案、验证 | 备选方案 |
| 跨服务调用变更 | 3-5 页 | 目标、架构图、接口契约、失败路径 | 备选方案、发布计划 |
| 核心链路改造 | 10-20 页 | 全部章节 | 性能测试方案、灰度策略 |
| 基础设施级变更 | 20+ 页 | 全部章节 + ADR | 回滚演练记录、容量规划 |
关于评审效率
评审会不是用来读文档的。如果评审者在会上才开始读文档,那文档的提前分发就没做到位。
我的经验是:提前 2 天把文档发出去,要求评审者在会前留下评论。评审会的重点放在「有争议的问题」上,而不是从头到尾讲一遍。
还有一个技巧:在文档开头写一个 TL;DR,用 5 行以内概括核心决策和需要评审者判断的问题。这能显著提高评审者提前阅读的概率。
评审流程本身也可以规范化。我通常遵循以下步骤:
- 作者写完文档 → 自评一遍(用本文的检查清单)
- 指定 1-2 个预审人 → 提前阅读,留下评论
- 作者回复评论 → 修改文档或标记为「开放问题」
- 评审会(30 分钟内)→ 只讨论有分歧的部分
- 会后纪要 → 记录决策和待办,更新到文档中
# 坏例子:评审会变成朗读会
- 会上从头到尾讲 PPT,评审者低头看手机
- 会后没有纪要,一周后没人记得讨论了什么
- 下次评审同样的问题又被提出来
# 好例子:高效的评审流程
- 文档提前 2 天发出,预审人留下书面评论
- 会上只讨论评论中标记的分歧点
- 会后 1 小时内发出纪要,包含决策和 action items
- 开放问题记录在文档末尾,跟踪到人
关于工具
文档放在哪里不重要,重要的是团队能不能找到它、能不能看到历史版本、能不能在上面留评论。
Google Docs、飞书文档、Confluence、Notion、甚至 Markdown 文件放在 Git 仓库里,都可以。关键是形成习惯——每个涉及架构变更的需求,都有一篇对应的设计文档,链接记录在项目管理工具里。
| 工具类型 | 代表产品 | 优势 | 劣势 | 适合场景 |
|---|---|---|---|---|
| 在线协作文档 | 飞书文档、Google Docs | 实时协作、评论方便、搜索友好 | 版本管理粗糙,无法 diff | 快速迭代期、跨团队协作 |
| Wiki 系统 | Confluence、Notion | 结构化组织、权限管理 | 搜索体验差、格式限制多 | 知识库沉淀、跨团队查阅 |
| Markdown + Git | GitHub、GitLab | 版本管理精确、可 code review | 协作门槛高、不支持实时编辑 | 技术方案、API 文档 |
| ADR 工具 | Log4brains、ADR Tools | 轻量、标准化、可追溯 | 功能单一、不适合长文档 | 架构决策记录 |
常见反模式
写了几年设计文档,也看了别人写的不少,我总结出几种最常见的反模式:
「百科全书」型
作者生怕漏掉任何细节,把整个系统的历史、所有模块的接口定义、数据库的完整 schema 全部塞进一篇文档。结果就是 60 页的文档,评审者根本看不完,评审会上也没人真正讨论核心问题。
设计文档不是系统规格说明书。它的目标是帮助评审者在有限时间内做出判断,而不是成为一本百科全书。不相关的细节会分散注意力,该砍就砍。
「技术炫技」型
明明是一个简单的 CRUD 需求,偏偏要引入微服务、事件驱动、CQRS。文档里充斥着「最终一致性」「领域事件」「Saga 模式」这类术语,但没有解释为什么现有架构不够用、为什么需要这么复杂的方案。
一个困难的问题可能会有一个简单的答案。方案复杂度和问题复杂度应该匹配。如果你要用一个复杂的方案,文档里必须解释清楚「简单方案为什么不行」。
「橡皮图章」型
文档在评审之前就已经决定了。评审会只是走个形式,所有人都知道方案已经和领导对过了。这种情况下,文档的存在只是为了留下「我们做过评审了」的记录。
这其实是最浪费的一种模式。写文档的人不会认真写,评审的人不会认真看,出了问题也没人翻文档——因为大家都知道那份文档和实际方案对不上。
| 反模式 | 核心问题 | 修复方法 |
|---|---|---|
| 百科全书型 | 信息过载,评审者找不到重点 | 按读者需求裁剪,核心决策前置 |
| 技术炫技型 | 方案复杂度超过问题复杂度 | 先论证「简单方案为什么不行」 |
| 橡皮图章型 | 评审走形式,决策在评审前已定 | 确保评审者有真正的否决权 |
| 知识诅咒型 | 默认读者知道所有背景 | 假设读者是不了解项目的新同事 |
| 模糊表述型 | 目标和数据缺乏具体数字 | 每个目标都要有可验证的指标 |
小结
系统设计文档不是为了证明作者想得很多。它的价值在于:让评审者能判断方案是否可落地,让后来的维护者能理解当时的取舍,让团队在出问题时有据可查。
好的设计文档不需要文采,需要的是结构清晰、风险暴露、决策有据。写完之前问自己一句:如果评审者只给我 5 分钟,他能不能知道我要做什么、为什么这样做、风险在哪里?
如果能,这份文档就及格了。
回到开头的支付系统翻车。如果当时那份四十页的文档里,「失败路径」不是「异常情况做重试处理」这八个字,而是逐条分析超时、重试、幂等、对账的完整链路——那次事故大概率可以避免。
文档不是万能的。但一份好的设计文档,至少能让团队在动手之前把最明显的坑踩掉。剩下的问题,留到实现和线上去发现。
参考资料
- 如何写一份高可读性的软件工程设计文档 - 腾讯云
- Software Engineering RFC and Design Doc Examples and Templates - Pragmatic Engineer
- My Favorite RFC Template - Software Philosopher
- Design Docs at Google - Industrial Empathy
- Scaling Engineering Teams via Writing Things Down (RFCs) - Pragmatic Engineer
- 架构设计文档模板 - 极客时间《从0开始学架构》
- 如何撰写好的技术方案设计 - 阿里云开发者社区
- Sourcegraph RFC Process - Sourcegraph Handbook