数据生命周期管理
那是”光影”上线第 3 个月的一个凌晨,手机疯狂震动:
- 当前容量: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)
三条必须提前知道的规则行为:
- 规则是”到期执行”的,不是”立刻生效”的。写完规则之后,OSS 在后台按天扫描,到期的对象才会被转存或删除。指望写完规则马上生效是不现实的,验证要等一个扫描周期。
- 归档存储的对象不能直接读。转成归档之后,所有读取路径都要先处理”需要解冻”的状态,否则用户看到的是坏图而不是老照片。
- 删除不是物理删除。开了回收站(软删除)的桶,被规则删除的对象会先进回收站保留一段时间,到期才真正物理删除。这对合规是好事,但要注意回收站本身也在计费。
应用层生命周期管理
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 规则删了一张还被引用的图”这类事故。
前端也需要感知图片的生命周期状态,特别是对于过期/归档的图片:
后端返回的图片信息
{
"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" };
}
四个必须注意的细节:
- 归档状态的判定要以后端返回的
storageClass为准,不要靠请求失败来猜。请求失败可能是网络问题,也可能是归档,这两者的处理方式完全不同——归档要发起解冻,网络错误要重试。 expired和archived的用户体验完全不同。归档是”还在,只是需要等一会儿”;过期是”已经没了”。前者要给”恢复”按钮,后者只能给一句解释和联系作者的入口。把两者混为一谈,会让用户以为自己的图片还能救回来。- 过期的图片不要再占图片位。列表页里用一个固定高度的占位块,避免一堆过期图片造成大片留白。
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 TB | 1.7 TB | 降 19% |
| 垃圾文件占比 | 87% | 5% | 降 82 个百分点 |
| 临时分片残留 | 平均 2.3 天 | 平均 0.4 天 | 靠分片清理规则 |
| 标准存储占比 | 96% | 58% | 分层开始起作用 |
最关键的不是省了多少钱,而是凌晨两点的那条短信不再来了。
上线后第一个月,告警短信一共收到两条,而且都是误报——一条是容量刚过阈值时发出的预警,一条是回访时确认已经降下来的通知。那种被手机在凌晨两点震醒的感觉消失之后,我才真正觉得这个系统算是”自己在管自己”。
三个上线后才发现的细节:
- 规则不是写完就生效的。OSS 按天扫描,所以规则上线后要等一两天才能看到第一批对象被转存。发布时要预留这个延迟,不要在第二天一早就断言”规则没生效”。
- 标准存储的占比是最好的验证指标。它比总容量更灵敏——总容量可能因为新上传而持平不动,但标准存储的占比一定会在规则生效后下降。如果它一直没降,说明规则从来没有被执行过。
- 别把清理和归档混为一谈。归档只是变便宜,删除是真的没了。所以”派生图清理”这条规则配的是
Expiration,不是Transitions——派生图的价值可以重建,删了不心疼;原图则是先转归档,两年后才删。
发现内容有问题?
告诉我们哪里需要改进。
