文章目录
- Go实现大文件异步流式采集引擎
-
-
- 为什么要做这个工具
-
- 项目概览
-
- 技术栈
- 项目结构
- 数据库设计
- 设计原则
-
- RabbitMQ 异步任务队列
-
- Docker 本地启动
- 两种消息格式
- 完整数据流
- 生产者/消费者
-
- 核心:流式下载的实现
-
- 为什么不能用 io.ReadAll
- 流式下载:io.Copy + progressWriter
- 本地文件名来源
- 禁用 HTTP/2 避免 flow control panic
-
- 本地存储 + OSS 上传
-
- 三种存储策略
- OSS 内网/外网域名分离
-
- 并发控制与原子占位
-
- 信号量控制并发
- PickNotSyncRow 三步精确占位
- 残留记录重置
-
- 日志和监控
-
- 日志文件
- 监控面板
-
- 踩坑和故障排查
-
- 踩坑过程
- 故障排查指南
- 常见问题
-
- 本地运行
-
- 前置条件
- 一键启动
- 手动添加采集任务
-
- Linux服务器部署
-
- systemd 守护进程
- 常用命令
-
- ClassIn 视频采集全流程
-
- ClassIn API 客户端
- ClassIn 消费者
- 启动顺序
- 扩展:如何接入新的采集源
-
- 测试用例
-
- 模式1: api --- 仅测试 ClassIn API 连通性
- 模式2: direct --- 直接入库
- 模式3: mq --- 完整 RabbitMQ 流程
- 测试课节配置
- 端到端验证结果
-
- 完整代码
-
Go实现大文件异步流式采集引擎
从视频同步实战中提炼的通用大文件采集工具,支持流式下载、可选本地留存/OSS上传、进度日志、监控面板。
1. 为什么要做这个工具
因为项目需求,需要改造一个已有的大存储视频文件同步服务的Go的项目,在排查问题和改造的过程中,我遇到了一系列问题,最主要的报错信息就是panic: flow control update exceeds maximum window size,经过分析,得出以下结论:
- 内存爆炸 :用
io.ReadAll一次性读取大文件到内存,400MB 的视频文件直接 OOM - 无法追踪进度:上传一个 1GB 的文件,中间只能干等,不知道卡在下载还是上传
- 并发冲突:多个协程同时处理同一条记录,重复下载上传
- 服务崩溃无人值守:进程 panic 后不会自动恢复,第二天上班才发现一堆文件没同步
这些问题单独看都不复杂,但组合在一起就形成了一个很尴尬的局面:服务能跑,但跑不稳;能同步,但不知道同步到哪了。每次出问题都要翻日志、查数据库、手动重试,运维成本极高。
优化完成了这个项目之后,我决定把这些踩过的坑全部解决,并提炼成一个通用的文件采集引擎。核心设计理念:
- 流式处理 --- 内存占用恒定,不管文件多大
- 异步解耦 --- RabbitMQ 消息队列驱动,生产者消费者分离
- 可观测性 --- 单文件进度日志 + Web 监控面板,随时掌握状态
- 可插拔可扩展 --- Adapter 接口设计,适配更多采集服务只需实现一个接口
最终效果:服务 7x24 小时无人值守运行,崩溃自动重启,每个文件的下载/上传进度实时可查,出问题自动重试。
#mermaid-svg-KnSDmiXYL00RUSPZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KnSDmiXYL00RUSPZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KnSDmiXYL00RUSPZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KnSDmiXYL00RUSPZ .error-icon{fill:#552222;}#mermaid-svg-KnSDmiXYL00RUSPZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KnSDmiXYL00RUSPZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KnSDmiXYL00RUSPZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KnSDmiXYL00RUSPZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KnSDmiXYL00RUSPZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KnSDmiXYL00RUSPZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KnSDmiXYL00RUSPZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KnSDmiXYL00RUSPZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KnSDmiXYL00RUSPZ .marker.cross{stroke:#333333;}#mermaid-svg-KnSDmiXYL00RUSPZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KnSDmiXYL00RUSPZ p{margin:0;}#mermaid-svg-KnSDmiXYL00RUSPZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KnSDmiXYL00RUSPZ .cluster-label text{fill:#333;}#mermaid-svg-KnSDmiXYL00RUSPZ .cluster-label span{color:#333;}#mermaid-svg-KnSDmiXYL00RUSPZ .cluster-label span p{background-color:transparent;}#mermaid-svg-KnSDmiXYL00RUSPZ .label text,#mermaid-svg-KnSDmiXYL00RUSPZ span{fill:#333;color:#333;}#mermaid-svg-KnSDmiXYL00RUSPZ .node rect,#mermaid-svg-KnSDmiXYL00RUSPZ .node circle,#mermaid-svg-KnSDmiXYL00RUSPZ .node ellipse,#mermaid-svg-KnSDmiXYL00RUSPZ .node polygon,#mermaid-svg-KnSDmiXYL00RUSPZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KnSDmiXYL00RUSPZ .rough-node .label text,#mermaid-svg-KnSDmiXYL00RUSPZ .node .label text,#mermaid-svg-KnSDmiXYL00RUSPZ .image-shape .label,#mermaid-svg-KnSDmiXYL00RUSPZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-KnSDmiXYL00RUSPZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KnSDmiXYL00RUSPZ .rough-node .label,#mermaid-svg-KnSDmiXYL00RUSPZ .node .label,#mermaid-svg-KnSDmiXYL00RUSPZ .image-shape .label,#mermaid-svg-KnSDmiXYL00RUSPZ .icon-shape .label{text-align:center;}#mermaid-svg-KnSDmiXYL00RUSPZ .node.clickable{cursor:pointer;}#mermaid-svg-KnSDmiXYL00RUSPZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KnSDmiXYL00RUSPZ .arrowheadPath{fill:#333333;}#mermaid-svg-KnSDmiXYL00RUSPZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KnSDmiXYL00RUSPZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KnSDmiXYL00RUSPZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KnSDmiXYL00RUSPZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KnSDmiXYL00RUSPZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KnSDmiXYL00RUSPZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KnSDmiXYL00RUSPZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KnSDmiXYL00RUSPZ .cluster text{fill:#333;}#mermaid-svg-KnSDmiXYL00RUSPZ .cluster span{color:#333;}#mermaid-svg-KnSDmiXYL00RUSPZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KnSDmiXYL00RUSPZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KnSDmiXYL00RUSPZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-KnSDmiXYL00RUSPZ .icon-shape,#mermaid-svg-KnSDmiXYL00RUSPZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KnSDmiXYL00RUSPZ .icon-shape p,#mermaid-svg-KnSDmiXYL00RUSPZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KnSDmiXYL00RUSPZ .icon-shape rect,#mermaid-svg-KnSDmiXYL00RUSPZ .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KnSDmiXYL00RUSPZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KnSDmiXYL00RUSPZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KnSDmiXYL00RUSPZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 推送/手动
消费
流式下载
流式上传
更新状态
查询
查询
信息源
RabbitMQ
采集引擎
本地存储
阿里云 OSS
MySQL
监控面板
2. 项目概览
技术栈
选型原则:够用就行,不追新不炫技。每个组件都是经过生产验证的成熟方案,避免引入不必要的复杂度。
| 组件 | 选型 | 说明 |
|---|---|---|
| HTTP 框架 | Gin | 轻量高效,生态丰富,团队熟悉度高 |
| ORM | GORM | 简洁的 Go ORM,AutoMigrate 省去手动建表 |
| 消息队列 | RabbitMQ | Docker 本地部署,生产可用阿里云 AMQP,支持消息确认和重试 |
| 对象存储 | 阿里云 OSS | 官方 SDK,支持 CNAME 自定义域名和内网上传 |
| 数据库 | MySQL 8.0 | 存储采集状态和元数据,DATETIME 时间格式可读优先 |
| 进程守护 | systemd | Linux 原生,崩溃自动重启,零额外依赖 |
💡 为什么不用 Kafka?对于文件采集场景,RabbitMQ 的消息确认机制(ACK/NACK)更适合------每条消息对应一个采集任务,需要确保处理成功后才确认。Kafka 更擅长高吞吐日志场景,这里杀鸡不用牛刀。
项目结构
项目采用经典的 分层架构,每个模块职责单一,方便定位问题和独立测试。标注 🔑 的是核心模块:
rx-go-grab/
├── main.go # 入口:启动HTTP + 消费者 + 采集引擎
├── config.yaml # 配置文件(含cookie/OSS/RabbitMQ)
├── internal/
│ ├── config/config.go # 配置结构体
│ ├── database/database.go # MySQL 连接
│ ├── model/
│ │ ├── information.go # 基础信息表
│ │ └── grab_file.go # 采集文件表
│ ├── classinapi/client.go # 🔑 ClassIn API 客户端(getClassVodList)
│ ├── consumer/classin.go # 🔑 RabbitMQ 消费者(消息→调API→写DB)
│ ├── grabber/
│ │ ├── grabber.go # 采集引擎(轮询→下载→上传→更新DB)
│ │ ├── stream.go # 流式下载+上传核心
│ │ ├── progress.go # 进度日志记录器
│ │ └── adapter.go # 适配器接口
│ ├── adapter/classin.go # ClassIn 视频适配器
│ ├── oss/uploader.go # OSS 上传封装(支持 CNAME)
│ ├── queue/rabbitmq.go # RabbitMQ 生产/消费 + ClassInSyncMessage
│ ├── monitor/
│ │ ├── handler.go # 监控HTTP Handler
│ │ └── logic.go # 监控数据采集
│ └── router/router.go # Gin 路由
├── test/
│ └── test_classin_flow.go # 🔑 测试用例(3种模式)
├── templates/monitor.html # 监控页面模板
├── sql/init.sql # 建表 DDL(含完整 COMMENT)
└── script/ # 运维脚本(systemd + restart)
数据库设计
数据库设计的核心原则是 "看到表结构就知道业务在干什么"。字段命名语义清晰,状态值用注释明确标注,时间字段用可读格式而非时间戳。
设计原则
- 时间字段统一使用
DATETIME(如2026-06-11 16:43:52),不用 Unix 时间戳,可读性优先 - 两张表一对多:
information(基础信息)→grab_file(采集文件) - 所有字段都有 COMMENT,方便在 Navicat / DBeaver 等工具中直接看到业务含义
file_status用 TINYINT 而非 VARCHAR,查询性能好,索引体积小
💡 为什么不用 Unix 时间戳?因为 90% 的排查场景是直接看数据库,
2026-06-11 16:43:52比1749656632直观得多。存储层多占 4 字节的代价可以忽略不计。
information 表 --- 基础信息
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT UNSIGNED | 主键 |
| unique_id | VARCHAR(100) | 业务唯一标识(UNIQUE) |
| name | VARCHAR(255) | 名称 |
| extra_name | VARCHAR(255) | 附加名称 |
| source_url | VARCHAR(500) | 来源URL |
| status | TINYINT | 0待处理 1处理中 2已完成 |
| remark | TEXT | 备注 |
| created_at / updated_at | DATETIME | 自动维护 |
grab_file 表 --- 采集文件
这是整个系统的核心表,每个文件从"待采集"到"已完成"的全生命周期都记录在这一行里。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT UNSIGNED | 主键 |
| info_id | INT UNSIGNED | 关联 information.id |
| file_url | VARCHAR(1000) | 源文件URL |
| file_name | VARCHAR(255) | 文件名 |
| file_status | TINYINT | 0待采集 1采集中 2已完成 3失败 |
| file_size / file_size_mb | BIGINT / DECIMAL | 文件大小 |
| local_path | VARCHAR(500) | 本地存储路径 |
| oss_object_key | VARCHAR(500) | OSS 对象键 |
| oss_url | VARCHAR(500) | OSS 完整访问URL |
| sync_start_time / sync_end_time | DATETIME | 同步时间 |
| sync_duration | VARCHAR(50) | 耗时描述 |
| error_msg | TEXT | 失败原因 |
| source_type | TINYINT | 1手动添加 2推送 |
| sequence_no | INT | 序号 |
完整 DDL 见
sql/init.sql,包含所有字段的 COMMENT。
3. RabbitMQ 异步任务队列
为什么需要消息队列?因为文件采集是一个耗时操作(几分钟到几十分钟不等),如果同步处理,HTTP 请求早就超时了。通过 MQ 解耦后,生产者只管发布任务,采集引擎按自己的节奏消费,互不干扰。
同时 RabbitMQ 的 ACK/NACK 机制 提供了天然的重试保障:消息处理失败时 Nack 回队列,不会丢失任务。
Docker 本地启动
开发环境用 Docker 一行命令启动,无需额外配置:
bash
# 拉取带有管理插件的版本
docker pull rabbitmq:management
# 创建并启动容器
docker run -d \
--name rabbitmq \
-p 5672:5672 \
-p 15672:15672 \
-e RABBITMQ_DEFAULT_USER=admin \
-e RABBITMQ_DEFAULT_PASS=admin123 \
rabbitmq:management
# 查看容器状态
docker ps | grep rabbitmq
# 查看日志
docker logs rabbitmq
5672:消息通信端口(生产者/消费者连接);15672:管理界面 Web 端口
访问 http://localhost:15672(账号 admin/admin123)查看管理界面。
两种消息格式
系统支持两种消息格式,分别对应不同的使用场景:
1. 采集文件消息(GrabMessage) :用于直接指定文件 URL,适合临时采集单个文件:
json
{
"fileId": 123,
"fileUrl": "https://example.com/video/abc.mp4",
"fileName": "abc.mp4",
"objectKey": "123/abc.mp4"
}
2. 同步消息(ClassInSyncMessage) :指定课节,由消费者自动调 API 获取视频列表,适合批量同步:
json
{
"courseId": 284880687,
"classId": 1029404489,
"courseName": "雅思课程",
"className": "雅思阅读 (10)"
}
完整数据流
下图展示了从消息发布到文件上传完成的完整链路,每个环节都是异步解耦的:
#mermaid-svg-uIHVXcLjwXQ5B8RC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uIHVXcLjwXQ5B8RC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uIHVXcLjwXQ5B8RC .error-icon{fill:#552222;}#mermaid-svg-uIHVXcLjwXQ5B8RC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uIHVXcLjwXQ5B8RC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uIHVXcLjwXQ5B8RC .marker.cross{stroke:#333333;}#mermaid-svg-uIHVXcLjwXQ5B8RC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uIHVXcLjwXQ5B8RC p{margin:0;}#mermaid-svg-uIHVXcLjwXQ5B8RC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uIHVXcLjwXQ5B8RC .cluster-label text{fill:#333;}#mermaid-svg-uIHVXcLjwXQ5B8RC .cluster-label span{color:#333;}#mermaid-svg-uIHVXcLjwXQ5B8RC .cluster-label span p{background-color:transparent;}#mermaid-svg-uIHVXcLjwXQ5B8RC .label text,#mermaid-svg-uIHVXcLjwXQ5B8RC span{fill:#333;color:#333;}#mermaid-svg-uIHVXcLjwXQ5B8RC .node rect,#mermaid-svg-uIHVXcLjwXQ5B8RC .node circle,#mermaid-svg-uIHVXcLjwXQ5B8RC .node ellipse,#mermaid-svg-uIHVXcLjwXQ5B8RC .node polygon,#mermaid-svg-uIHVXcLjwXQ5B8RC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uIHVXcLjwXQ5B8RC .rough-node .label text,#mermaid-svg-uIHVXcLjwXQ5B8RC .node .label text,#mermaid-svg-uIHVXcLjwXQ5B8RC .image-shape .label,#mermaid-svg-uIHVXcLjwXQ5B8RC .icon-shape .label{text-anchor:middle;}#mermaid-svg-uIHVXcLjwXQ5B8RC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uIHVXcLjwXQ5B8RC .rough-node .label,#mermaid-svg-uIHVXcLjwXQ5B8RC .node .label,#mermaid-svg-uIHVXcLjwXQ5B8RC .image-shape .label,#mermaid-svg-uIHVXcLjwXQ5B8RC .icon-shape .label{text-align:center;}#mermaid-svg-uIHVXcLjwXQ5B8RC .node.clickable{cursor:pointer;}#mermaid-svg-uIHVXcLjwXQ5B8RC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uIHVXcLjwXQ5B8RC .arrowheadPath{fill:#333333;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uIHVXcLjwXQ5B8RC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uIHVXcLjwXQ5B8RC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uIHVXcLjwXQ5B8RC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uIHVXcLjwXQ5B8RC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uIHVXcLjwXQ5B8RC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uIHVXcLjwXQ5B8RC .cluster text{fill:#333;}#mermaid-svg-uIHVXcLjwXQ5B8RC .cluster span{color:#333;}#mermaid-svg-uIHVXcLjwXQ5B8RC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uIHVXcLjwXQ5B8RC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uIHVXcLjwXQ5B8RC rect.text{fill:none;stroke-width:0;}#mermaid-svg-uIHVXcLjwXQ5B8RC .icon-shape,#mermaid-svg-uIHVXcLjwXQ5B8RC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uIHVXcLjwXQ5B8RC .icon-shape p,#mermaid-svg-uIHVXcLjwXQ5B8RC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uIHVXcLjwXQ5B8RC .icon-shape rect,#mermaid-svg-uIHVXcLjwXQ5B8RC .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uIHVXcLjwXQ5B8RC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uIHVXcLjwXQ5B8RC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uIHVXcLjwXQ5B8RC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} PublishClassInSync
Consume
调用 getClassVodList
返回视频列表
写入
写入
轮询 file_status=0
流式下载
流式上传
更新状态
全部完成
测试用例/API
RabbitMQ Queue
ClassIn消费者
ClassIn API
information 表
grab_file 表
采集引擎
本地存储
阿里云 OSS
information.status=2
生产者/消费者
生产者发布消息、消费者接收消息的代码非常简洁,所有复杂性(重试、确认、错误处理)都封装在 rabbitmq.go 内部:
go
// internal/queue/rabbitmq.go
// 发布 ClassIn 同步消息
mq.PublishClassInSync(&queue.ClassInSyncMessage{
CourseID: 284880687, ClassID: 1029404489,
CourseName: "雅思课程", ClassName: "雅思阅读",
})
// 发布直接采集消息
mq.Publish(&queue.GrabMessage{FileID: 123, FileURL: "...", ObjectKey: "..."})
// 消费消息
deliveries, _ := mq.Consume()
for msg := range deliveries {
// 处理消息...
msg.Ack(false)
}
4. 核心:流式下载的实现
这是整个项目最核心的部分,也是解决"内存爆炸"问题的关键。
为什么不能用 io.ReadAll
先看一个反面教材。这个项目早期的实现方式是这样的:
go
// 错误做法:一次性读取整个文件到内存
data, _ := io.ReadAll(resp.Body) // 400MB 视频直接 OOM!
io.ReadAll 会把整个 HTTP 响应体读进内存。对于小文件(几 KB 的 JSON)没问题,但对于大文件(几百MB甚至几GB),内存瞬间爆掉。实测 400MB 的视频直接 OOM 崩溃。
流式下载:io.Copy + progressWriter
解决思路很简单:不要一次性读完,而是像流水一样一段一段地传 。io.Copy 是 Go 标准库提供的流式拷贝工具,内部维护一个 32KB 的 buffer,循环 Read → Write,内存占用恒定:
go
// 正确做法:流式写入本地文件,内存占用恒定
localFile, _ := os.Create(localPath)
pw := &ProgressWriter{writer: localFile, progress: progressLogger}
written, _ := io.Copy(pw, resp.Body) // 每次只读 32KB buffer
io.Copy 的原理是维护一个 32KB 的 buffer,循环 Read → Write,内存占用恒定。不管文件是 100MB 还是 10GB,内存始终只占 32KB。
internal/grabber/stream.go 核心代码:
go
pw := &ProgressWriter{writer: localFile, progress: progress}
written, err := io.Copy(pw, resp.Body)
localFile.Close()
if err != nil {
_ = os.Remove(localFullPath)
return 0, "", "", fmt.Errorf("下载文件到本地失败: %w", err)
}
实现原理:
#mermaid-svg-atVOtz5HvsS8tdbC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-atVOtz5HvsS8tdbC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-atVOtz5HvsS8tdbC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-atVOtz5HvsS8tdbC .error-icon{fill:#552222;}#mermaid-svg-atVOtz5HvsS8tdbC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-atVOtz5HvsS8tdbC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-atVOtz5HvsS8tdbC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-atVOtz5HvsS8tdbC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-atVOtz5HvsS8tdbC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-atVOtz5HvsS8tdbC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-atVOtz5HvsS8tdbC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-atVOtz5HvsS8tdbC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-atVOtz5HvsS8tdbC .marker.cross{stroke:#333333;}#mermaid-svg-atVOtz5HvsS8tdbC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-atVOtz5HvsS8tdbC p{margin:0;}#mermaid-svg-atVOtz5HvsS8tdbC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-atVOtz5HvsS8tdbC .cluster-label text{fill:#333;}#mermaid-svg-atVOtz5HvsS8tdbC .cluster-label span{color:#333;}#mermaid-svg-atVOtz5HvsS8tdbC .cluster-label span p{background-color:transparent;}#mermaid-svg-atVOtz5HvsS8tdbC .label text,#mermaid-svg-atVOtz5HvsS8tdbC span{fill:#333;color:#333;}#mermaid-svg-atVOtz5HvsS8tdbC .node rect,#mermaid-svg-atVOtz5HvsS8tdbC .node circle,#mermaid-svg-atVOtz5HvsS8tdbC .node ellipse,#mermaid-svg-atVOtz5HvsS8tdbC .node polygon,#mermaid-svg-atVOtz5HvsS8tdbC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-atVOtz5HvsS8tdbC .rough-node .label text,#mermaid-svg-atVOtz5HvsS8tdbC .node .label text,#mermaid-svg-atVOtz5HvsS8tdbC .image-shape .label,#mermaid-svg-atVOtz5HvsS8tdbC .icon-shape .label{text-anchor:middle;}#mermaid-svg-atVOtz5HvsS8tdbC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-atVOtz5HvsS8tdbC .rough-node .label,#mermaid-svg-atVOtz5HvsS8tdbC .node .label,#mermaid-svg-atVOtz5HvsS8tdbC .image-shape .label,#mermaid-svg-atVOtz5HvsS8tdbC .icon-shape .label{text-align:center;}#mermaid-svg-atVOtz5HvsS8tdbC .node.clickable{cursor:pointer;}#mermaid-svg-atVOtz5HvsS8tdbC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-atVOtz5HvsS8tdbC .arrowheadPath{fill:#333333;}#mermaid-svg-atVOtz5HvsS8tdbC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-atVOtz5HvsS8tdbC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-atVOtz5HvsS8tdbC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-atVOtz5HvsS8tdbC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-atVOtz5HvsS8tdbC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-atVOtz5HvsS8tdbC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-atVOtz5HvsS8tdbC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-atVOtz5HvsS8tdbC .cluster text{fill:#333;}#mermaid-svg-atVOtz5HvsS8tdbC .cluster span{color:#333;}#mermaid-svg-atVOtz5HvsS8tdbC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-atVOtz5HvsS8tdbC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-atVOtz5HvsS8tdbC rect.text{fill:none;stroke-width:0;}#mermaid-svg-atVOtz5HvsS8tdbC .icon-shape,#mermaid-svg-atVOtz5HvsS8tdbC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-atVOtz5HvsS8tdbC .icon-shape p,#mermaid-svg-atVOtz5HvsS8tdbC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-atVOtz5HvsS8tdbC .icon-shape rect,#mermaid-svg-atVOtz5HvsS8tdbC .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-atVOtz5HvsS8tdbC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-atVOtz5HvsS8tdbC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-atVOtz5HvsS8tdbC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 32KB buffer
Write
AddBytes
HTTP Response Body
progressWriter
本地文件
进度日志
流式传输有一个副作用:因为数据是分块传输的,所以无法直接知道当前进度。解决方案是用装饰器模式包装 Writer/Reader,在每次 Write/Read 时累加字节数,顺便记录进度日志:
go
// internal/grabber/stream.go
type ProgressWriter struct {
writer io.Writer
progress *ProgressLogger
}
func (pw *ProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
pw.progress.AddBytes(int64(n)) // 追踪已传输字节数
return n, err
}
同理,上传 OSS 时用 progressReader 包装 io.Reader,原理完全一样:
go
file, _ := os.Open(localPath)
pr := &ProgressReader{reader: file, progress: progressLogger}
bucket.PutObject(objectKey, pr) // 流式上传,不加载到内存
本地文件名来源
这是一个容易被忽视但很实用的细节。StreamDownloadAndUpload 接受 fileName 参数,优先使用数据库的 file_name 字段,为空时才从 URL 尾部提取:
go
// internal/grabber/stream.go
// fileName 参数来自数据库 grab_file.file_name
func StreamDownloadAndUpload(cfg, uploader, fileURL, fileName, objectKey, tag, progress) {
if fileName == "" {
// 兜底: 从 URL 提取
parts := strings.Split(fileURL, "/")
fileName = parts[len(parts)-1]
}
// 本地路径: storage/2026/06/11/5145403724701034009_f0.mp4
localRelativePath := datePath + "/" + fileName
}
为什么不用 URL 末段?这是一个踩坑经验。ClassIn 视频的 URL 末段都是
f0.mp4,多个视频会互相覆盖。数据库中存储的是fileId_fileName格式(如5145403724701034009_f0.mp4),确保唯一。
禁用 HTTP/2 避免 flow control panic
这是另一个踩过的大坑。Go 默认的 HTTP 客户端会尝试使用 HTTP/2,但在大文件场景下会出问题:
go
// internal/grabber/stream.go
transport := &http.Transport{
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
}
client := &http.Client{Transport: transport}
这是踩过的大坑:Go 的 HTTP/2 实现在大文件场景下,如果 response.Body 没有被完全消费就关闭,会触发 flow control panic。禁用 HTTP/2 强制使用 HTTP/1.1 可以避免。
具体表现是服务随机 panic 崩溃,而且很难复现------因为只有在特定网络条件下(如连接被服务端提前关闭)才会触发。生产环境跑了几个月才定位到这个问题。
5. 本地存储 + OSS 上传
下载完成后,文件可以上传到 OSS 实现持久化存储。但不同场景对本地文件的需求不同------有时需要保留本地副本方便快速回放,有时磁盘空间有限需要及时清理。因此我设计了三种存储策略。
三种存储策略
通过 config.yaml 的 storage_mode 配置,灵活适配不同场景:
| 模式 | 说明 |
|---|---|
local_only |
仅下载到本地,不上传 OSS |
oss_only |
下载后上传 OSS,然后删除本地文件 |
both |
下载到本地 + 上传 OSS,本地文件保留(默认) |
生产环境推荐用
both,本地保留一份作为快速回放缓存,OSS 作为持久化备份。磁盘空间不足时可以定期清理storage/目录。
OSS 内网/外网域名分离
这是一个性能优化点。如果服务器在阿里云 ECS 上,上传走内网 endpoint(免费、快速,不占公网带宽),访问走外网域名(CNAME 自定义域名):
yaml
oss:
endpoint: "test.oss-cn-beijing-internal.aliyuncs.com" # 内网,上传用
file_host: "https://oss-test.xxxx.com" # 外网,访问用
use_cname: true
代码实现:
go
// internal/oss/uploader.go
// Upload 流式上传 io.Reader 到 OSS
func (u *Uploader) Upload(objectKey string, reader io.Reader) error {
if !u.enabled {
return fmt.Errorf("OSS未启用")
}
return u.bucket.PutObject(objectKey, reader)
}
// GetFullURL 获取文件的完整访问URL
func (u *Uploader) GetFullURL(objectKey string) string {
if u.fileHost != "" {
return strings.TrimRight(u.fileHost, "/") + "/" + objectKey
}
// 回退: 拼接 bucket endpoint
if u.enabled {
return fmt.Sprintf("https://%s/%s", u.bucket.BucketName, objectKey)
}
return ""
}
6. 并发控制与原子占位
文件采集是 IO 密集型任务,单协程太慢。但多协程并发带来了两个问题:重复认领 (两个协程同时处理同一个文件)和崩溃残留(协程 panic 后文件状态卡在"采集中")。这两个问题必须同时解决,否则并发越多越乱。
信号量控制并发
用 Go 原生的 channel 作为信号量,简洁高效,不需要引入额外的并发库:
go
sem := make(chan struct{}, concurrency) // 如 concurrency=3
for {
file := pickNextFile()
sem <- struct{}{} // 等待有空位
go func(f *GrabFile) {
defer func() { <-sem }()
processFile(f)
}(file)
}
PickNotSyncRow 三步精确占位
这是并发控制的核心难点。多协程环境下,不能简单 UPDATE WHERE file_status=0 LIMIT 1,否则会重复认领。因此,我设计了一个三步占位法,利用数据库的原子性保证每个文件只被一个协程处理:
go
// internal/grabber/grabber.go
// 第一步: 找到最小的待采集 id
SELECT id FROM grab_file WHERE file_status=0 ORDER BY id ASC LIMIT 1
// 第二步: 原子占位(只有 file_status 仍为 0 才更新成功)
UPDATE grab_file SET file_status=1 WHERE id=? AND file_status=0
// 第三步: 查询完整记录
SELECT * FROM grab_file WHERE id=?
如果第二步 RowsAffected == 0,说明被其他协程抢先了,返回 nil 继续下一轮。这个设计的精妙之处在于:用数据库的原子 UPDATE 代替分布式锁,简单且可靠。
残留记录重置
服务异常退出时(如 kill -9、OOM),可能留下 file_status=1(采集中)的残留记录。如果不处理,这些文件永远不会被重新采集。解决方案:启动时统一重置:
go
db.Model(&GrabFile{}).Where("file_status = ?", 1).Update("file_status", 0)
7. 日志和监控
日志文件
"到底卡在哪了?"------这是运维最常问的问题。进度日志系统就是为了解决这个问题。每个采集任务独立一个日志文件,记录从开始到完成的每一个阶段,包括速度、剩余时间等关键指标。
go
// internal/grabber/progress.go
func (p *ProgressLogger) writeProgress() {
p.mu.Lock()
defer p.mu.Unlock()
if p.file == nil {
return
}
now := time.Now()
elapsed := now.Sub(p.startTime)
elapsedStr := FormatDuration(elapsed)
var speedStr, percentStr, etaStr string
if elapsed.Seconds() > 0 {
speedBps := float64(p.written) / elapsed.Seconds()
speedStr = FormatSpeed(speedBps)
if p.totalSize > 0 {
percent := float64(p.written) / float64(p.totalSize) * 100
if percent > 100 {
percent = 100
}
percentStr = fmt.Sprintf("%.1f%%", percent)
if speedBps > 0 && p.totalSize > p.written {
remaining := float64(p.totalSize-p.written) / speedBps
etaStr = FormatDuration(time.Duration(remaining) * time.Second)
} else {
etaStr = "即将完成"
}
} else {
percentStr = "未知"
etaStr = "未知"
}
}
line := fmt.Sprintf("[%s] 阶段: %s | 已传输: %s / %s | 进度: %s | 速度: %s | 已耗时: %s | 预计剩余: %s | 本地路径: %s\n",
now.Format("2006-01-02 15:04:05"), p.phase,
FormatBytes(p.written), FormatBytes(p.totalSize),
percentStr, speedStr, elapsedStr, etaStr, p.localPath,
)
_, _ = p.file.WriteString(line)
_ = p.file.Sync()
}
每个采集任务独立一个日志文件,格式:grab_20260611_182211_123.log。日志文件名包含时间戳和文件 ID,方便快速定位问题:

