数据生命周期管理

凌晨两点的告警短信

那是”光影”上线第 3 个月的一个凌晨,手机疯狂震动:

Inbox
From:阿里云监控
To:我
Time:凌晨 02:15
Subject: [告警] OSS 存储容量超过阈值
  • 当前容量:1.89 TB / 告警阈值:1.50 TB
  • 增长率:+12 GB/天
  • 预计 30 天后达到 2.25 TB
  • 请及时处理!

12 GB/天?我仔细查了一下,发现存储增长的主要来源并不是用户上传的新图片——而是垃圾文件:

87% 的存储增长是垃圾文件。 这不是存储的问题,是管理的问题。

我需要一套完整的生命周期管理机制。

生命周期管理的全景图

一个图片从诞生到消亡,应该经历这样的生命周期:

                     ┌──────────────┐
                     │   用户上传    │
                     └──────┬───────┘
                            │
                     ┌──────▼───────┐
                     │   临时存储    │ ← 分片上传的临时空间
                     │   (temp/)    │
                     └──────┬───────┘
                            │ 上传完成
                     ┌──────▼───────┐
                     │   内容审核    │ ← 鉴黄、OCR、版权
                     └──────┬───────┘
                            │ 审核通过
                 ┌──────────▼──────────┐
                 │   标准存储(热)     │ ← 新图片,频繁访问
                 │   originals/        │
                 │   thumbs/           │
                 └──────────┬──────────┘
                            │ 30 天无人访问
                 ┌──────────▼──────────┐
                 │   低频存储(温)     │ ← 偶尔有人翻看
                 │   originals/        │
                 └──────────┬──────────┘
                            │ 90 天无人访问
                 ┌──────────▼──────────┐
                 │   归档存储(冷)     │ ← 几乎没人看
                 │   originals/        │
                 └──────────┬──────────┘
                            │ 2 年以上无人访问
                 ┌──────────▼──────────┐
                 │      删除           │ ← 释放存储空间
                 └────────────────────┘

     临时文件的生命周期(独立路径):
     ┌──────────┐     7 天
     │ temp/    │ ──────────→ 删除
     └──────────┘

     缩略图的生命周期:
     ┌──────────┐     随原图删除
     │ thumbs/  │ ──────────→ 一起删除
     └──────────┘

生命周期规则配置

OSS 原生生命周期规则

阿里云 OSS 提供了内置的生命周期管理功能,可以在控制台或通过 SDK 配置:

一条生命周期规则 = 作用范围 + 一组动作

生命周期规则
├── 作用范围:Prefix(目录前缀)或 Filter(标签)
├── 动作 1:Transitions —— 在第 N 天转成另一个存储类型
├── 动作 2:Expiration    —— 在第 N 天删除对象
├── 动作 3:AbortMultipartUpload —— 清理 N 天未完成的分片上传
└── 动作 4:NoncurrentVersionExpiration —— 清理非当前版本

规则文件是一份 JSON,文件名必须叫 lifecycle.json,放在桶的根目录:

{
  "Rules": [
    {
      "ID": "rule-temp-cleanup",
      "Status": "Enabled",
      "Filter": { "Prefix": "tmp/" },
      "Expiration": { "Days": 7 },
      "AbortMultipartUpload": { "Days": 3 }
    },
    {
      "ID": "rule-original-tiering",
      "Status": "Enabled",
      "Filter": { "Prefix": "originals/" },
      "Transitions": [
        { "Days": 30, "StorageClass": "IA" },
        { "Days": 90, "StorageClass": "Archive" }
      ],
      "Expiration": { "Days": 730 }
    },
    {
      "ID": "rule-derived-cleanup",
      "Status": "Enabled",
      "Filter": { "Prefix": "derived/" },
      "Expiration": { "Days": 180 }
    }
  ]
}

几个字段的硬性要求:规则 ID 必须以 rule 开头,否则接口会直接拒绝;Status 只能是 Enabled 或 Disabled;StorageClass 的取值是 Standard、IA、Archive、ColdArchive、DeepColdArchive。

用 SDK 上传这份规则:

import json

import oss2

BUCKET_NAME = "guangying-img"
ENDPOINT = "https://oss-cn-beijing.aliyuncs.com"

