结算服务的 40 台闲置实例
arch-lab 的结算服务在去年按峰值 100 台规划,签了一年预留:每台每月 60 元,无论用不用都要付。今年批处理改成按小时分批后,实际峰值降到 60 台,但预留合同还没到期。财务看到的是每月 6,000 元固定支出,运维看到的是 40 台机器利用率不到 10%,两边说的是同一件事,却拿着不同的数字。
更麻烦的是“平均值陷阱”。月度平均利用率 55% 看起来还行,但拆到小时粒度,峰值时段 60 台跑满,低谷时段 20 台以下;预留按峰值买,闲置也按峰值付。容量决策必须同时看峰值、分布和承诺周期,只看月均会把过量预留解释成“还有余量”。
这一节把预留、弹性、闲置放进同一个成本模型:给定基线需求、预留数量和两种单价,算出总成本、覆盖率、闲置率与盈亏平衡利用率,再据此决定是继续预留、改按量,还是先回收闲置。SLO 与容量的关系在 SLA、SLO 与容量规划 里已经展开,这里只算钱。
预留不是折扣,是下限承诺
预留实例的本质是用“承诺用量”换单价:单价从 100 元降到 60 元,代价是不管用不用都要付。它适合需求下限稳定的服务,比如订单 API 的常驻副本;不适合峰值短、波谷长、随时可能下线的批处理。
按量实例相反,单价高但不承诺,适合削峰和临时任务。两者不是替代关系,而是组合:预留覆盖基线,按量覆盖基线以上的部分。真正需要决策的是基线画在哪里,以及画错之后多久能纠偏。
| 方案 | 需求 100 时成本 | 需求 60 时成本 | 需求 60 时闲置 | 适用判断 |
|---|---|---|---|---|
| 全部按量 | 10,000 | 6,000 | 0 | 需求波动大、随时可能下线 |
| 预留 60 + 弹性 40 | 7,600 | 3,600 | 0 | 下限稳定,峰值可弹 |
| 预留 80 + 弹性 20 | 6,800 | 4,800 | 20 台 / 1,200 | 下限较稳,留一点缓冲 |
| 预留 100 | 6,000 | 6,000 | 40 台 / 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 成本治理机制:预算、评审与责任到团队 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。