大概半年前,我写了一个简单的Python脚本,用来下载FC2上的公开视频。当时只是自用,能满足需求就行。但随着身边朋友的使用和后来公开上线(也就是现在的 twittervideodownloaderx.com/fc2_downloader_cn),这个脚本经历了好几次彻底的重构。
这篇文章不讲具体的FC2反爬细节(之前聊过不少了),而是从软件架构的角度,复盘一下这个下载器从“单体脚本”到“分布式服务”的演进过程。希望能给正在做类似工具或想了解后端服务设计的同学一些参考。
第一阶段:单体脚本 —— “能跑就行”
架构描述:
最开始的版本就是一个纯粹的Python脚本,核心逻辑是:输入URL -> 解析页面 -> 返回直链 -> 调用wget或requests下载。
第一代架构:单体脚本
def download_fc2_video(fc2_url, output_path):
1. 解析
video_info = parse_fc2(fc2_url) 内部包含复杂的解析逻辑
2. 下载
download_file(video_info['url'], output_path,
headers={'Referer': fc2_url})
3. 可选:转码/合并等
if video_info.get('has_separate_audio'):
merge_audio_video(output_path)
print("下载完成!")
使用方式
download_fc2_video("https://video.fc2.com/content/xxx", "./video.mp4")
优点:
简单直接:所有逻辑都在一个文件里,依赖少,调试方便。
适合自用:对于个人偶尔下载,完全够用。
痛点与问题:
- 阻塞式操作:解析和下载是串行的,且是单线程。下载一个大文件时,整个程序卡住,无法处理新请求。
- 错误处理粗糙:网络超时、解析失败等异常,通常就是
print报错然后退出,缺乏重试和容错机制。 - 难以扩展:想加个Web界面?得改大量代码。想支持多用户并发?几乎不可能。所有功能耦合在一起,牵一发而动全身。
- 资源浪费:每下载一个视频,就要占用一个进程/线程,内存和CPU利用率很低。
这个阶段的代码,只能叫“脚本”,离“服务”还有十万八千里。

