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 + 按队列深度扩缩;③ 真的没流量→ 降副本或下线。三种的解法完全不同,而拆微服务只对 ① 有部分帮助。顺序是:先量出“每个模块的副本需求与规格需求”,再决定分池还是拆服务——没有这个测量,拆服务只是把浪费从“进程内”搬到“网络调用”上。