日志内容示例:
========== 文件采集进度日志 ==========
标签: [file:123]
源URL: https://example.com/video/abc.mp4
预期大小: 384.16MB
开始时间: 2026-06-11 18:22:11
======================================
[2026-06-11 18:22:41] 阶段: 下载中 | 已传输: 25.50MB / 384.16MB | 进度: 6.6% | 速度: 1.14MB/s | 已耗时: 30秒 | 预计剩余: 5分钟13秒 | 本地路径: /storage/2026/06/11/abc.mp4
[2026-06-11 18:23:11] 阶段: 下载中 | 已传输: 51.00MB / 384.16MB | 进度: 13.3% | 速度: 1.09MB/s | ...
[2026-06-11 18:45:22] 阶段: 上传OSS | 已传输: 384.16MB / 384.16MB | 进度: 100.0% | ...
[2026-06-11 18:45:22] 完成 | 总大小: 384.16MB | 总耗时: 23分钟11秒 | OSS: https://...
日志设计亮点:
- 完成后自动移动到
OK/2026/06/11/目录归档,方便快速区分成功/失败任务 - 记录间隔可配置(
log_interval: 30,单位秒),避免日志文件过大 - 包含本地路径和 OSS URL,方便快速定位文件位置
监控面板
日志是"事后查看"的,监控面板是"实时查看"的。访问 http://localhost:8080/monitor,包含 4 个 Tab,覆盖了运维最常用的查看场景:
| Tab | 内容 |
|---|---|
| 概览 | 总文件数、各状态数量、队列深度 |
| 消息队列 | 队列信息、一键清空 |
| 采集日志 | 实时日志文件,展开查看最后50行 |
| 服务器信息 | 内存、磁盘、CPU负载、运行时间 |
认证方式:页面内嵌登录表单,输入一次账号密码后 localStorage 自动登录。密码从 config.yaml 读取,避免裸奔。静态页面放在templates/monitor.html。

