Skip to content

系统设计方法论:把需求一步步变成能验证的方案 ​

拿到“设计一个任务管理系统”,直接画网关、缓存、消息队列,很快就能画满一页。难点在于解释:为什么需要它们?少一个会违反哪条要求?数据库提交后网络断了,用户再点一次,任务会不会多一份?

这篇用一个贯穿始终的假设案例走完设计过程:团队成员创建任务、查看列表、修改状态,任务创建后异步通知负责人。重点是建立一条可检查的推理链:需求决定约束,数量级缩小选项,接口和数据模型表达约束,架构负责在失败时仍守住它们。案例数字是教学假设,不是某个真实产品的流量报告。

先问会改变设计的问题 ​

功能需求说明用户要完成什么;非功能需求说明在多少负载、多久延迟、哪些故障下仍要完成。两类不能分开讨论。例如“通知允许晚几秒”直接决定能否把通知移出创建任务的事务。

要澄清的事本案例暂定答案对后续设计的影响
用户与权限多租户团队;成员只能访问所属团队每条数据带 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 的扫描成本,但并不自动提供跨页快照:翻页中间若发生插入或删除,仍要决定是接受动态视图,还是提供有期限的快照读取。

数据模型:先写必须守住的约束 ​

数据关键字段与约束为什么留这份数据
tasksid;tenant_id;title;status;version;created_at任务当前状态;列表索引从 tenant_id 开始
create_requeststenant_id、key 唯一;request_fingerprint;task_id;响应摘要;expires_at相同业务请求只有一个结果;同键异参不会误复用
outboxevent_id 唯一;task_id;event_type;payload_version;payload;created_at创建提交后,仍能找回尚未成功发送的事件
inboxrecipient_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
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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: 原结果,不重复创建
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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 需求

完成设计后,还应交出三个验证动作:用代表性租户分布压测核心查询;注入“提交后丢响应”和“投递后未记账”的故障;从备份恢复并计时。它们分别检验容量、正确性和可恢复性。设计图到这里才有了能被证据纠正的出口。

依据与验证边界 ​

完整最小代码与测试 ​

将下列文件放在同一目录,用 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.0

main.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)
	}
}

保留各书来源与许可 · 示例运行条件以正文为准