第二阶段:Web服务化 —— “让用户能用”
架构描述:
为了让更多人能方便地使用,我决定给它套上一个Web外壳。我选择了Python的轻量级框架Flask,将核心逻辑拆分为几个模块,并通过Redis作为任务队列来解耦。
[用户请求] -> Flask Web层 (接收URL) -> Redis队列 -> Worker进程 (解析+下载) -> 返回结果/文件
核心代码片段(Web层与任务解耦):
Web服务层 (Flask)
from flask import Flask, request, jsonify
import redis
import uuid
app = Flask(__name__)
redis_client = redis.StrictRedis(host='localhost', port=6379, db=0)
@app.route('/download', methods=['POST'])
def submit_download():
url = request.json.get('url')
task_id = str(uuid.uuid4())
将任务推入Redis队列
task_data = {'id': task_id, 'url': url, 'status': 'pending'}
redis_client.rpush('download_queue', json.dumps(task_data))
return jsonify({'task_id': task_id, 'status': 'pending'})
@app.route('/status/<task_id>')
def get_status(task_id):
从Redis中查询任务状态(由Worker更新)
data = redis_client.get(f'task:{task_id}')
return jsonify(json.loads(data))
if __name__ == '__main__':
app.run(debug=True)
Worker进程 (单独运行)
import redis
import json
import time
redis_client = redis.StrictRedis(...)
while True:
阻塞式获取任务
_, task_json = redis_client.blpop('download_queue')
task = json.loads(task_json)
try:
更新状态为 processing
redis_client.set(f'task:{task["id"]}', json.dumps({...}))
执行核心下载逻辑 (调用之前的解析函数)
result = download_fc2_video(task['url'])
更新状态为 completed,并存储结果路径
redis_client.set(f'task:{task["id"]}', json.dumps({
'status': 'completed',
'path': result['path']
}))
except Exception as e:
更新状态为 failed
redis_client.set(f'task:{task["id"]}', json.dumps({'status': 'failed', 'error': str(e)}))
time.sleep(0.1) 避免CPU空转
改进之处:
解耦:Web层和Worker层分离,Web只负责接收请求和返回状态,Worker专心干活。
异步处理:用户提交后立即获得task_id,不用一直等待下载完成,体验更好。
基础并发:可以启动多个Worker进程,并行处理多个下载任务,提高了吞吐量。
新出现的痛点:
- Worker单点故障:如果Worker进程挂了,队列里的任务就没人处理了。
- 下载仍在Worker内:下载大文件时,Worker仍然会被阻塞。如果同时有多个大文件下载,Worker资源很快耗尽。
- 状态管理复杂:任务状态(pending, processing, completed, failed)需要在Redis里维护,代码里到处都是状态更新的逻辑。
- 文件存储问题:下载的视频存在Worker的本地磁盘上,用户怎么获取?通过Web层提供下载?那Web层又变成了文件服务器,流量一高就垮。
第三阶段:微服务与事件驱动 —— “面向生产”
架构描述:
为了解决第二阶段的问题,我参考了一些现代后端的设计模式,对架构进行了大刀阔斧的重构。
[用户请求] -> API Gateway (认证/限流)
-> [解析服务] (无状态, 只解析URL, 返回视频元数据)
-> [下载任务] -> 消息队列 (Kafka/RabbitMQ)
-> [下载服务集群] (多个节点, 负责从FC2拉流)
-> 文件上传到 [对象存储 (S3/MinIO)]
-> [CDN分发]
-> [状态服务] (WebSocket, 实时推送进度)
核心变化与思考:
-
服务拆分:
解析服务:只负责从FC2页面提取视频元数据(标题、画质列表、音频流地址、视频流地址)。它是无状态的,可以水平扩展。
下载服务:不负责解析,只负责“拉流”。它从消息队列拿到任务(包含视频流地址),然后启动一个异步IO (aiohttp) 的下载任务,将数据流式传输到对象存储。
状态服务:通过WebSocket与用户保持连接,实时推送“解析中”、“下载中(XX%)”、“已完成”等进度。 -
事件驱动:不再用Redis简单队列,而是引入真正的消息队列(如RabbitMQ)。任务状态的变化通过事件来驱动。例如,“解析完成”事件触发“创建下载任务”动作,“下载完成”事件触发“通知状态服务”动作。
-
异步非阻塞IO:下载服务不再使用
requests这种同步库,全面拥抱aiohttp。一个下载服务进程可以同时维持成百上千个下载连接,而不会被阻塞。
下载服务核心 (异步非阻塞)
import asyncio
import aiohttp
from aiohttp import ClientSession
async def stream_to_storage(session, video_url, storage_path):
async with session.get(video_url, headers={'Referer': '...'}) as resp:
假设有一个异步的存储客户端
await storage_client.upload_fileobj(resp.content, storage_path)
async def download_worker():
async with ClientSession() as session:
while True:
从消息队列获取任务 (异步)
task = await message_queue.get()
为每个任务创建子任务,并发执行
asyncio.create_task(
stream_to_storage(session, task.video_url, task.storage_path)
)
不等待结果,立即处理下一个消息
运行事件循环
asyncio.run(download_worker())
- 对象存储与CDN:下载完成的视频不再留在Worker本地,而是统一上传到对象存储(我用的是MinIO自建)。然后通过CDN(如Cloudflare)对外提供下载链接。这样,真正的文件传输压力都落在了CDN上,我的源站和Worker只需要处理逻辑和流式上传,抗压能力大幅提升。
总结:架构演进带来的收益
经过这三轮重构,这个下载器终于从一个脆弱的脚本,变成了一个相对健壮的服务。目前的架构(也是twittervideodownloaderx.com正在运行的架构)具备以下能力:
高可用:任何单一服务(如某个下载节点)宕机,不影响整体。
弹性伸缩:下载高峰时,可以自动增加下载服务节点;低峰时,减少节点节省成本。
速度快:异步IO和CDN分发,确保了用户端的下载体验。
可观测性:每个服务都有日志和监控,出了问题能快速定位。
当然,架构没有银弹,每次演进都引入了新的复杂性(如分布式事务、消息丢失、服务发现等)。但总的来说,对于一个想要长期维护、服务多人的在线工具来说,这样的投入是值得的。
如果你也在折腾类似的下载服务,希望我的架构演进之路能给你一些参考。当然,如果你只是想安安静静下个视频,那么直接使用这个已经踩过无数坑的工具,可能是最省心的选择。
浙公网安备 33010602011771号