8. 踩坑和故障排查
踩坑过程
这部分记录的都是真实生产环境遇到的问题,每个都导致了服务异常或数据错误。个人感悟:最好的代码不是写出来的,是改出来的。
1. HTTP/2 flow control 导致 panic
现象 :下载大文件时偶尔 panic: connection reset by peer
根因:Go 的 HTTP/2 实现要求 response.Body 被完整消费。如果提前关闭(如状态码非 200),flow control 计数器不一致导致 panic。
修复:
go
// 非成功响应也必须消费完 body
if resp.StatusCode != 200 {
_, _ = io.ReadAll(resp.Body) // 消费完再关闭
return fmt.Errorf("下载失败: %s", resp.Status)
}
同时禁用 HTTP/2(见第六章)。
2. git pull 后忘了 go build
现象:改了代码,git pull 到服务器,但行为没变。
根因 :git pull 只更新源码,运行的还是旧二进制文件。
修复 :发布脚本必须包含 go build 步骤。
3. 连表查询字段歧义
现象 :Column 'id' in field list is ambiguous
根因 :多表 JOIN 时,两个表都有 id 字段。
修复 :连表查询时用 grab_file.id 带表前缀。
4. 本地文件名覆盖
现象 :多个视频下载到本地后,文件名都是 f0.mp4,互相覆盖,最终只剩一个文件。
根因 :从 URL 尾部提取文件名时,ClassIn 视频 URL 末段都是 f0.mp4(如 .../b9f7e850.../f0.mp4)。
修复 :使用数据库 file_name 字段(格式 fileId_fileName,如 5145403724701034009_f0.mp4),而非从 URL 提取。
5. OSS AccessKey 权限问题
现象 :AccessDenied 或 InvalidAccessKeyId
排查:
- 登录阿里云 OSS 管理台,确认 AccessKey 是否启用
- 确认 RAM 用户有 OSS 读写权限(
AliyunOSSFullAccess) - 确认 Bucket 的跨域和读写策略
故障排查指南
服务出问题时不要慌,按下面的决策树一步步排查,90% 的问题都能快速定位:
#mermaid-svg-AMX58dsNw9rgC3Km{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AMX58dsNw9rgC3Km .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AMX58dsNw9rgC3Km .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AMX58dsNw9rgC3Km .error-icon{fill:#552222;}#mermaid-svg-AMX58dsNw9rgC3Km .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AMX58dsNw9rgC3Km .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AMX58dsNw9rgC3Km .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AMX58dsNw9rgC3Km .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AMX58dsNw9rgC3Km .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AMX58dsNw9rgC3Km .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AMX58dsNw9rgC3Km .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AMX58dsNw9rgC3Km .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AMX58dsNw9rgC3Km .marker.cross{stroke:#333333;}#mermaid-svg-AMX58dsNw9rgC3Km svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AMX58dsNw9rgC3Km p{margin:0;}#mermaid-svg-AMX58dsNw9rgC3Km .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-AMX58dsNw9rgC3Km .cluster-label text{fill:#333;}#mermaid-svg-AMX58dsNw9rgC3Km .cluster-label span{color:#333;}#mermaid-svg-AMX58dsNw9rgC3Km .cluster-label span p{background-color:transparent;}#mermaid-svg-AMX58dsNw9rgC3Km .label text,#mermaid-svg-AMX58dsNw9rgC3Km span{fill:#333;color:#333;}#mermaid-svg-AMX58dsNw9rgC3Km .node rect,#mermaid-svg-AMX58dsNw9rgC3Km .node circle,#mermaid-svg-AMX58dsNw9rgC3Km .node ellipse,#mermaid-svg-AMX58dsNw9rgC3Km .node polygon,#mermaid-svg-AMX58dsNw9rgC3Km .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-AMX58dsNw9rgC3Km .rough-node .label text,#mermaid-svg-AMX58dsNw9rgC3Km .node .label text,#mermaid-svg-AMX58dsNw9rgC3Km .image-shape .label,#mermaid-svg-AMX58dsNw9rgC3Km .icon-shape .label{text-anchor:middle;}#mermaid-svg-AMX58dsNw9rgC3Km .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-AMX58dsNw9rgC3Km .rough-node .label,#mermaid-svg-AMX58dsNw9rgC3Km .node .label,#mermaid-svg-AMX58dsNw9rgC3Km .image-shape .label,#mermaid-svg-AMX58dsNw9rgC3Km .icon-shape .label{text-align:center;}#mermaid-svg-AMX58dsNw9rgC3Km .node.clickable{cursor:pointer;}#mermaid-svg-AMX58dsNw9rgC3Km .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-AMX58dsNw9rgC3Km .arrowheadPath{fill:#333333;}#mermaid-svg-AMX58dsNw9rgC3Km .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-AMX58dsNw9rgC3Km .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-AMX58dsNw9rgC3Km .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AMX58dsNw9rgC3Km .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-AMX58dsNw9rgC3Km .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AMX58dsNw9rgC3Km .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-AMX58dsNw9rgC3Km .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-AMX58dsNw9rgC3Km .cluster text{fill:#333;}#mermaid-svg-AMX58dsNw9rgC3Km .cluster span{color:#333;}#mermaid-svg-AMX58dsNw9rgC3Km div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-AMX58dsNw9rgC3Km .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-AMX58dsNw9rgC3Km rect.text{fill:none;stroke-width:0;}#mermaid-svg-AMX58dsNw9rgC3Km .icon-shape,#mermaid-svg-AMX58dsNw9rgC3Km .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-AMX58dsNw9rgC3Km .icon-shape p,#mermaid-svg-AMX58dsNw9rgC3Km .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-AMX58dsNw9rgC3Km .icon-shape rect,#mermaid-svg-AMX58dsNw9rgC3Km .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-AMX58dsNw9rgC3Km .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-AMX58dsNw9rgC3Km .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-AMX58dsNw9rgC3Km :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否
卡住
服务异常
进程是否存活?
检查 journalctl -u rx-go-grab
常见: 端口占用/配置错误/MySQL断连
文件是否在采集?
检查 grab_file 表 file_status=0 的记录
检查采集引擎日志
检查进度日志 logs/ 目录
常见: 源URL不可达/OSS权限不足
检查 error_msg 字段
常见问题
以下是生产环境中最常遇到的问题及排查方法:
| 问题 | 排查命令 |
|---|---|
| 服务启动失败 | journalctl -u rx-go-grab -n 50 |
| 文件采集卡住 | 查看 logs/grab_*.log 进度日志 |
| OSS 上传失败 | 检查 grab_file.error_msg;确认 AccessKey 有效 |
| 下载速度慢 | 检查源服务器限速;考虑使用 OSS 内网 endpoint |
| 内存持续增长 | 检查 goroutine 泄漏:curl localhost:8080/api/stats |
9. 本地运行
本章介绍如何在本地快速启动服务,适合开发调试和首次体验。
前置条件
确保本地已安装以下环境:
- Go 1.21+
- MySQL 8.0(本地运行)
- RabbitMQ(Docker 方式)
一键启动
按顺序执行以下命令,5 分钟内即可跑起来:
bash
# 1. 启动 RabbitMQ
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
# 2. 创建数据库并建表
mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS \`rx-go-grab\` DEFAULT CHARSET utf8mb4"
mysql -u root -p rx-go-grab < sql/init.sql
# 3. 修改 config.yaml 中的 MySQL 密码和 OSS 配置
# 4. 运行
go run main.go
# 或者编译后运行:
# go build -o rx-go-grab . && ./rx-go-grab
启动后:
- HTTP 服务:
http://localhost:8080 - 监控面板:
http://localhost:8080/monitor(账号密码见 config.yaml) - 健康检查:
http://localhost:8080/health(返回 200 即服务正常)
手动添加采集任务
服务启动后,可以通过两种方式添加采集任务。采集引擎会自动轮询 file_status=0 的记录并开始处理,无需手动触发。
方式一:通过 API 添加(推荐,适合临时采集):
bash
curl -u admin:admin123 -X POST http://localhost:8080/monitor/api/add \
-H "Content-Type: application/json" \
-d '{"fileUrl":"https://example.com/large-file.zip","fileName":"large-file.zip","infoName":"测试采集"}'
方式二:直接往数据库插入(适合批量导入):
sql
INSERT INTO information (unique_id, name, status) VALUES ('test-001', '测试项目', 1);
INSERT INTO grab_file (info_id, file_url, file_name, file_status) VALUES (1, 'https://example.com/file.zip', 'file.zip', 0);
采集引擎会自动轮询 file_status=0 的记录并开始处理。
提示:添加任务后无需手动触发采集,引擎每隔几秒自动轮询一次。
10. Linux服务器部署
本地开发完成后,部署到 Linux 服务器只需要三步:编译、上传、配置 systemd。提供了完整的脚本,一键搞定。
systemd 守护进程
systemd 是 Linux 原生的进程管理工具,零额外依赖。配置后服务崩溃会自动重启,服务器重启后自动启动。
bash
# 首次安装(只需一次)
sudo bash script/install_service.sh
# 之后发布
bash script/restart.sh
核心配置(script/rx-go-grab.service):
ini
Restart=always # 崩溃自动重启
RestartSec=5 # 5秒后重启
WantedBy=multi-user.target # 开机自启
常用命令
部署完成后,以下命令是日常运维最常用的:
bash
systemctl status rx-go-grab # 查看状态
systemctl restart rx-go-grab # 重启
journalctl -u rx-go-grab -f # 实时日志
11. ClassIn 视频采集全流程
这里以 ClassIn 为例,完整展示如何接入一个采集源。ClassIn 是一个在线教育平台,课程视频存储在 ClassIn 的服务器上,三个月前的视频就无法访问了,因此,我们需要定期把视频拉取到自己的 OSS 上。
ClassIn API 客户端
ClassIn 提供了 HTTP API 接口查询课节视频列表。我们封装了一个客户端,处理了 cookie 认证、分页、BOM 去除等细节:
go
// internal/classinapi/client.go
client := classinapi.NewClient(cookie, userAgent, domain)
// 获取单个课节的视频列表(自动分页)
videos, err := client.FetchVideoList(courseID, classID)
// 返回 []VideoItem:
// FileID: "5145403724701034009"
// FileName: "5145403724701034009_f0.mp4" ← fileId_原始名,确保唯一
// VideoURL: "https://playback.eeo.cn/.../f0.mp4"
// FileSize: "221639130"
API 细节(都是踩过的坑):
- 接口地址 :
POST https://dynamic.eeo.cn/saasajax/school.ajax.php?action=getClassVodList - 请求参数 :
courseId,classId,page,perpage(form-urlencoded) - 请求头 :
Cookie+User-Agent(从 config.yaml 读取,cookie 过期需要重新登录获取) - 响应 :带 UTF-8 BOM 的 JSON,需
bytes.TrimPrefix去除,否则 JSON 解析失败 - 成功标识 :
error_info.errno == 1(不是 HTTP 状态码 200,而是业务层面的成功)
ClassIn 消费者
消费者是连接"消息队列"和"采集引擎"的桥梁。它监听 RabbitMQ 队列,收到消息后调用 ClassIn API 获取视频列表,然后写入数据库,采集引擎自动接管后续工作。
go
// internal/consumer/classin.go
func (c *ClassInConsumer) handleMessage(msg amqp.Delivery) {
var syncMsg queue.ClassInSyncMessage
if err := json.Unmarshal(msg.Body, &syncMsg); err != nil {
log.Printf("[ClassIn消费者] 消息解析失败: %v, 原始: %s", err, string(msg.Body[:min(len(msg.Body), 200)]))
msg.Nack(false, false) // 不重试,丢掉
return
}
log.Printf("[ClassIn消费者] 收到同步任务: courseId=%d, classId=%d, %s - %s",
syncMsg.CourseID, syncMsg.ClassID, syncMsg.CourseName, syncMsg.ClassName)
if err := c.processSync(&syncMsg); err != nil {
log.Printf("[ClassIn消费者] 处理失败: %v", err)
msg.Nack(false, true) // 重回队列
return
}
msg.Ack(false)
log.Printf("[ClassIn消费者] 同步完成: courseId=%d, classId=%d", syncMsg.CourseID, syncMsg.ClassID)
}
完整流程如下:
- 解析消息获取
courseId+classId - 调用
classinapi.FetchVideoList获取视频列表(自动分页,一次调用拿完所有视频) - 查找或创建
information记录(unique_id = courseId-classId,保证幂等性) - 逐个创建
grab_file记录(file_status=0,source_type=2推送) - 已存在的视频(按
file_url判重)自动跳过,不会重复入库 - 采集引擎自动轮询
file_status=0的记录并开始下载
OSS 采集引擎 MySQL ClassIn API ClassIn消费者 RabbitMQ 测试用例 OSS 采集引擎 MySQL ClassIn API ClassIn消费者 RabbitMQ 测试用例 #mermaid-svg-WXhtKCo2dmYFlPgg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WXhtKCo2dmYFlPgg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WXhtKCo2dmYFlPgg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WXhtKCo2dmYFlPgg .error-icon{fill:#552222;}#mermaid-svg-WXhtKCo2dmYFlPgg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WXhtKCo2dmYFlPgg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WXhtKCo2dmYFlPgg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WXhtKCo2dmYFlPgg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WXhtKCo2dmYFlPgg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WXhtKCo2dmYFlPgg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WXhtKCo2dmYFlPgg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WXhtKCo2dmYFlPgg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WXhtKCo2dmYFlPgg .marker.cross{stroke:#333333;}#mermaid-svg-WXhtKCo2dmYFlPgg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WXhtKCo2dmYFlPgg p{margin:0;}#mermaid-svg-WXhtKCo2dmYFlPgg .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WXhtKCo2dmYFlPgg text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-WXhtKCo2dmYFlPgg .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WXhtKCo2dmYFlPgg .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-WXhtKCo2dmYFlPgg .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-WXhtKCo2dmYFlPgg .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-WXhtKCo2dmYFlPgg #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-WXhtKCo2dmYFlPgg .sequenceNumber{fill:white;}#mermaid-svg-WXhtKCo2dmYFlPgg #sequencenumber{fill:#333;}#mermaid-svg-WXhtKCo2dmYFlPgg #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-WXhtKCo2dmYFlPgg .messageText{fill:#333;stroke:none;}#mermaid-svg-WXhtKCo2dmYFlPgg .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WXhtKCo2dmYFlPgg .labelText,#mermaid-svg-WXhtKCo2dmYFlPgg .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-WXhtKCo2dmYFlPgg .loopText,#mermaid-svg-WXhtKCo2dmYFlPgg .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-WXhtKCo2dmYFlPgg .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WXhtKCo2dmYFlPgg .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-WXhtKCo2dmYFlPgg .noteText,#mermaid-svg-WXhtKCo2dmYFlPgg .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-WXhtKCo2dmYFlPgg .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WXhtKCo2dmYFlPgg .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WXhtKCo2dmYFlPgg .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WXhtKCo2dmYFlPgg .actorPopupMenu{position:absolute;}#mermaid-svg-WXhtKCo2dmYFlPgg .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-WXhtKCo2dmYFlPgg .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WXhtKCo2dmYFlPgg .actor-man circle,#mermaid-svg-WXhtKCo2dmYFlPgg line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-WXhtKCo2dmYFlPgg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} PublishClassInSync(courseId, classId) Consume 消息 FetchVideoList(courseId, classId) 返回视频列表 INSERT information + grab_file Ack 轮询 file_status=0 流式下载到本地 流式上传 OSS UPDATE file_status=2
启动顺序
服务启动时自动启动消费者(需 classin.enabled=true 且 classin.cookie 非空)。如果 cookie 过期,消费者不会启动,但其他功能(手动添加、监控面板)不受影响:
yaml
# config.yaml
classin:
enabled: true
cookie: "_eeos_uid=xxx; ..." # ClassIn 登录后的 cookie
user_agent: "Mozilla/5.0 ..." # 浏览器 User-Agent
domain: "https://www.eeo.cn" # ClassIn API 域名
扩展:如何接入新的采集源
整个采集引擎是面向接口设计的,接入新的采集源只需要实现一个 Adapter 接口,其余的流式下载、进度追踪、并发控制全部复用。
go
// internal/grabber/adapter.go
type Adapter interface {
Name() string
FetchFiles() ([]FileItem, error)
}
示例:接入一个 ZIP 文件采集源
go
type ZipAdapter struct { db *gorm.DB }
func (a *ZipAdapter) Name() string { return "ZipFile" }
func (a *ZipAdapter) FetchFiles() ([]grabber.FileItem, error) {
var files []model.GrabFile
a.db.Where("file_status = 0 AND file_url LIKE '%.zip'").Find(&files)
// 转换为 FileItem 返回...
return items, nil
}
只需实现一个接口,采集引擎的流式下载、进度追踪、并发控制全部复用。
实际项目中,我已经用这个接口接入了 ClassIn 视频、ZIP 文件、PDF 文档等多种采集源,接入新的采集源比较容易扩展。
12. 测试用例
为了保证全流程的可靠性,我编写了端到端测试用例。每次修改代码后跑一遍测试,确保不会引入回归问题。
go
// test/test_classin_flow.go
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
mode := flag.String("mode", "mq", "运行模式: mq=通过RabbitMQ, direct=直接入库, api=仅测试ClassIn API")
configPath := flag.String("config", "config.yaml", "配置文件路径")
flag.Parse()
// 加载配置
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
switch *mode {
case "api":
// 仅测试 ClassIn API 连通性
testAPIOnly(cfg)
case "direct":
// 直接入库(不经过 RabbitMQ)
testDirectInsert(cfg)
case "mq":
// 完整流程: 发布消息到 RabbitMQ → 消费者入库 → 引擎抓取
testViaMQ(cfg)
default:
log.Fatalf("未知模式: %s (支持: api, direct, mq)", *mode)
}
}
提供 3 种模式,覆盖从 API 连通性到完整 MQ 流程的端到端测试:
模式1: api --- 仅测试 ClassIn API 连通性
这是最轻量的测试,不操作数据库,不需要服务运行,快速验证 cookie 是否有效。建议每次更新 cookie 后先跑这个模式:
bash
cd test && go build -o test_classin_flow . && ./test_classin_flow -mode=api
输出示例:
测试 API: courseId=276697775, classId=1065306051 (测试课程A - 课节1)
获取到 1 个视频:
[1] 5145403724701034009_f0.mp4 | URL: https://playback.eeo.cn/.../f0.mp4 | Size: 221639130
模式2: direct --- 直接入库
跳过 MQ,直接调用 API 并写入数据库。适合调试消费者逻辑,或者临时批量导入视频:
bash
./test/test_classin_flow -mode=direct
流程:调用API → 创建 information → 创建 grab_file → 打印统计
模式3: mq --- 完整 RabbitMQ 流程
这是最完整的端到端测试,验证从消息发布到文件上传的全链路。需要 rx-go-grab 服务已在运行:
bash
# 先确保服务运行中
./rx-go-grab &
# 发布测试消息
./test/test_classin_flow -mode=mq
输出:
已发布 [1]: {"courseId":276697775,"classId":1065306051,...}
已发布 [2]: {"courseId":286755739,"classId":1060857119,...}
已发布 [3]: {"courseId":295640253,"classId":1056789811,...}
消息已全部发布!
消费者会自动处理: 调用 ClassIn API → 写入 information + grab_file
采集引擎会自动: 轮询 grab_file → 下载 → 上传 OSS
查看进度:
监控面板: http://localhost:8080/monitor
服务日志: tail -f /tmp/rx-go-grab.log
测试课节配置
测试用例中内置了 3 个课节,可按需修改 test/test_classin_flow.go 中的 testLessons 变量。建议至少包含一个单视频课节和一个多视频课节,验证分页逻辑:
go
var testLessons = []queue.ClassInSyncMessage{
{CourseID: 276697775, ClassID: 1065306051, CourseName: "测试课程A", ClassName: "课节1"},
{CourseID: 286755739, ClassID: 1060857119, CourseName: "测试课程B", ClassName: "课节2"},
{CourseID: 295640253, ClassID: 1056789811, CourseName: "测试课程C", ClassName: "课节3"},
}
端到端验证结果
以下是最近一次完整测试的结果,供参考:
| 步骤 | 结果 | 详情 |
|---|---|---|
| ClassIn API 调用 | ✅ | 3个课节共获取4个视频 |
| RabbitMQ 发布 | ✅ | 3条消息全部发布 |
| 消费者入库 | ✅ | 创建 information + grab_file 记录 |
| 流式下载 | ✅ | 211MB 文件 37秒完成,~5.8MB/s |
| 流式上传 OSS | ✅ | 42秒完成,~4.7MB/s |
| 本地文件名唯一 | ✅ | 5145403724701034009_f0.mp4 不覆盖 |
| 进度日志归档 | ✅ | 完成后移动到 OK/2026/06/11/ |
| 数据库状态 | ✅ | file_status=2,sync_duration=1分钟18秒 |
💡 211MB 视频从下载到上传 OSS 总共只用了 1 分 18 秒,内存占用始终在 30MB 以内。这就是流式处理的优势。
13. 完整代码
完整代码请参考:rx-go-grab