列表页二十张图要么 403、要么加载慢到转圈——上线第一天,直接引用的 B 站和 YouTube 原始图片链接就集体翻车。最初我直接在前端引用 B 站、YouTube 的原始图片链接,上线第一天就翻车:列表页二十张图要么 403,要么加载慢到转圈。排查下来是防盗链和 CDN 限速,这才决定自己写缩略图代理。它统一承担防盗链绕过、尺寸缩放、格式统一、缓存加速四件事,下面把完整实现、性能数据和踩过的坑拆开讲。
一、为什么需要缩略图代理
直接拿平台 CDN 链接展示,问题比想象中多:
- 防盗链:YouTube/B 站 Referer 检查,跨站访问 403;
- 限速:CDN 对单 IP 限速,高并发打挂;
- 缩放:不同位置要不同尺寸,原图 4K 浪费带宽;
- 格式统一:各家格式不统一(jpg/webp/avif);
- CORS:跨域直接 img 显示受 CORS 限制。
五类问题里,防盗链 403 和限速是直接可见的故障,缩略图加载不出来,用户第一印象就差了;格式和尺寸问题则让带宽成本白白上涨。
二、整体架构
代理服务的架构不复杂,核心就一条链路:前端请求统一打到后端,由后端判断缓存、回源、压缩:
前端用 img 标签展示缩略图
↓
后端代理服务
├─ 检查缓存(Redis)
├─ 缓存命中:直接返回
└─ 缓存未命中:下载原图 → 压缩 → 缓存 → 返回
这条链路里缓存是命门:命中时 Redis 直出,毫秒级;未命中才回源下载。热点视频的缩略图命中率上来之后,回源量非常小,源站 CDN 也不会被我们打限速。
三、FastAPI 实现
主体逻辑写在 FastAPI 的一个接口里,参数就两个:原始图片 URL 和目标宽度:
@app.get('/api/thumb')
async def thumbnail(url: str, w: int = 480):
"""视频缩略图代理(带缓存 + 压缩)"""
# 1) 校验 URL(防止 SSRF)
if not is_safe_url(url):
raise HTTPException(400, "Invalid URL")
# 2) 缓存 key
cache_key = f'thumb:{hashlib.md5(url.encode()).hexdigest()}:{w}'
# 3) 查缓存
cached = await redis.get(cache_key)
if cached:
return Response(content=cached, media_type='image/webp')
# 4) 下载原图
async with httpx.AsyncClient() as client:
resp = await client.get(url, headers={
'User-Agent': 'Mozilla/5.0 ...',
'Referer': extract_referer(url)
})
img_bytes = resp.content
# 5) 压缩(可选)
if w < 1280:
img_bytes = await resize_image(img_bytes, width=w)
# 6) 缓存
await redis.setex(cache_key, 86400, img_bytes) # 24h
return Response(content=img_bytes, media_type='image/webp')
接口的顺序很关键:先校验 URL,再查缓存,然后回源下载,按需压缩,最后写缓存。缓存判断永远在下载之前,否则并发一上来,回源请求会把源站打爆。
四、SSRF 防护
代理能把任意 URL 变成”服务器帮你下载”,这正是 SSRF 的温床。上线前我把内网地址段全部拉黑:
import ipaddress
from urllib.parse import urlparse
BLOCKED_NETWORKS = [
ipaddress.ip_network('127.0.0.0/8'), # 回环
ipaddress.ip_network('10.0.0.0/8'), # 私网
ipaddress.ip_network('172.16.0.0/12'),
ipaddress.ip_network('192.168.0.0/16'),
ipaddress.ip_network('169.254.0.0/16'), # 链路本地
ipaddress.ip_network('::1/128'),
ipaddress.ip_network('fc00::/7'), # 仅本地地址段
]
def is_safe_url(url: str) -> bool:
"""校验 URL 不指向内网"""
try:
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return False
# 解析 IP
import socket
ip = socket.gethostbyname(parsed.hostname)
ip_obj = ipaddress.ip_address(ip)
for net in BLOCKED_NETWORKS:
if ip_obj in net:
return False
return True
except Exception:
return False
防护分两层:协议白名单加内网段拦截。DNS 解析这步不能省,否则攻击者用域名伪装指向内网的 IP,光靠 URL 字符串判断根本拦不住。
五、Referer 注入
防盗链的应对手段是模拟来源,不同平台检查的 Referer 不一样,我按域名做了映射:
def extract_referer(url: str) -> str:
if 'youtube.com' in url or 'ytimg.com' in url:
return 'https://www.youtube.com/'
if 'bilibili.com' in url:
return 'https://www.bilibili.com'
if 'douyin.com' in url:
return 'https://www.douyin.com/'
if 'xiaohongshu.com' in url:
return 'https://www.xiaohongshu.com'
return 'https://example.com'
映射表维护成本很低,新平台接入时加一行就行。个别平台还会校验 User-Agent,我统一带上了桌面浏览器的 UA。
六、图片压缩
原图动辄几 MB,4K 缩略图直接推给移动端太奢侈。压缩放在回源之后、缓存之前:
from PIL import Image
import io
async def resize_image(img_bytes: bytes, width: int) -> bytes:
"""等比缩放图片到指定宽度"""
img = Image.open(io.BytesIO(img_bytes))
if img.width <= width:
return img_bytes
ratio = width / img.width
height = int(img.height * ratio)
img = img.resize((width, height), Image.LANCZOS)
# 转 WebP(比 JPEG 小 30%)
if img.mode != 'RGB':
img = img.convert('RGB')
buf = io.BytesIO()
img.save(buf, format='WEBP', quality=85)
return buf.getvalue()
缩放优先转 WebP,同质量下比 JPEG 小大约三成。等比缩放只按宽度,避免裁切失真;图片本来就小于目标宽度时直接透传,省 CPU。
七、多尺寸支持
同一个视频在列表页、详情页、背景图要的尺寸完全不同,与其前端各裁各的,不如代理按参数出图:
| 位置 | 宽度 | 场景 |
|---|---|---|
| 列表卡片 | 320 | 缩略图 |
| 详情页 | 640 | 视频介绍 |
| 视频背景 | 1920 | 模糊背景 |
前端用参数 ?w=320 指定:
img 标签通过参数指定宽度
后端按 width 缓存不同尺寸:
cache_key = f'thumb:{hash}:{w}' # 不同尺寸不同缓存
尺寸参数挂进缓存 key,不同宽度的图各自缓存互不覆盖,前端只需要在链接上带 w 参数,改动量很小。
八、CDN 缓存
代理后端再套一层 CDN,边缘节点命中后用户访问几乎无感:
Cache-Control: public, max-age=86400
CDN 边缘命中后用户访问更快。两层缓存各自的过期时间要配合好:CDN 的 max-age 比 Redis 短一点,避免边缘节点集体过期后一窝蜂打到 Redis 回源。
九、批量预热
热门视频的缩略图,等用户第一次访问才生成就慢了。我会在视频进入热门榜时主动预热:
async def preheat_thumbnails(video_urls: list[str]):
"""预热热门视频缩略图"""
for url in video_urls:
thumb = get_thumbnail_url(url)
if thumb:
# 主动拉一次
async with httpx.AsyncClient() as client:
await client.get(f'https://cdn.example.com/api/thumb?url={thumb}')
预热只拉列表页需要的 320 宽度,其他尺寸按需生成。预热任务加了去重,避免定时任务重复拉同一个 URL。
十、性能数据
改造前后各测了一轮首屏数据:
| 场景 | 直链 | 代理 |
|---|---|---|
| 列表页 20 个缩略图 | 5s | 0.5s |
| 首屏加载 | 3s | 0.3s |
| CDN 缓存命中 | N/A | < 50ms |
从数据看,代理加缓存把列表页加载从 5 秒压到 0.5 秒,CDN 命中时 50ms 以内,对弱网用户尤其明显。
十一、缩略图占位
加载再快也有网络波动的时刻。占位图我用 blurhash 方案,先显示模糊色块,再平滑切到清晰图:
import blurhash
def encode_placeholder(img_bytes: bytes) -> str:
"""生成 blurhash 占位"""
img = Image.open(io.BytesIO(img_bytes))
return blurhash.encode(img, x_components=4, y_components=3)
前端:
img 标签 src 指向占位图
img 标签绑 :src 和 @load 事件
blurhash 把整张图编码成几十个字符的字符串,体积小、渲染快。前端在图片加载完成事件里切换,过渡自然不闪烁。
十二、踩过的坑
这套服务踩过的坑不少,按影响面排序:
- B 站图片防盗链:必须带 Referer: https://www.bilibili.com。
- YouTube maxresdefault.jpg 可能 404:fallback 到 hqdefault.jpg。
- 抖音 webp 不支持:用 jpg。
- 小红书图片加密:URL 是动态签名,每小时过期。平台签名后立即用。
- 大图片 OOM:1MB+ 图片 Pillow 加载时内存峰值 100MB。限制最大尺寸 4096×4096。
- 缓存击穿:同一 URL 高并发,缓存 miss 时 100 个请求同时回源。加 singleflight。
- CORS:直接 img 显示跨域图片一般没问题(图片天然允许),但 canvas 操作会触发 CORS 错误。
- HTTPS 证书:部分老图片 URL 是 HTTP,混合内容。强制 HTTPS 或代理转 HTTPS。
八个坑里,小红书签名过期和缓存击穿最容易造成线上故障:前者要抢时间拉取,后者靠 singleflight 合并回源。
十三、安全审计
代理服务是公网入口,访问日志和安全审计不能省,每个请求都记录调用者、URL、宽度:
import logging
logger = logging.getLogger('thumb_proxy')
@app.get('/api/thumb')
async def thumbnail(url: str, w: int = 480, user = None):
# 记录访问日志
logger.info('thumb_fetch', extra={
'user_id': user.id if user else None,
'url': url[:100], # 截断避免日志爆炸
'width': w
})
if not is_safe_url(url):
logger.warning('thumb_blocked_ssrf', extra={'url': url})
raise HTTPException(400, "Invalid URL")
# ...
日志里 URL 只截前 100 字符,防止日志库被撑爆。被 SSRF 拦截的请求单独打 warning,配合告警,攻击尝试一眼就能看到。
十四、对比三种方案
项目早期我在三种方案里犹豫过,摊开对比是这样:
| 方案 | 优点 | 缺点 | 平台选 |
|---|---|---|---|
| 直链 | 简单 | 防盗链/限速 | ❌ |
| 服务端代理 | 完全控制 | 占用带宽 | ✅ |
| Cloudflare Workers | 全球加速 | 复杂 | ❌(规模不够) |
Cloudflare Workers 的边缘节点确实诱人,但当时规模撑不起它的复杂度,服务端代理够用且可控,就先用了。等流量上来再迁不迟,接口层是隔离的,切换成本可控。
十五、未来优化
接下来要做的三件事,都在这条管线的延伸上:
- AVIF 格式:比 WebP 还小 20%,但浏览器支持度待提升;
- AI 缩略图:自动选最关键帧做封面;
- 视频预览动图:截取前 3 秒转 GIF。
AVIF 的收益比较直接,等浏览器兼容数据再更新一版就切;AI 选帧和视频预览动图属于体验升级,排期靠后。
常见问题(FAQ)
Q1:代理会增加延迟吗?
首次会增加 50-100ms。缓存命中后几乎无感。
Q2:能代理任意 URL 吗?
不能。SSRF 防护会拒绝内网地址。
Q3:缓存多大合适?
按用户访问量定。平台缓存 10 万张图,约 5GB Redis 内存。