《后端架构设计·演进与治理》12.2 容量与成本:预留、弹性与闲置回收

从 arch-lab 结算服务预留 100 台、实际峰值只剩 60 台、每月白付 2400 元的场景切入,对比全按量、部分预留与过量预留三种方案,给出覆盖率、闲置率与盈亏平衡利用率的 Go 计算模型、闲置回收排序、判据表与可复现的真实测试输出。

结算服务的 40 台闲置实例

arch-lab 的结算服务在去年按峰值 100 台规划,签了一年预留:每台每月 60 元,无论用不用都要付。今年批处理改成按小时分批后,实际峰值降到 60 台,但预留合同还没到期。财务看到的是每月 6,000 元固定支出,运维看到的是 40 台机器利用率不到 10%,两边说的是同一件事,却拿着不同的数字。

更麻烦的是“平均值陷阱”。月度平均利用率 55% 看起来还行,但拆到小时粒度,峰值时段 60 台跑满,低谷时段 20 台以下;预留按峰值买,闲置也按峰值付。容量决策必须同时看峰值、分布和承诺周期,只看月均会把过量预留解释成“还有余量”。

这一节把预留、弹性、闲置放进同一个成本模型:给定基线需求、预留数量和两种单价,算出总成本、覆盖率、闲置率与盈亏平衡利用率,再据此决定是继续预留、改按量,还是先回收闲置。SLO 与容量的关系在 SLA、SLO 与容量规划 里已经展开,这里只算钱。

预留不是折扣,是下限承诺

预留实例的本质是用“承诺用量”换单价:单价从 100 元降到 60 元,代价是不管用不用都要付。它适合需求下限稳定的服务,比如订单 API 的常驻副本;不适合峰值短、波谷长、随时可能下线的批处理。

按量实例相反,单价高但不承诺,适合削峰和临时任务。两者不是替代关系,而是组合:预留覆盖基线,按量覆盖基线以上的部分。真正需要决策的是基线画在哪里,以及画错之后多久能纠偏。

方案需求 100 时成本需求 60 时成本需求 60 时闲置适用判断
全部按量10,0006,0000需求波动大、随时可能下线
预留 60 + 弹性 407,6003,6000下限稳定,峰值可弹
预留 80 + 弹性 206,8004,80020 台 / 1,200下限较稳,留一点缓冲
预留 1006,0006,00040 台 / 2,400需求确定且长期不降

这张表的关键不是哪一行最便宜,而是“需求 60”这一列的差值。需求为 100 时预留越多越省;需求掉到 60 时,预留 100 比预留 60 贵 2,400 元。预留决策因此是对需求下限的赌注,而不是纯粹的采购折扣。

盈亏平衡利用率

把单价代入就能得到一个简单判据:预留单价 60、按量单价 100 时,盈亏平衡利用率是 0.6。也就是说,只要你预计会用到预留容量的 60% 以上,预留就比同等需求全按量便宜;低于 60%,按量更划算。

这个数只比较“同样的需求”,不能直接拿来和“预留了但完全不用”比较。完全不用时利用率是 0,预留仍然要付全款,损失是 100%。所以模型里要同时输出 Utilization、WasteShare 和 ReservePaysOff 三个量:利用率回答“买得值不值”,闲置率回答“浪费了多少”,布尔值回答“当前是否越过盈亏线”。

盈亏平衡点还会随合同变化。三年期预留单价更低,平衡点可能降到 0.4,但承诺期更长,需求判断错误的代价也更大。把合同参数写进配置而不是硬编码,才能在续约时复算,而不是凭记忆拍脑袋。

回收顺序:先关、再降配、最后改承诺

发现闲置之后,动作顺序影响风险和收益。第一步永远是关掉完全没用的资源:测试环境遗留机器、已经下线的服务副本、没人认领的磁盘。这类回收不需要改架构,风险最低,见效最快。

第二步是降配:把 16 核降到 8 核、把副本从 6 个降到 3 个。降配要先确认峰值和故障域,不能只看平均利用率;一个有状态服务从 6 副本降到 3 副本,可能直接失去多数派。降配前应走一轮容量验证,必要时用 健康检查与流量摘除 的方式逐个摘除、观察、再摘下一个。

第三步才是改预留承诺。合同变更周期长、可能产生违约金,而且一旦降了再想加回来,价格和库存都不一定还在。所以回收报告要把三类动作分开列:立即执行、需要验证、需要谈判,避免把所有“节省”混成一个数字。

可运行实现

