设计原则总结

从一张 8.7MB 的图片说起

六个月前,“光影”上线第一天,一张 8.7MB 的 RAW 照片拖垮了整台服务器。那是我第一次意识到——图片系统不是”存个文件”那么简单。

六个月后的今天,“光影”支撑着 12000 个摄影师、85000 张图片、日均 15 万次页面浏览。我坐在电脑前,回顾这个从 0 到 1 的过程,把踩过的坑、想明白的道理,提炼成了 8 条设计原则。

这些原则不是从教科书里抄的。每一条背后,都是凌晨三点的告警短信。

原则 1:原图不可丢

用户上传的原图是唯一的数据源,缩略图可以重新生成,原图丢了就真的丢了。

实践:原图保护机制

“原图不可丢”有两层含义:数据不丢,和原图不被人随便拿走。前者靠备份和版本控制,后者靠访问控制。两件事必须一起做,否则保护不住。

我把保护拆成四层:

层次手段解决什么问题不足
存储层原图与派生图分目录、分 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}"
    )

三个容易忽略的细节:

  1. 签名参数不能进 CDN 缓存键。否则每个签名都是一份独立缓存,命中率直接归零。正确做法是:缓存键只用对象路径,签名在边缘节点校验。上一节的”回源优化”里已经讲过这个原则。
  2. 付费下载原图要走独立通道。原图下载涉及计费和版权,需要单独的签名入口、单独的授权校验和完整的下载审计日志,不能和”页面里显示的图片”共用一套规则。
  3. 防盗链是补充,不是替代。Referer 可以被伪造,它只能挡住”直接复制链接到别的网站”这种最常见的行为;真正防住盗链的是签名 URL + 频率限制 + 异常下载告警。

有了这四层,原图既不会丢,也不会被无成本地搬走。

原则 2:渐进增强

永远有兜底方案。用户应该先看到内容,再看到更好的内容。

实践:前端渐进加载

“用户应该先看到内容,再看到更好的内容”落到前端,就是三层渐进:占位 → 可用 → 更好。

第 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;
}

几个必须做对的细节:

  1. 占位图和正式图的宽高比必须一致。否则图片加载完成的瞬间会撑开布局,CLS 直接把”渐进加载”变成”抖动”。
  2. 首屏图不要加 loading="lazy"。首屏图是 LCP 元素,懒加载等于让它自己推迟自己,LCP 必然变差。首屏用 fetchpriority="high" 提优先级,屏幕之外的才用 loading="lazy"。
  3. decoding="async" 让解码不阻塞主线程。在低端机上,一张大图的同步解码会卡住渲染。
  4. 第 3 层要按需。原图不该默认加载,应该等用户点击”查看原图”再请求,并且原图本身要走签名 URL。

这一套做下来,用户感知的是”图片慢慢变清晰”,而不是”图一直糊着”或者”图突然出现”。渐进增强的本质是把等待拆开,让每一步都有可交付的内容。

原则 3:成本意识

从第一天就关心成本。不是省钱,是确保项目可持续。

实践:成本仪表盘

“从第一天就关心成本”这句话,如果不落到一个每天都会看的面板上,就只是一句口号。

我的成本仪表盘分五个面板,每个面板回答一个具体问题:

面板关键指标口径回答的问题
总览本月总成本、日均成本、各成本项占比、与上月环比按账单成本项拆分钱主要花在哪
单位成本每千次展示成本、单张图片月均成本、单张平均流量账单费用 ÷ 业务埋点次数成本涨了是因为业务涨了,还是因为效率退化了
存储原图 / 派生图 / 临时文件容量、存储类型分布、增长趋势对象存储用量统计有没有垃圾在堆积
流量CDN 出网流量、回源流量、边缘命中率、按格式与地域的分布CDN 访问日志聚合带宽异常来自哪个维度
处理任务量、失败率、处理延迟 P99、派生图数量处理服务记录算力够不够、派生图是不是失控

再加一块归属视图:按业务线、按场景、按用户等级把成本分摊开。元数据表里本来就记了业务归属,成本分摊只是把这些字段聚合一次的事。有了它,“某个功能上线后成本涨了”才能定位到具体功能,而不是全团队一起背锅。

看板要满足两条纪律:

  1. 口径必须和账单对得上。看板的”本月 CDN 成本”和阿里云账单里的数字必须能对齐,差一点就要能解释为什么差。对不上的看板比没有看板更危险——它会让人基于错误数字做决策。
  2. 必须能下钻。从”本月成本上涨”点进去,应该能看到”是 AVIF 派生图变多了”还是”是某个尺寸的请求量暴涨”。只显示总量的看板只能告诉你”有事发生了”,不能告诉你”该改什么”。

告警规则也要跟着走,成本异常很少是突变:

