导航菜单

关键决策回顾

那些让我纠结到凌晨三点的选择

回头看”光影”的 6 个月开发历程,技术方案的选型并不是一路顺风的。很多决策当时纠结了很久——甚至有几个选择,我后来发现并非最优解。

这一章,我想诚实地回顾每一个关键决策:当时考虑了什么、放弃了什么、结果如何。

决策 1:为什么先审后处理?

背景:用户上传一张图片后,是先审核内容再生成缩略图,还是先生成缩略图再审核?

方案 A:先审后处理(最终选择)
用户上传 → 审核 → 通过 → 生成缩略图 → 可见

方案 B:先处理后审核
用户上传 → 生成缩略图 → 审核 → 通过后可见

方案 C:边审边处理
用户上传 → 审核 + 处理并行 → 汇合 → 可见
DECISION_1 = {
    '问题': '审核与处理的顺序',
    '选择': '方案 A:先审后处理',
    '理由': [
        '1. 节约处理资源:违规图片不生成缩略图(每张图 5 个缩略图 = 5 次处理)',
        '2. 防止违规内容进入 CDN:审核不通过的图片不会出现在任何缓存中',
        '3. 简化状态机:图片状态只有 上传中→审核中→处理中→就绪',
    ],
    '替代方案': {
        '方案 B(先处理后审核)': {
            '优点': '用户等待时间更短(审核和处理并行)',
            '缺点': [
                '违规图片也会生成缩略图,浪费处理资源',
                '违规图片可能短暂出现在 CDN 缓存中',
                '审核不通过后需要额外清理缩略图和 CDN 缓存',
            ],
        },
        '方案 C(并行)': {
            '优点': '最快完成',
            '缺点': [
                '实现复杂度高(需要协调两个异步任务的结果)',
                '如果审核不通过,需要取消正在进行的处理任务',
                '对于"光影"的规模,收益不大',
            ],
        },
    },
    '结果': '正确决策。实际运行中约 2% 的图片审核不通过,'
            '节省了 2% × 5 = 10% 的处理资源',
}

但这个决策有一个副作用:用户上传后需要等审核完成(200ms1s)再等处理完成(13s),总共 2~4 秒才能看到图片。我通过在审核通过后立即返回”上传成功”、后台异步处理缩略图来缓解这个问题。

决策 2:为什么客户端直传 OSS?

背景:图片上传可以走服务器中转,也可以让客户端直接传到 OSS。

方案 A:客户端直传 OSS(最终选择)
客户端 → OSS(通过 STS 临时凭证)

方案 B:服务器中转
客户端 → 应用服务器 → OSS

方案 C:服务器中转 + 流式转发
客户端 → 应用服务器(流式转发)→ OSS
DECISION_2 = {
    '问题': '上传链路设计',
    '选择': '方案 A:客户端直传 OSS',
    '理由': [
        '1. 带宽成本:图片不经过服务器,节省服务器带宽',
        '2. 服务器压力:上传是带宽密集型操作,不走服务器可以减少实例数',
        '3. 速度:客户端直连 OSS 比经过服务器更快(少一跳)',
        '4. 分片上传:OSS SDK 原生支持大文件分片上传',
    ],
    '替代方案': {
        '方案 B(服务器中转)': {
            '优点': [
                '服务器可以在上传时立即处理(压缩、校验、打水印)',
                '更容易控制上传权限(不需要 STS)',
                '可以做上传限速',
            ],
            '缺点': [
                '服务器带宽成本高(ECS 带宽比 OSS 贵 3~5 倍)',
                '服务器是单点(上传高峰时可能成为瓶颈)',
                '大文件上传可能超时(服务器需要完整接收再转发)',
            ],
        },
        '方案 C(流式转发)': {
            '优点': '不需要完整接收再转发,内存占用更小',
            '缺点': '实现复杂,仍然消耗服务器带宽',
        },
    },
    '结果': '正确决策。直传让上传服务只需要处理 STS 签发和回调,'
            '3 台低配服务器即可支撑日均 450 张上传',
}

直传的风险:用户可能绕过审核,直接访问 OSS 上的原图。我通过以下措施防护:

# 直传安全措施
SECURITY_MEASURES = {
    'STS 临时凭证': {
        '有效期': '15 分钟',
        '权限': '仅允许 PUT 到 uploads/ 前缀',
        '限制': '单文件最大 20 MB',
    },
    'Bucket 策略': {
        '原图目录': '私有读写(需要签名 URL 才能访问)',
        '缩略图目录': '公共读(通过 CDN 分发)',
    },
    '上传回调': {
        '机制': 'OSS 上传完成后回调应用服务器',
        '校验': '验证文件类型、大小、上传者身份',
    },
}

决策 3:为什么选 WebP 作为主力格式?

背景:现代图片格式有 WebP 和 AVIF 两种选择。

方案 A:WebP 为主 + AVIF 辅助(最终选择)
方案 B:AVIF 为主 + WebP 兜底
方案 C:只用 WebP
方案 D:只用 JPEG(不转码)
DECISION_3 = {
    '问题': '图片格式选型',
    '选择': '方案 A:WebP 为主 + AVIF 辅助',
    '理由': [
        '1. WebP 浏览器支持率 97%(2024 年),几乎全覆盖',
        '2. WebP 比 JPEG 小 30~50%,效果显著',
        '3. AVIF 比 WebP 再小 30%,但支持率只有 92%,且编码速度慢 5~10 倍',
        '4. 渐进增强:先保证所有人都能看到图(WebP),再给支持的浏览器更好的体验(AVIF)',
    ],
    '替代方案对比': {
        'AVIF 为主': {
            '优点': '体积最小,画质最好',
            '缺点': [
                '编码速度慢(单张 5MB 照片转 AVIF 需要 3~5 秒,WebP 只要 0.5 秒)',
                '浏览器支持率 92%(2024 年初),8% 的用户看到 JPEG 兜底',
                '服务端需要更强的 CPU(处理服务成本增加)',
                'CDN 边缘处理对 AVIF 支持不完善',
            ],
        },
        '只用 WebP': {
            '优点': '简单,一套格式走天下',
            '缺点': '放弃了 AVIF 带来的额外 30% 体积优化',
        },
        '不转码(JPEG)': {
            '优点': '零处理成本,零延迟',
            '缺点': '体积大 2~3 倍,CDN 流量费翻倍',
        },
    },
    '实际方案': (
        '<picture> 标签做格式协商:\n'
        'AVIF → WebP → JPEG\n'
        '浏览器按优先级选择第一个支持的格式'
    ),
}

前端实现:

<!-- 渐进增强的 <picture> 标签 -->
<picture>
  <source
    srcset="https://cdn.guangying.com/thumbs/avif/2024/06/a3/abc.avif"
    type="image/avif"
  />
  <source
    srcset="https://cdn.guangying.com/thumbs/webp/2024/06/a3/abc.webp"
    type="image/webp"
  />
  <img
    src="https://cdn.guangying.com/thumbs/jpeg/2024/06/a3/abc.jpeg"
    alt="摄影师作品"
    loading="lazy"
  />
</picture>

事后评估:这个决策基本正确,但有一个遗憾——AVIF 的编码速度确实太慢。如果当时选择”AVIF 异步生成 + WebP 同步返回”,用户体验会更好。

决策 4:为什么用消息队列而不是直接调用?

背景:审核和处理是异步流程,可以用消息队列解耦,也可以直接在代码里调用。

方案 A:消息队列(RabbitMQ)(最终选择)
上传服务 → RabbitMQ → 审核服务 → RabbitMQ → 处理服务

方案 B:直接 HTTP 调用
上传服务 → HTTP 调用审核服务 → HTTP 调用处理服务

方案 C:数据库轮询
上传服务写入数据库 → 审核服务轮询数据库 → 处理服务轮询数据库
DECISION_4 = {
    '问题': '异步流程的通信方式',
    '选择': '方案 A:消息队列(RabbitMQ)',
    '理由': [
        '1. 解耦:上传服务不需要知道审核和处理的细节',
        '2. 缓冲:如果审核/处理服务宕机,消息队列会暂存任务',
        '3. 可扩展:每个服务可以独立扩容',
        '4. 可观测:队列深度是很好的监控指标',
    ],
    '替代方案': {
        '方案 B(HTTP 直接调用)': {
            '优点': '实现简单,不需要额外的基础设施',
            '缺点': [
                '紧耦合:任何一个服务宕机,整个链路中断',
                '没有缓冲:突发流量直接冲击下游服务',
                '没有重试机制(需要自己实现)',
                '难以监控(没有一个集中的地方看任务状态)',
            ],
        },
        '方案 C(数据库轮询)': {
            '优点': '不需要额外组件',
            '缺点': [
                '延迟高(轮询间隔 1~5 秒)',
                '数据库压力大(每秒 N 次查询)',
                '多实例竞争同一个任务(需要分布式锁)',
            ],
        },
    },
    '结果': '正确决策。RabbitMQ 在"光影"的规模下完全够用,'
            '月费 150 元换来的是整个链路的可靠性',
}

