《互联网产品的服务端架构·产品、流量与基础能力》12.2 存储、带宽与机器成本

从「回声」只估了数据库、上线后被带宽与备份账单打回的事故写起,把存储、带宽与机器成本拆成可核算的账本,说明保留天数、副本数与 CDN 回源如何放大费用,给出 SteadyState、AfterDays 与 Monthly 的可测试实现和真实测试输出。

一张 332 元的估算,和一张 6800 元的账单

「回声」上线前,团队做了一版成本估算:8 核应用服务器跑满一个月,加 2TB 数据库存储,按当时拿到的单价算下来是 332 元。这个数字被写进了预算表,没有人提出异议。

上线第三十天,账单到了:计算 292 元,数据库存储 40 元,和估算基本一致;但对象存储、备份、CDN 回源和跨区域复制加起来接近 6500 元。总账单约 6800 元,是估算的 20 倍。

财务问的第一个问题是“是不是被攻击了”。排查后发现没有攻击,只是团队只估了“数据库里那 2TB”,没有估图片对象存储的日增、90 天保留和三副本,没有估每天的全量备份,也没有估 92% 命中率之外那 8% 的 CDN 回源流量。

三本账:计算、存储、带宽

成本估算要先分成三本账,每本账的计费口径完全不同。混在一起看,就会像「回声」一样,把 292 元的计算费当成全部。

项目计费口径案例假设用量案例假设月成本易漏点
应用计算核数 × 小时 × 单价8 核 × 730 小时292 元闲置实例、扩缩容峰值
数据库存储GB 月2000 GB40 元索引、WAL、临时文件
对象存储GB 月 × 副本11300 GB226 元副本、版本、未清理的旧图
备份快照GB 月 × 保留份数6000 GB60 元全量快照按天保留
CDN 出流量GB × 单价150000 GB3000 元图片和视频的主要成本
回源流量GB × 单价30000 GB1800 元命中率下降时成倍增长
跨区域复制GB × 单价80000 GB1600 元容灾与合规要求

存储:写入 × 保留 × 副本

存储成本的骨架是一个乘法:日写入量 × 保留天数 × 副本数。三因子任何一个翻倍,账单就翻倍;而团队通常只盯着第一个。

假设每天新增 40GB 图片,保留 90 天,三副本,已有 500GB。稳态占用是 500 + 40 × 90 × 3 = 11300GB。如果只按“日增 40GB”乘以 30 天估,会得到 1200GB,差了近十倍。

保留天数由产品和合规共同决定,不是“越长越安全”。90 天保留意味着任何一天的写入都要在盘上躺 90 天;如果为了排查问题把日志保留从 30 天改成 365 天,存储成本会直接涨 12 倍,而真正被查询的历史数据可能不到 1%。

副本数也要算清楚。三副本不是“数据库三副本”一个概念:对象存储的冗余、跨可用区复制、跨区域容灾,每一层都可能单独计费。案例里的 3 只算了对象存储的三副本,没有算跨区域复制,后者在表里是单独一行。

还有一类容易被忽略的“影子存储”:数据库的 WAL、临时排序文件、未清理的软删除记录、版本控制保留的旧对象、构建产物和镜像层。它们不在业务表里,但都占 GB 月。

失败场景:只算了数据库,没算回源和备份

「回声」的大促当晚,CDN 命中率从平日的 92% 掉到 70%。原因是大量用户同时刷新同一批新图,边缘节点还没缓存,请求全部回源。回源流量从平日 30000GB 涨到 90000GB,单这一项就多出 3600 元。

同一周,备份任务因为数据量增长,全量快照从 2000GB 涨到 6000GB,保留 30 天。团队一直以为“备份是附带的”,直到账单里出现独立的快照存储行。

更隐蔽的是未清理的旧图。用户更换头像后,旧对象没有删除,版本控制又保留了一份,三个月累积了 2TB。业务数据库里只有最新头像的地址,没有人会去对象存储里翻旧版本,但这些对象一直在计费。

三件事叠加,账单从估算的 332 元变成约 6800 元。它们都不是“算错乘法”,而是“账本里根本没有这一行”。成本估算的第一版应该先把所有计费项列全,再谈单价和折扣。

存储模型与它的测试

SteadyState 回答“按当前写入速度,长期会占多少盘”:已有数据 + 日增 × 保留天数 × 副本数。AfterDays 回答“days 天后有多少”,包含已有数据,但故意不模拟过期删除;过期后的稳态由 SteadyState 回答,两个函数职责分开,测试才不会互相污染。

