《互联网产品的服务端架构·规模化与架构演进》5.3 闲置回收与预算机制

「回声」的月度计算账单多出 12 万元,成本看板却只有一个总额,六个星期没人说清是哪条链路。本节把闲置回收拆成证据、负责人和护栏三个条件,让预算成为上线前的布尔判断,给出预算准入与回收金额的 Go 实现和测试,并复盘一次只报总额、回收脚本误删季度任务的事故。

账单涨了 12 万,没人认领

「回声」的计算资源分布在三个云账号、六个可用区、四十多个服务里。某个月,计算账单比上月多了 12 万元(案例假设值)。成本看板上只有一个总数,没有按服务拆分,也没有按环境拆分。财务把截图发到群里,问了一句“这个月为什么涨”,群里安静了半天,最后有人回复“可能是大促”。

六个星期后,答案才浮出来:一个两年前上线的实时推荐实验集群一直没有下线,实例数从 8 台悄悄扩到了 48 台;另一个是数据分析团队的夜间批处理任务,任务本身已经迁走,调度配置还留着,每天空跑两个小时。两项加起来约 9 万元,剩下的 3 万来自正常的业务增长。

这件事暴露的不是技术问题,而是责任问题。账单是平台付的,资源是各团队创建的,成本却没有明确的负责人。看板只报总额,等于把“哪条链路在烧钱”这个问题留给了所有人的直觉;而直觉在四十多个服务面前,基本等于没有。

这一节讨论三件事:闲置回收需要哪些证据,预算应该放在上线流程的哪个位置,以及怎样把阈值判断写成可测试的函数。

回收需要证据、负责人和护栏

“这台机器看起来没人用”不是回收依据。真正的回收决策至少要同时满足三个条件:有可复算的闲置证据,有明确的责任人确认,有回滚或恢复的护栏。缺任何一个,回收都会变成一次赌博。

资源状态判定信号(案例假设)处置动作负责人护栏
持续低负载CPU 低于 5% 且持续 30 天停机观察 7 天服务 owner保留磁盘快照
无请求网关请求数为 0 且持续 14 天缩容到 1 台服务 owner保留入口与告警
调度空跑任务连续 10 次无输出暂停调度数据 owner确认下游无依赖
预发环境夜间闲置20:00 至次日 08:00 无请求定时关机平台组白名单与手动开机
已下线服务残留部署记录标记 retired 超过 30 天删除资源原 owner 或架构组30 天冷静期

表里的“负责人”一列比“判定信号”更重要。没有负责人的资源,即使证据充分也没人敢删;有负责人的资源,即使信号不够精确,也能通过沟通确认。回收流程的第一步不是写脚本,而是给每个资源打上 owner 标签,并让标签成为创建资源的必填项。

“护栏”一列决定回收能不能回滚。停机前保留快照,缩容前保留入口,删除前设置冷静期,这些都是把“不可逆操作”变成“可逆操作”的手段。回收的目标是省钱,不是证明脚本勇敢;一次误删造成的恢复成本,可能超过一整年的闲置费用。

闲置判定要看趋势,不看瞬时值

瞬时 CPU 低不代表闲置。一个每天凌晨跑批的服务,白天 CPU 接近零;一个按周触发的对账任务,六天没有请求。用 5 分钟的平均值做判定,会把它们全部误伤。

更稳的做法是看一个完整业务周期内的趋势:至少覆盖 7 天,最好覆盖 30 天;同时看 CPU、请求数、队列消费和网络流量四个信号,任何一个信号活跃都说明资源可能仍在使用。只看 CPU 会漏掉 I/O 密集型任务,只看请求数会漏掉定时任务。

趋势数据还要能追溯到原始监控,而不是只保留一个“闲置”标签。当 owner 质疑判定结果时,团队应该能打开最近 30 天的曲线,指出哪几天完全没有请求。没有原始数据的判定,最后都会退化成“平台说它闲置”。

回收金额要按月成本算,不要按 CPU 利用率算

“这台机器利用率只有 3%,所以能省 97% 的钱”是错误算法。停机省下的是整台实例的月成本,不是按利用率折算的比例;反过来,缩容到 1 台也不是省下 47/48,因为剩下的那台可能要承担全部流量,规格还得上调。

回收金额应该以账单上的月成本为基数,再扣除快照、迁移、恢复演练和人工确认的成本。一个月成本 5000 元的实例,快照每月 200 元,迁移和确认花掉两个工程师半天,回收的净收益要按季度看,而不是按单月看。

认领流程怎么落地

给资源补 owner 标签听起来是体力活,做起来更像组织问题。最有效的切入点是新建流程:新资源没有 owner 标签就不能创建,存量资源则按成本从高到低逐批认领。先处理账单前 20% 的资源,通常能覆盖 70% 以上的成本。