RULES = {
    "Rules": [
        {
            "ID": "rule-temp-cleanup",
            "Status": "Enabled",
            "Filter": {"Prefix": "tmp/"},
            "Expiration": {"Days": 7},
            "AbortMultipartUpload": {"Days": 3},
        },
        {
            "ID": "rule-original-tiering",
            "Status": "Enabled",
            "Filter": {"Prefix": "originals/"},
            "Transitions": [
                {"Days": 30, "StorageClass": "IA"},
                {"Days": 90, "StorageClass": "Archive"},
            ],
            "Expiration": {"Days": 730},
        },
        {
            "ID": "rule-derived-cleanup",
            "Status": "Enabled",
            "Filter": {"Prefix": "derived/"},
            "Expiration": {"Days": 180},
        },
    ]
}


def apply_lifecycle(bucket: oss2.Bucket) -> None:
    """把生命周期规则写入桶根目录的 lifecycle.json。

    oss2 的 put_bucket_lifecycle 会把本地文件原样上传到桶根目录,
    文件名必须是 lifecycle.json,规则 ID 必须以 rule 开头。
    """
    with open("lifecycle.json", "w", encoding="utf-8") as fp:
        json.dump(RULES, fp, ensure_ascii=False, indent=2)
    bucket.put_bucket_lifecycle("lifecycle.json")


def read_lifecycle(bucket: oss2.Bucket) -> dict:
    """读回当前生效的规则,作为配置变更后的校验。"""
    bucket.get_bucket_lifecycle("lifecycle.json")
    with open("lifecycle.json", encoding="utf-8") as fp:
        return json.load(fp)

三条必须提前知道的规则行为:

  1. 规则是”到期执行”的,不是”立刻生效”的。写完规则之后,OSS 在后台按天扫描,到期的对象才会被转存或删除。指望写完规则马上生效是不现实的,验证要等一个扫描周期。
  2. 归档存储的对象不能直接读。转成归档之后,所有读取路径都要先处理”需要解冻”的状态,否则用户看到的是坏图而不是老照片。
  3. 删除不是物理删除。开了回收站(软删除)的桶,被规则删除的对象会先进回收站保留一段时间,到期才真正物理删除。这对合规是好事,但要注意回收站本身也在计费。

应用层生命周期管理

OSS 原生规则只管存储层,但有些逻辑需要在应用层处理:

OSS 原生规则能做的:              应用层必须做的:
├── 按天转存到更便宜的存储类型      ├── 判断"这张图还有没有人要"(业务语义)
├── 按天删除对象                  ├── 判断派生图能不能重建
├── 清理未完成的分片              ├── 删除前检查还有没有地方引用它
└── 清理非当前版本                ├── 处理"删除但要留痕"的合规要求
                                 └── 处理归档对象的"复活"流程

两者的边界很清楚:OSS 规则回答”过了多少天怎么办”,应用层回答”这张图现在该不该办”。

具体来说有四类逻辑是 OSS 规则做不了的。

第一类是业务语义判断。同样是 365 天没访问的图,头像、付费作品、已下架商品的处理方式完全不同:

# 图片表里需要带的字段
# storage_class     当前存储类型
# last_accessed_at  最后一次被访问的时间
# is_avatar         是否是用户头像
# is_paid_work      是否是付费作品
# is_taken_down     是否已被下架
# param_version     生成派生图时用的压缩参数版本


def decide_tier(image: dict, now) -> str:
    """决定这张图应该待在哪个存储类型。"""
    if image["is_taken_down"]:
        # 已下架内容要留痕,不能因为冷就悄悄归档掉
        return "Archive"

    if image["is_avatar"]:
        # 头像永远有人访问,不参与自动分层
        return "Standard"

    days = (now - image["last_accessed_at"]).days

    if image["is_paid_work"]:
        # 付费作品的生命周期由业务决定,不由访问频次决定
        return "Standard" if days < 180 else "IA"

    if days > 90:
        return "Archive"
    if days > 30:
        return "IA"
    return "Standard"

第二类是派生图的清理。OSS 规则可以按天数删除 derived/ 下的对象,但前提是这些派生图确实可以重建。如果重建依赖的压缩参数已经改过,或者处理工具链下线了,删掉就是永久的 404。所以派生图的清理必须和应用层的”当前压缩参数版本”绑定:

def derived_is_rebuildable(image: dict, current_param_version: str) -> bool:
    """派生图能不能重建,取决于它是用哪个版本的参数生成的。"""
    return image["param_version"] == current_param_version

