冷热分离

账单上的一记闷棍

“光影”上线第 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';
}

三个必须注意的细节:

  1. 靠错误码判断,不要靠超时判断。超时分不清”网络慢”和”正在解冻”,而错误码是确定的语义。
  2. 解冻请求要幂等。用户可能连点,或者多个页面同时请求同一张图,后端必须保证重复发起解冻不会报错、也不会重复计费。
  3. 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)

三个必须在设计时就想清楚的问题:

  1. 迁移是有成本的,而且成本不低。每次 CopyObject 都是一次完整的对象读写请求——对象多大,就产生多大的请求次数和流量。批量迁移前先算一遍:10 万个对象、每个平均 3 MB,一次迁移就是 10 万次写请求加上 300 GB 的读写流量。这笔钱比省下来的存储费还多的时候,迁移就不划算。
  2. 归档不是”改个标签”那么简单。对象迁到归档存储后不能直接读,必须先解冻。归档存储的解冻在 1 到 5 分钟,冷归档要 1 到 12 小时。迁移到归档之前,要先确认这张图近期确实不会再被访问——否则用户点开就是几分钟的等待。
  3. 分层要单向下沉,不要来回搬。低频和归档都有最短存储时间限制,放进去没几天就迁走,会按满周期计费,看起来降本实际更贵。所以规则必须设计成单向下沉:热 → 温 → 冷,不允许冷 → 温。

最后是任务本身的保护措施:

保护项做法防止什么
互斥concurrencyPolicy: Forbid 加数据库里的任务锁两个实例同时迁移同一批对象
分批每批 1000 个对象,批间 sleep 一下把 OSS 和数据库打满
失败记录失败的 key 写进失败表,下次任务先重试静默丢失对象
限速每分钟处理的请求数设上限迁移任务本身造成源站抖动
灰度第一天只处理 5% 的对象,观察无误再放开一次任务写错规则,批量搬错对象

发现内容有问题?

告诉我们哪里需要改进。