33.1 单体部署

一句话

单体部署不是“落后的部署方式”,而是把资源粒度绑死在进程粒度上:所有模块共享同一组副本与同一个规格,最热的模块决定副本数、最重的模块决定单副本规格——浪费量 = 模块需求方差 × 副本数差,可以用一行乘法算出来。

部署单元的四个“一”

一个单体 = 含义 带来的好处 带来的约束
一个制品 一次构建产出一个镜像/jar 版本天然一致,不存在“服务 A 1.2 调服务 B 1.1” 任何改动都要整包重建、整包发布
一份配置 一套环境变量/配置文件 配置只有一处漂移风险 改一个模块的配置要重启整个进程
一组副本 一个 Deployment 的 N 个 Pod 扩容一条命令 扩容只能整体扩,最热模块以外的部分跟着浪费
一条流水线 构建 → 测试 → 发布 → 回滚 流水线只有一条要维护 排队(27.1 的排队模型)

发布策略(滚动/蓝绿/金丝雀)本身的主场在 55.1 发布策略,本节只算单体特有的那笔账:资源粒度。

单体部署的浪费从哪来

假设一个单体里三个模块的需求完全不同:

模块 需要几个副本 单副本 CPU 单副本内存
web(在线接口) 10 0.5 核 1 GB
batch(批处理) 2 4 核 8 GB
admin(后台) 2 0.2 核 0.5 GB

单体部署只有一种副本规格,所以只能取“副本数 = max(10, 2, 2) = 10”“单副本 = 全部模块之和 = 4.7 核 / 9.5 GB”。结果:只有 2 个实例在跑批处理,却要为此付 10 份 4 核。

package main

import "fmt"

type module struct {
	name     string
	replicas int
	cpu      float64 // 单副本核数
	memGB    float64
}

var modules = []module{
	{"web", 10, 0.5, 1.0},
	{"batch", 2, 4.0, 8.0},
	{"admin", 2, 0.2, 0.5},
}

const (
	coreHourPrice = 0.10 // 每核时单价(相对单位,换成你自己的账单)
	memGBHourPrice = 0.01
	hoursPerMonth = 730.0
)

func main() {
	// ① 单体:副本数取最大需求,规格取全部模块之和
	maxRep := 0
	var cpuPerPod, memPerPod float64
	for _, m := range modules {
		if m.replicas > maxRep {
			maxRep = m.replicas
		}
		cpuPerPod += m.cpu
		memPerPod += m.memGB
	}

	fmt.Println("=== 打包成一个单体部署 ===")
	fmt.Printf("副本数 %d,单副本 %.1f 核 / %.1f GB\n", maxRep, cpuPerPod, memPerPod)
	totalCPU := float64(maxRep) * cpuPerPod
	totalMem := float64(maxRep) * memPerPod
	fmt.Printf("合计 %.1f 核,%.1f GB\n", totalCPU, totalMem)
	monoCost := totalCPU*hoursPerMonth*coreHourPrice + totalMem*hoursPerMonth*memGBHourPrice
	fmt.Printf("月成本 %.0f(相对单位)\n\n", monoCost)

	// ② 拆开:每个模块按自己的副本数与规格
	fmt.Println("=== 按模块需求分别部署 ===")
	var needCPU, needMem float64
	for _, m := range modules {
		c := float64(m.replicas) * m.cpu
		mm := float64(m.replicas) * m.memGB
		needCPU += c
		needMem += mm
		fmt.Printf("%-6s %2d 副本 × %.1f 核 / %.1f GB = %5.1f 核,%5.1f GB\n",
			m.name, m.replicas, m.cpu, m.memGB, c, mm)
	}
	splitCost := needCPU*hoursPerMonth*coreHourPrice + needMem*hoursPerMonth*memGBHourPrice
	fmt.Printf("合计 %.1f 核,%.1f GB,月成本 %.0f\n\n", needCPU, needMem, splitCost)

	fmt.Printf("单体浪费 %.1f 倍 CPU、%.1f 倍内存;月成本多 %.0f(%.0f%%)\n",
		totalCPU/needCPU, totalMem/needMem, monoCost-splitCost, (monoCost-splitCost)*100/splitCost)
}

本机实测(macOS,Apple M1,Go 1.27):

=== 打包成一个单体部署 ===
副本数 10,单副本 4.7 核 / 9.5 GB
合计 47.0 核,95.0 GB
月成本 4124(相对单位)

=== 按模块需求分别部署 ===
web    10 副本 × 0.5 核 / 1.0 GB =   5.0 核, 10.0 GB
batch   2 副本 × 4.0 核 / 8.0 GB =   8.0 核, 16.0 GB
admin   2 副本 × 0.2 核 / 0.5 GB =   0.4 核,  1.0 GB
合计 13.4 核,27.0 GB,月成本 1175

单体浪费 3.5 倍 CPU、3.5 倍内存;月成本多 2949(251%)

读数:

读数 含义
单体 47 核 vs 拆开 13.4 核 浪费 3.5 倍,全部来自“批处理只需要 2 个实例,却按 10 个实例付费”
内存同样 3.5 倍 内存与 CPU 同步浪费,因为规格是打包的
月成本差近 3 千(相对单位) 这个数字与“多写多少代码”无关,纯由模块需求方差决定