下面的包把容量方案折算成成本与风险。Evaluate 先让预留覆盖需求,超出部分按量计费,预留高于需求的部分计为闲置;Reclaimable 按利用率阈值筛出可回收资源,并按月成本降序排列,保证先处理金额最大的项。

// file: internal/platform/cost/capacity.go
// tier: L1
// verify: go test ./internal/platform/cost/ -count=1
package cost

import (
	"fmt"
	"sort"
)

// CapacityPlan is one monthly capacity decision: how much to reserve, what
// elasticity costs, and how much idle reserve is tolerated.
type CapacityPlan struct {
	Name              string
	BaselineUnits     int64 // p95 demand: capacity that must exist for the SLO
	ReservedUnits     int64
	ReservedUnitCents int64 // billed whether used or not
	OnDemandUnitCents int64 // billed only while serving
	IdleTolerance     float64
}

// CapacityResult is the price and the risk of a plan.
type CapacityResult struct {
	ReservedCost    int64
	ElasticCost     int64
	IdleCost        int64
	TotalCents      int64
	Coverage        float64
	Utilization     float64
	WasteUnits      int64
	WasteShare      float64
	BreakEvenUse    float64
	ReservePaysOff  bool
	WithinTolerance bool
}

// Evaluate prices the plan. Reserved units cover demand first; demand above
// the reservation is bought on demand; reserved units above demand are idle
// and still billed.
func (p CapacityPlan) Evaluate() (CapacityResult, error) {
	if p.BaselineUnits < 0 || p.ReservedUnits < 0 {
		return CapacityResult{}, fmt.Errorf("cost: plan %q has negative units", p.Name)
	}
	if p.ReservedUnitCents < 0 || p.OnDemandUnitCents < 0 {
		return CapacityResult{}, fmt.Errorf("cost: plan %q has negative price", p.Name)
	}
	if p.IdleTolerance < 0 || p.IdleTolerance > 1 {
		return CapacityResult{}, fmt.Errorf("cost: plan %q idle tolerance must be within [0,1]", p.Name)
	}

	used := p.ReservedUnits
	if p.BaselineUnits < used {
		used = p.BaselineUnits
	}
	elastic := p.BaselineUnits - used
	if elastic < 0 {
		elastic = 0
	}
	waste := p.ReservedUnits - used

	res := CapacityResult{
		ReservedCost: p.ReservedUnits * p.ReservedUnitCents,
		ElasticCost:  elastic * p.OnDemandUnitCents,
		IdleCost:     waste * p.ReservedUnitCents,
		WasteUnits:   waste,
	}
	res.TotalCents = res.ReservedCost + res.ElasticCost
	if p.BaselineUnits > 0 {
		res.Coverage = float64(used) / float64(p.BaselineUnits)
	}
	if p.ReservedUnits > 0 {
		res.Utilization = float64(used) / float64(p.ReservedUnits)
		res.WasteShare = float64(waste) / float64(p.ReservedUnits)
	}
	if p.OnDemandUnitCents > 0 {
		res.BreakEvenUse = float64(p.ReservedUnitCents) / float64(p.OnDemandUnitCents)
	}
	res.ReservePaysOff = res.Utilization >= res.BreakEvenUse
	res.WithinTolerance = res.WasteShare <= p.IdleTolerance
	return res, nil
}

// UtilizationSample is one resource's observed usage over a month.
type UtilizationSample struct {
	ResourceID     string
	AvgUtilization float64
	MonthlyCents   int64
}

// Reclaimable returns idle resources sorted by monthly cost descending, plus
// the total monthly saving if they are switched off. Threshold is the
// utilization below which a resource counts as idle.
func Reclaimable(samples []UtilizationSample, threshold float64) ([]UtilizationSample, int64) {
	if threshold <= 0 || threshold > 1 {
		threshold = 0.10
	}
	var idle []UtilizationSample
	var saving int64
	for _, s := range samples {
		if s.AvgUtilization < threshold && s.MonthlyCents > 0 {
			idle = append(idle, s)
			saving += s.MonthlyCents
		}
	}
	sort.Slice(idle, func(i, j int) bool {
		if idle[i].MonthlyCents != idle[j].MonthlyCents {
			return idle[i].MonthlyCents > idle[j].MonthlyCents
		}
		return idle[i].ResourceID < idle[j].ResourceID
	})
	return idle, saving
}