认领不是填一个名字。owner 要确认三件事:这条资源做什么、每月预算多少、闲置时是否可以回收。三个问题都答完,资源才算真正有主。只填名字不确认预算,回收时仍然会卡住。

对于确实找不到 owner 的资源,应该进入“无主资源池”,由架构组或平台组代管,并设定一个认领期限。期限到了仍然无人认领,才进入回收流程。无主不等于可删,代管期间要保证业务不被误伤。

回收要和容量演练一起做

回收闲置资源和容量演练看起来是两件事,实际上共享同一套数据。演练需要知道哪些资源可以临时接管流量,回收需要知道哪些资源停掉之后还有冗余。把两张清单放在一起维护,能避免“刚回收完就遇到容量不足”的尴尬。

一个实用做法是每季度做一次“假设回收”演练:按候选清单模拟停机,观察核心指标是否变化,但不真正删除资源。演练结果反过来修正回收规则,例如某些看起来闲置的资源其实是故障转移的备用节点。

预算不是财务报表,是上线前的判断

很多团队把预算理解成财务每月发的 Excel:先花钱,月底对账,超了再解释。这种模式对闲置资源几乎没有约束力,因为资源创建时没人问“这条链路一年要花多少”,等账单出来,木已成舟。

更有效的做法是把预算前移到上线流程:每个服务在创建资源时声明月度预算上限,平台在部署前做一次布尔判断——当前已花费加上本次预计成本,是否仍在预算内。这个判断不阻止业务发展,它只是让“超预算”变成一个需要显式审批的动作,而不是月底才发现的意外。

预算的粒度也很重要。按云账号设一个总预算,等于没有预算;按服务设预算,才能让 owner 感受到约束。共享资源(网关、消息队列、监控)可以单独设平台预算,再由平台按调用量分摊,但分摊规则要公开、可复算。

预算还需要一个“已花费”的实时来源。如果 Spent 每天才更新一次,部署时的判断就会滞后;如果实时计量和账单口径不一致,判断又会失真。折中方案是:部署前用实时计量做准入,月底用账单做对账,两者偏差超过阈值时告警,而不是直接改数字。

超预算后的三条路

预算判断返回 false 之后,团队通常有三条路:缩容或优化现有资源、申请追加预算、推迟上线。三条路都合理,但不能默认走第二条,否则预算会变成一张随时可以重签的表格。

追加预算应该要求说明原因:是业务增长超出预期,还是资源使用效率下降。前者可以按增长比例追加,后者应该先优化再追加。把两种原因分开,能避免“增长”成为所有超支的万能解释。

推迟上线是最容易被忽视的选项。很多功能并不需要在当月上线,推迟两周换取一次容量评估,往往比匆忙扩容更划算。预算机制的价值之一,就是让“现在必须上”这个假设被迫接受一次检验。

预算要定期复审

预算不是设一次就永久有效。业务增长、架构调整、单价变化都会让原来的上限失真。建议每季度复审一次,重点看三类服务:连续三个月接近上限的、连续三个月只用了不到 20% 的、刚发生过重大架构变更的。

接近上限说明预算可能阻碍增长,需要评估是追加还是优化;长期用不完说明预算虚高,可以下调并把余量释放给其他服务;架构变更后,旧预算的计量口径可能已经不适用,需要重新核定。

复审记录应该保留。下次有人问“为什么这个服务的预算这么高”,能翻到当时的判断依据,而不是只看到一个数字。

一次只报总额的成本看板

回到开头那次 12 万元的涨幅。团队后来补了按服务、按环境、按 owner 三个维度的拆分,才发现实时推荐实验集群和空跑调度。复盘时,他们列出了三个失误。

第一,看板只报总额,没有下钻路径。总额是一个结果指标,它能告诉你“变贵了”,不能告诉你“哪里变贵了”。成本看板的第一屏应该是按服务排序的月度变化,第二屏才是总额趋势。

第二,资源创建没有 owner 必填。那个实验集群的创建人已经离职,接手的人以为它是推荐主链路的一部分,不敢动;推荐团队以为它是实验平台的资源,也不动。两边都不动,账单就一直在。

第三,回收脚本没有护栏。发现空跑调度后,值班同学写了一个“连续 10 次无输出就暂停”的脚本,第二天把一个月度对账任务也停了。对账任务每月 1 号运行,之前 10 次当然没有输出。脚本的判断逻辑没错,错在没有覆盖“月级周期”这个边界。

修复方案有三步:给所有资源补 owner 标签,没有标签的资源进入待认领列表;成本看板增加“服务 × 环境 × owner”的下钻;回收脚本先输出候选清单,由 owner 确认后再执行,执行前自动打快照。三步里最难的是第一步,因为它需要跨团队推动,而不是写代码。

看板的最小下钻

