一张 332 元的估算,和一张 6800 元的账单
「回声」上线前,团队做了一版成本估算:8 核应用服务器跑满一个月,加 2TB 数据库存储,按当时拿到的单价算下来是 332 元。这个数字被写进了预算表,没有人提出异议。
上线第三十天,账单到了:计算 292 元,数据库存储 40 元,和估算基本一致;但对象存储、备份、CDN 回源和跨区域复制加起来接近 6500 元。总账单约 6800 元,是估算的 20 倍。
财务问的第一个问题是“是不是被攻击了”。排查后发现没有攻击,只是团队只估了“数据库里那 2TB”,没有估图片对象存储的日增、90 天保留和三副本,没有估每天的全量备份,也没有估 92% 命中率之外那 8% 的 CDN 回源流量。
三本账:计算、存储、带宽
成本估算要先分成三本账,每本账的计费口径完全不同。混在一起看,就会像「回声」一样,把 292 元的计算费当成全部。
| 项目 | 计费口径 | 案例假设用量 | 案例假设月成本 | 易漏点 |
|---|---|---|---|---|
| 应用计算 | 核数 × 小时 × 单价 | 8 核 × 730 小时 | 292 元 | 闲置实例、扩缩容峰值 |
| 数据库存储 | GB 月 | 2000 GB | 40 元 | 索引、WAL、临时文件 |
| 对象存储 | GB 月 × 副本 | 11300 GB | 226 元 | 副本、版本、未清理的旧图 |
| 备份快照 | GB 月 × 保留份数 | 6000 GB | 60 元 | 全量快照按天保留 |
| CDN 出流量 | GB × 单价 | 150000 GB | 3000 元 | 图片和视频的主要成本 |
| 回源流量 | GB × 单价 | 30000 GB | 1800 元 | 命中率下降时成倍增长 |
| 跨区域复制 | GB × 单价 | 80000 GB | 1600 元 | 容灾与合规要求 |
存储:写入 × 保留 × 副本
存储成本的骨架是一个乘法:日写入量 × 保留天数 × 副本数。三因子任何一个翻倍,账单就翻倍;而团队通常只盯着第一个。
假设每天新增 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%,回源可能翻几倍。
- 单价不写地区、计费类型和日期。三个月后无法解释账单变化。
- 把案例假设值当成真实账单。本文所有金额和用量都是案例假设,预算必须用自己的询价和监控重算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。