实现刻意没有引入概率分布。真实容量规划会用历史分位数或蒙特卡洛估算需求分布,但那需要至少几个月的小时级数据;在数据不足时,用 p95 基线加显式闲置容忍度反而更诚实。IdleTolerance 是治理参数,不是技术参数,它决定报表在什么时候把闲置标红。

测试覆盖三种典型状态:预留不足但有弹性、预留过量导致闲置、全部无效输入;另外验证回收列表的排序与总额。

// file: internal/platform/cost/capacity_test.go
// verify: go test ./internal/platform/cost/ -run 'TestEvaluatePlan|TestReclaimable' -count=1 -v
package cost

import "testing"

func TestEvaluatePlanCoversDemandWithReserve(t *testing.T) {
	plan := CapacityPlan{
		Name:              "order-api-prod",
		BaselineUnits:     100,
		ReservedUnits:     80,
		ReservedUnitCents: 6000,
		OnDemandUnitCents: 10000,
		IdleTolerance:     0.20,
	}
	got, err := plan.Evaluate()
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}
	if got.ReservedCost != 480000 || got.ElasticCost != 200000 || got.TotalCents != 680000 {
		t.Fatalf("cost = %+v", got)
	}
	if got.WasteUnits != 0 {
		t.Fatalf("waste units = %d, want 0", got.WasteUnits)
	}
	if got.Coverage != 0.8 {
		t.Fatalf("coverage = %v, want 0.8", got.Coverage)
	}
	if got.BreakEvenUse != 0.6 {
		t.Fatalf("break-even = %v, want 0.6", got.BreakEvenUse)
	}
	if !got.ReservePaysOff || !got.WithinTolerance {
		t.Fatalf("flags = %+v", got)
	}
	t.Logf("baseline=%d reserved=%d total=%d coverage=%.2f breakeven=%.2f",
		plan.BaselineUnits, plan.ReservedUnits, got.TotalCents, got.Coverage, got.BreakEvenUse)
}

func TestEvaluatePlanFlagsIdleReserve(t *testing.T) {
	plan := CapacityPlan{
		Name:              "settlement-overbought",
		BaselineUnits:     60,
		ReservedUnits:     100,
		ReservedUnitCents: 6000,
		OnDemandUnitCents: 10000,
		IdleTolerance:     0.20,
	}
	got, err := plan.Evaluate()
	if err != nil {
		t.Fatalf("Evaluate: %v", err)
	}
	if got.WasteUnits != 40 {
		t.Fatalf("waste units = %d, want 40", got.WasteUnits)
	}
	if got.IdleCost != 240000 {
		t.Fatalf("idle cost = %d, want 240000", got.IdleCost)
	}
	if got.Utilization != 0.6 || got.WasteShare != 0.4 {
		t.Fatalf("utilization = %v waste = %v", got.Utilization, got.WasteShare)
	}
	if got.WithinTolerance {
		t.Fatalf("expected idle reserve to exceed tolerance")
	}
	if !got.ReservePaysOff {
		t.Fatalf("at 60%% utilization the 0.6 break-even is still met")
	}
	t.Logf("waste_units=%d idle_cost=%d utilization=%.2f waste_share=%.2f within_tolerance=%v",
		got.WasteUnits, got.IdleCost, got.Utilization, got.WasteShare, got.WithinTolerance)
}

func TestEvaluatePlanRejectsInvalidInput(t *testing.T) {
	cases := []CapacityPlan{
		{Name: "negative", BaselineUnits: -1},
		{Name: "price", OnDemandUnitCents: -1},
		{Name: "tolerance", IdleTolerance: 1.5},
	}
	for _, c := range cases {
		if _, err := c.Evaluate(); err == nil {
			t.Fatalf("plan %q: expected error", c.Name)
		}
	}
}

func TestReclaimableSortsBySaving(t *testing.T) {
	samples := []UtilizationSample{
		{ResourceID: "vm-a", AvgUtilization: 0.03, MonthlyCents: 40000},
		{ResourceID: "vm-b", AvgUtilization: 0.01, MonthlyCents: 90000},
		{ResourceID: "vm-c", AvgUtilization: 0.55, MonthlyCents: 70000},
		{ResourceID: "vm-d", AvgUtilization: 0.00, MonthlyCents: 5000},
	}
	idle, saving := Reclaimable(samples, 0.10)
	if len(idle) != 3 {
		t.Fatalf("idle count = %d, want 3", len(idle))
	}
	if idle[0].ResourceID != "vm-b" || idle[1].ResourceID != "vm-a" {
		t.Fatalf("order = %v", idle)
	}
	if saving != 135000 {
		t.Fatalf("saving = %d, want 135000", saving)
	}
	t.Logf("idle=%d monthly_saving=%d first=%s", len(idle), saving, idle[0].ResourceID)
}