一个能用的成本看板,第一屏至少要有四列:服务名、owner、本月成本、环比变化。按环比变化排序,涨幅最大的排在最前面。总额可以放在顶部,但它不是第一眼要看的东西。

第二屏再展开到环境和资源类型。点击一个服务,应该能看到它的计算、存储、带宽各占多少,以及最近 30 天的趋势。趋势里要标出变更事件:扩容、上线、迁移、大促。没有事件标注,涨了只能猜。

第三屏才是原始账单条目。大多数人不应该需要看到这一层,但当 owner 质疑分摊结果时,必须能一路点到具体的计费项。下钻链路的价值,不是让所有人都看明细,而是让质疑有地方落地。

把阈值和金额算成可测试的函数

配套代码 internal/cost/budget.go 只做两个判断:一笔预计成本能否放进预算,以及一个资源闲置多少天后进入回收候选、对应多少月度金额。它不读取监控、不发送通知、不执行删除,这些动作都应该由带护栏的流程完成。

// file: internal/cost/budget.go
// tier: L1
// verify: go test ./internal/cost/

package cost

import "fmt"

// Budget 是某条资源链路在一个周期内的预算,单位:分。
// Limit 由预算负责人确认,Spent 来自账单或实时计量,二者必须同口径。
type Budget struct {
	Limit int64
	Spent int64
}

// Allow 判断一笔预计成本能否在预算内放下。
// 负数成本直接拒绝:退款与抵扣应该在账单侧冲减 Spent,
// 而不是伪装成一次“负支出”绕过预算判断。
func Allow(b Budget, cost int64) bool {
	if cost < 0 {
		return false
	}
	return b.Spent+cost <= b.Limit
}

// IdleReclaim 返回超过闲置阈值后进入回收候选的月度金额,单位:分。
// 未超过阈值时返回 0;参数为负数说明埋点或配置出错,直接报错。
func IdleReclaim(idleDays, thresholdDays int, monthlyCost int64) (int64, error) {
	if idleDays < 0 {
		return 0, fmt.Errorf("budget: idleDays must be non-negative, got %d", idleDays)
	}
	if thresholdDays < 0 {
		return 0, fmt.Errorf("budget: thresholdDays must be non-negative, got %d", thresholdDays)
	}
	if monthlyCost < 0 {
		return 0, fmt.Errorf("budget: monthlyCost must be non-negative, got %d", monthlyCost)
	}
	if idleDays <= thresholdDays {
		return 0, nil
	}
	return monthlyCost, nil
}

Allow 在 Spent 已经超过 Limit 时,即使 cost 为 0 也返回 false。这样“已超预算”的状态不会被一次零成本操作洗掉,必须先处理超支,才能继续申请新资源。

Allow 拒绝负数成本,而不是把它当成退款。退款应该由账单系统冲减 Spent,不应该让业务方用一个负成本绕过预算判断。否则任何团队都可以传一个足够大的负数,让判断永远为真。

IdleReclaim 使用 idleDays <= thresholdDays 返回 0,意味着阈值当天仍不进入回收。边界必须明确写出来:30 天阈值下,第 30 天不动,第 31 天才进入候选。模糊的“超过 30 天”在实现里很容易变成 >=,测试要把这个边界钉死。

函数返回的是整月成本,不是按闲置天数比例折算的金额。一旦资源进入回收候选,省下的是整份月度成本,后续的快照、迁移和人工成本由流程单独核算。把比例折算塞进这个函数,会让金额看起来更精确,实际上掩盖了回收是一次性动作的事实。

测试:阈值边界与负成本

预算和回收的测试重点在边界:刚好用完、刚好超支、刚好到阈值、负数输入。下面的测试把这些情况全部固定下来。

// file: internal/cost/budget_test.go
// tier: L1
// verify: go test ./internal/cost/

package cost

import "testing"

func TestAllow(t *testing.T) {
	b := Budget{Limit: 1000, Spent: 700}
	if !Allow(b, 300) {
		t.Fatal("exact remaining budget should be allowed")
	}
	if !Allow(b, 299) {
		t.Fatal("cost below remaining budget should be allowed")
	}
	if Allow(b, 301) {
		t.Fatal("cost above remaining budget should be rejected")
	}
	if Allow(b, -1) {
		t.Fatal("negative cost should be rejected")
	}
}

func TestAllowWhenAlreadyOverBudget(t *testing.T) {
	b := Budget{Limit: 100, Spent: 150}
	if Allow(b, 0) {
		t.Fatal("zero-cost request should still be rejected when overspent")
	}
}

func TestIdleReclaimBelowThreshold(t *testing.T) {
	got, err := IdleReclaim(10, 30, 50000)
	if err != nil {
		t.Fatalf("IdleReclaim(): %v", err)
	}
	if got != 0 {
		t.Fatalf("IdleReclaim() = %d, want 0", got)
	}
}