只有可重建的派生图才交给 OSS 的生命周期规则删除;不可重建的留在应用层,按业务规则处理。

第三类是引用检查。OSS 规则不知道数据库里还有多少地方引用着这张图。删掉一张还被别的记录引用的图,数据库就指向了一个不存在的对象。所以删除前要查一次引用计数:

-- 删除前的引用检查:有引用就不删
SELECT COUNT(*) FROM posts      WHERE cover_image_id = :image_id
UNION ALL
SELECT COUNT(*) FROM collections WHERE cover_image_id = :image_id;

第四类是”复活”流程。归档之后用户想看,系统要能发起解冻,并把结果通知到用户。这一步是应用层的事——OSS 只提供 restore_object 这个动作,“什么时候该解冻、解冻完告诉谁”是业务逻辑。

def request_restore(bucket: oss2.Bucket, key: str, days: int = 1) -> None:
    """给归档对象发起解冻。归档对象读取前必须先解冻,调用是幂等的。"""
    bucket.restore_object(key, days)


def is_archived(bucket: oss2.Bucket, key: str) -> bool:
    """通过对象元数据判断对象当前是不是归档状态。"""
    meta = bucket.head_object(key)
    storage_class = meta.headers.get("x-oss-storage-class", "Standard")
    return storage_class in {"Archive", "ColdArchive", "DeepColdArchive"}

把四类逻辑接起来之后,职责划分是这样的:

事情谁来做为什么
90 天没访问就转归档应用层决定,OSS 规则执行OSS 只认天数,认不了”这是头像”
删除可重建的派生图OSS 规则纯按前缀和天数,可靠且不花钱
删除前检查引用应用层OSS 看不到数据库
发起解冻、通知用户应用层这是业务流程,不是存储动作
清理未完成的分片OSS 规则纯粹的时间问题

一句话总结:OSS 规则负责”到点就办”,应用层负责”该不该办、办完了告诉谁”。 两者配合好了,生命周期管理才能自动化;配合不好,就会出现”OSS 规则删了一张还被引用的图”这类事故。

TypeScript 版本:面向前端的过期文件检测

前端也需要感知图片的生命周期状态,特别是对于过期/归档的图片:

后端返回的图片信息
{
  "id": "a3f8c2e1",
  "url": "https://cdn.guangying.com/i/a3f8c2e1.jpg",
  "storageClass": "Archive",          // Standard / IA / Archive / ColdArchive
  "lastAccessedAt": "2025-03-14T08:12:00Z",
  "expireAt": "2026-09-14T08:12:00Z", // 生命周期里的删除时间
  "state": "archived"                 // active / expiring / archived / expired
}

前端要处理的是四种状态,每一种对应完全不同的用户动作:

状态含义前端表现用户能做什么
active正常,标准存储直接显示图片正常浏览
expiring即将被删除(距到期 30 天内)正常显示,加一个”即将清理”的角标收藏、下载、分享
archived已归档,读取需要先解冻占位态加”正在恢复”点一下发起恢复
expired已被生命周期删除明确的”该图片已被清理”占位态找作者要一份

判断逻辑放在前端一个纯函数里,最好测:

export type ImageLifecycleState = "active" | "expiring" | "archived" | "expired";

export interface ImageMeta {
  id: string;
  url: string;
  storageClass: "Standard" | "IA" | "Archive" | "ColdArchive" | "DeepColdArchive";
  lastAccessedAt: string;
  expireAt: string;
}

const EXPIRING_WINDOW_DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;

/** 把后端返回的图片元信息翻译成前端要处理的状态。 */
export function resolveLifecycleState(
  meta: ImageMeta,
  now: Date = new Date(),
): ImageLifecycleState {
  const expireAt = new Date(meta.expireAt).getTime();
  const nowMs = now.getTime();

  if (Number.isNaN(expireAt) || expireAt <= nowMs) {
    return "expired";
  }

  const isArchived = ["Archive", "ColdArchive", "DeepColdArchive"].includes(meta.storageClass);
  if (isArchived) {
    return "archived";
  }

  const daysLeft = (expireAt - nowMs) / DAY_MS;
  if (daysLeft <= EXPIRING_WINDOW_DAYS) {
    return "expiring";
  }

  return "active";
}

前端拿到 archived 状态时,发起解冻请求的代码和”冷热分离”那一节是同一套逻辑,这里只补上前端侧该做的判断:

export interface RestoreResponse {
  id: string;
  state: "restoring" | "ready" | "failed";
}

/**
 * 对归档图片发起解冻。
 *
 * 注意:解冻是分钟级的操作,前端不轮询等待,
 * 后端解冻完成后通过通知告诉用户。
 */
export async function requestRestore(imageId: string): Promise<RestoreResponse> {
  const response = await fetch(`/api/images/${imageId}/restore`, {
    method: "POST",
    headers: { "content-type": "application/json" },
  });

  if (!response.ok) {
    return { id: imageId, state: "failed" };
  }

  const body = (await response.json()) as { state?: string };
  return { id: imageId, state: body.state === "restoring" ? "restoring" : "ready" };
}

四个必须注意的细节:

  1. 归档状态的判定要以后端返回的 storageClass 为准,不要靠请求失败来猜。请求失败可能是网络问题,也可能是归档,这两者的处理方式完全不同——归档要发起解冻,网络错误要重试。
  2. expired 和 archived 的用户体验完全不同。归档是”还在,只是需要等一会儿”;过期是”已经没了”。前者要给”恢复”按钮,后者只能给一句解释和联系作者的入口。把两者混为一谈,会让用户以为自己的图片还能救回来。
  3. 过期的图片不要再占图片位。列表页里用一个固定高度的占位块,避免一堆过期图片造成大片留白。
  4. expiring 状态要做产品决策,而不是技术决策。要不要提示、提示放在哪里、用户点进来要不要先”认领”这张图——这些是产品要定的事。技术上只需要保证后端能准确返回 expireAt,前端的呈现规则写在配置里。

存储空间监控和告警

生命周期管理的最后一环是监控。你无法管理你看不见的东西:

要盯的四类指标

1. 容量     —— 总容量、按存储类型分、按前缀分
2. 增速     —— 日增长量,以及"存储增长"和"业务增长"的比值
3. 规则生效 —— 生命周期规则实际执行了多少次
4. 异常     —— 解冻请求量、删除量、回收站容量

日常巡检用 ossutil 就能拿到大部分数字:

# 统计整个桶的容量
ossutil du oss://guangying-img/

# 只看原图目录的容量
ossutil du oss://guangying-img/originals/

# 统计对象数量和总大小
ossutil ls oss://guangying-img/ -s

但这些是”事后的”,真正的监控要能第一时间发现异常。所以我把关键指标暴露成 Prometheus 的指标,在容器里加一个导出器:

# exporter.py:定时把 OSS 的容量信息刷成 Prometheus 指标
import os
import time

from prometheus_client import Gauge, start_http_server

from oss2 import Auth, Bucket

OSS_ENDPOINT = "https://oss-cn-beijing.aliyuncs.com"
OSS_BUCKET = "guangying-img"

bucket = Bucket(
    Auth(os.environ["OSS_ACCESS_KEY_ID"], os.environ["OSS_ACCESS_KEY_SECRET"]),
    OSS_ENDPOINT,
    OSS_BUCKET,
)

# 按存储类型统计的容量(字节)
storage_bytes = Gauge("oss_storage_bytes", "按存储类型统计的容量", ["storage_class"])
# 总容量
total_bytes = Gauge("oss_total_bytes", "桶的总容量")
# 生命周期规则触发的转存次数
lifecycle_transitions = Gauge(
    "oss_lifecycle_transitions_total", "生命周期规则触发的转存次数", ["rule_id"]
)


def collect() -> None:
    totals: dict[str, int] = {}
    for obj in bucket.list_objects():
        storage_class = obj.storage_class or "Standard"
        totals[storage_class] = totals.get(storage_class, 0) + obj.size

    for storage_class, size in totals.items():
        storage_bytes.labels(storage_class=storage_class).set(size)
    total_bytes.set(sum(totals.values()))


start_http_server(9108)
while True:
    collect()
    time.sleep(300)

告警规则用 Prometheus 的标准格式表达:

groups:
  - name: oss-lifecycle
    rules:
      - alert: OssStorageGrowthTooFast
        expr: (oss_total_bytes - oss_total_bytes offset 7d) / 604800 > 12884901888
        for: 30m
        labels:
          severity: warning
        annotations:
          summary: "OSS 存储日增长超过 12 GB"
          description: "和凌晨那次告警同一个量级,可能是垃圾文件没有被清理。"

      - alert: OssLifecycleRuleNotFiring
        expr: increase(oss_lifecycle_transitions_total[7d]) == 0
        for: 24h
        labels:
          severity: critical
        annotations:
          summary: "生命周期规则一周内没有执行过任何转存"
          description: "规则写错了、桶名不对,或者扫描周期还没到。但连续一周为零一定是异常。"

      - alert: OssArchiveRestoreSpike
        expr: increase(oss_restore_requests_total[1h]) > 100
        labels:
          severity: warning
        annotations:
          summary: "归档对象解冻请求激增"
          description: "可能是某个爬虫在批量抓取历史图片,也可能是分层规则把不该归档的图归档了。"

四个必须设的告警,以及它们各自对应的真实故障:

告警触发条件对应的真实故障
存储日增长超阈值日增长超过 12 GB分片上传的垃圾文件没清理(凌晨两点那次就是这个问题)
存储增速远超业务增速存储增长量超过图片上传量的 1.5 倍同上,而且已经开始堆积
生命周期规则一周零执行转存计数一周没动规则写错或桶名写错,规则根本没生效
解冻请求量突增一小时超过 100 次分层策略过激,或者有人在批量抓历史图片

最后一条纪律:告警必须指向动作。“存储增长 12 GB”只是个现象,值班的人看到之后要能立刻知道该做什么。把告警的 description 直接写成排查步骤——先查 tmp/ 前缀的分片数量,再查最近一次规则变更记录——比写”请检查存储配置”有用得多。

实施效果

生命周期管理上线后,我把配置写入代码仓库,用 Terraform 管理:

# lifecycle.tf —— 生命周期规则也进版本库
resource "alicloud_oss_bucket_lifecycle" "temp_cleanup" {
  bucket  = alicloud_oss_bucket.img.id
  rule_id = "rule-temp-cleanup"
  prefix  = "tmp/"
  status  = "Enabled"

  expiration_days              = 7
  abort_multipart_upload_days = 3
}

resource "alicloud_oss_bucket_lifecycle" "original_tiering" {
  bucket  = alicloud_oss_bucket.img.id
  rule_id = "rule-original-tiering"
  prefix  = "originals/"
  status  = "Enabled"

  transition_days          = 30
  transition_storage_class = "IA"
  expiration_days          = 730
}

resource "alicloud_oss_bucket_lifecycle" "derived_cleanup" {
  bucket  = alicloud_oss_bucket.img.id
  rule_id = "rule-derived-cleanup"
  prefix  = "derived/"
  status  = "Enabled"

  expiration_days = 180
}

把规则写进 Terraform 之后有三个好处:改动留痕、可以评审、可以回滚。以前改一条规则是在控制台点几下,改错了只有记忆能证明;现在规则在代码里,git diff 就能看清改了哪条规则、为什么改。

(如果 provider 版本较老,同一份配置也可以写成 alicloud_oss_bucket 资源里的 lifecycle_rule 块,字段语义是一样的。)

生效后的数据:

指标上线前上线后变化
存储费(成本模型口径)324.00 元97.50 元降 69.9%
桶容量2.1 TB1.7 TB降 19%
垃圾文件占比87%5%降 82 个百分点
临时分片残留平均 2.3 天平均 0.4 天靠分片清理规则
标准存储占比96%58%分层开始起作用

最关键的不是省了多少钱,而是凌晨两点的那条短信不再来了。

上线后第一个月,告警短信一共收到两条,而且都是误报——一条是容量刚过阈值时发出的预警,一条是回访时确认已经降下来的通知。那种被手机在凌晨两点震醒的感觉消失之后,我才真正觉得这个系统算是”自己在管自己”。

三个上线后才发现的细节:

  1. 规则不是写完就生效的。OSS 按天扫描,所以规则上线后要等一两天才能看到第一批对象被转存。发布时要预留这个延迟,不要在第二天一早就断言”规则没生效”。
  2. 标准存储的占比是最好的验证指标。它比总容量更灵敏——总容量可能因为新上传而持平不动,但标准存储的占比一定会在规则生效后下降。如果它一直没降,说明规则从来没有被执行过。
  3. 别把清理和归档混为一谈。归档只是变便宜,删除是真的没了。所以”派生图清理”这条规则配的是 Expiration,不是 Transitions——派生图的价值可以重建,删了不心疼;原图则是先转归档,两年后才删。

发现内容有问题?

告诉我们哪里需要改进。