冷热分离
“光影”上线第 4 个月的一天早上,我收到一封阿里云的月度账单邮件。随手点开一看——存储费 380 元。
380 块?我们才 8000 个用户、5 万张图片,怎么就 380 了?
我打开 OSS 控制台一看,存储量已经飙到 2.1 TB。原因很简单:用户每上传一张 5MB 的原图,我们会生成 5 张缩略图(大/中/小/WebP/AVIF),加上原图本身,一张图片就占了将近 8MB 的存储空间。
但更让我不安的不是这些——而是这些存储的利用率。
我写了一个脚本,分析过去 30 天所有图片的访问日志:
20% 的图片贡献了 84.3% 的流量。
更极端的是,前 1% 的图片(520 张)贡献了 42% 的流量——这些都是热门摄影师的作品、首页推荐图和社区活动中的照片。
而底部的 60% 图片(31,200 张),在 30 天内一次都没被访问过。它们就静静地躺在 OSS 上,每个月产生标准存储的费用。
42.7% 的图片是僵尸图片,30 天内无人问津,却在用最贵的标准存储。
这就是冷热分离的价值——把钱花在刀刃上。
OSS 提供了三种存储类型,价格差距巨大:
存储类型 价格(元/GB/月) 适用场景 读取延迟
───────────────────────────────────────────────────────────────
标准存储 0.12 频繁访问(热数据) 毫秒级
低频存储 0.08 偶尔访问(温数据) 毫秒级
归档存储 0.03 极少访问(冷数据) 1~5 分钟(需解冻)
冷归档存储 0.01 合规归档 1~12 小时(需解冻)
我的分层策略:
接下来是核心——自动分析访问频率并迁移存储层级的定时任务。
这里有个细节值得注意:缩略图不应该跟原图走同一套分层规则。
存储费直接砍掉 45%! 从 30.48 元降到 16.63 元。
冷数据归档后,如果用户突然想看一张老照片怎么办?
前端配合:解冻等待体验
归档存储有个绕不开的特性:对象在归档状态时不能直接读。用户点开一张 200 天没访问过的老照片,请求会被存储层拒绝。
这意味着前端必须能识别”这不是图片坏了,是这张图在解冻途中”,否则用户看到的就是一个破图图标。
用户点击老照片
│
▼
请求图片 → 存储层返回归档状态错误(403 + 归档错误码)
│
├─ 前端识别到归档状态 ──→ 展示"正在恢复中"的占位态
│ │
│ ▼
│ 后端发起解冻请求
│ │
│ ▼
│ 解冻是分钟级操作,不在前端等待
│ │
│ ▼
│ 解冻完成后通知用户(站内信 / 推送)
│
└─ 其他错误 ──→ 展示真正的失败态
前端只需要做三件事:识别状态、展示占位、发起解冻。不要在前端轮询等待解冻完成——解冻是分钟级的,让用户盯着转圈几分钟是糟糕的体验。正确做法是”发起 + 通知”:页面提示”这张老照片正在恢复,稍后会通知你”,用户可以去干别的。
/** 归档对象在解冻前无法读取,会返回 403 + 归档类错误码。 */
const ARCHIVE_ERROR_CODES = new Set(['ArchiveObjectNotFound', 'InvalidObjectState']);
export type ImageState = 'ready' | 'restoring' | 'failed';
export async function loadImage(url: string, signal?: AbortSignal): Promise<ImageState> {
const response = await fetch(url, { signal });
if (response.ok) return 'ready';
const body = await response.json().catch(() => ({}));
const code = String(body?.code ?? '');
if (ARCHIVE_ERROR_CODES.has(code)) {
// 只负责发起解冻,不负责等待:解冻是分钟级操作,由后端在完成后通知用户
await fetch('/api/images/restore', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url }),
});
return 'restoring';
}
return 'failed';
}
三个必须注意的细节:
- 靠错误码判断,不要靠超时判断。超时分不清”网络慢”和”正在解冻”,而错误码是确定的语义。
- 解冻请求要幂等。用户可能连点,或者多个页面同时请求同一张图,后端必须保证重复发起解冻不会报错、也不会重复计费。
- CDN 缓存可能让错误也被缓存。归档状态的 403 如果被边缘缓存下来,用户会反复拿到错误。后端在触发解冻后应立即刷新该 URL 的缓存,并且让归档错误响应带上”不缓存”的指令。
冷热分析是一个定时任务,每天凌晨运行:
每天 03:00(错开整点,避开整点的账单和批处理高峰)
│
▼
1. 拉取过去 30 天的 CDN 访问日志
│ 从日志服务里查,或从 OSS 上按天下载访问日志文件
▼
2. 统计每张图片的访问次数
│ 按图片 ID 聚合,得到 hot_score
▼
3. 判定冷热,决定目标存储类型
│ 热 → Standard;温 → IA;冷 → Archive
▼
4. 执行迁移(分批,每批 1000 个对象)
│ 同一时间只允许一个实例在跑
▼
5. 写迁移记录,并更新图片表的 storage_class 字段
任务本身用 Python 写,调度交给 K8s 的 CronJob(或者一台机器上的 crontab,逻辑完全一样):
apiVersion: batch/v1
kind: CronJob
metadata:
name: image-storage-tiering
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid # 上一次没跑完就不启动下一次
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
containers:
- name: tiering
image: registry.example.com/image-tools:1.4.2
command: ["python", "-m", "tools.storage_tiering"]
env:
- name: OSS_ENDPOINT
value: oss-cn-beijing.aliyuncs.com
- name: OSS_BUCKET
value: guangying-img
concurrencyPolicy: Forbid 是必须的:迁移任务如果跑两个实例,同一批对象会被处理两次,白白消耗请求费用,还可能把对象在两层之间来回搬。
迁移的核心逻辑在 ossutil 和 SDK 之间选一个。单个对象用 ossutil 最省事,因为同名覆盖自身就能改变存储类型:
ossutil cp oss://guangying-img/originals/2024/03/a.jpg oss://guangying-img/originals/2024/03/a.jpg --storage-class Archive
但逐个对象执行命令太慢,也不方便重试。所以实际生产里我用的是 SDK 的批量接口,并且把批次切开:
import oss2
BUCKET_NAME = "guangying-img"
ENDPOINT = "https://oss-cn-beijing.aliyuncs.com"
BATCH_SIZE = 1000
def change_storage_class(bucket: oss2.Bucket, key: str, storage_class: str) -> None:
"""通过同名 CopyObject 变更对象的存储类型。
OSS 的存储类型是对象的属性,变更方式是把它复制到自身,
并在这次拷贝请求里带上 x-oss-storage-class。
"""
headers = {"x-oss-storage-class": storage_class}
bucket.copy_object(key, key, headers=headers)
三个必须在设计时就想清楚的问题:
- 迁移是有成本的,而且成本不低。每次 CopyObject 都是一次完整的对象读写请求——对象多大,就产生多大的请求次数和流量。批量迁移前先算一遍:10 万个对象、每个平均 3 MB,一次迁移就是 10 万次写请求加上 300 GB 的读写流量。这笔钱比省下来的存储费还多的时候,迁移就不划算。
- 归档不是”改个标签”那么简单。对象迁到归档存储后不能直接读,必须先解冻。归档存储的解冻在 1 到 5 分钟,冷归档要 1 到 12 小时。迁移到归档之前,要先确认这张图近期确实不会再被访问——否则用户点开就是几分钟的等待。
- 分层要单向下沉,不要来回搬。低频和归档都有最短存储时间限制,放进去没几天就迁走,会按满周期计费,看起来降本实际更贵。所以规则必须设计成单向下沉:热 → 温 → 冷,不允许冷 → 温。
最后是任务本身的保护措施:
| 保护项 | 做法 | 防止什么 |
|---|---|---|
| 互斥 | concurrencyPolicy: Forbid 加数据库里的任务锁 | 两个实例同时迁移同一批对象 |
| 分批 | 每批 1000 个对象,批间 sleep 一下 | 把 OSS 和数据库打满 |
| 失败记录 | 失败的 key 写进失败表,下次任务先重试 | 静默丢失对象 |
| 限速 | 每分钟处理的请求数设上限 | 迁移任务本身造成源站抖动 |
| 灰度 | 第一天只处理 5% 的对象,观察无误再放开 | 一次任务写错规则,批量搬错对象 |
发现内容有问题?
告诉我们哪里需要改进。
