主题
系统设计方法论:把需求一步步变成能验证的方案
拿到“设计一个任务管理系统”,直接画网关、缓存、消息队列,很快就能画满一页。难点在于解释:为什么需要它们?少一个会违反哪条要求?数据库提交后网络断了,用户再点一次,任务会不会多一份?
这篇用一个贯穿始终的假设案例走完设计过程:团队成员创建任务、查看列表、修改状态,任务创建后异步通知负责人。重点是建立一条可检查的推理链:需求决定约束,数量级缩小选项,接口和数据模型表达约束,架构负责在失败时仍守住它们。案例数字是教学假设,不是某个真实产品的流量报告。
先问会改变设计的问题
功能需求说明用户要完成什么;非功能需求说明在多少负载、多久延迟、哪些故障下仍要完成。两类不能分开讨论。例如“通知允许晚几秒”直接决定能否把通知移出创建任务的事务。
| 要澄清的事 | 本案例暂定答案 | 对后续设计的影响 |
|---|---|---|
| 用户与权限 | 多租户团队;成员只能访问所属团队 | 每条数据带 tenant_id;鉴权不能只检查是否登录 |
| 核心操作 | 创建、按团队分页列出、按版本改状态 | 先做明确访问路径;暂不做全文搜索、附件、复杂报表 |
| 重试语义 | 同一创建请求重试,返回同一任务 | 需要业务幂等键及其结果记录 |
| 并发修改 | 不允许后提交的旧页面默默覆盖新状态 | 更新带 expected_version,冲突后重新读取 |
| 列表一致性 | 创建者立刻能查到;其他人的统计允许滞后 | 创建后的读取走权威数据;统计可异步 |
| 通知 | 可以重试,目标是 99% 在 30 秒内进入站内收件箱 | 事务内留通知意图,事务外投递,收件箱去重 |
| API 目标 | 30 天内合规请求成功率 99.9%;服务端 p99 小于 200 ms | 必须定义采样边界、超时、错误分类及峰值条件 |
| 数据恢复 | 暂定 RPO 5 分钟、RTO 30 分钟,待业务签字 | 备份和恢复演练进入方案,不能只写“主从高可用” |
RPO 是可以接受丢失多长时间的数据,RTO 是故障后多快恢复服务。这两个数字是需要确认的业务选择。若“已确认创建”的任务一条都不能丢,RPO 5 分钟便不合要求,需要重新讨论提交确认、同步复制及跨故障域的成本。
成功率的分母也要明确。鉴权失败和参数非法通常不应算成系统故障,但服务过载时拒绝的本来合法请求不能靠改成 4xx 从报表里消失。客户端超时而服务端稍后成功,用户体验仍可能失败。SLI 是测量口径,SLO 是目标,带违约后果的 SLA 则是另一类约定。定义方法参见 Google SRE 的 SLO 章节。
把“不做什么”写在需求旁:本轮不设计全球多活,不做实时多人编辑,不保证邮件一定被人读到。这样审阅者知道缺项是范围选择还是遗漏,也能明确增加一项要求会改动哪里。
容量估算:先看哪一维可能限制方案
暂定 10 万日活,每人每天读 20 次、创建 2 个任务;按全天平均,读约 23.15 QPS,创建约 2.31 QPS。再假设峰均比为 10,得到读峰值约 231.48 QPS、写峰值约 23.15 QPS。修改状态尚未纳入,应该单列;不能把“创建请求”悄悄改称全部写请求。
每条任务平均 1000 字节,保留 365 天,新增任务数据约 73 GB。假设索引和记录开销将其放大到 1.5 倍,保留含主节点在内的三份副本,再按 70% 目标装载率预留空间,得到约 469.29 GB 的集群总配置容量。这里没有计入 outbox、审计日志、备份与临时索引构建空间。
这些数字只能说明:在当前假设下,先验证单个关系数据库的事务和索引方案是合理起点;它们不能证明某台数据库一定扛得住。列表查询是否扫了大量旧任务、大团队是否占了绝大部分流量、通知风暴是否拖累连接池,都需要接近真实数据分布的测量。
| 改变一个假设 | 直接变化 | 可能改变的设计 |
|---|---|---|
| 保留时间从一年变两年 | 任务存量翻倍,QPS 不变 | 归档、分区、备份恢复时间 |
| 峰均比从 10 变 30 | 峰值吞吐变三倍,日存量不变 | 实例数、限流、连接池、峰时降级 |
| 某团队占 40% 请求 | 总量不变,分区负载不均 | 按团队分片可能形成热点 |
| 每任务通知 1 人变 100 人 | 通知扇出变百倍 | 通知队列与批处理需要单独估算 |
| 要求跨地区立即可读 | 不能用当前单地区读写路径说明 | 延迟、一致性与故障时可用性需重做 |
做敏感性分析不是再报一组数字,而是指出哪个假设一变,选择就可能失效。未知的平均行宽、热门团队占比和数据库稳定吞吐,应明确列为待测项。
接口设计:把失败后的动作也设计进去
| 操作 | 接口与关键字段 | 返回与重试约定 |
|---|---|---|
| 创建任务 | POST /tasks;Idempotency-Key;title、assignee | 首次 201;重复同一请求返回已保存结果;同键不同参数拒绝 |
| 读单个任务 | GET /tasks/ | 只在调用者所属租户查找;不存在与无权访问避免泄漏对象信息 |
| 分页列表 | GET /tasks?after=...&limit=20 | 游标绑定租户、筛选条件和稳定排序;限制最大页长 |
| 修改状态 | PATCH /tasks/{id};expected_version、status | 版本匹配才修改;冲突返回明确错误,客户端重新取值 |
| 查询创建结果 | GET /requests/ | 网络超时后查询是否已提交;权限仍按租户和调用者检查 |
这里的幂等键是本 API 的设计约定,不是 HTTP 自动替所有 POST 做的保证。HTTP 方法本身的幂等含义见 RFC 9110 §9.2.2。成功结果要保存哪些字段、保存多久、过期后能否再次使用同一键,都应成为接口契约。重试窗口比幂等记录寿命长,旧请求就可能再次生效。
列表用 (created_at, id) 作为稳定排序键,而不是只用时间。相同时间的任务必须有确定顺序。游标分页能避免大 offset 的扫描成本,但并不自动提供跨页快照:翻页中间若发生插入或删除,仍要决定是接受动态视图,还是提供有期限的快照读取。
数据模型:先写必须守住的约束
| 数据 | 关键字段与约束 | 为什么留这份数据 |
|---|---|---|
| tasks | id;tenant_id;title;status;version;created_at | 任务当前状态;列表索引从 tenant_id 开始 |
| create_requests | tenant_id、key 唯一;request_fingerprint;task_id;响应摘要;expires_at | 相同业务请求只有一个结果;同键异参不会误复用 |
| outbox | event_id 唯一;task_id;event_type;payload_version;payload;created_at | 创建提交后,仍能找回尚未成功发送的事件 |
| inbox | recipient_id、event_id 唯一;内容;read_at | 对同一事件重投不会生成两条站内通知 |
创建任务时,tasks、create_requests、outbox 放在同一个数据库事务中。约束落在数据库,不能只依靠“先查有没有,再插入”的应用代码;两个请求可能同时查到不存在。关系库的唯一约束和冲突处理有明确行为,PostgreSQL 18 的约束文档可用于核对具体实现。
修改状态则采用条件更新:找到租户和 ID,要求版本仍是客户端读到的值,成功后版本加一。受影响行数为零时,要区分不存在、无权和版本冲突,但对外错误不要泄露其他租户数据。这是乐观并发控制:冲突少时省去长时间持锁,冲突多时重试会浪费工作。
高层架构:沿一次请求看数据去哪
正在绘制图示…
查看图源代码
flowchart LR
U["不可信客户端"] -->|"TLS 请求、身份凭据、幂等键"| E["入口:限额、认证、请求大小限制"]
subgraph T["服务信任边界"]
E --> A["任务 API:授权、校验、并发版本检查"]
A -->|"单事务:任务 + 请求结果 + outbox"| D[("权威数据库")]
D -->|"待投递事件"| R["outbox 转发器"]
R -->|"可能重复投递"| Q["有界事件通道"]
Q --> N["通知消费者"]
N -->|"event_id 去重后写入"| I[("站内收件箱")]
A -->|"单条与列表读取"| D
end
A -->|"提交后返回 task_id"| U
这张图刻意没有先放缓存。当前读量与访问模式还不足以证明缓存值得引入。缓存会增加失效、权限隔离、读旧值和故障回源问题;等数据库读成本成为瓶颈,再决定缓存哪些可接受滞后的结果。
“有界事件通道”起初也可以只是数据库轮询加 worker,未必需要独立消息集群。需要多个独立消费者、长时间保留、按分区回放或更高吞吐时,再比较消息系统。组件的引入必须对应需要,而不是对应面试图里的常见形状。
深入一个组件:用户没收到响应,任务却已经创建
正在绘制图示…
查看图源代码
sequenceDiagram
participant C as 客户端
participant A as API
participant D as 原子存储边界
C->>A: 创建,key=k
A->>D: 任务、幂等结果、outbox 一起提交
D-->>A: 提交成功
A--xC: 响应丢失
C->>A: 相同 key 和参数重试
A->>D: 读取已提交的 k
D-->>A: 原 task_id
A-->>C: 原结果,不重复创建
本地模型用一个 mutex 模拟“检查幂等记录和写入三份数据不可被其他请求穿插”。它只验证并发与重试逻辑,不提供持久化,不模拟数据库隔离、磁盘损坏或分布式提交。正文中的调用和真实输出如下:
go
first, replay, err := s.Create("team-a", "request-1", "写设计")
fmt.Println(first.ID, replay, err)
second, replay, err := s.Create("team-a", "request-1", "写设计")
fmt.Println(second.ID, replay, err)
_, _, err = s.Create("team-a", "request-1", "改标题")
fmt.Println(err)
fmt.Printf("tasks=%d outbox=%d\n", len(s.tasks), len(s.outbox))text
1 false <nil>
1 true <nil>
same key, different payload
tasks=1 outbox=1第二次返回 ID 1,第三次因参数变更失败。测试还让 100 个 goroutine 同时重试同一个键,最后只保留一个任务和一个 outbox 事件。并发测试通过只能覆盖这段内存实现,生产数据库仍要测试唯一键冲突、事务回滚和提交结果不明的路径。
outbox 转发还有另一种窗口:事件已发出,但转发器在标记成功前崩溃。恢复后会再发。把“已发送”标记提前,又可能先标记后崩溃而永远漏发。因此这里接受重复投递,由收件箱把 event_id 去重与写通知放在同一事务内。Transactional Outbox 原始模式说明讨论的正是这个边界。
瓶颈与扩展:先分清缺的是哪种资源
| 观测到的症状 | 先查什么 | 有条件的处理方式与代价 |
|---|---|---|
| 列表变慢,CPU 不高 | 查询计划、扫描行数、锁等待、连接池等待 | 补匹配访问路径的索引;索引也增加写成本和容量 |
| 一个团队拖慢全部用户 | 分租户 QPS、队列等待、最热键 | 租户配额或隔离;分片前检查大租户是否仍压在一片 |
| API 实例 CPU 满 | 序列化、授权、日志、分配热点 | 优化或扩容无状态实例;数据库上限不会随之消失 |
| 通知延迟不断增长 | 入队速率与完成速率、最老消息年龄 | 增加消费者或降级低优先级通知;不能无限堆队列 |
| 数据库故障后大量请求同时重试 | 重试层数、超时预算、恢复容量 | 限制重试次数,加退避和抖动,入口快速拒绝超额请求 |
| 备份成功但恢复超时 | 恢复吞吐、索引重建、回放时间 | 做恢复演练、调整备份策略;副本不能替代可回溯备份 |
扩容规划至少检查正常峰值、一个实例失效、缓存冷启动和依赖变慢四种状态。过载时保住创建与授权路径,优先暂停统计、批量导出等非核心工作;拒绝应有明确错误和可执行的重试约定。相关原则见 SRE Handling Overload。
如果要分片,先说清数据归属。按 tenant_id 分片让同团队事务留在一处,但大团队迁移、跨团队报表和全局唯一键会更麻烦。增加一层路由并不等于完成扩展方案,还要有迁移时的单写者约束、校验和回退。
取舍说明:留下别人能推翻的理由
一份能评审的结论可以写成这样:当前先选模块化单体加一个关系数据库,因为核心事务都围绕任务创建和团队权限,估算的写量不支持为吞吐引入分布式事务。通知使用 outbox,接受几十秒内的延迟,换取创建路径不依赖通知系统。暂不加列表缓存;若查询计划正常而数据库读 CPU、I/O 或尾延迟仍超标,再按可接受的陈旧时间设计缓存。
| 当前选择 | 收益 | 承担的成本 | 重新评估的信号 |
|---|---|---|---|
| 单库事务 | 幂等结果和任务状态容易一起提交 | 单库故障与容量边界 | 实测逼近容量、隔离需求改变 |
| 乐观版本更新 | 无需跨用户操作长时间持锁 | 用户遇到冲突要刷新 | 同一任务高频并发编辑 |
| 异步通知 | 通知故障不拖垮创建 | 延迟、重复投递与排障复杂度 | 业务要求同步确认收件箱写入 |
| 暂无跨地区写入 | 故障与一致性模型较简单 | 跨地区时延、地区故障风险 | 确认跨地区 RTO/RPO 需求 |
完成设计后,还应交出三个验证动作:用代表性租户分布压测核心查询;注入“提交后丢响应”和“投递后未记账”的故障;从备份恢复并计时。它们分别检验容量、正确性和可恢复性。设计图到这里才有了能被证据纠正的出口。
依据与验证边界
- RFC 9110 §9.2.2:HTTP 幂等方法的语义。
- PostgreSQL 18 Constraints、Transaction Isolation:数据库约束及事务隔离;本文未执行 PostgreSQL 实验。
- Transactional Outbox:本地事务与消息投递之间的故障窗口。
- Google SRE SLO 与 Handling Overload:指标口径与过载处理。
- 本文系统、API、容量、恢复目标和选择都是教学设计。2026-10-13 在 Go 1.27.2、Linux/amd64 执行下附程序、普通测试、race 和 vet,均通过。没有部署真实任务系统或做生产容量测试。
完整最小代码与测试
将下列文件放在同一目录,用 Go 1.27.2 执行:
sh
go run .
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...go.mod
go.mod
module example.com/designmethod
go 1.27.0main.go
代码块 · 55 行 · 点击收起 / 展开
go
package main
import (
"errors"
"fmt"
"sync"
)
var ErrConflict = errors.New("same key, different payload")
type RequestKey struct{ Tenant, Key string }
type Task struct {
ID int
Tenant, Title string
}
type Store struct {
mu sync.Mutex
next int
requests map[RequestKey]Task
tasks map[int]Task
outbox []int
}
func NewStore() *Store {
return &Store{requests: make(map[RequestKey]Task), tasks: make(map[int]Task)}
}
// One lock models one atomic transaction, not durable database storage.
func (s *Store) Create(tenant, key, title string) (Task, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
k := RequestKey{tenant, key}
if old, ok := s.requests[k]; ok {
if old.Title != title {
return Task{}, false, ErrConflict
}
return old, true, nil
}
s.next++
t := Task{s.next, tenant, title}
s.tasks[t.ID] = t
s.requests[k] = t
s.outbox = append(s.outbox, t.ID)
return t, false, nil
}
func main() {
s := NewStore()
first, replay, err := s.Create("team-a", "request-1", "写设计")
fmt.Println(first.ID, replay, err)
second, replay, err := s.Create("team-a", "request-1", "写设计")
fmt.Println(second.ID, replay, err)
_, _, err = s.Create("team-a", "request-1", "改标题")
fmt.Println(err)
fmt.Printf("tasks=%d outbox=%d\n", len(s.tasks), len(s.outbox))
}main_test.go
代码块 · 39 行 · 点击收起 / 展开
go
package main
import (
"errors"
"sync"
"testing"
)
func TestConcurrentRetry(t *testing.T) {
s := NewStore()
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
task, _, err := s.Create("a", "k", "title")
if err != nil || task.ID != 1 {
t.Errorf("task=%+v err=%v", task, err)
}
})
}
wg.Wait()
if len(s.tasks) != 1 || len(s.outbox) != 1 {
t.Fatal("duplicate effects")
}
if _, _, err := s.Create("a", "k", "other"); !errors.Is(err, ErrConflict) {
t.Fatal(err)
}
if task, _, err := s.Create("b", "k", "title"); err != nil || task.ID != 2 {
t.Fatal(task, err)
}
}
func TestLostResponseModel(t *testing.T) {
s := NewStore()
// The first response is discarded after the write committed.
_, _, _ = s.Create("a", "k", "title")
got, replay, err := s.Create("a", "k", "title")
if err != nil || !replay || got.ID != 1 {
t.Fatal(got, replay, err)
}
}