队列配置细节

# RabbitMQ 交换机和队列配置
QUEUE_CONFIG = {
    'exchange': 'image-events',
    'queues': {
        'upload-events': {
            'binding_key': 'image.uploaded',
            'consumers': ['audit-service'],
            'dlx': 'upload-dead-letter',  # 死信队列
        },
        'audit-events': {
            'binding_key': 'image.audited.*',
            'consumers': ['process-service', 'notification-service'],
            'dlx': 'audit-dead-letter',
        },
        'process-events': {
            'binding_key': 'image.processed',
            'consumers': ['notification-service'],
            'dlx': 'process-dead-letter',
        },
    },
    'retry_policy': {
        'max_retries': 3,
        'delay_seconds': [30, 300, 3600],  # 30s → 5min → 1h
    },
}

决策 5:为什么用 CDN 边缘裁剪而不是预生成所有尺寸?

背景:缩略图可以预先全部生成,也可以在 CDN 边缘实时裁剪。

方案 A:预生成固定尺寸 + CDN 边缘裁剪兜底(最终选择)
方案 B:完全预生成所有尺寸
方案 C:完全依赖 CDN 边缘裁剪
DECISION_5 = {
    '问题': '缩略图生成策略',
    '选择': '方案 A:预生成固定尺寸 + 边缘裁剪兜底',
    '理由': [
        '1. 预生成保证了常用尺寸的访问速度(CDN 缓存命中)',
        '2. 边缘裁剪解决了"新需求尺寸"的问题(不需要重新处理历史图片)',
        '3. 渐进策略:先上线预生成,验证后再开通边缘裁剪',
    ],
    '替代方案': {
        '方案 B(完全预生成)': {
            '优点': 'CDN 缓存命中率最高,不依赖边缘处理能力',
            '缺点': [
                '每次新增尺寸需要重新处理全量图片',
                '存储空间浪费(很多尺寸可能永远不会被请求)',
                '响应新需求的周期长(全量处理需要数小时)',
            ],
        },
        '方案 C(完全边缘裁剪)': {
            '优点': '灵活,任何尺寸都能实时生成,零存储浪费',
            '缺点': [
                '首次访问慢(边缘节点需要回源获取原图再裁剪)',
                'CDN 边缘处理有额外费用',
                '参数注入攻击风险(用户随意输入尺寸可能消耗资源)',
            ],
        },
    },
    '结果': '正确决策。5 个预生成尺寸覆盖了 95% 的场景,'
            '边缘裁剪处理了 5% 的特殊需求(如邮件模板、社交媒体分享卡片)',
}

决策 6:为什么文件命名用日期+哈希而不是自增 ID?

背景:图片存储路径可以有多种命名方式。

方案 A:日期 + 哈希(最终选择)
originals/2024/06/a3/d4e5f6...jpg

方案 B:自增 ID
originals/00001.jpg, originals/00002.jpg, ...

方案 C:用户 ID + 时间戳
originals/user_123/1717286400.jpg

方案 D:UUID
originals/550e8400-e29b-41d4-a716-446655440000.jpg
DECISION_6 = {
    '问题': '图片文件命名策略',
    '选择': '方案 A:日期 + 内容哈希',
    '格式': 'originals/{year}/{month}/{hash_prefix}/{content_hash}.{ext}',
    '示例': 'originals/2024/06/a3/d4e5f67890abcdef.jpg',
    '理由': [
        '1. 日期前缀:便于按时间范围扫描和管理(生命周期规则)',
        '2. 哈希前缀:OSS 内部按前缀分片,避免热点目录',
        '3. 内容哈希:相同内容的图片自动去重(省存储)',
        '4. 无序性:无法通过 URL 猜测其他图片的路径(安全)',
    ],
    '替代方案': {
        '方案 B(自增 ID)': {
            '优点': '简单,有序',
            '缺点': [
                '容易遍历(知道 00001 就能猜到 00002)',
                '单目录文件过多(影响 OSS 列举性能)',
            ],
        },
        '方案 C(用户 ID)': {
            '优点': '便于按用户管理',
            '缺点': [
                '大用户的目录会变成热点',
                '用户删除账号后不方便批量处理',
            ],
        },
        '方案 D(UUID)': {
            '优点': '全局唯一,无序',
            '缺点': '无法按时间管理,文件名太长',
        },
    },
    '结果': '正确决策。日期+哈希在管理性、性能和安全性之间取得了很好的平衡',
}

