成本分析实战
“光影”上线第 5 个月,用户量到了 12000,我决定做一次彻底的成本审计。
不是看阿里云的账单总额——那个数字太粗糙了。我要把每一分钱拆开,看看到底花在了哪里。
等等——服务器占了 81%?
仔细一看,处理服务 4 台 4C8G 实例就花了 1200 元/月。加上上传和审核服务 5 台,还有队列和监控,总计 4030 元。
但 CDN 才 795 元?这跟我最初的直觉不一样——我以为 CDN 会是最大开支。
搞清楚钱花在哪里后,我制定了三条降本路径:
路径 1:格式转换(减少体积 → 减少 CDN 流量)
路径 2:缓存优化(提高命中率 → 减少回源)
路径 3:存储分层 + 生命周期(减少存储费和垃圾堆积)
路径 1:格式转换——体积砍半
先看最容易算清楚的一条。把同一张图换成编码效率更高的格式,体积下降会同时作用于 CDN 出网流量和回源流量——这两项是图片系统里最贵的两项。
用成本模型表达就很直观。设月请求数为 、边缘命中率为 、平均单图体积为 :
减半,CDN 流量费和回源流量费同时减半。这是格式转换在所有优化手段里性价比最高的原因。
具体怎么做:
- 原图不动,只转派生图。原图是事实来源,格式可以转换但数据要保留。派生图全部按目标格式生成,这一步同时解决了存储和流量。
- 异步批量转换。AVIF 编码比 WebP 慢得多,放在上传请求里同步做会让用户干等。做法是上传事件进队列,处理服务异步产出 WebP 和 AVIF 两个版本,JPEG 留作兜底。
- 格式写进 URL。
xxx_400.webp和xxx_400.jpg是两个不同的对象,缓存天然分开,不需要依赖Vary头去分裂缓存。 - 按
Accept协商。边缘节点在有 AVIF 版本时优先返回 AVIF,老浏览器退回 WebP 或 JPEG。
效果对照(取自本节末尾的综合对比表):
CDN 流量费 720.00 元 → 307.96 元 节省 412.04 元
措施:AVIF 转换
四个必须避开的坑:
- 只转派生图、不清理旧格式。历史派生图不会自己消失,要配套清理任务,否则存储省下来了、流量没省下来。
- 重复转码。已经是 WebP 的对象再转一次,体积不变但画质可能二次损失。要在处理入口按格式和标记跳过。
- 参数不一致。同一个场景下不同批次的图用了不同质量因子,体积分布会出现无法解释的毛刺。压缩参数必须来自同一份规则表。
- 只看平均值。平均体积降了一半,但 P95 体积没动,说明大图那部分没被覆盖(比如原图直出没转)。验收要看分位数。
衡量这条路径是否真的生效,有两个口径:同场景对比转换前后的体积分布(同批图片、同一尺寸档、同一质量档),以及流量结构里现代格式的占比。前者验证压缩效果,后者验证覆盖率——只有平均值好看、占比不涨,说明大部分流量根本没走转换后的版本。
路径 2:缓存优化——命中率从 90% 提升到 96%
缓存优化直接作用于回源流量。同一套模型:
命中率从 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:存储分层 + 生命周期
存储费是所有成本项里唯一只增不减的一项:流量每天重新结算,存储却会一直累积。这条路径要同时解决两个问题——把贵的容量换成便宜的,和让不该存在的容量消失。
存储费的模型很简单:
是各类对象的容量, 是它所在存储类型的单价。降低它有两条路:把 挪到单价更低的层级(分层),或者让 直接归零(生命周期)。
具体做法:
| 对象类型 | 分层依据 | 目标状态 | 保留理由 |
|---|---|---|---|
| 原图 | 访问热度 + 业务语义 | 热:标准;温:低频;冷:归档 | 事实来源,可重建派生图但不可替代自身 |
| 派生图 | 是否仍被访问、能否重建 | 长期无访问即清理 | 可以按当前策略重新生成 |
| 临时分片 | 上传是否完成 | 短周期强制清理 | 未完成的分片是纯垃圾 |
| 用户头像 | 业务语义 | 永不自动归档 | 头像是用户资料的一部分 |
| 已下架内容 | 业务状态 | 进入归档或按合规要求删除 | 涉及合同与合规留痕 |
效果对照(取自本节末尾的综合对比表):
存储费 324.00 元 → 97.50 元 节省 226.50 元
措施:分层 + 清理
五个必须提前想清楚的坑:
- 清理派生图之前要确认能重建。如果重建依赖的压缩参数已经变更,或者原始工具链下线了,清理后就是永久的 404。清理规则要和”派生图可重建”这个前提绑定。
- 低频和归档有最短存储时长。对象放进去没几天就迁走,可能按满周期计费,看起来降本实际更贵。分层规则要设计成”单向下沉”,避免来回搬运。
- 归档对象不能直接读。所有读取路径都要处理”需要先解冻”的状态,否则用户会看到坏图。
- 不能只按文件年龄粗暴处理。同样是 200 天没访问的图,头像、付费作品、已下架商品的处理方式完全不同。规则要能读到业务语义。
- 批量迁移本身有成本。一次迁移几十万对象会产生可观的请求费用,迁移要分批、放低峰、并监控失败重试。
衡量这条路径有两个口径,都要看趋势而不是绝对值:存储量增速与业务量增速的比值,以及存储构成随时间的变化(标准 / 低频 / 归档各自占比)。前者持续大于 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%,而存储是唯一只增不减的一项——这两条各自对应一条降本路径。
顺序不能反。先去优化占比百分之几的项,不管优化得多漂亮,总账上都看不出来。
心法二:优化的对象是”流量结构”,不是”文件大小”
“把图片压小一点”听起来像个技术细节,但它在成本上是有杠杆的。
用本节开头的模型:设月请求数为 、边缘命中率为 、平均单图体积为 ,
减半,CDN 流量费和回源流量费同时减半——因为两者都正比于单图体积。这就是为什么”格式转换”排在三条路径的第一位:它一次改动同时命中了两项最贵的成本。
反过来,命中率的杠杆作用要小得多: 从 0.90 提到 0.96,只影响 这一项,而且是在它本来就不大的基数上。
结论是:优先去做那些能同时作用于多个成本项的改动,而不是逐项抠。
心法三:每次优化都要留下可复现的口径
这一条是血泪教训。我第一次做格式转换时,只记了”优化前平均 78 KB,优化后 52 KB”,三个月后想复盘到底省了多少钱,发现当时没记请求量、没记命中率、也没记流量结构——同样的 52 KB,配上不同的请求数,是完全不同的账单。
所以从那次之后,我给每次优化都固定留三样东西:
| 留什么 | 为什么 |
|---|---|
| 对比口径 | 同批图片、同一尺寸档、同一质量档,不能拿”上个月的平均值”和”这个月的新算法”比 |
| 验收指标 | 用分位数,不用平均数;用绝对值,不用百分比 |
| 回滚条件 | 事先写好”什么情况下认为这次优化失败” |
另外,每个成本项都要有一个”单位成本”的口径——每张图的成本、每 GB 流量的成本、每个用户的成本。绝对金额会随着业务量一起涨,看起来像”优化无效”,但单位成本降了才说明优化是真的生效了。
最后一句总结:降本不是一次性动作,是一套持续的口径。账单每月都会来一次,如果没有可比的口径,每个月都只能从零开始分析一次。
发现内容有问题?
告诉我们哪里需要改进。