// file: internal/platform/capacity/storage.go
// tier: L1
// verify: go test ./internal/platform/capacity/

package capacity

import "fmt"

// Growth 描述一个只会长大的存储账本。保留天数与副本数常被漏算,
// 而它们往往比日增量更能决定账单。
type Growth struct {
	CurrentGB     float64
	DailyWriteGB  float64
	RetentionDays int
	Replication   int
}

func (g Growth) validate() error {
	switch {
	case g.CurrentGB < 0:
		return fmt.Errorf("capacity: current storage must not be negative")
	case g.DailyWriteGB < 0:
		return fmt.Errorf("capacity: daily write must not be negative")
	case g.RetentionDays < 0:
		return fmt.Errorf("capacity: retention days must not be negative")
	case g.Replication < 1:
		return fmt.Errorf("capacity: replication must be at least 1")
	}
	return nil
}

// SteadyState 计算写入量与过期删除量相等时的总占用:
// 已有数据 + 日增 × 保留天数 × 副本数。
func SteadyState(g Growth) (float64, error) {
	if err := g.validate(); err != nil {
		return 0, err
	}
	retained := g.DailyWriteGB * float64(g.RetentionDays) * float64(g.Replication)
	return g.CurrentGB + retained, nil
}

// AfterDays 计算 days 天后的总量,含已有数据和期间的全部写入。
func AfterDays(g Growth, days int) (float64, error) {
	if err := g.validate(); err != nil {
		return 0, err
	}
	if days < 0 {
		return 0, fmt.Errorf("capacity: days must not be negative")
	}
	added := g.DailyWriteGB * float64(days) * float64(g.Replication)
	return g.CurrentGB + added, nil
}

测试里有三个关键断言:稳态 11300GB、30 天后 4100GB、180 天后 22100GB。最后一个故意超过 90 天保留期,用来固定“AfterDays 只做线性累加”这个契约;如果读者想算稳态,应该调用 SteadyState,而不是把天数无限加大。

// file: internal/platform/capacity/storage_test.go
// tier: L1
// verify: go test ./internal/platform/capacity/

package capacity

import (
	"math"
	"testing"
)

func nearStorage(a, b float64) bool { return math.Abs(a-b) < 1e-6 }

func TestSteadyStateCountsRetentionAndReplication(t *testing.T) {
	got, err := SteadyState(Growth{
		CurrentGB:     500,
		DailyWriteGB:  40,
		RetentionDays: 90,
		Replication:   3,
	})
	if err != nil {
		t.Fatal(err)
	}
	// 500 + 40 × 90 × 3 = 11300 GB。
	if !nearStorage(got, 11_300) {
		t.Fatalf("steady state = %f", got)
	}
}

func TestAfterDaysIncludesExistingData(t *testing.T) {
	g := Growth{CurrentGB: 500, DailyWriteGB: 40, RetentionDays: 90, Replication: 3}
	got, err := AfterDays(g, 30)
	if err != nil {
		t.Fatal(err)
	}
	// 500 + 40 × 30 × 3 = 4100 GB。
	if !nearStorage(got, 4_100) {
		t.Fatalf("after 30 days = %f", got)
	}

	// 零天时应只剩已有数据。
	zero, err := AfterDays(g, 0)
	if err != nil {
		t.Fatal(err)
	}
	if !nearStorage(zero, 500) {
		t.Fatalf("after 0 days = %f", zero)
	}

	// 超过保留天数后仍按写入量线性增长:这里只回答“要留多少盘”,
	// 不负责模拟过期删除,稳态由 SteadyState 回答。
	long, err := AfterDays(g, 180)
	if err != nil {
		t.Fatal(err)
	}
	if !nearStorage(long, 22_100) {
		t.Fatalf("after 180 days = %f", long)
	}
}

func TestStorageRejectsBadInput(t *testing.T) {
	ok := Growth{CurrentGB: 10, DailyWriteGB: 1, RetentionDays: 7, Replication: 2}
	bad := []Growth{
		{CurrentGB: -1, DailyWriteGB: 1, RetentionDays: 7, Replication: 2},
		{CurrentGB: 10, DailyWriteGB: -1, RetentionDays: 7, Replication: 2},
		{CurrentGB: 10, DailyWriteGB: 1, RetentionDays: -1, Replication: 2},
		{CurrentGB: 10, DailyWriteGB: 1, RetentionDays: 7, Replication: 0},
	}
	for i, g := range bad {
		if _, err := SteadyState(g); err == nil {
			t.Fatalf("steady case %d: want error", i)
		}
		if _, err := AfterDays(g, 1); err == nil {
			t.Fatalf("after case %d: want error", i)
		}
	}
	if _, err := AfterDays(ok, -1); err == nil {
		t.Fatal("negative days: want error")
	}
	if _, err := SteadyState(ok); err != nil {
		t.Fatalf("valid input rejected: %v", err)
	}
}