所有决策一览

┌───────────────────┬──────────────────────┬────────────────────────────────┐
│     决策问题      │       最终选择       │          核心理由              │
├───────────────────┼──────────────────────┼────────────────────────────────┤
│ 审核/处理顺序     │ 先审后处理           │ 省处理资源,防违规进 CDN       │
│ 上传链路          │ 客户端直传 OSS       │ 省带宽,快,支持分片           │
│ 图片格式          │ WebP 主力 + AVIF 辅助│ 97% 支持率 + 渐进增强          │
│ 异步通信          │ RabbitMQ 消息队列    │ 解耦,缓冲,可扩展            │
│ 缩略图策略        │ 预生成 + 边缘裁剪    │ 95% 场景预生成,5% 边缘兜底   │
│ 文件命名          │ 日期 + 内容哈希      │ 可管理 + 无热点 + 可去重      │
│ CDN 选型          │ 阿里云 CDN           │ 国内节点多,与 OSS 同厂商     │
│ 存储方案          │ 阿里云 OSS           │ 无需运维,按需付费            │
│ 监控方案          │ Prometheus+Grafana   │ 开源免费,生态丰富            │
│ 数据库            │ PostgreSQL           │ JSON 支持好,事务可靠         │
└───────────────────┴──────────────────────┴────────────────────────────────┘

决策模式总结

回看这些决策,我发现了三个模式:

DECISION_PATTERNS = {
    '模式 1:渐进增强优于一步到位': (
        'WebP 先行 + AVIF 后补;预生成先行 + 边缘裁剪后补。'
        '不追求一步到位的最优解,而是先跑起来再迭代。'
    ),
    '模式 2:托管服务优于自建': (
        'OSS > 自建存储;云 CDN > 自建 CDN;云审核 > 自建模型。'
        '小团队的时间应该花在业务逻辑上,不是基础设施。'
    ),
    '模式 3:解耦优于简单': (
        '消息队列 > 直接调用;STS 临时凭证 > 固定密钥。'
        '解耦带来的可扩展性和可靠性,远远超过额外的复杂度。'
    ),
}

那些”如果重来”的决策

不是所有决策都是最优的。诚实地记录那些我后悔的选择:

REGRETS = [
    {
        '决策': '最初用了本地磁盘存储图片',
        '问题': '服务器磁盘满了,迁移到 OSS 花了一整天',
        '教训': '一开始就用对象存储,不要自建文件存储',
        '影响': '中等(迁移期间有 2 小时不可用)',
    },
    {
        '决策': '审核只做了图片鉴黄,没做 OCR 文字审核',
        '问题': '有用户上传带违规文字的图片,被投诉后紧急加了 OCR',
        '教训': '内容审核要全面,不要只做最明显的',
        '影响': '低(快速修复了)',
    },
    {
        '决策': '一开始缩略图只生成了 JPEG 格式',
        '问题': '后来加 WebP 支持时,需要重新处理所有 5 万张图片',
        '教训': '格式支持要提前规划,一开始就生成多格式',
        '影响': '中等(处理花了 2 天,期间部分图片无 WebP)',
    },
    {
        '决策': '监控告警上线太晚(第 4 个月才加)',
        '问题': '前 4 个月出了好几次故障,都是用户反馈了才知道',
        '教训': '监控要和第一个功能一起上线',
        '影响': '高(影响了用户信任)',
    },
]

本节小结

我学到了什么

  • 每个技术选型都有取舍,关键是理清核心需求和约束条件
  • “光影”的决策模式:渐进增强、托管优先、解耦设计
  • 先审后处理、直传 OSS、消息队列——这些选择在事后看都是正确的
  • 最大的遗憾是监控上线太晚,应该在第一天就搭建

