导航菜单

设计原则总结

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

与实际的区别

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

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

搜索