同样是 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 亿倍,且结果看起来仍然“像一个金额”。
- 用一次归档的节省额证明方案成功,却不看后续三个月的取回和迁移账单。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。