18.1 架构复盘与容量规划
前 17 章一路把 TaskHub 从「能跑」做到了「能发布、能防御、能追溯」。在真正上线前,还剩两个绕不开的问题:这套架构经得起复盘吗?这台机器到底能扛多少流量? 前者关乎长期维护成本,后者关乎钱和稳定性——容量估高了浪费,估低了上线即崩。
本节是全书收口的第一节:先做一次结构化架构复盘,再用本机真跑的基准数据推算 TaskHub 的容量与副本数。所有性能数字都是本机实测,不是拍脑袋。
18.1.1 架构复盘:先问「依赖方向对不对」
复盘不是重画一遍架构图,而是拿几个可判定的问题去敲打它。第一个问题是最容易被忽视、也最致命的:依赖方向。第 1 章就定过规矩——cmd 依赖 api/infra,api 依赖 domain,domain 谁都不依赖。一年后回头看,如果 domain 里出现了 import "net/http" 或数据库驱动,说明边界已经被侵蚀。
复盘的五个必答问题:
| 问题 | 健康的答案 | 被侵蚀的信号 |
|---|---|---|
| domain 依赖谁? | 零外部依赖 | import 了 http/db/redis |
| 事务边界在哪? | service 层,一个用例一个事务 | handler 里开事务、跨层开事务 |
| 租户隔离靠什么? | 查询强制注入 tenant_id | 靠调用方记得传 |
| 失败会怎样? | 每个外部调用都有超时 | 无超时的网络调用 |
| 能回滚吗? | schema 先加后删 | 发布即删列 |
这些问题一旦有了答案,架构的健康度就一目了然。复盘的价值不在「发现新问题」,而在「确认旧约定还成立」。
18.1.2 依赖图与失败模式
第二个复盘动作是画依赖图并标注失败模式。TaskHub 的外部依赖与「它挂了会怎样」:
| 依赖 | 挂了的影响 | 缓解 |
|---|---|---|
| Postgres | 读写全失败 | 连接池超时 + 快速失败 + 只读降级 |
| Redis | 缓存穿透到 DB | singleflight + 本地缓存兜底 |
| 消息队列 | 异步任务堆积 | 积压告警 + 消费端限流 |
| 对象存储 | 上传下载失败 | 重试 + 预签名 URL 直连 |
| 下游 HTTP 服务 | 接口超时 | 超时 + 熔断(第 9 章) |
复盘时最该警惕的是「没有超时的外部调用」——它会让一个慢依赖拖垮整个服务。第 9 章的熔断、第 6 章的超时预算,都是为这张表服务的。
一个实用的复盘技巧是给每个依赖标注「失败是快速还是慢速」。数据库连不上是快速失败(连接拒绝,毫秒级),但数据库「连得上但很慢」是慢速失败(查询挂起,秒级)——后者更危险,因为它会占满连接池与 goroutine。所以对慢速失败,超时必须显式设置,而不能依赖 TCP 的默认行为。
18.1.3 基准测量:拿真实数字说话
容量规划不能靠感觉。先对 TaskHub 最热的一段纯 CPU 逻辑——「解析任务创建请求体 + 校验」——做基准测试:
func HandleCreateTask(body []byte) (Task, error) {
var t Task
if err := json.Unmarshal(body, &t); err != nil {
return Task{}, err
}
if t.TenantID == "" || t.Title == "" {
return Task{}, errEmpty
}
return t, nil
}
基准测试文件长这样,-benchmem 会额外报告每次操作的分配情况:
var sample = []byte(`{"id":"01H","tenant_id":"t1","title":"部署 TaskHub","priority":2}`)
func BenchmarkHandleCreateTask(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if _, err := HandleCreateTask(sample); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkHandleCreateTaskParallel(b *testing.B) {
b.ReportAllocs()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := HandleCreateTask(sample); err != nil {
b.Fatal(err)
}
}
})
}
RunParallel 会用 GOMAXPROCS 个 goroutine 并发调用,配合 -cpu 参数就能画出扩展曲线。跑 go test -bench=. -benchmem -cpu=1,4 -run=^$,本机真实输出:
goos: darwin
goarch: arm64
pkg: taskhub/capacity
cpu: Apple M1 Pro
BenchmarkHandleCreateTask 2542010 536.6 ns/op 64 B/op 1 allocs/op
BenchmarkHandleCreateTask-4 2237352 513.3 ns/op 64 B/op 1 allocs/op
BenchmarkHandleCreateTaskParallel 2276319 522.8 ns/op 64 B/op 1 allocs/op
BenchmarkHandleCreateTaskParallel-4 6401068 210.5 ns/op 64 B/op 1 allocs/op
PASS
ok taskhub/capacity 7.319s
本机是 Apple M1 Pro。两个关键读数:单核串行 536.6 ns/op,4 路并行 210.5 ns/op;每次操作 1 次分配、64 字节——分配次数少,说明这段逻辑对 GC 友好。
18.1.4 并发扩展曲线
再看并发度从 1 加到 8 的表现:
go test -bench=BenchmarkHandleCreateTaskParallel -benchmem -cpu=1,2,4,8 -run=^$ ./capacity
BenchmarkHandleCreateTaskParallel 2010552 543.8 ns/op
BenchmarkHandleCreateTaskParallel-2 3374782 312.7 ns/op
BenchmarkHandleCreateTaskParallel-4 6550510 244.3 ns/op
BenchmarkHandleCreateTaskParallel-8 8589717 135.7 ns/op
把它换算成吞吐(每核每秒的操作数 = 1e9 / ns/op):
| 并发 | ns/op | 吞吐(万 ops/s) | 相对单核加速比 |
|---|---|---|---|
| 1 | 543.8 | 183.9 | 1.00x |
| 2 | 312.7 | 319.8 | 1.74x |
| 4 | 244.3 | 409.3 | 2.23x |
| 8 | 135.7 | 736.9 | 4.01x |
读这张表有两个结论。第一,扩展不是线性的:从 1 到 2 拿到 1.74x,但从 4 到 8 才勉强接近线性——说明 8 路时调度、内存带宽开始成为瓶颈。第二,不要用单核数字乘以核数去估容量,那会高估。
18.1.5 从基准到容量:加上安全系数
基准测的是纯 CPU 逻辑,真实请求还要算上网络 I/O、数据库往返、序列化、中间件。用基准吞吐直接当容量会严重高估。一套务实的推算:
单副本容量 = 实测吞吐 × CPU 占比折算 × 安全系数
- CPU 占比:一次真实请求里,纯 CPU 部分可能只占 5%~20%,其余在等 DB/网络。取 10% 作为保守估计。
- 安全系数:为突发流量、GC 停顿、邻居干扰留余量,通常取 0.5。
- 目标水位:不要把副本跑满,留 30%~40% 余量给故障转移。
以本机 8 路并行 736.9 万 ops/s 为纯 CPU 上限,取 10% CPU 占比、0.5 安全系数:
单副本有效容量 ≈ 736.9 万 × 0.10 × 0.50 ≈ 36.8 万 req/s
看起来很大,但这是纯逻辑的乐观值——真正决定容量的是下游依赖,尤其是数据库。TaskHub 的创建任务要走一次 INSERT,Postgres 单实例在这类简单写入上通常几千到上万 TPS,远低于上面的数字。所以:
实际单副本容量 = min(应用层容量, 依赖层容量 / 副本数共享)
容量规划的常见错误就是「按应用层算了一堆副本,结果把数据库打挂了」。正确的做法是先找瓶颈依赖,让数据库成为约束条件,而不是事后才发现。
18.1.6 用 Little 定律校验并发与延迟
容量规划还有一个必须校验的量:在途请求数。Little 定律说,系统中的平均请求数 = 到达率 × 平均停留时间:
并发数 L = 吞吐 λ × 延迟 W
用它检查两个方向。若目标 3000 QPS、平均延迟 50ms,则:
L = 3000 × 0.05 = 150
意味着任意时刻平均有 150 个请求在处理中。如果连接池只有 20 个连接,那 130 个请求只能排队——延迟会被排队进一步推高,形成恶性循环。所以连接池大小(第 6 章)与副本数必须和这个数字对齐。
反过来,若已知连接池上限 200、延迟 50ms,则理论最大吞吐是 200 / 0.05 = 4000 QPS——这是连接池给的天花板,副本再多也突破不了。
Little 定律的价值在于把「并发、吞吐、延迟」三个量串起来:只看吞吐会漏掉排队,只看延迟会漏掉容量。三个一起看,才能发现「延迟高其实不是慢,而是在排队」。
18.1.7 容量规划表
把上面的推理落成一张可维护的表(示例值,用于说明方法):
| 指标 | 数值 | 来源 |
|---|---|---|
| 峰值 QPS(预期) | 3000 | 业务预估 |
| 单副本应用层容量 | 约 36.8 万 | 本机基准 × 折算 |
| Postgres 单实例写 TPS | 约 5000 | 需实测 |
| 目标水位 | 60% | 留 40% 冗余 |
| 系统吞吐上限(受 DB 限) | 3000 | min(应用层, DB × 水位) |
| 需要的副本数 | 3 | 吞吐只需 1,取 3 为冗余下限(见 18.1.8) |
注意这里的系统吞吐上限取的是 DB 约束:即使应用层能扛几十万,只要数据库写 TPS 是 5000、目标水位 60%,整体最多到 3000 左右。副本数的计算要让数据库成为共享瓶颈——加副本不能突破 DB 上限,只能分担连接与 CPU。
18.1.8 成本与冗余的权衡
副本数不是越多越好。多一个副本意味着更多内存、更多 DB 连接、更高成本。取舍如下:
| 副本数 | 可用性 | 成本 | 适用 |
|---|---|---|---|
| 1 | 无冗余,重启即中断 | 最低 | 开发环境 |
| 2 | 可滚动更新,但一挂就半容量 | 中 | 内部服务 |
| 3 | 挂一个仍有两份,滚动无感 | 中高 | 生产推荐 |
| N+2 | 容忍同时挂两个 | 高 | 关键服务 |
TaskHub 的生产选择是 3 副本:这是「滚动更新期间不降容量」的最小值(滚一个还剩两个),也是多数云厂商可用区故障时仍能存活的常见起点。再往上加,边际收益递减。
18.1.9 复盘与容量自检清单
- domain 层零外部依赖,依赖方向未被侵蚀
- 事务边界在 service 层,一个用例一个事务
- 租户隔离靠查询强制注入 tenant_id,不靠调用方自觉
- 每个外部调用都有超时,关键依赖有熔断
- 基准测试覆盖热路径,有真实 ns/op 与 allocs/op
- 容量按「瓶颈依赖」而非「应用层」估算
- 目标水位留 30%~40% 冗余
- 副本数满足「滚动更新不降容量」
小结
- 架构复盘靠五个可判定问题敲打:domain 依赖、事务边界、租户隔离、失败处理、可回滚性。
- 画依赖图并标注失败模式,最该警惕的是「没有超时的外部调用」。
- 本机基准(Apple M1 Pro):单核
536.6 ns/op、4 路并行210.5 ns/op、每次 1 alloc / 64 B。 - 并发扩展非线性的:1→2 得 1.74x,4→8 才接近线性;不要用单核乘核数估容量。
- 容量 = 实测吞吐 × CPU 占比折算 × 安全系数;但真正瓶颈通常是数据库,要按 min(应用, 依赖) 估算。
- 副本数 3 是「滚动更新不降容量」的最小生产值;加副本不能突破 DB 上限。
- Little 定律(L = λ × W)把并发、吞吐、延迟串起来,用来校验连接池与副本数是否匹配。
容量算清了,下一步是验证它在故障下真的成立。18.2 故障演练 会真的把 Postgres 停掉、注入延迟,看 TaskHub 是快速失败还是被拖垮。
阅读导航:上一节:17.3 审计与合规 · 下一节:18.2 故障演练 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。