⚠️ 踩过的坑

  • 一开始用本地磁盘存储,差点丢数据
  • 缩略图只生成 JPEG,后来加 WebP 时需要全量重新处理
  • 没有做文字审核,被用户投诉后才补上

🎯 下一步:最后一节——从所有决策中提炼出的设计原则,以及”光影”从 0 到 1 的完整数据回顾。

我的思考

思考 1

如果”光影”面向的是海外市场(欧美用户为主),这些技术决策会有哪些不同?

参考答案

海外市场的技术选型会有显著差异:

云服务商:
- 国内:阿里云(OSS + CDN + 内容安全)
- 海外:AWS(S3 + CloudFront)或 Cloudflare(R2 + CDN)

CDN 选型:
- 国内:阿里云 CDN(国内 2800+ 节点)
- 海外:Cloudflare(全球 300+ 城市,免费计划无限流量)
  → 这意味着 CDN 流量费可能为 0!

图片格式:
- 海外 WebP 支持率更高(>98%)
- AVIF 支持率也更高(Safari 16+ 已支持)
- 可以更激进地使用 AVIF

内容审核:
- 海外没有强制鉴黄要求
- 但需要遵守 GDPR(欧盟)的数据隐私法规
- 用户有权删除所有数据("被遗忘权")
- 需要更完善的数据删除机制

存储 Region:
- 主要用户在北美和欧洲
- S3 部署在 us-east-1 + eu-west-1
- CloudFront 自动就近分发

成本差异:
- Cloudflare 免费计划让 CDN 成本降到 0
- S3 存储费略高于 OSS($0.023/GB vs ¥0.12/GB ≈ $0.017/GB)
- 但带宽费更贵(AWS 数据传出 $0.09/GB)
- 综合:海外运营成本可能更低(得益于 Cloudflare 免费流量)

思考 2

回顾所有决策,如果只能给一个新项目一条建议,你会说什么?

参考答案

监控先行,其他靠后。

为什么?

1. 没有监控,你不知道系统是否正常
   - CDN 命中率下降了?你不知道
   - 存储空间快满了?你不知道
   - 图片处理失败了?你不知道
   - 用户看到的是错误图片?你不知道

2. 没有监控,你无法做决策
   - WebP 转换节省了多少流量?不知道
   - 冷热分层的收益有多大?不知道
   - 缓存命中率是多少?不知道
   - 没有 data,所有优化都是拍脑袋

3. 没有监控,你无法发现后悔的决策
   - 如果第 1 天就有存储监控,我就能在垃圾堆积前发现问题
   - 如果第 1 天就有 CDN 监控,我就能在用户抱怨前发现延迟
   - 如果第 1 天就有错误监控,我就能在批量处理失败时第一时间知道

最佳实践:
- 第一行代码上线前,先搭好监控
- 监控什么:错误率、延迟、流量、存储
- 告警什么:任何超过阈值的情况
- "光影"最大的遗憾不是选错了技术,而是太晚看到数据

思考 3

消息队列选 RabbitMQ 还是 Kafka?对”光影”这个规模,有没有必要换?

参考答案

“光影”的规模用 RabbitMQ 完全够用,不需要换 Kafka。

对比:

RabbitMQ:
- 适合中小规模(日处理 < 1000 万条消息)
- 功能丰富(优先级队列、延迟消息、死信队列)
- 部署简单(单节点即可)
- 运维成本低
- "光影"日均消息量:450 × 3(上传→审核→处理)= 1350 条
  → RabbitMQ 处理这个量级绰绰有余

Kafka:
- 适合超大规模(日处理 > 1 亿条消息)
- 吞吐量极高(百万级 TPS)
- 持久化能力强(日志存储)
- 需要集群部署(至少 3 broker)
- 运维复杂度高

何时考虑换 Kafka:
- 用户量 > 100 万
- 日均上传 > 10 万张
- 需要消息回溯(重新消费历史消息)
- 需要流处理(实时分析访问日志)

结论:
不要过早优化基础设施。
RabbitMQ → Kafka 的迁移成本远高于继续使用 RabbitMQ 的成本。
等到 RabbitMQ 真的成为瓶颈再考虑。

搜索