成本分析实战

一笔算到肉的成本账

“光影”上线第 5 个月,用户量到了 12000,我决定做一次彻底的成本审计。

不是看阿里云的账单总额——那个数字太粗糙了。我要把每一分钱拆开,看看到底花在了哪里。

等等——服务器占了 81%?

仔细一看,处理服务 4 台 4C8G 实例就花了 1200 元/月。加上上传和审核服务 5 台,还有队列和监控,总计 4030 元。

但 CDN 才 795 元?这跟我最初的直觉不一样——我以为 CDN 会是最大开支。

三条降本路径

搞清楚钱花在哪里后,我制定了三条降本路径:

路径 1:格式转换(减少体积 → 减少 CDN 流量)
路径 2:缓存优化(提高命中率 → 减少回源)
路径 3:存储分层 + 生命周期(减少存储费和垃圾堆积)

路径 1:格式转换——体积砍半

先看最容易算清楚的一条。把同一张图换成编码效率更高的格式,体积下降会同时作用于 CDN 出网流量和回源流量——这两项是图片系统里最贵的两项。

用成本模型表达就很直观。设月请求数为 NN、边缘命中率为 hh、平均单图体积为 Sˉ\bar{S}:

Ccdn=N⋅Sˉ⋅pedge,Corigin=N⋅(1−h)⋅Sˉ⋅poriginC_{cdn} = N \cdot \bar{S} \cdot p_{edge}, \qquad C_{origin} = N \cdot (1 - h) \cdot \bar{S} \cdot p_{origin}

Sˉ\bar{S} 减半,CDN 流量费和回源流量费同时减半。这是格式转换在所有优化手段里性价比最高的原因。

具体怎么做:

  1. 原图不动,只转派生图。原图是事实来源,格式可以转换但数据要保留。派生图全部按目标格式生成,这一步同时解决了存储和流量。
  2. 异步批量转换。AVIF 编码比 WebP 慢得多,放在上传请求里同步做会让用户干等。做法是上传事件进队列,处理服务异步产出 WebP 和 AVIF 两个版本,JPEG 留作兜底。
  3. 格式写进 URL。xxx_400.webp 和 xxx_400.jpg 是两个不同的对象,缓存天然分开,不需要依赖 Vary 头去分裂缓存。
  4. 按 Accept 协商。边缘节点在有 AVIF 版本时优先返回 AVIF,老浏览器退回 WebP 或 JPEG。

效果对照(取自本节末尾的综合对比表):

CDN 流量费   720.00 元  →  307.96 元      节省 412.04 元
措施:AVIF 转换

四个必须避开的坑:

  • 只转派生图、不清理旧格式。历史派生图不会自己消失,要配套清理任务,否则存储省下来了、流量没省下来。
  • 重复转码。已经是 WebP 的对象再转一次,体积不变但画质可能二次损失。要在处理入口按格式和标记跳过。
  • 参数不一致。同一个场景下不同批次的图用了不同质量因子,体积分布会出现无法解释的毛刺。压缩参数必须来自同一份规则表。
  • 只看平均值。平均体积降了一半,但 P95 体积没动,说明大图那部分没被覆盖(比如原图直出没转)。验收要看分位数。

衡量这条路径是否真的生效,有两个口径:同场景对比转换前后的体积分布(同批图片、同一尺寸档、同一质量档),以及流量结构里现代格式的占比。前者验证压缩效果,后者验证覆盖率——只有平均值好看、占比不涨,说明大部分流量根本没走转换后的版本。

路径 2:缓存优化——命中率从 90% 提升到 96%

缓存优化直接作用于回源流量。同一套模型:

Corigin=N⋅(1−h)⋅Sˉ⋅poriginC_{origin} = N \cdot (1 - h) \cdot \bar{S} \cdot p_{origin}

命中率从 0.90 提到 0.96,未命中比例从 10% 降到 4%,回源流量减少了六成。同时首字节延迟也整体下降,因为绝大多数请求不再需要走完整回源链路。

具体做了六件事:

手段做什么针对什么问题
缓存键稳定只让影响图片内容的参数进 key,时间戳、签名、追踪参数不进参数抖动导致的命中率崩塌
TTL 分级按图片类型和热度设不同过期时间热门图和长尾图用同一个 TTL,两头不讨好
热点预热运营活动、首页推荐位在生效前主动推送第一波流量集体触发回源
参数白名单限定允许的尺寸、格式、质量组合恶意请求造出海量变体,每个只访问一次
过期先返回旧内容配合 stale-while-revalidate过期瞬间的回源尖峰
主动刷新只用于审核召回、内容替换等必须立刻生效的场景等待自然过期导致用户看到旧图

效果对照(取自本节末尾的综合对比表):

回源流量费   150.00 元  →  60.00 元      节省 90.00 元
措施:缓存优化

四个容易骗过自己的地方:

  • 命中率可以靠”不缓存错误响应”刷上去。所以必须同时看字节命中率(命中的响应占总字节的比例):请求数命中了但每次只返回几 KB 的小图,对回源流量的改善远小于数字看上去。
  • 命中率必须按维度拆开。整体 96% 可能掩盖着长尾 URL 只有 60%。至少要按 URL 类型、格式、地域三个维度看。
  • 缓存了不该缓存的,命中率越高风险越大。带签名的原图被缓存,等于绕过了鉴权。
  • 刷新不及时。命中率上去了,但审核召回的图要等很久才从边缘消失——这时候用户投诉的不是成本,是”违规内容还在”。

衡量这条路径的三个口径:回源流量费的绝对值、回源请求数、回源产生的 P99 延迟。只看第一个,会把”把大文件都推给源站”这种假优化也当成成绩。

路径 3:存储分层 + 生命周期

存储费是所有成本项里唯一只增不减的一项:流量每天重新结算,存储却会一直累积。这条路径要同时解决两个问题——把贵的容量换成便宜的,和让不该存在的容量消失。

存储费的模型很简单:

Cstorage=T⋅∑iVi⋅ptier(i)C_{storage} = T \cdot \sum_i V_i \cdot p_{tier(i)}

ViV_i 是各类对象的容量,ptier(i)p_{tier(i)} 是它所在存储类型的单价。降低它有两条路:把 ViV_i 挪到单价更低的层级(分层),或者让 ViV_i 直接归零(生命周期)。

具体做法:

对象类型分层依据目标状态保留理由
原图访问热度 + 业务语义热:标准;温:低频;冷:归档事实来源,可重建派生图但不可替代自身
派生图是否仍被访问、能否重建长期无访问即清理可以按当前策略重新生成
临时分片上传是否完成短周期强制清理未完成的分片是纯垃圾
用户头像业务语义永不自动归档头像是用户资料的一部分
已下架内容业务状态进入归档或按合规要求删除涉及合同与合规留痕

效果对照(取自本节末尾的综合对比表):

存储费   324.00 元  →  97.50 元      节省 226.50 元
措施:分层 + 清理

五个必须提前想清楚的坑:

  1. 清理派生图之前要确认能重建。如果重建依赖的压缩参数已经变更,或者原始工具链下线了,清理后就是永久的 404。清理规则要和”派生图可重建”这个前提绑定。
  2. 低频和归档有最短存储时长。对象放进去没几天就迁走,可能按满周期计费,看起来降本实际更贵。分层规则要设计成”单向下沉”,避免来回搬运。
  3. 归档对象不能直接读。所有读取路径都要处理”需要先解冻”的状态,否则用户会看到坏图。
  4. 不能只按文件年龄粗暴处理。同样是 200 天没访问的图,头像、付费作品、已下架商品的处理方式完全不同。规则要能读到业务语义。
  5. 批量迁移本身有成本。一次迁移几十万对象会产生可观的请求费用,迁移要分批、放低峰、并监控失败重试。

衡量这条路径有两个口径,都要看趋势而不是绝对值:存储量增速与业务量增速的比值,以及存储构成随时间的变化(标准 / 低频 / 归档各自占比)。前者持续大于 1,说明有垃圾在堆积;后者能看出分层是否真的在起作用——如果标准存储的占比一直不降,说明分层规则从来没被触发过。

综合降本效果

三条路径叠加后的效果:

优化前后对比表