本机实测

环境同样是 macOS darwin/arm64、Go 1.26.0。gofmt -l . 与 go vet ./... 无输出,以下是容量用例的原始结果。

$ go test ./internal/platform/cost/ -run 'TestEvaluatePlan|TestReclaimable' -v -count=1
=== RUN   TestEvaluatePlanCoversDemandWithReserve
    capacity_test.go:33: baseline=100 reserved=80 total=680000 coverage=0.80 breakeven=0.60
--- PASS: TestEvaluatePlanCoversDemandWithReserve (0.00s)
=== RUN   TestEvaluatePlanFlagsIdleReserve
    capacity_test.go:65: waste_units=40 idle_cost=240000 utilization=0.60 waste_share=0.40 within_tolerance=false
--- PASS: TestEvaluatePlanFlagsIdleReserve (0.00s)
=== RUN   TestEvaluatePlanRejectsInvalidInput
--- PASS: TestEvaluatePlanRejectsInvalidInput (0.00s)
=== RUN   TestReclaimableSortsBySaving
    capacity_test.go:99: idle=3 monthly_saving=135000 first=vm-b
--- PASS: TestReclaimableSortsBySaving (0.00s)
PASS
ok  	archlab/internal/platform/cost	0.240s

第一组数说明“部分预留”的账:基线 100、预留 80,预留成本 4,800 元,弹性 20 台按 100 元计 2,000 元,总成本 6,800 元,覆盖率 80%,闲置为零。第二组是结算服务的现状:需求掉到 60 却仍预留 100,闲置 40 台,闲置成本 2,400 元,利用率 60% 刚好压在盈亏线上,within_tolerance=false 说明已经超过 20% 的容忍度。

第三组是回收清单:4 个资源里 3 个利用率低于 10%,按月成本排序后先处理 vm-b,可回收总额 1,350 元。这里要注意,AvgUtilization 是月均值,一个白天跑满、夜里闲置的资源可能月均只有 0.3,不会进入这张清单,但它可能仍有降配空间。

容量判据

观测判据动作退出条件
预留利用率连续 4 周低于盈亏平衡点评估降预留或转按量利用率回到平衡点以上
闲置率超过 IdleTolerance进入回收清单,按金额排序回落到容忍度以内
单资源利用率月均 < 10%先确认非灾备/非冷备,再关停资源下线或给出保留理由
峰值/基线比> 3 且持续用弹性覆盖峰值,不扩预留预留只覆盖基线
合同剩余期< 3 个月且需求下降不再续约,转按量观察新合同按新基线签

常见错误与边界

第一,用月均利用率做关停决策。月均是结果,不是分布;一个每天峰值 10 分钟打满的资源,月均可能只有 15%,关掉会直接违反 SLO。回收前至少看 p95 和峰值持续时间。

第二,把预留当成纯折扣。预留买的是单价,卖的是灵活性;需求一旦下降,省下的单价抵不过闲置的固定支出。签约前要写清楚需求下限由谁负责,以及下降时谁承担差额。

第三,只看单台成本,不看故障域。为了省钱把 6 个副本降到 3 个,可能让服务在单个可用区故障时失去多数派;容量模型必须叠加故障域约束,不能只输出金额。

第四,回收清单不区分资源类型。关掉一台无状态计算实例和关掉一个数据库主节点,风险差了几个数量级;清单应按“无状态/有状态/数据留存”分组,并给每组不同的审批级别。

小结

预留、弹性、闲置是同一笔预算的三种形态。预留单价低但承诺刚性,按量灵活但单价高,闲置则是承诺与实际需求之间的差额。把三者放进一个模型,才能回答“基线画在哪、闲置容忍多少、什么时候改合同”。

本机这组固定输入下,基线 100、预留 80 的方案总成本 6,800 元、覆盖率 80%;需求掉到 60 的过量预留方案闲置 40 台、每月多付 2,400 元;回收清单按金额排序后优先处理 vm-b,合计可回收 1,350 元。这些是模型输出,不是线上账单,真实决策要代入自己的单价和分位数。

算清楚之后,还需要一套机制让数字持续被看见:预算怎么定、异常怎么发现、超支之后谁来处理。这是下一节的主题。

阅读导航:上一节:12.1 成本归因:标签、分摊与单位经济 · 下一节:12.3 成本治理机制:预算、评审与责任到团队 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查