《互联网产品的服务端架构·规模化与架构演进》5.2 存储与带宽成本

「回声」把 1 TB 存储和 1 TB 带宽当成同一个成本项,直到冷归档的取回费超过省下的存储费。本节拆开存量与流量两种计费方式,给出热、温、冷分层与带宽单价的 Go 实现和测试,并把存储费、取回费、请求费与回源费放进同一次回放,复盘缓存键带时间戳导致回源翻三倍的事故。

同样是 1 TB,账单差了一个数量级

「回声」的图片和短视频存放在对象存储里,前面挂了一层 CDN。某月账单出来,存储费用 34 万元,带宽费用 52 万元(均为案例假设值)。运营同学在周报里写了一句“存储和带宽各占一半,重点优化存储”,这句话让团队把接下来两周的精力放错了地方。

存储的 1 TB 是“这个月一直放着”的存量,按 GB 乘月单价计费;带宽的 1 TB 是“这个月流出去”的流量,按 GB 乘出网单价计费。两者单位里都有 GB,但一个乘的是时间,一个乘的是次数。把它们的账单放在一起看,很容易误以为可以互相替代:少存 1 TB 能省一份钱,少发 1 TB 也能省一份钱,但前者需要删数据或换层级,后者需要改缓存或降码率。

更麻烦的是,这两个成本对业务动作的响应方向经常相反。为了省存储,把图片压得更小,可能减少存储量;但压缩后的图片要实时转码,增加计算和回源。为了提高缓存命中率,把 URL 参数规范化,可能减少回源带宽;但缓存键变粗,又会让不同清晰度的图片互相覆盖。

这一节先把两种计费方式拆开,再讨论热、温、冷分层的真实代价,最后复盘一次“归档省了四成、总账单反而涨了”的事故。

存储看存量,带宽看流量

把两类成本放进同一张表,差异会立刻显出来。下表的单价都是案例假设值,用于说明结构,不代表任何云厂商的实际报价。

成本类型计费基数时间维度主要驱动优化手段常见副作用
热存储存量 GB × 月持续累积保留期、副本数删过期数据、降副本删错数据、恢复成本
温存储存量 GB × 月持续累积访问频率下降生命周期规则取回延迟、最低保留期
冷存储存量 GB × 月持续累积合规与备份归档、压缩取回费、请求费
出网带宽流出 GB每月重置峰值、缓存命中率CDN、压缩、限流画质下降、命中率波动
回源带宽回源 GB每月重置缓存键、过期策略提高命中率、预取缓存污染、更新延迟

表里最容易被忽略的是“常见副作用”一列。存储优化往往是一次性动作,删了就是删了;带宽优化是持续性动作,缓存策略改一次,命中率可能在这个月上升、下个月因为内容结构变化又掉回去。两类成本需要不同的治理节奏。

存储成本的第一变量是保留期,不是单价。假设每天新增 2 TB 原始视频(案例假设值),保留 30 天和保留 365 天的存量相差 12 倍。很多团队一上来就谈“换更便宜的存储”,却没人回答“这条数据为什么要留一年”。保留期应该由业务规则和合规要求共同决定,而不是由存储介质的容量决定。

带宽成本的第一变量是峰值和回源比例。CDN 通常按流量计费,部分厂商对峰值带宽有保底承诺;回源流量则同时消耗源站带宽和对象存储的取回费用。缓存命中率从 92% 掉到 78%,看起来只差 14 个百分点,回源流量却可能翻两倍多,因为分母变小了。

分层不是换个单价那么简单

热、温、冷三层的单价差距很大,但每一层都有自己的门槛。冷存储通常要求最短保留期,提前删除要补收费用;取回数据按 GB 计费,还可能按请求次数计费;取回不是瞬时的,有的层级需要几分钟到几小时。把“单价最低”当成“成本最低”,是分层设计里最常见的错误。

分层之前至少要回答三个问题:这条数据多久被读一次?读的时候能不能等?删的时候会不会被合规拦住?三个问题里有一个答不上来,就不应该急着写生命周期规则。

副本与版本也是存储成本

对象存储的副本策略、版本控制、跨区域复制都会成倍放大存量。开启版本控制后,每次覆盖写都会保留旧版本,账单上的“存量”可能远大于业务方以为的文件总大小。跨区域复制则会把一份数据变成两份,还要加上跨区流量费。

这些功能本身没有错,问题在于它们经常被默认开启,却没人把成本算进容量规划。一个实用的做法是给每个存储桶标注“副本数、版本保留天数、是否跨区”,让成本报表能按这些维度拆分。没有这层元数据,账单只能告诉你“存储很贵”,没法告诉你“贵在哪条规则”。

带宽成本要按峰值和回源拆