成本模型与它的测试

Monthly 把用量拆成计算、存储、带宽三项,并要求总计等于分项之和。计算按核数 × 小时,存储按 GB 月,带宽按 GB 出流量;这三项已经能覆盖大部分架构决策,剩下的备份、复制、日志可以按同样方式增加字段。

// file: internal/platform/capacity/cost.go
// tier: L1
// verify: go test ./internal/platform/capacity/

package capacity

import "fmt"

// Price 是单价表。云厂商的计费维度很多,这里只保留与架构决策相关的三项。
type Price struct {
	CoreHour   float64 // 每核每小时
	GBMonth    float64 // 每 GB 每月
	GBTransfer float64 // 每 GB 出流量
}

// Usage 是月度用量。机器成本按核数 × 小时数计,而不是按整月包干。
type Usage struct {
	Cores      int
	StorageGB  float64
	TransferGB float64
	Hours      int
}

type Cost struct {
	Compute  float64
	Storage  float64
	Transfer float64
	Total    float64
}

// Monthly 把用量拆成计算、存储、带宽三项,并要求总计与分项一致。
func Monthly(u Usage, p Price) (Cost, error) {
	switch {
	case u.Cores <= 0:
		return Cost{}, fmt.Errorf("capacity: cores must be positive")
	case u.Hours < 1:
		return Cost{}, fmt.Errorf("capacity: hours must be at least 1")
	case u.StorageGB < 0:
		return Cost{}, fmt.Errorf("capacity: storage must not be negative")
	case u.TransferGB < 0:
		return Cost{}, fmt.Errorf("capacity: transfer must not be negative")
	case p.CoreHour < 0 || p.GBMonth < 0 || p.GBTransfer < 0:
		return Cost{}, fmt.Errorf("capacity: prices must not be negative")
	}

	c := Cost{
		Compute:  float64(u.Cores) * float64(u.Hours) * p.CoreHour,
		Storage:  u.StorageGB * p.GBMonth,
		Transfer: u.TransferGB * p.GBTransfer,
	}
	c.Total = c.Compute + c.Storage + c.Transfer
	return c, nil
}

测试覆盖两个场景:一组手算的 452 元账单,以及一个带宽远高于计算的图片产品。后者不是精确报价,而是用案例假设值说明“带宽可能是主成本”这个结论;如果测试里带宽没有超过计算,说明参数设置没有体现图片产品的特征。

// file: internal/platform/capacity/cost_test.go
// tier: L1
// verify: go test ./internal/platform/capacity/

package capacity

import (
	"math"
	"testing"
)

func nearCost(a, b float64) bool { return math.Abs(a-b) < 1e-9 }

func TestMonthlySplitsComputeStorageTransfer(t *testing.T) {
	got, err := Monthly(Usage{
		Cores:      8,
		StorageGB:  2_000,
		TransferGB: 1_500,
		Hours:      730,
	}, Price{
		CoreHour:   0.05,
		GBMonth:    0.02,
		GBTransfer: 0.08,
	})
	if err != nil {
		t.Fatal(err)
	}
	// 计算 8 × 730 × 0.05 = 292;存储 2000 × 0.02 = 40;
	// 带宽 1500 × 0.08 = 120;合计 452。
	if !nearCost(got.Compute, 292) {
		t.Fatalf("compute = %f", got.Compute)
	}
	if !nearCost(got.Storage, 40) {
		t.Fatalf("storage = %f", got.Storage)
	}
	if !nearCost(got.Transfer, 120) {
		t.Fatalf("transfer = %f", got.Transfer)
	}
	if !nearCost(got.Total, 452) {
		t.Fatalf("total = %f", got.Total)
	}
	if math.Abs(got.Total-(got.Compute+got.Storage+got.Transfer)) > 0.01 {
		t.Fatalf("total %.6f does not match parts", got.Total)
	}
}