┌────────────────┬───────────────┬───────────────┬──────────┬──────────┐
│     成本项     │    优化前     │    优化后     │   节省   │  降本措施 │
├────────────────┼───────────────┼───────────────┼──────────┼──────────┤
│ 存储费         │   324.00 元   │    97.50 元   │ 226.50元 │ 分层+清理 │
│ CDN 流量费     │   720.00 元   │   307.96 元   │ 412.04元 │ AVIF转换 │
│ 回源流量费     │   150.00 元   │    60.00 元   │  90.00元 │ 缓存优化 │
│ 处理费         │     0.88 元   │     0.88 元   │   0.00元 │    -     │
│ 请求费         │     1.14 元   │     1.14 元   │   0.00元 │    -     │
│ 审核费         │     0.08 元   │     0.08 元   │   0.00元 │    -     │
│ 服务器         │  4030.00 元   │  3430.00 元   │ 600.00元 │ 缩容2台  │
├────────────────┼───────────────┼───────────────┼──────────┼──────────┤
│ 合计           │  5226.10 元   │  3897.56 元   │1328.54元 │          │
└────────────────┴───────────────┴───────────────┴──────────┴──────────┘

嗯?省了 1328 元,但离 800 元的月度总成本还远。因为服务器占了 88%。

服务器降本的终极方案

服务器才是真正的大头。我重新审视了服务器成本:

最终方案:弹性伸缩 + 混合部署

说实话,1327 元已经从 5226 元砍掉了 75%。但要到 800 元,还需要:

现在的成本结构(优化后 3897.56 元)

存储费        97.50 元    ██
CDN 流量费   307.96 元    █████
回源流量费     60.00 元    █
服务器      3430.00 元    ████████████████████████████████████████████████████████
其他           2.10 元

服务器占了 88%,其余几项加起来不到 10%。 继续在 CDN 参数上抠那几十元,收益和投入完全不成比例。真正的突破口只有两个:让服务器按需付费,以及把该跑的活挪到更便宜的地方。

第一步是把服务器拆开看。4030 元里,4 台处理服务是 1200 元,5 台上传和审核服务、还有队列、数据库、负载均衡和监控——后几项是固定成本,机器停了也要付。

服务器项现在优化后手段
图片处理600 元(2 台 4C8G 常驻)300 元弹性伸缩组,队列积压驱动,月均 1 台
上传 + 审核1500 元(5 台 4C8G)120 元合并为 1 台 2C4G 复用,审核本就异步
队列 + 监控 + 负载均衡1330 元30 元云消息队列按量付费、Prometheus 自建、Nginx 自建替代 SLB
合计3430 元450 元

“上传 + 审核合并为 1 台 2C4G”这一条要解释一下:上传服务做的是签名和分片协调,几乎不吃 CPU;审核服务是异步任务,峰值高但平均利用率低。两者放在同一台上跑,比开五台 4C8G 便宜得多。审核的突发峰值用 FaaS 承接——队列积压告警的时候,把任务甩给按量付费的函数计算。

第二步是弹性伸缩。关键在于伸缩的触发指标不能只有 CPU:

# 以队列积压量作为扩缩容指标
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: image-worker
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: image-worker
  minReplicas: 1
  maxReplicas: 8
  metrics:
    - type: External
      external:
        metric:
          name: rabbitmq_queue_messages_ready
          selector:
            matchLabels:
              queue: image.process
        target:
          type: AverageValue
          averageValue: "200"
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
      policies:
        - type: Pods
          value: 4
          periodSeconds: 30
    scaleDown:
      # 缩容要慢,扩容要快
      stabilizationWindowSeconds: 600
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60

为什么用队列积压而不是 CPU?因为处理 Worker 大部分时间在等 I/O——下载原图、等 OSS 写回。CPU 使用率可能只有三成,但队列已经堆了一万条任务,这时候必须扩。反过来,队列空了但 CPU 还没降下来(因为正在处理的任务还没做完),这时候要缩。

缩容窗口设为 10 分钟,是为了让 Worker 有时间把手上的任务做完再退出。直接把副本数砍掉一半,任务会在处理到一半的时候被杀掉,然后被重新投递——看起来是缩容了,实际是把 CPU 又烧了一遍。

第三步是混合部署。不是所有任务都值得跑在常驻机器上:

工作负载放在哪理由
上传签名、分片协调常驻小机器全天有流量,延迟敏感
审核异步任务常驻 + FaaS 兜底峰值高、均值低
缩略图生成、格式转换弹性伸缩组批量、可延迟
历史图片批量重处理竞价实例可中断、纯离线、错了重来
定时任务(冷热分析、清理)竞价实例只在凌晨跑几分钟