CDN 的账单通常有两行:边缘流量和回源流量。边缘流量是用户实际下载的量,回源流量是 CDN 没命中、回到源站取的数量。两者的单价不同,责任方也不同:边缘流量由产品流量和文件大小决定,回源流量由缓存策略和内容更新频率决定。

只报一个“CDN 总费用”,团队无法判断该优化哪一边。如果边缘流量稳定但回源占比上升,问题在缓存;如果回源占比稳定但边缘流量上涨,问题在用户请求量或文件体积。把两者拆开,才能把指标分配给不同的负责人。

峰值也需要单独记录。按流量计费的厂商对突发流量相对宽容,按峰值计费的厂商则会把一个持续十分钟的尖峰算进整月账单。大促、推送和定时任务都可能制造尖峰,成本报表应该同时给出日均流量和峰值流量,而不是只给一个平均数。

保留期要用业务规则算

保留期是存储成本最大的杠杆,也是最容易被拍脑袋决定的参数。常见的错误是“先留一年,以后再说”,一年后数据量翻了几倍,删除又需要业务方逐条确认,最后只能继续留。

更稳的做法是按数据类型分别定规则:用户上传的原图保留 180 天,之后只保留压缩后的展示图;日志保留 30 天,聚合指标保留 13 个月;合规备份保留法规要求的年限,到期自动进入删除审批。每条规则都写明依据、负责人和到期动作。

规则还需要一个“例外清单”。不是所有数据都能自动过期:涉及纠纷、审计或大客户合同的数据,可能需要延长保留。例外清单应该显式维护,并定期复核;如果例外越来越多,说明主规则已经不符合业务,应该改规则,而不是无限加例外。

压缩与转码的账

为了省存储和带宽,团队通常会把图片压小、视频降码率。压缩确实能降低存储和出网流量,但它会增加计算成本,还可能降低用户体验。这笔账要一起算。

假设原图平均 2 MB,压缩后 400 KB,存储省了 80%,CDN 流量也省了 80%;但每次上传都要跑一次转码,转码实例每月多花 6 万元,而省下的存储和带宽合计 15 万元(均为案例假设值),净收益仍然为正。如果压缩率只有 30%,转码成本不变,净收益可能为负。

判断压缩是否值得,要看三个数:压缩带来的存储和带宽节省、转码增加的计算成本、用户体验的变化。前两个可以算,第三个需要产品判断。把三个数放在同一张评审表里,比单方面追求“更小的文件”更可靠。

单价会变,口径不能变

云厂商的单价会随合同、区域、用量阶梯变化。如果代码里到处写死单价,每次调价都要改十几个地方,迟早漏掉一个。更稳的做法是把单价集中成命名常量或配置项,并在报表里记录本次计算使用的价格版本。

价格版本不需要精确到每次调价,但至少要能回答“这个月的数字是按哪版价格算的”。当财务拿账单来对账时,价格版本能解释大部分差异;没有版本号,只能重新跑一遍历史数据,而历史数据可能已经被覆盖。

口径比单价更稳定。什么叫“存储量”、是否包含副本、是否包含未完成的分片上传,这些定义不应该随价格变化。价格是输入,口径是契约;输入可以换,契约不能悄悄改。

什么时候不该自己做分层

如果数据量只有几 TB、访问模式也很简单,自己做生命周期脚本可能比直接用云厂商的规则更贵。脚本要维护、要测试、要处理失败重试,还要有人值班。省下的存储费可能还不够一个工程师半天的排期。

判断是否值得自建,可以看三个条件:存量是否足够大、分层规则是否频繁变化、厂商规则是否无法满足合规要求。三个条件都不成立时,优先用托管能力,把精力留给真正的业务问题。

一次“归档省了四成”的账单反转

「回声」的用户头像和历史封面图占了约 180 TB(案例假设值)。存储团队算了一笔账:热存储单价 120 分/GB/月,冷存储 12 分/GB/月,全部归档后每月能省约 19.4 万元,降幅 90%。为了保证“可接受”,他们保留了一个月的观察期,然后批量执行。

第一个月账单确实降了,存储费用少了 18 万。第二个月开始,账单出现了一个新条目:冷存储取回费 11 万,请求费 3 万。原因是头像在信息流里每天被读几百万次,虽然 CDN 挡住了一部分,但缓存过期后回源取回仍然走冷存储。冷存储的取回延迟还让头像加载变慢,前端团队只好加了一次预取,进一步放大了请求数。

第三个月,团队试图把头像迁回热存储,又产生了迁移流量和提前删除的补收费用。三个月合计,存储省了 54 万,取回和请求多花了 47 万,迁移和补收花了 9 万,净亏 2 万,还搭进去两个工程师的排期。