异常信号判断依据处置方向
单位展示成本环比上升单位成本而不是总量查格式分布、缓存命中率、是否存在异常请求
存储增速远超业务增速存储增长 ÷ 业务增长查临时文件残留、派生图是否无人访问
命中率下滑缓存命中率的日环比查缓存键设计、TTL、是否有新的 URL 参数
单图体积分布上移P95 体积而非均值查压缩参数变更、是否有原图直出

最后一条心法:成本仪表盘是给开发每天看的,不是月底给财务看的。它的价值在于让成本问题在变成账单之前就被发现。

原则 4:缓存为王

如果一个问题可以通过缓存解决,那就用缓存解决。

实践:多级缓存配置

“如果一个问题可以通过缓存解决,那就用缓存解决”——但前提是每一层解决的是不同的问题。四层缓存各管一段:

层级缓存什么典型 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 或占位图

反过来做就会出问题:先刷缓存再改状态,中间有个时间窗口,回源请求拿到的是旧内容,边缘又把旧内容缓存了一遍。用户看到的还是那张违规图。

还有两条纪律:

  1. 不要缓存不该缓存的东西。带签名的原图、用户的私有图片、错误响应,都不应该被缓存。错误响应一旦被缓存,就会变成”用户持续看到坏图”。
  2. 每一层都要能被观测。命中率、缓存大小、淘汰次数、失效次数分开统计。只看总命中率,会掩盖”下层命中率很高、上层失效太频繁”这类结构性问题。

原则 5:异步优先

能异步的就不要同步,用户等得起的结果,不要让他等着。

原则 6:安全纵深

安全不是一道墙,是一层又一层的防护。

原则 7:可观测先行

如果你看不见,你就管不了。

实践:核心监控面板

“如果你看不见,你就管不了”——这句话要落成一个每天都会打开的页面。我的监控面板分五组:

面板组关键指标口径用来发现什么
流量与命中请求量、边缘命中率、回源率、4xx / 5xx 比例CDN 访问日志按分钟聚合流量突增、缓存失效、错误上升
性能边缘延迟 P50 / P95 / P99、TTFB、LCP边缘日志 + 真实用户监控用户感知变慢,且能定位到是回源还是传输
质量单图体积分布、格式分布、SSIM 抽样合格率处理记录 + 边缘日志压缩策略退化、格式转换没覆盖到
处理队列积压量、处理延迟 P99、失败率、重试次数处理服务与队列指标算力不足、某个格式的编码在批量失败
成本单位展示成本、存储增速、回源流量账单 + 埋点 + 用量统计成本异常增长,并定位到具体维度

三个设计上的要点:

  1. 面板要能回答”现在影响用户了吗”。所以每个面板都同时有技术指标和用户指标:延迟变高如果 LCP 没变,说明变高的只是长尾,可以慢慢优化;LCP 同步变差,就得立刻处理。
  2. 每个指标都要有基线。没有基线,“命中率 95%“只是一个数字;有了基线,“命中率比上周降了 6 个百分点”才是告警。基线可以取上周同期,也可以取同星期几的均值——图片流量有明显的周内波动,用简单均值当基线周末必然误报。
  3. 告警要分级,也要降噪。把”提示 / 严重 / 致命”分开,只有致命级才叫人起床。同一个根因引发的多个指标异常(比如源站挂了会同时抬高延迟、5xx 和回源率)要合并成一条告警,否则一次故障会收到二十条短信,然后所有人都不再相信告警。

监控面板和缓存面板是配套的:缓存面板告诉你”缓存在起作用”,监控面板告诉你”没缓存在起作用的地方在哪里”。两者一起看,才能判断某个指标异常是缓存的问题还是缓存键设计的问题。

原则 8:简单至上

不要为了技术而技术。最简单的方案往往是最好的方案。

“光影”从 0 到 1 的数据回顾

六个月的完整数据:

六个月里发生的事

第 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 边缘裁剪
  ✅ 完善监控告警
  → 目标:抗增长

与实际的区别:

  1. 监控从第 5 个月提前到第 1 周
  2. CDN 从第 2 个月提前到第 2 周
  3. 审核 from 第 2 个月提前到第 3 周
  4. 缩略图从一开始就生成多格式(WebP + JPEG),不走过场

核心改变:先让系统可观测、可用,再迭代功能。不是先堆功能再补监控。

最终答案:

按生存线 → 体验线 → 安全线 → 成本线 → 规模线推进:第 1 周做 OSS 存储、客户端直传、基础缩略图,并把监控系统提前到第 1 周;第 2 周做 CDN 加速与渐进增强;第 34 周做内容审核与凭证安全;第 23 月做格式支持与存储分层;第 4~6 月做弹性伸缩与边缘裁剪。核心原则是:先让系统可观测、可用,再迭代功能。

发现内容有问题?

告诉我们哪里需要改进。