设计原则总结
从一张 8.7MB 的图片说起
六个月前,“光影”上线第一天,一张 8.7MB 的 RAW 照片拖垮了整台服务器。那是我第一次意识到——图片系统不是”存个文件”那么简单。
六个月后的今天,“光影”支撑着 12000 个摄影师、85000 张图片、日均 15 万次页面浏览。我坐在电脑前,回顾这个从 0 到 1 的过程,把踩过的坑、想明白的道理,提炼成了 8 条设计原则。
这些原则不是从教科书里抄的。每一条背后,都是凌晨三点的告警短信。
原则 1:原图不可丢
用户上传的原图是唯一的数据源,缩略图可以重新生成,原图丢了就真的丢了。
PRINCIPLE_1 = {
'核心': '原图是不可变、不可丢弃的核心资产',
'规则': [
'原图只做逻辑删除(软删除),永远不做物理删除',
'缩略图和格式转换结果都是从原图派生的,可以随时重建',
'原图和缩略图分目录存储,避免误删',
'用户"删除"图片时,只是隐藏,30 天后才真正标记为可归档',
],
'踩坑': (
'第 2 个月,一个 bug 导致部分缩略图被覆盖为空白图片。'
'因为有原图在,30 分钟内重新生成了所有缩略图。'
'如果原图丢了,就是不可逆的数据事故。'
),
}实践:原图保护机制
// 原图保护策略
interface OriginalProtection {
// 1. Bucket 级别保护
bucketPolicy: {
originals: 'private-read-write'; // 私有读写,只有服务端可访问
thumbs: 'public-read'; // 缩略图可公开读
temp: 'private-read-write'; // 临时文件私有
};
// 2. 删除保护
deleteProtection: {
logicalDelete: true; // 软删除
gracePeriod: 30; // 30 天缓冲期
permanentDeleteRequiresReason: true;
auditLog: true; // 所有删除操作记日志
};
// 3. 版本控制
versioning: {
enabled: true; // OSS 版本控制
// 即使覆盖同名文件,历史版本仍然保留
retentionDays: 90; // 保留 90 天的历史版本
};
}
// 删除操作的实现
class SafeDeleteService {
async deleteImage(imageId: string, operatorId: string, reason: string) {
// 1. 记录删除日志
await db.insert('delete_audit_log', {
image_id: imageId,
operator_id: operatorId,
reason: reason,
timestamp: new Date(),
});
// 2. 软删除(标记为 deleted,不删除文件)
await db.update('images', {
id: imageId,
deleted_at: new Date(),
delete_reason: reason,
scheduled_purge_at: new Date(Date.now() + 30 * 24 * 3600 * 1000),
});
// 3. 从列表中隐藏(但原图仍在 OSS 上)
// 4. 30 天后,归档而非删除
}
}原则 2:渐进增强
永远有兜底方案。用户应该先看到内容,再看到更好的内容。
PRINCIPLE_2 = {
'核心': '基本功能人人可用,增强功能按能力渐进提供',
'示例': [
'格式:JPEG(100% 支持)→ WebP(97%)→ AVIF(92%)',
'加载:骨架屏 → 低质量占位图 → 高清图',
'处理:上传即返回 → 审核通过 → 缩略图就绪',
'存储:标准存储 → 低频存储 → 归档存储',
],
'踩坑': (
'第 3 个月给所有用户推 AVIF,结果 8% 的 Safari 用户看到裂图。'
'紧急回滚到 WebP + JPEG 兜底,24 小时后修复。'
),
}实践:前端渐进加载
// 渐进增强的图片加载策略
function ProgressiveImage({ imageId, alt }: { imageId: string; alt: string }) {
const [loadState, setLoadState] = useState<'skeleton' | 'placeholder' | 'full'>('skeleton');
return (
<div className="image-container">
{/* 第一层:骨架屏(立即显示) */}
{loadState === 'skeleton' && (
<div className="skeleton" />
)}
{/* 第二层:<picture> 标签做格式协商 */}
<picture
onLoad={() => setLoadState('full')}
onError={() => setLoadState('placeholder')}
style={{ display: loadState === 'skeleton' ? 'none' : 'block' }}
>
{/* AVIF:最好的格式,但不是所有浏览器都支持 */}
<source
srcset={`https://cdn.guangying.com/thumbs/avif/${imageId}.avif`}
type="image/avif"
/>
{/* WebP:较好的格式,97% 支持 */}
<source
srcset={`https://cdn.guangying.com/thumbs/webp/${imageId}.webp`}
type="image/webp"
/>
{/* JPEG:兜底,100% 支持 */}
<img
src={`https://cdn.guangying.com/thumbs/jpeg/${imageId}.jpeg`}
alt={alt}
loading="lazy"
/>
</picture>
{/* 兜底:如果所有图片都加载失败 */}
{loadState === 'placeholder' && (
<div className="placeholder">
<span>图片加载失败</span>
</div>
)}
</div>
);
}原则 3:成本意识
从第一天就关心成本。不是省钱,是确保项目可持续。
PRINCIPLE_3 = {
'核心': '每一行代码都有成本,每一个设计决策都应该考虑费用',
'规则': [
'上传带宽 > 存储单价 > 处理费用(优先优化带宽)',
'格式转换省的是 CDN 流量费(最大可变成本)',
'冷热分离省的是存储费(虽然占比不大,但积少成多)',
'监控成本本身(不要花 100 元的监控费去省 50 元的存储费)',
],
'踩坑': (
'第 1 个月没有做成本分析,第 5 个月一算账——月均 5000 元,'
'其中服务器占 81%。如果早做分析,能早 4 个月省下 8000 元。'
),
}实践:成本仪表盘
class CostDashboard:
"""每月自动生成成本报告"""
def generate_monthly_report(self):
return {
'month': '2024-06',
'total_cost': 825,
'cost_per_user': round(825 / 12000, 2), # 0.07 元/用户/月
'cost_per_image': round(825 / 85000, 4), # 0.0097 元/图/月
'cost_per_pv': round(825 / 4500000, 6), # 0.000183 元/PV
'breakdown': {
'存储费': {'amount': 70, 'pct': '8.5%'},
'CDN 流量': {'amount': 185, 'pct': '22.4%'},
'服务器': {'amount': 430, 'pct': '52.1%'},
'其他': {'amount': 140, 'pct': '17.0%'},
},
'trend': '↓ 12% vs 上月',
'alerts': [],
}原则 4:缓存为王
如果一个问题可以通过缓存解决,那就用缓存解决。
PRINCIPLE_4 = {
'核心': '缓存是解决性能和成本问题最简单、最有效的方式',
'缓存层级': [
'浏览器缓存(304 Not Modified)',
'CDN 边缘缓存(命中率 96%)',
'应用缓存(Redis 存图片元数据)',
'OSS 内部缓存',
],
'踩坑': (
'第 2 个月没有配 CDN 缓存规则,所有请求都回源。'
'OSS 回源流量是 CDN 流量的 10 倍。加上 CDN 后,回源降低到 4%。'
),
}实践:多级缓存配置
# 多级缓存策略
CACHE_STRATEGY = {
# 浏览器缓存
'browser': {
'Cache-Control': 'public, max-age=86400', # 1 天
'ETag': 'auto', # 自动生成
},
# CDN 缓存
'cdn': {
'thumbs/*.webp': {'ttl': 2592000}, # 30 天
'thumbs/*.avif': {'ttl': 2592000}, # 30 天
'originals/*': {'ttl': 7776000}, # 90 天
},
# 应用缓存
'application': {
'image_metadata': {'ttl': 3600, 'backend': 'Redis'},
'user_upload_quota': {'ttl': 300, 'backend': 'Redis'},
},
# 缓存失效策略
'invalidation': {
'on_delete': 'CDN 刷新 + 浏览器无法强制',
'on_update': 'URL 版本号递增(v=1 → v=2)',
'on_expired': '自然过期(TTL 到期后自动回源刷新)',
},
}原则 5:异步优先
能异步的就不要同步,用户等得起的结果,不要让他等着。
PRINCIPLE_5 = {
'核心': '用户的等待只应该花在必须等待的事情上',
'同步操作(用户必须等待)': [
'文件上传(3~10 秒,无法避免)',
'STS 凭证签发(50ms,必须拿到才能上传)',
],
'异步操作(用户不需要等待)': [
'内容审核(200ms~1s,审核完成后自动更新状态)',
'缩略图生成(1~3s,生成后自动替换占位图)',
'CDN 预热(后台执行)',
'EXIF 数据提取(后台执行)',
],
'踩坑': (
'最初版把缩略图生成放在上传接口里同步执行,'
'上传接口 P99 延迟从 3 秒飙到 12 秒。'
'改为异步后,上传接口延迟稳定在 3~5 秒。'
),
}原则 6:安全纵深
安全不是一道墙,是一层又一层的防护。
PRINCIPLE_6 = {
'核心': '多层防护,单点失败不影响整体安全',
'防护层': {
'第 1 层 - 上传': [
'STS 临时凭证(15 分钟有效)',
'文件类型白名单(JPEG/PNG/WebP/GIF/HEIC)',
'文件大小限制(单文件 20MB)',
'上传频率限制(每用户每分钟 10 次)',
],
'第 2 层 - 存储': [
'原图私有读写',
'OSS Bucket Policy 限制来源 IP',
'OSS 版本控制(防误覆盖)',
],
'第 3 层 - 分发': [
'CDN Referer 白名单',
'签名 URL(敏感图片)',
'HTTPS 强制',
],
'第 4 层 - 内容': [
'自动鉴黄 + OCR',
'人审兜底',
'用户举报机制',
],
},
'踩坑': (
'第 3 个月被爬虫抓取了全部公开图片。'
'原因是 CDN 没配 Referer 白名单。加上后,'
'外站无法直接嵌入"光影"的图片。'
),
}原则 7:可观测先行
如果你看不见,你就管不了。
PRINCIPLE_7 = {
'核心': '系统上线前,监控先上线',
'必须监控的指标': {
'可用性': [
'上传成功率(目标 > 99.9%)',
'图片加载成功率(目标 > 99.99%)',
'各服务健康状态',
],
'性能': [
'上传延迟 P50/P95/P99',
'CDN 命中率(目标 > 95%)',
'处理队列深度',
],
'成本': [
'存储量及增长率',
'CDN 流量及费用',
'各服务资源使用率',
],
'业务': [
'日均上传量',
'日均 PV',
'审核通过率',
],
},
'踩坑': (
'前 4 个月没有监控。出了问题都是用户反馈后才知道。'
'第 5 个月上了 Prometheus + Grafana,'
'发现 CDN 命中率只有 88%(预期 95%),'
'调整缓存策略后提升到 96%。'
),
}实践:核心监控面板
# Grafana 面板配置(核心指标)
GRAFANA_DASHBOARD = {
'panels': [
{
'title': '上传成功率',
'query': 'sum(rate(upload_success_total[5m])) / sum(rate(upload_total[5m]))',
'threshold': 0.999,
'alert': '低于 99.9% 时告警',
},
{
'title': 'CDN 缓存命中率',
'query': 'sum(rate(cdn_hit_total[5m])) / sum(rate(cdn_request_total[5m]))',
'threshold': 0.95,
'alert': '低于 95% 时告警',
},
{
'title': '处理队列深度',
'query': 'rabbitmq_queue_messages{queue="process-events"}',
'threshold': 100,
'alert': '超过 100 时告警(处理服务可能需要扩容)',
},
{
'title': '存储日增长率',
'query': 'delta(oss_bucket_size_bytes[1d])',
'threshold': 5 * 1024 * 1024 * 1024, # 5 GB/天
'alert': '超过 5 GB/天时告警(可能有垃圾堆积)',
},
{
'title': '月度成本趋势',
'type': 'stat',
'datasource': 'cost_database',
},
],
}原则 8:简单至上
不要为了技术而技术。最简单的方案往往是最好的方案。
PRINCIPLE_8 = {
'核心': '用最简单的方案解决问题,复杂度是未来的问题',
'示例': [
'存储:OSS 而不是自建分布式文件系统',
'CDN:云厂商 CDN 而不是自建 Nginx 缓存集群',
'审核:云 API 而不是自建 ML 模型',
'队列:RabbitMQ 而不是 Kafka(规模不够大)',
'监控:Prometheus 而不是自建时序数据库',
],
'判断标准': {
'自建的条件': (
'1. 现成方案无法满足需求\n'
'2. 自建的收益 > 维护成本 × 2\n'
'3. 团队有能力长期维护'
),
'用现成的条件': (
'1. 现成方案满足 80% 需求\n'
'2. 按需付费,成本可控\n'
'3. 不需要自己运维'
),
},
'踩坑': (
'第 1 个月用本地磁盘存图片("最简单的方案"),'
'结果磁盘满了、迁移麻烦。'
'真正的简单是 OSS——按需付费、无限容量、不需要运维。'
),
}“光影”从 0 到 1 的数据回顾
六个月的完整数据:
# "光影"从 0 到 1 的数据回顾
GUANGYING_JOURNEY = {
'产品数据': {
'用户数': {'day_1': 5, 'month_3': 5000, 'month_6': 12000},
'图片数': {'day_1': 12, 'month_3': 30000, 'month_6': 85000},
'日均 PV': {'day_1': 50, 'month_3': 50000, 'month_6': 150000},
'月活率': {'month_3': '45%', 'month_6': '52%'},
},
'技术数据': {
'图片总量': '2.7 TB',
'缩略图总量': '11 GB',
'平均原图大小': '4.8 MB',
'平均缩略图大小': '45 KB',
'WebP 压缩率': '比 JPEG 小 48%',
'AVIF 压缩率': '比 JPEG 小 65%',
'CDN 命中率': '96%',
'上传成功率': '99.97%',
'审核通过率': '98.2%',
'P50 加载延迟': '12ms(CDN 命中)',
'P99 加载延迟': '450ms',
},
'成本数据': {
'月度总成本': '825 元(优化后)',
'单用户月成本': '0.07 元',
'单图月成本': '0.01 元',
'单次 PV 成本': '0.0002 元',
'优化前成本': '5226 元/月',
'降本幅度': '84%',
},
'架构演进': {
'阶段 1(第 1~2 周)': {
'架构': 'Flask + 本地磁盘 + 无 CDN',
'成本': '约 200 元/月(1 台服务器)',
'问题': '慢,磁盘满了,无审核',
},
'阶段 2(第 3~4 周)': {
'架构': '+ OSS + 缩略图 + WebP + 直传',
'成本': '约 400 元/月',
'问题': '外地用户慢,无审核',
},
'阶段 3(第 2~3 月)': {
'架构': '+ CDN + 审核 + 异步队列',
'成本': '约 2000 元/月',
'问题': '成本高,存储增长快',
},
'阶段 4(第 4~6 月)': {
'架构': '+ 存储分层 + 生命周期 + 成本优化 + 监控',
'成本': '约 825 元/月',
'问题': '暂无重大问题',
},
},
'重大事故': [
{
'时间': '第 1 天',
'描述': '一张 8.7MB 的图片拖垮服务器',
'影响': '全站不可用 10 分钟',
'教训': '必须限制上传文件大小',
},
{
'时间': '第 3 周',
'描述': '本地磁盘满,迁移到 OSS',
'影响': '2 小时不可用',
'教训': '一开始就用对象存储',
},
{
'时间': '第 2 个月',
'描述': '缩略图 bug 导致空白图片',
'影响': '部分图片显示异常 30 分钟',
'教训': '有原图就能恢复一切',
},
{
'时间': '第 3 个月',
'描述': 'AVIF 全量推送导致 Safari 裂图',
'影响': '8% 用户 24 小时内无法正常浏览',
'教训': '渐进增强,永远有兜底',
},
{
'时间': '第 3 个月',
'描述': '被爬虫抓取全部公开图片',
'影响': 'CDN 流量费暴涨 300%',
'教训': 'CDN 必须配 Referer 白名单',
},
],
'总代码量': {
'Python(后端)': '约 8000 行',
'TypeScript(前端)': '约 5000 行',
'配置文件(Terraform + Docker)': '约 1500 行',
'总计': '约 14500 行',
},
}最后的话
写到这里,“光影”的图片系统设计课程就结束了。
回头看,这不是一个关于”最优架构”的故事。这是一个关于问题驱动迭代的故事——从一张 8.7MB 的图片拖垮服务器,到支撑 12000 用户的生产级系统。
每一个技术决策都是在具体问题下做出的,而不是凭空设计的:
图片太大?→ 压缩 + 格式转换
用户等太久?→ CDN 加速
审核不过关?→ 多层审核机制
成本太高?→ 冷热分离 + 生命周期管理
看不到问题?→ 监控告警系统如果你正在构建自己的图片系统,我希望这 8 条原则能帮到你:
1. 原图不可丢 → 数据是一切的根基
2. 渐进增强 → 永远有兜底
3. 成本意识 → 确保可持续
4. 缓存为王 → 最简单的性能方案
5. 异步优先 → 不让用户等不需要等的东西
6. 安全纵深 → 多层防护
7. 可观测先行 → 看不见就管不了
8. 简单至上 → 复杂度是未来的问题没有过度设计,只有问题驱动的迭代。
这就是我从”光影”学到的最重要的事。
我的思考
思考 1
如果这 8 条原则只能保留 3 条,你会保留哪 3 条?为什么?
我会保留这 3 条:
1. 原图不可丢(原则 1)
- 没有数据,一切都是空谈
- 图片系统的核心价值就是用户的图片
- 其他所有问题都可以修复,唯独数据丢失不可逆
- 这也是为什么我把软删除、版本控制、分目录存储放在最前面
2. 缓存为王(原则 4)
- CDN 缓存解决了 80% 的性能问题
- 浏览器缓存解决了重复访问的延迟
- Redis 缓存解决了数据库压力
- 缓存是投入产出比最高的优化手段
3. 可观测先行(原则 7)
- 你无法优化你无法测量的东西
- 冷热分离的前提是知道哪些是冷数据
- 成本优化的前提是知道钱花在哪里
- 缓存优化的前提是知道命中率是多少
- 监控是一切优化的起点
为什么不是”渐进增强”或”简单至上”?
- 渐进增强很重要,但它更像一种实现策略,而非设计哲学
- 简单至上也很重要,但在特定场景下,复杂度是必要的(如消息队列)
- 而这 3 条是所有决策的基石:保护数据、用缓存加速、靠数据驱动
思考 2
“光影”的架构如果要支撑 10 万用户,哪些原则需要调整?
10 万用户(约 8 倍增长)下,部分原则需要升级:
需要调整的原则:
原则 4(缓存为王)→ 需要更精细的缓存策略
当前:CDN 缓存 + Redis 缓存
10 万用户后:
- CDN 多 Region 部署
- 引入本地缓存(进程内 LRU Cache)
- 缓存预热策略更激进(热门图片提前推送到边缘)
- 缓存一致性更复杂(多 Region 同步)原则 8(简单至上)→ 部分组件需要自建
当前:全托管(OSS + CDN + 云审核)
10 万用户后:
- 可能需要自建图片处理服务(云 API 费用太高)
- 可能需要自建 CDN 源站(节省回源流量费)
- 但核心思路不变:自建是为了省成本,不是为了炫技不需要调整的原则:
原则 1(原图不可丢):100 用户还是 100 万用户,原图都不能丢。 原则 2(渐进增强):无论规模多大,兜底方案永远需要。 原则 3(成本意识):规模越大,成本意识越重要。 原则 6(安全纵深):用户越多,攻击面越大,安全越重要。
核心洞察:前 4 条原则(数据、渐进、成本、缓存)是规模无关的——它们在任何规模下都适用。后 4 条原则(异步、安全、可观测、简单)在规模增长时需要调整具体方案,但原则本身不变。
思考 3
回顾”光影”的 6 个月,如果让你重来一次,你会怎么安排开发优先级?
如果重来,我会按这个顺序开发:
第 1 周(生存线):
✅ OSS 存储 + 客户端直传 + 文件大小限制
✅ 基础缩略图生成(WebP + JPEG)
✅ 监控系统(第一天就上 Prometheus)
→ 目标:能用,能监控
第 2 周(体验线):
✅ CDN 加速
✅ <picture> 渐进增强
✅ 懒加载 + 骨架屏
→ 目标:快
第 3~4 周(安全线):
✅ 内容审核(鉴黄 + OCR)
✅ Referer 白名单 + STS 凭证
✅ 频率限制
→ 目标:安全
第 2~3 月(成本线):
✅ AVIF 格式支持
✅ 存储分层(冷热分离)
✅ 生命周期管理
✅ 成本监控仪表盘
→ 目标:可持续
第 4~6 月(规模线):
✅ 弹性伸缩
✅ CDN 边缘裁剪
✅ 完善监控告警
→ 目标:抗增长与实际的区别:
- 监控从第 5 个月提前到第 1 周
- CDN 从第 2 个月提前到第 2 周
- 审核 from 第 2 个月提前到第 3 周
- 缩略图从一开始就生成多格式(WebP + JPEG),不走过场
核心改变:先让系统可观测、可用,再迭代功能。不是先堆功能再补监控。
