设计原则总结
六个月前,“光影”上线第一天,一张 8.7MB 的 RAW 照片拖垮了整台服务器。那是我第一次意识到——图片系统不是”存个文件”那么简单。
六个月后的今天,“光影”支撑着 12000 个摄影师、85000 张图片、日均 15 万次页面浏览。我坐在电脑前,回顾这个从 0 到 1 的过程,把踩过的坑、想明白的道理,提炼成了 8 条设计原则。
这些原则不是从教科书里抄的。每一条背后,都是凌晨三点的告警短信。
用户上传的原图是唯一的数据源,缩略图可以重新生成,原图丢了就真的丢了。
实践:原图保护机制
“原图不可丢”有两层含义:数据不丢,和原图不被人随便拿走。前者靠备份和版本控制,后者靠访问控制。两件事必须一起做,否则保护不住。
我把保护拆成四层:
| 层次 | 手段 | 解决什么问题 | 不足 |
|---|---|---|---|
| 存储层 | 原图与派生图分目录、分 Bucket | 原图可以设为私有,派生图公开;两者的生命周期和权限独立配置 | 防不住内部越权访问 |
| 访问层 | 私有 Bucket + 签名 URL | 链接带过期时间,转发出去也会失效 | 链接发出后在有效期内仍可被转发 |
| 访问层 | Referer 白名单(防盗链) | 阻止图片被直接嵌入第三方站点 | Referer 可被伪造,只能防君子 |
| 数据层 | 软删除 + 版本化 + 跨区复制 | 误删可恢复,单机故障不丢数据 | 存储成本上升 |
签名 URL 的核心是:签名必须覆盖”路径 + 过期时间”,并且过期时间要短到足以止损、又长到足以完成一次正常浏览。我用的是 HMAC-SHA256:
import base64
import hashlib
import hmac
import time
from urllib.parse import quote
def sign_url(access_key_secret: str, object_key: str, expires_in: int) -> str:
"""生成带过期时间的私有对象访问签名。
签名覆盖 object_key 和 expire_at:
路径被改成别的图片、过期时间被改长,验签都会失败。
"""
expire_at = int(time.time()) + expires_in
sign_string = f"/{object_key}\n{expire_at}"
digest = hmac.new(
access_key_secret.encode("utf-8"),
sign_string.encode("utf-8"),
hashlib.sha256,
).digest()
signature = base64.b64encode(digest).decode("utf-8")
return (
f"https://img.example.com/{quote(object_key)}"
f"?sign={quote(signature)}&t={expire_at}"
)
三个容易忽略的细节:
- 签名参数不能进 CDN 缓存键。否则每个签名都是一份独立缓存,命中率直接归零。正确做法是:缓存键只用对象路径,签名在边缘节点校验。上一节的”回源优化”里已经讲过这个原则。
- 付费下载原图要走独立通道。原图下载涉及计费和版权,需要单独的签名入口、单独的授权校验和完整的下载审计日志,不能和”页面里显示的图片”共用一套规则。
- 防盗链是补充,不是替代。Referer 可以被伪造,它只能挡住”直接复制链接到别的网站”这种最常见的行为;真正防住盗链的是签名 URL + 频率限制 + 异常下载告警。
有了这四层,原图既不会丢,也不会被无成本地搬走。
永远有兜底方案。用户应该先看到内容,再看到更好的内容。
实践:前端渐进加载
“用户应该先看到内容,再看到更好的内容”落到前端,就是三层渐进:占位 → 可用 → 更好。
第 1 层 占位(LQIP)
低分辨率模糊图,随 HTML 一起到达
目的:立刻给出"这里有一张图"的视觉锚点,页面不塌陷
第 2 层 可用(srcset 选中的尺寸)
浏览器按视口和 DPR 选中的那一版
目的:用户已经能看到清晰可辨的图
第 3 层 更好(高清 / 原图)
用户主动放大、点击,或网络条件允许时再换
目的:把昂贵的下载推迟到真正需要的时刻
第 1 层的 LQIP(低质量图像占位)有两种实现:把一张极小尺寸的图转成 WebP 后以 base64 内联在 HTML 里,省掉一次请求;或者让它指向一个独立的极小图 URL。我选了内联,因为内联的那张图只有几 KB,跟着 HTML 一起到达,天然零延迟。
第 2 层就是标准的响应式图片,靠 srcset 和 sizes 让浏览器自己选:
<figure class="photo">
<img
class="photo-lqip"
src="https://cdn.example.com/i/a3f8c2e1_lqip.webp"
alt=""
aria-hidden="true"
/>
<img
class="photo-full"
src="https://cdn.example.com/i/a3f8c2e1_1200.webp"
srcset="https://cdn.example.com/i/a3f8c2e1_800.webp 800w,
https://cdn.example.com/i/a3f8c2e1_1200.webp 1200w,
https://cdn.example.com/i/a3f8c2e1_2000.webp 2000w"
sizes="(max-width: 768px) 100vw, 640px"
width="1200"
height="800"
alt="夜景"
loading="lazy"
decoding="async"
/>
</figure>
配套的 CSS 负责”从糊到清”的过渡和防抖动:
.photo {
position: relative;
overflow: hidden;
}
.photo-lqip {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(12px);
transform: scale(1.05);
}
.photo-full {
position: relative;
width: 100%;
height: auto;
}
几个必须做对的细节:
- 占位图和正式图的宽高比必须一致。否则图片加载完成的瞬间会撑开布局,CLS 直接把”渐进加载”变成”抖动”。
- 首屏图不要加
loading="lazy"。首屏图是 LCP 元素,懒加载等于让它自己推迟自己,LCP 必然变差。首屏用fetchpriority="high"提优先级,屏幕之外的才用loading="lazy"。 decoding="async"让解码不阻塞主线程。在低端机上,一张大图的同步解码会卡住渲染。- 第 3 层要按需。原图不该默认加载,应该等用户点击”查看原图”再请求,并且原图本身要走签名 URL。
这一套做下来,用户感知的是”图片慢慢变清晰”,而不是”图一直糊着”或者”图突然出现”。渐进增强的本质是把等待拆开,让每一步都有可交付的内容。
从第一天就关心成本。不是省钱,是确保项目可持续。
实践:成本仪表盘
“从第一天就关心成本”这句话,如果不落到一个每天都会看的面板上,就只是一句口号。
我的成本仪表盘分五个面板,每个面板回答一个具体问题:
| 面板 | 关键指标 | 口径 | 回答的问题 |
|---|---|---|---|
| 总览 | 本月总成本、日均成本、各成本项占比、与上月环比 | 按账单成本项拆分 | 钱主要花在哪 |
| 单位成本 | 每千次展示成本、单张图片月均成本、单张平均流量 | 账单费用 ÷ 业务埋点次数 | 成本涨了是因为业务涨了,还是因为效率退化了 |
| 存储 | 原图 / 派生图 / 临时文件容量、存储类型分布、增长趋势 | 对象存储用量统计 | 有没有垃圾在堆积 |
| 流量 | CDN 出网流量、回源流量、边缘命中率、按格式与地域的分布 | CDN 访问日志聚合 | 带宽异常来自哪个维度 |
| 处理 | 任务量、失败率、处理延迟 P99、派生图数量 | 处理服务记录 | 算力够不够、派生图是不是失控 |
再加一块归属视图:按业务线、按场景、按用户等级把成本分摊开。元数据表里本来就记了业务归属,成本分摊只是把这些字段聚合一次的事。有了它,“某个功能上线后成本涨了”才能定位到具体功能,而不是全团队一起背锅。
看板要满足两条纪律:
- 口径必须和账单对得上。看板的”本月 CDN 成本”和阿里云账单里的数字必须能对齐,差一点就要能解释为什么差。对不上的看板比没有看板更危险——它会让人基于错误数字做决策。
- 必须能下钻。从”本月成本上涨”点进去,应该能看到”是 AVIF 派生图变多了”还是”是某个尺寸的请求量暴涨”。只显示总量的看板只能告诉你”有事发生了”,不能告诉你”该改什么”。
告警规则也要跟着走,成本异常很少是突变:
| 异常信号 | 判断依据 | 处置方向 |
|---|---|---|
| 单位展示成本环比上升 | 单位成本而不是总量 | 查格式分布、缓存命中率、是否存在异常请求 |
| 存储增速远超业务增速 | 存储增长 ÷ 业务增长 | 查临时文件残留、派生图是否无人访问 |
| 命中率下滑 | 缓存命中率的日环比 | 查缓存键设计、TTL、是否有新的 URL 参数 |
| 单图体积分布上移 | P95 体积而非均值 | 查压缩参数变更、是否有原图直出 |
最后一条心法:成本仪表盘是给开发每天看的,不是月底给财务看的。它的价值在于让成本问题在变成账单之前就被发现。
如果一个问题可以通过缓存解决,那就用缓存解决。
实践:多级缓存配置
“如果一个问题可以通过缓存解决,那就用缓存解决”——但前提是每一层解决的是不同的问题。四层缓存各管一段:
| 层级 | 缓存什么 | 典型 TTL | 失效方式 | 主要收益 |
|---|---|---|---|---|
| 浏览器 | 完整的图片响应 | 天级到周级 | 用户清缓存、页面主动失效 | 成本为零,重复访问最快 |
| CDN 边缘 | 派生图与处理结果 | 天级到周级 | 刷新接口、版本号切换 | 跨地域就近访问,命中率决定回源量 |
| 应用层(Redis) | 元数据、热点变体的处理结果 | 分钟级 | 业务事件、审核状态变更 | 挡住回源,也挡住重复计算 |
| 进程内(LRU) | 热点元数据、规则与配置 | 秒级 | TTL + 容量淘汰 | 减少 Redis 往返与序列化开销 |
四层的关系是越往下越通用、越往上越贴近业务语义。边缘缓存的键是 URL,Redis 的键可以是业务 ID,浏览器根本不知道业务存在。这样设计的好处是:业务状态一改,可以精确地失效下层,而不必等上层自然过期。
各层的具体写法:
第 1 层 浏览器 / CDN 边缘
Cache-Control: public, max-age=604800, stale-while-revalidate=86400
ETag: "a3f8c2e1-800-webp-v3"
(max-age 决定缓存多久;stale-while-revalidate 让过期后先返回旧内容再后台更新;
ETag 绑定内容版本,版本一变,边缘就知道这不是同一份内容)
第 2 层 应用层 Redis
key : img:variant:{image_id}:{size}:{format}
value : { object_key, etag, width, height, format, version }
ttl : 与边缘 TTL 对齐,但留出失效窗口
第 3 层 进程内 LRU
key : img:meta:{image_id}
value : 展示所需的元数据子集
容量 : 按条目数设上限,超出按 LRU 淘汰
失效顺序比失效策略更重要。以”图片被审核召回”为例,正确的顺序是:
1. 更新数据库状态:audit_status = blocked
2. 删除 Redis 里的变体缓存
3. 刷新 CDN 边缘缓存(按 URL 或目录)
4. 回源时返回 403 或占位图
反过来做就会出问题:先刷缓存再改状态,中间有个时间窗口,回源请求拿到的是旧内容,边缘又把旧内容缓存了一遍。用户看到的还是那张违规图。
还有两条纪律:
- 不要缓存不该缓存的东西。带签名的原图、用户的私有图片、错误响应,都不应该被缓存。错误响应一旦被缓存,就会变成”用户持续看到坏图”。
- 每一层都要能被观测。命中率、缓存大小、淘汰次数、失效次数分开统计。只看总命中率,会掩盖”下层命中率很高、上层失效太频繁”这类结构性问题。
能异步的就不要同步,用户等得起的结果,不要让他等着。
安全不是一道墙,是一层又一层的防护。
如果你看不见,你就管不了。
实践:核心监控面板
“如果你看不见,你就管不了”——这句话要落成一个每天都会打开的页面。我的监控面板分五组:
| 面板组 | 关键指标 | 口径 | 用来发现什么 |
|---|---|---|---|
| 流量与命中 | 请求量、边缘命中率、回源率、4xx / 5xx 比例 | CDN 访问日志按分钟聚合 | 流量突增、缓存失效、错误上升 |
| 性能 | 边缘延迟 P50 / P95 / P99、TTFB、LCP | 边缘日志 + 真实用户监控 | 用户感知变慢,且能定位到是回源还是传输 |
| 质量 | 单图体积分布、格式分布、SSIM 抽样合格率 | 处理记录 + 边缘日志 | 压缩策略退化、格式转换没覆盖到 |
| 处理 | 队列积压量、处理延迟 P99、失败率、重试次数 | 处理服务与队列指标 | 算力不足、某个格式的编码在批量失败 |
| 成本 | 单位展示成本、存储增速、回源流量 | 账单 + 埋点 + 用量统计 | 成本异常增长,并定位到具体维度 |
三个设计上的要点:
- 面板要能回答”现在影响用户了吗”。所以每个面板都同时有技术指标和用户指标:延迟变高如果 LCP 没变,说明变高的只是长尾,可以慢慢优化;LCP 同步变差,就得立刻处理。
- 每个指标都要有基线。没有基线,“命中率 95%“只是一个数字;有了基线,“命中率比上周降了 6 个百分点”才是告警。基线可以取上周同期,也可以取同星期几的均值——图片流量有明显的周内波动,用简单均值当基线周末必然误报。
- 告警要分级,也要降噪。把”提示 / 严重 / 致命”分开,只有致命级才叫人起床。同一个根因引发的多个指标异常(比如源站挂了会同时抬高延迟、5xx 和回源率)要合并成一条告警,否则一次故障会收到二十条短信,然后所有人都不再相信告警。
监控面板和缓存面板是配套的:缓存面板告诉你”缓存在起作用”,监控面板告诉你”没缓存在起作用的地方在哪里”。两者一起看,才能判断某个指标异常是缓存的问题还是缓存键设计的问题。
不要为了技术而技术。最简单的方案往往是最好的方案。
六个月的完整数据:
六个月里发生的事
第 1 个月 上线。OSS 直传,处理服务 4 台常驻
第 2 周:深夜接到网安电话,接入云端鉴黄 API
第 3 周:设计师反馈 UI 截图被压糊,上线无损压缩
第 3 个月 凌晨两点收到存储告警,发现 87% 的增长是垃圾文件
同步启动生命周期管理
第 4 个月 月账单里存储费 380 元,冷热分析上线,缩略图单独分层
接入 CDN,广州用户首字节从 3200 ms 降到 120 ms
第 5 个月 成本审计:5226.10 元,服务器占 81%,制定三条降本路径
路径落地后降到 3897.56 元
第 6 个月 服务器弹性伸缩加混合部署,账单降到 802.10 元
广州用户首字节 10 ms
真正想说明的是,每个问题都是被一次具体的失败逼出来的:
遇到的问题 采取的措施 结果
──────────────────────────────────────────────────────────────────
广州用户等 3.2 秒 接入阿里云 CDN 首字节 3200 ms → 120 ms,命中后 10 ms
上传要等 12 秒 处理流程异步化,丢消息队列 12 s → 0.3 s
存储费越涨越快 生命周期规则加冷热分层 324.00 元 → 97.50 元,容量 2.1 TB → 1.7 TB
AI 把人体艺术照判成色情 分级审核、阈值配置化、加申诉通道 准确率 86.5% → 97.5%,误报率 15% → 1.5%
月账单 5226 元 三条降本路径加弹性伸缩 5226.10 元 → 802.10 元
最值得看的不是任何一个绝对值,而是两个比例。
第一个是同样的用户量,成本差了一个数量级。第五个月和第六个月的用户数都是 12000,成本却从 5226.10 元降到 802.10 元,降了 84.6%。按第五个月的账单,这套系统一年要多花五万多元——而这部分钱本来可以拿来给用户做更清晰的处理。
第二个是规模和体验的脱钩。业务量涨上去的同时,广州用户的首字节延迟从 3200 ms 变成了 10 ms。规模上去了,体验还变好了,这才是这一轮改造真正的成绩。
成本的完整构成:
| 成本项 | 优化前 | 三条路径之后 | 弹性伸缩之后 |
|---|---|---|---|
| 存储费 | 324.00 元 | 97.50 元 | 60.00 元 |
| CDN 流量费 | 720.00 元 | 307.96 元 | 250.00 元 |
| 回源流量费 | 150.00 元 | 60.00 元 | 40.00 元 |
| 处理费 | 0.88 元 | 0.88 元 | 0.88 元 |
| 请求费 | 1.14 元 | 1.14 元 | 1.14 元 |
| 审核费 | 0.08 元 | 0.08 元 | 0.08 元 |
| 服务器 | 4030.00 元 | 3430.00 元 | 450.00 元 |
| 合计 | 5226.10 元 | 3897.56 元 | 802.10 元 |
最后一列的目标是下一轮要做的:存储再降 37.50 元靠派生图清理和分级归档,CDN 流量再降 57.96 元靠命中率和长尾 URL 参数收敛,服务器从 3430 元压到 450 元靠弹性伸缩和混合部署。
回头看这六个月,最重要的经验其实只有一句:每一次改动都从一个具体的失败出发,每一次失败都留下了可以量化的记录。 账单、告警、投诉——这些不是运营的负担,而是系统的反馈输入。
写到这里,“光影”的图片系统设计课程就结束了。
回头看,这不是一个关于”最优架构”的故事。这是一个关于问题驱动迭代的故事——从一张 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 条是所有决策的基石:保护数据、用缓存加速、靠数据驱动
最终答案:
保留原则 1「原图不可丢」、原则 4「缓存为王」、原则 7「可观测先行」。前两条守住数据与性能底线,第三条让后续所有优化都有依据;渐进增强与简单至上更偏实现策略,可以后置。
思考 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 条原则(异步、安全、可观测、简单)在规模增长时需要调整具体方案,但原则本身不变。
最终答案:
需要升级实现的是原则 4(缓存为王:CDN 多 Region + 进程内 LRU + 更激进的预热)与原则 8(简单至上:必要时自建图片处理服务与源站以控成本);原则 1、2、3、6 完全不变,原则 5、7 随规模加强但原则本身不变。核心是前 4 条(数据、渐进、成本、缓存)规模无关,后 4 条调整方案不调原则。
思考 3
回顾”光影”的 6 个月,如果让你重来一次,你会怎么安排开发优先级?
解题思路:
重排开发顺序的依据是「每一步是否给下一步创造条件」。监控和 CDN 都不是功能,但它们决定了后面的优化能不能被验证、体验能不能被感知,所以必须提前。
详细步骤:
如果重来,我会按这个顺序开发:
第 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),不走过场
核心改变:先让系统可观测、可用,再迭代功能。不是先堆功能再补监控。
最终答案:
按生存线 → 体验线 → 安全线 → 成本线 → 规模线推进:第 1 周做 OSS 存储、客户端直传、基础缩略图,并把监控系统提前到第 1 周;第 2 周做 CDN 加速与渐进增强;第 34 周做内容审核与凭证安全;第 23 月做格式支持与存储分层;第 4~6 月做弹性伸缩与边缘裁剪。核心原则是:先让系统可观测、可用,再迭代功能。
发现内容有问题?
告诉我们哪里需要改进。