事故的根因不是冷存储不好,而是分层决策只看单价、不看访问模式。头像和高频封面图属于“存量不大、读取极频繁”的数据,应该留在热层,靠 CDN 和压缩降带宽;真正适合冷层的是访问次数极低的合规备份、历史原始素材和超过保留期的日志。

修复后的规则按“最近 30 天读取次数”分流:读取次数为 0 且超过 180 天的数据进冷层;读取次数低于每天 1 次但仍有查询需求的进温层;其余留在热层。规则上线前,先用一个月的访问日志做离线回放,估算取回费和请求费,再决定是否执行。这个回放不需要改生产,只需要把日志按层级单价重算一遍。

回放脚本要输出什么

回放脚本至少输出四行:按当前层级计算的月度成本、按目标层级计算的存储成本、目标层级下的取回与请求成本、迁移与提前删除的补收费用。四行相加,才是这次分层动作的真实账单变化。

只输出“存储单价下降 90%”是没有意义的,因为取回费和请求费可能远大于节省。回放还应该按访问频次分桶,展示哪些数据适合归档、哪些不适合。如果 80% 的存量集中在少数高频对象上,整体归档的收益会被这些对象拖垮。

回放结果要和实际账单做一次对账。如果回放算出的当前成本与账单偏差超过 10%,说明访问日志不完整、单价过期或副本数没有计入,先修数据再谈决策。

把单价和层级校验写进函数

配套代码 internal/cost/storage.go 不负责生命周期规则,也不读取访问日志。它只做两件事:按层级把 GB 换算成月度存储成本,按流量单价把 GB 换算成带宽成本。所有单价都是显式常量,方便在评审时逐项替换成合同价。

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

package cost

import "fmt"

// Tier 表示对象存储的访问层级。
type Tier string

const (
	Hot  Tier = "hot"
	Warm Tier = "warm"
	Cold Tier = "cold"
)

// 案例假设单价,单位:分/GB/月。
// 真实报价随区域、请求次数和最低保留期变化,上线前必须替换成合同价。
const (
	HotPricePerGB  int64 = 120
	WarmPricePerGB int64 = 45
	ColdPricePerGB int64 = 12
)

// MonthlyCost 按层级计算 gb 的月度存储成本。
// gb 为负数通常意味着统计口径或上游解析出错,直接报错比返回负成本安全。
func MonthlyCost(gb int64, t Tier) (int64, error) {
	if gb < 0 {
		return 0, fmt.Errorf("storage: gb must be non-negative, got %d", gb)
	}
	var price int64
	switch t {
	case Hot:
		price = HotPricePerGB
	case Warm:
		price = WarmPricePerGB
	case Cold:
		price = ColdPricePerGB
	default:
		return 0, fmt.Errorf("storage: unknown tier %q", t)
	}
	return gb * price, nil
}

// BandwidthCost 按“每 GB 流量单价”计算月度出流量成本。
// 存储是存量,带宽是流量:前者随保留期累积,后者每月重新开始。
func BandwidthCost(gb, perGB int64) (int64, error) {
	if gb < 0 {
		return 0, fmt.Errorf("bandwidth: gb must be non-negative, got %d", gb)
	}
	if perGB < 0 {
		return 0, fmt.Errorf("bandwidth: perGB must be non-negative, got %d", perGB)
	}
	return gb * perGB, nil
}

MonthlyCost 用 switch 显式列出三个层级,而不是用 map 查表。map 查表少写几行,但漏掉一个层级时只会得到零值,成本会静默变成 0;switch 的 default 分支会把未知层级直接变成错误。对于成本计算,静默归零比报错危险得多。

BandwidthCost 同时校验流量和单价。只校验流量是不够的:单价可能来自配置文件,配置解析失败时很容易得到 0 或负数。负数单价乘正流量会得到负成本,和退款混在一起后根本查不出来。

两个函数都没有做溢出检查。gb * price 的结果如果超过 int64 上限会回绕,但按当前量级,即使 10 亿 GB 乘 120 分也远小于上限。真正需要担心的是调用方把“字节”当成“GB”传进来,这会让结果放大 10 亿倍。单位应该在函数名和注释里写死,不要让调用方猜。

测试:未知层级、负数与零值

存储和带宽的测试要覆盖三类输入:正常换算、边界零值、明显错误。下面的测试把三种层级、零 GB、负 GB、未知层级、负单价都固定下来。

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

package cost

import "testing"