func TestMonthlyBandwidthDominatesMediaProduct(t *testing.T) {
	// 图片产品的带宽账单超过计算账单:这是案例假设值,不是实测。
	got, err := Monthly(Usage{
		Cores:      4,
		StorageGB:  500,
		TransferGB: 80_000,
		Hours:      730,
	}, Price{
		CoreHour:   0.05,
		GBMonth:    0.02,
		GBTransfer: 0.06,
	})
	if err != nil {
		t.Fatal(err)
	}
	if got.Transfer <= got.Compute {
		t.Fatalf("transfer %.2f should dominate compute %.2f", got.Transfer, got.Compute)
	}
	if !nearCost(got.Total, got.Compute+got.Storage+got.Transfer) {
		t.Fatalf("total = %f", got.Total)
	}
}

func TestMonthlyRejectsBadUsage(t *testing.T) {
	okPrice := Price{CoreHour: 0.05, GBMonth: 0.02, GBTransfer: 0.08}
	bad := []Usage{
		{Cores: 0, StorageGB: 1, TransferGB: 1, Hours: 730},
		{Cores: 2, StorageGB: 1, TransferGB: 1, Hours: 0},
		{Cores: 2, StorageGB: -1, TransferGB: 1, Hours: 730},
		{Cores: 2, StorageGB: 1, TransferGB: -1, Hours: 730},
	}
	for i, u := range bad {
		if _, err := Monthly(u, okPrice); err == nil {
			t.Fatalf("case %d: want error", i)
		}
	}
	badPrices := []Price{
		{CoreHour: -1, GBMonth: 0.02, GBTransfer: 0.08},
		{CoreHour: 0.05, GBMonth: -1, GBTransfer: 0.08},
		{CoreHour: 0.05, GBMonth: 0.02, GBTransfer: -1},
	}
	for i, p := range badPrices {
		if _, err := Monthly(Usage{Cores: 1, Hours: 1}, p); err == nil {
			t.Fatalf("price case %d: want error", i)
		}
	}
	if _, err := Monthly(Usage{Cores: 1, Hours: 1}, okPrice); err != nil {
		t.Fatalf("valid input rejected: %v", err)
	}
}

运行命令与真实输出

cd .local/checks/webapp-lab
go test ./internal/platform/capacity/ -run 'TestSteadyState|TestAfterDays|TestStorage|TestMonthly' -v -count=1
=== RUN   TestMonthlySplitsComputeStorageTransfer
--- PASS: TestMonthlySplitsComputeStorageTransfer (0.00s)
=== RUN   TestMonthlyBandwidthDominatesMediaProduct
--- PASS: TestMonthlyBandwidthDominatesMediaProduct (0.00s)
=== RUN   TestMonthlyRejectsBadUsage
--- PASS: TestMonthlyRejectsBadUsage (0.00s)
=== RUN   TestSteadyStateCountsRetentionAndReplication
--- PASS: TestSteadyStateCountsRetentionAndReplication (0.00s)
=== RUN   TestAfterDaysIncludesExistingData
--- PASS: TestAfterDaysIncludesExistingData (0.00s)
=== RUN   TestStorageRejectsBadInput
--- PASS: TestStorageRejectsBadInput (0.00s)
PASS
ok  	webapp/internal/platform/capacity	0.221s

小结

成本估算要先把账本列全,再算乘法。存储的核心是“日增 × 保留 × 副本”,带宽的核心是“出流量 + 回源流量”,计算的核心是“核数 × 小时 × 闲置率”。三者之中,图片和视频产品的带宽往往最先超过计算。

「回声」的 332 元不是算错,而是漏项。只算数据库、不算对象存储和备份,只算 CDN 出流、不算回源,只算主副本、不算跨区域复制,任何一项都能让账单翻倍。

下一节讨论容量里最容易被当成浪费的部分:余量和弹性。留多少、什么时候扩、扩出来要多久,都是成本与可用性之间的直接取舍。

阅读导航:上一节:12.1 DAU 到 QPS 的估算 · 下一节:12.3 容量余量与弹性策略 。

常见错误与边界

  • 只算数据库存储。对象存储、备份、日志、镜像层都可能单独计费。
  • 存储只乘天数不乘副本。三副本意味着同样的数据要付三份钱。
  • 把备份当免费。全量快照按天保留,数据量增长后是独立的大头。
  • 只算 CDN 出流量,不算回源。命中率从 92% 掉到 70%,回源可能翻几倍。
  • 单价不写地区、计费类型和日期。三个月后无法解释账单变化。
  • 把案例假设值当成真实账单。本文所有金额和用量都是案例假设,预算必须用自己的询价和监控重算。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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