《Go 语言编程实战》18.1 架构复盘与容量规划

上线前的最后一道算术题是「这台机器到底能扛多少」。本节用本机真实的基准数据(M1 Pro 上 536.6 ns/op、1 alloc/op,并发 8 路 135.7 ns/op)做架构复盘与容量规划:从实测吞吐推算单副本容量,用安全系数与依赖瓶颈算出需要的副本数,并给出成本与冗余的权衡表。

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缓存穿透到 DBsingleflight + 本地缓存兜底
消息队列异步任务堆积积压告警 + 消费端限流
对象存储上传下载失败重试 + 预签名 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)相对单核加速比
1543.8183.91.00x
2312.7319.81.74x
4244.3409.32.23x
8135.7736.94.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 限)3000min(应用层, 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 故障演练 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练