在线文件处理服务架构设计:如何支撑 10 万日活的 PDF/图片转换
发布于 2026-09-01
问题背景
我们的在线 PDF 转换服务在日活 10 万量级下遇到了三个核心问题:Gunicorn worker 超时(HTTP 500)、内存泄漏导致容器频繁重启、单文件处理太慢影响用户体验。本文分享最终落地的架构方案。
旧架构的问题
用户 → Nginx → Gunicorn sync worker → 同步处理(阻塞)→ S3
↑ 4 个 worker,任何长任务都会占满
问题清单:
- Gunicorn sync worker 被长任务占满:PDF OCR 一次要 15 秒,4 个 worker 全被占 → 后续请求排队超时
- Registry 坏条目阻塞:历史遗留的 registry_index.json 条目指向不存在的文件,每个条目触发 10 次重试 ≈ 1.1s,15 条 → 16.5s 超时
- 进程级内存膨胀:图片处理任务的 PIL 对象不会被及时回收
新架构:同步入口 + 异步任务调度器
用户 → Nginx → Gunicorn(sync 4 worker,只做 API 层)
↓ 立即返回 task_id
Redis 任务队列
↓
独立 TaskScheduler(多进程)
↓
专用 Worker 进程池
↓
每个任务独立子进程 → 处理完自动释放内存
核心设计:每个任务跑在独立子进程里
import multiprocessing as mp
def run_in_isolated_process(task_func, *args, **kwargs):
"""把 CPU 密集型任务放到独立子进程,处理完自动释放内存"""
ctx = mp.get_context('spawn')
queue = ctx.Queue()
proc = ctx.Process(target=task_func, args=(queue, *args), kwargs=kwargs)
proc.start()
proc.join(timeout=120)
if proc.is_alive():
proc.terminate() # 硬杀,避免内存泄漏
proc.join()
raise TimeoutError('任务超时')
return queue.get()
Registry 自动清理 + 降级
为了解决旧机器拷贝过来的坏条目问题,做了三层防御:
# 1. FileBatchRegistry.get() 加载失败自动 pop 坏条目
def get(self, batch_id):
try:
return self._read_entry(batch_id)
except FileNotFoundError:
self._registry.pop(batch_id, None) # 坏条目直接删
self._save_index()
return None
# 2. load_batch_info() 在调用 set_batch_task_dir 之前先检查目录
# 不存在立即 raise,而不是创建空壳目录 + 重试 10 次
candidate = os.path.join(TEST_DIR, user_id, batch_id)
if not os.path.isdir(candidate):
raise FileNotFoundError(f'批次目录不存在: {candidate}')
# 3. get_user_task_num() 整体 + 内层逐条 try/except,降级返回 0
try:
return sum(1 for batch_id in registry if ...)
except Exception:
return 0
效果对比
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 平均响应时间(PDF 压缩) | 4.2s | 1.8s |
| HTTP 500 错误率 | 3.7% | 0.02% |
| 容器重启频率 | 每日 3-5 次 | 0 |
| 峰值并发 | 60 | 300+ |
经验总结
"在线文件处理服务的核心不是优化某个算法,而是隔离任务生命周期——每个任务独立子进程、独立内存、独立超时,哪怕它挂了也不会影响整个服务。"
这个原则在 OCR、AI 抠图、视频压缩三类 CPU 密集型任务上都得到了验证。
常见问题
这篇文章的代码可以直接用吗?
文中代码示例均来自真实线上项目,但具体使用需要根据你的环境调整参数和依赖版本。
有问题想请教作者怎么办?
欢迎在文章页面留言评论,我们看到后会尽快回复。也可以通过网站联系我们。