🔑 判断你的单体是否在浪费资源,只问三个数:模块的副本需求差(10 vs 2)、规格需求差(0.5 核 vs 4 核)、进程里有没有“只需要 2 个实例却必须跑 10 份”的模块。三个数任何一个差 5 倍以上,打包部署就在烧钱。

不拆服务的三个降浪费手段

拆服务不是唯一解。下面三招能把浪费压下去,而且都保持“一个制品”:

手段 做法 省什么 代价
旁路进程(Sidecar 式拆分) 把批处理/定时任务拆成独立 Deployment,共用同一个镜像、不同启动参数 省掉“10 份 4 核” 多一套部署清单,但制品仍是一个
按模块分池 同一镜像起两个 Deployment:在线池(小规格多副本)+ 离线池(大规格少副本) 规格按池定,不再打包 需要代码支持“启动模式”开关
垂直拆分运行时 只把最重的模块(如视频转码)挪到独立服务 省掉最贵的那部分 这一个模块开始有网络调用成本

💡 第二招最常被忽略:同一个镜像 + 不同启动参数 = 两个部署单元。它没有引入服务边界(代码仍在一个仓、一个模块系统里),却把资源规格分开了。这是“单体但部署合理”的标准形态。

单体也能弹性:无状态副本可以做实例级扩缩容,比多服务编排简单得多(不用协调服务边界,只改副本数)。伸缩单元怎么选、追赶延迟有多长、配额会卡在哪,见 5.4 弹性伸缩与自动扩缩容。

单体部署仍然正确的场景

场景 为什么单体部署更合适
团队 < 10 人、单一产品线 一条流水线、一套配置、一次发布,协作成本最低(27.1)
各模块负载相近(方差 < 2 倍) 上面那笔浪费几乎为零,拆开只剩纯成本
强一致事务为主(下单、扣款) 本地事务,不需要分布式事务(24.9)
需要“一次发布整体回滚” 单制品回滚是原子的;多服务回滚要按依赖顺序
早期验证阶段 需求与边界都在变,拆了要反复改边界(29.1)

反模式

反模式 后果
为一个“重模块”把整个单体规格拉到 8 核 16G 所有副本都为那一个模块付钱,浪费按副本数放大
副本数按“最热模块”定,却不给其他模块限流 最热模块把 CPU 吃满,其他模块跟着超时(进程内没有隔离)
用“多起几个单体实例”解决批处理慢 批处理重复执行(没有分布式锁),数据被写坏
单体里跑定时任务 + 在线接口 定时任务占用请求线程,白天跑批时接口抖动
把所有环境(dev/staging/prod)用同一份配置模板 配置漂移,一次误改影响全部环境(54.5 配置注入)
单体不做健康检查与优雅停机 滚动发布时正在处理的请求被直接杀掉

自检

Q1 一个单体里 web 需要 12 个副本、报表任务需要 1 个副本(但要 16 核)。打包部署按 12 个副本 × 16 核算,浪费多少?用本节的算式说明。

▸ 答案:打包时单副本规格必须装下报表任务(16 核),副本数取 12,合计 12 × 16 = 192 核;拆开后是 12 × web单副本核数 + 1 × 16。若 web 单副本 0.5 核,拆开后是 6 + 16 = 22 核,浪费 8.7 倍。浪费量 ≈ (副本数差) × (重模块的规格),本例是 (12-1) × 16 = 176 核。这就是“最热模块定副本数、最重模块定规格”的乘法效应。

Q2 为什么“同一镜像 + 不同启动参数起两个 Deployment”不算拆微服务?

▸ 答案:因为它没有引入服务边界:代码仍是一个仓库、一套模块依赖、一次构建;两个 Deployment 只是同一制品的两个运行模式(在线/离线),它们之间不通过 API 通信、不各自拥有数据、不独立演进。从架构分类看,它仍是单体(一个部署形态的变体);从资源看,它解决了“规格打包”这一项浪费。判断标准回到 30.1 微服务的定义与前提 的四条:数据独立与团队独立都不成立。

Q3 单体的“一次发布整体回滚”优势,在什么情况下会变成劣势?

▸ 答案:当改动的影响面与发布面不匹配时。比如只改了一个后台页面的文案,却要重启全部 12 个在线副本、承担整站发布风险——影响面 1%、发布面 100%。这种情况下的劣势不是“回滚难”,而是“每次小改动都在承担全量风险”。缓解手段是特性开关(55.2 特性开关)与按模块分池部署,而不是立刻拆微服务。

Q4 一个团队的单体 CPU 利用率只有 8%,老板要求“优化成本”。按本节的分析,第一步该做什么?

▸ 答案:先分清 8% 是哪一种:① 规格过大(为最重模块买的规格,其他时候闲置)→ 分池部署;② 副本过多(按峰值配置,平时闲置)→ HPA + 按队列深度扩缩;③ 真的没流量→ 降副本或下线。三种的解法完全不同,而拆微服务只对 ① 有部分帮助。顺序是:先量出“每个模块的副本需求与规格需求”,再决定分池还是拆服务——没有这个测量,拆服务只是把浪费从“进程内”搬到“网络调用”上。

关联