func TestMonthlyCostByTier(t *testing.T) {
	cases := []struct {
		gb   int64
		tier Tier
		want int64
	}{
		{100, Hot, 12000},
		{100, Warm, 4500},
		{100, Cold, 1200},
		{0, Cold, 0},
	}
	for _, c := range cases {
		got, err := MonthlyCost(c.gb, c.tier)
		if err != nil {
			t.Fatalf("MonthlyCost(%d, %s): %v", c.gb, c.tier, err)
		}
		if got != c.want {
			t.Fatalf("MonthlyCost(%d, %s) = %d, want %d", c.gb, c.tier, got, c.want)
		}
	}
}

func TestMonthlyCostRejectsNegativeGB(t *testing.T) {
	if _, err := MonthlyCost(-1, Hot); err == nil {
		t.Fatal("want error for negative gb")
	}
}

func TestMonthlyCostRejectsUnknownTier(t *testing.T) {
	if _, err := MonthlyCost(100, Tier("frozen")); err == nil {
		t.Fatal("want error for unknown tier")
	}
}

func TestBandwidthCost(t *testing.T) {
	got, err := BandwidthCost(250, 30)
	if err != nil {
		t.Fatalf("BandwidthCost(): %v", err)
	}
	if got != 7500 {
		t.Fatalf("BandwidthCost() = %d, want 7500", got)
	}
}

func TestBandwidthCostRejectsNegativeInputs(t *testing.T) {
	if _, err := BandwidthCost(-1, 30); err == nil {
		t.Fatal("want error for negative gb")
	}
	if _, err := BandwidthCost(10, -30); err == nil {
		t.Fatal("want error for negative perGB")
	}
}

TestMonthlyCostByTier 用表驱动覆盖三层和零 GB。零 GB 返回 0 是合法结果,新桶还没有数据时不应该报错;但零 GB 和“层级未知”必须区分开,前者是合法业务状态,后者是配置错误。

TestMonthlyCostRejectsUnknownTier 用 Tier("frozen") 而不是随便传一个空字符串。空字符串在真实系统里可能是“未设置”,而 frozen 更像一个拼写错误的新层级,能更清楚地表达“未知值必须失败”。

测试没有覆盖取回费和请求费。这两项依赖访问日志和厂商的阶梯计价,不适合塞进一个纯函数。更合适的做法是单独写一个回放脚本,用真实访问日志乘单价,输出预估账单,再和实际账单对比。

运行命令与真实输出

在仓库根目录执行:

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

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

=== RUN   TestMonthlyCostByTier
--- PASS: TestMonthlyCostByTier (0.00s)
=== RUN   TestMonthlyCostRejectsNegativeGB
--- PASS: TestMonthlyCostRejectsNegativeGB (0.00s)
=== RUN   TestMonthlyCostRejectsUnknownTier
--- PASS: TestMonthlyCostRejectsUnknownTier (0.00s)
=== RUN   TestBandwidthCost
--- PASS: TestBandwidthCost (0.00s)
=== RUN   TestBandwidthCostRejectsNegativeInputs
--- PASS: TestBandwidthCostRejectsNegativeInputs (0.00s)
PASS
ok  	webapp/internal/cost	0.274s

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

小结

存储和带宽都按 GB 计价,但一个是存量乘时间,一个是流量乘次数。把两者混在一起看,会得出“各占一半、随便优化一个”的结论;拆开看,才会发现存储的第一变量是保留期和层级,带宽的第一变量是峰值和回源比例。

分层决策必须先看访问模式,再看单价。冷存储省下的是月度存储费,付出的是取回费、请求费、延迟和提前删除的补收费用。一个负责任的归档方案,应该在执行前用访问日志做一次离线回放,把四类费用一起算进去。

下一节讨论闲置回收与预算机制:知道了成本结构,还需要有人对“这条链路该不该继续花钱”负责。

阅读导航:上一节:5.1 单位经济模型 · 下一节:5.3 闲置回收与预算机制 。

常见错误与边界

  • 把存储 GB 和带宽 GB 当成同一种资源。前者按存量乘月计费,后者按流出量计费,优化手段和节奏完全不同。
  • 只比较层级单价,不算取回费和请求费。低频数据归档可能省钱,高频数据归档几乎一定亏钱。
  • 忽略最低保留期和提前删除补收。归档规则上线前,要确认数据在保留期内不会被业务删除。
  • 用平均带宽做容量规划。账单按峰值或 95 分位计费时,平均值会严重低估成本。
  • 让缓存键包含时间戳、随机数或完整鉴权参数。每个请求都变成新键,命中率归零,回源流量成倍上升。
  • 开启版本控制和跨区域复制却不计入容量规划。副本数和旧版本会让存量远超业务方以为的文件大小。
  • 把字节当 GB 传进成本函数。单位错误会放大 10 亿倍,且结果看起来仍然“像一个金额”。
  • 用一次归档的节省额证明方案成功,却不看后续三个月的取回和迁移账单。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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