func TestIdleReclaimAtThreshold(t *testing.T) {
	got, err := IdleReclaim(30, 30, 50000)
	if err != nil {
		t.Fatalf("IdleReclaim(): %v", err)
	}
	if got != 0 {
		t.Fatalf("IdleReclaim() = %d, want 0 at threshold", got)
	}
}

func TestIdleReclaimAboveThreshold(t *testing.T) {
	got, err := IdleReclaim(31, 30, 50000)
	if err != nil {
		t.Fatalf("IdleReclaim(): %v", err)
	}
	if got != 50000 {
		t.Fatalf("IdleReclaim() = %d, want 50000", got)
	}
}

func TestIdleReclaimRejectsNegativeInputs(t *testing.T) {
	if _, err := IdleReclaim(-1, 30, 50000); err == nil {
		t.Fatal("want error for negative idleDays")
	}
	if _, err := IdleReclaim(10, -1, 50000); err == nil {
		t.Fatal("want error for negative thresholdDays")
	}
	if _, err := IdleReclaim(10, 30, -1); err == nil {
		t.Fatal("want error for negative monthlyCost")
	}
}

TestAllow 同时覆盖“刚好等于剩余预算”和“超出一分钱”。成本是整数分,301 分就是超支,不存在浮点误差的借口;边界越明确,调用方越容易理解规则。

TestAllowWhenAlreadyOverBudget 专门锁住“零成本也要拒绝”的行为。这个用例看起来不近人情,但它防止了一种常见事故:超支后有人传 0 想试探接口,接口返回允许,于是后续请求继续涌入。

TestIdleReclaimAtThreshold 和 TestIdleReclaimAboveThreshold 一起钉住阈值边界。只测“低于阈值”和“高于阈值”是不够的,实现里把 <= 写成 <,只有“刚好等于”的用例能抓住。

测试没有覆盖“回收候选清单”和“通知 owner”。这两件事依赖资源标签和消息系统,属于流程层,不是这个纯函数的职责。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/cost/ -run 'TestAllow|TestIdleReclaim' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestAllow
--- PASS: TestAllow (0.00s)
=== RUN   TestAllowWhenAlreadyOverBudget
--- PASS: TestAllowWhenAlreadyOverBudget (0.00s)
=== RUN   TestIdleReclaimBelowThreshold
--- PASS: TestIdleReclaimBelowThreshold (0.00s)
=== RUN   TestIdleReclaimAtThreshold
--- PASS: TestIdleReclaimAtThreshold (0.00s)
=== RUN   TestIdleReclaimAboveThreshold
--- PASS: TestIdleReclaimAboveThreshold (0.00s)
=== RUN   TestIdleReclaimRejectsNegativeInputs
--- PASS: TestIdleReclaimRejectsNegativeInputs (0.00s)
PASS
ok  	webapp/internal/cost	0.345s

六个用例全部通过,退出码为 0。-count=1 关闭缓存;-run 只匹配预算与回收用例,单位成本和存储测试没有执行。

小结

闲置资源不会因为“大家都知道它闲置”而消失。它需要一个可复算的证据、一个愿意签字的负责人、一个能回滚的护栏,以及一个在创建时就生效的预算约束。四者缺一,回收就会在“不敢删”和“删错了”之间来回摆动。

预算机制的价值不在于省钱,而在于把成本变成上线前必须回答的问题。一个服务在创建资源时说清楚“每月最多花多少、谁来负责、超了怎么办”,比月底对着总额猜原因有效得多。

下一章转向可靠性治理,从 SLO 与错误预算开始:成本有预算,稳定性同样需要一套可量化的容忍度。

阅读导航:上一节:5.2 存储与带宽成本 · 下一节:6.1 SLO 与错误预算 。

常见错误与边界

  • 只看 CPU 利用率判断闲置。I/O 密集型、定时任务和按周触发的对账任务都可能 CPU 很低但仍在工作。
  • 用瞬时监控值做回收决策。至少覆盖一个完整业务周期,并把原始曲线留给 owner 复核。
  • 回收脚本直接删除资源。先停机或缩容观察,保留快照和入口,把不可逆操作拆成可逆步骤。
  • 按利用率比例计算节省金额。停机省的是整份月成本,缩容后的剩余实例可能还需要升配。
  • 预算按云账号设置。账号级总额无法让任何单个 owner 感受到约束,应按服务和环境拆分。
  • 允许负数成本通过预算判断。退款应在账单侧冲减,负数准入等于给了一条绕过预算的口子。
  • 把“超过阈值”实现成模糊的边界。阈值当天是否进入候选必须明确,并用测试钉住。
  • 成本看板只报总额。没有服务、环境、owner 三个维度的下钻,总额只能制造焦虑,不能指导行动。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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