竞价实例(抢占式实例)能把成本压到折价水平,但会被随时回收。所以只适合”中断了可以重来”的活:批量重处理、离线统计。跑一半被回收,下次任务从断点续跑就行。上传签名、审核这种对外接口,绝不能放在竞价实例上。

把这三件事做完,总账变成这样:

成本项优化前现状目标
存储费324.00 元97.50 元60.00 元
CDN 流量费720.00 元307.96 元250.00 元
回源流量费150.00 元60.00 元40.00 元
处理 / 请求 / 审核费2.10 元2.10 元2.10 元
服务器4030.00 元3430.00 元450.00 元
合计5226.10 元3897.56 元802.10 元

目标列里的存储费还能再降 37.50 元,来自”缩略图按内容类型分级归档加派生图清理”;CDN 流量再降 57.96 元,来自”命中率继续提升加长尾 URL 参数收敛”。两项加起来,剩下的 2.10 元按次计费的杂项费用不用管。

最后留一句提醒:弹性伸缩优化的是账,不是风险。月均 1 台的假设建立在”上传量在可预测的区间内”这个前提上。如果某天来了一个活动,流量是平时的十倍,弹性伸缩会临时扩到 8 台,那个月的账单会比预计高很多。所以弹性伸缩必须配三样东西:扩容上限、成本告警、以及活动前的手动预扩容。

成本优化的核心心法

经过这一轮完整的成本分析,我总结了三条心法:

心法一:先算账,再优化

成本优化最常见的失败方式,是从”我知道这个东西能优化”开始,而不是从”钱花在哪里”开始。

我这次的第一步不是看 CDN 参数,而是把 5226.10 元拆成六个成本项,然后问三个问题:哪一项占大头、哪一项只增不减、哪一项和用户体验直接挂钩。答案是服务器占 81%,而存储是唯一只增不减的一项——这两条各自对应一条降本路径。

顺序不能反。先去优化占比百分之几的项,不管优化得多漂亮,总账上都看不出来。

心法二:优化的对象是”流量结构”,不是”文件大小”

“把图片压小一点”听起来像个技术细节,但它在成本上是有杠杆的。

用本节开头的模型:设月请求数为 NN、边缘命中率为 hh、平均单图体积为 Sˉ\bar{S},

Ccdn=N⋅Sˉ⋅pedge,Corigin=N⋅(1−h)⋅Sˉ⋅poriginC_{cdn} = N \cdot \bar{S} \cdot p_{edge}, \qquad C_{origin} = N \cdot (1 - h) \cdot \bar{S} \cdot p_{origin}

Sˉ\bar{S} 减半,CDN 流量费和回源流量费同时减半——因为两者都正比于单图体积。这就是为什么”格式转换”排在三条路径的第一位:它一次改动同时命中了两项最贵的成本。

反过来,命中率的杠杆作用要小得多:hh 从 0.90 提到 0.96,只影响 CoriginC_{origin} 这一项,而且是在它本来就不大的基数上。

结论是:优先去做那些能同时作用于多个成本项的改动,而不是逐项抠。

心法三:每次优化都要留下可复现的口径

这一条是血泪教训。我第一次做格式转换时,只记了”优化前平均 78 KB,优化后 52 KB”,三个月后想复盘到底省了多少钱,发现当时没记请求量、没记命中率、也没记流量结构——同样的 52 KB,配上不同的请求数,是完全不同的账单。

所以从那次之后,我给每次优化都固定留三样东西:

留什么为什么
对比口径同批图片、同一尺寸档、同一质量档,不能拿”上个月的平均值”和”这个月的新算法”比
验收指标用分位数,不用平均数;用绝对值,不用百分比
回滚条件事先写好”什么情况下认为这次优化失败”

另外,每个成本项都要有一个”单位成本”的口径——每张图的成本、每 GB 流量的成本、每个用户的成本。绝对金额会随着业务量一起涨,看起来像”优化无效”,但单位成本降了才说明优化是真的生效了。

最后一句总结:降本不是一次性动作,是一套持续的口径。账单每月都会来一次,如果没有可比的口径,每个月都只能从零开始分析一次。

发现内容有问题?

告诉我们哪里需要改进。