Go实现大文件异步流式采集引擎

文章目录

  • Go实现大文件异步流式采集引擎
      1. 为什么要做这个工具
      1. 项目概览
      • 技术栈
      • 项目结构
      • 数据库设计
      • 设计原则
      1. RabbitMQ 异步任务队列
      • Docker 本地启动
      • 两种消息格式
      • 完整数据流
      • 生产者/消费者
      1. 核心:流式下载的实现
      • 为什么不能用 io.ReadAll
      • 流式下载:io.Copy + progressWriter
      • 本地文件名来源
      • 禁用 HTTP/2 避免 flow control panic
      1. 本地存储 + OSS 上传
      • 三种存储策略
      • OSS 内网/外网域名分离
      1. 并发控制与原子占位
      • 信号量控制并发
      • PickNotSyncRow 三步精确占位
      • 残留记录重置
      1. 日志和监控
      • 日志文件
      • 监控面板
      1. 踩坑和故障排查
      • 踩坑过程
      • 故障排查指南
      • 常见问题
      1. 本地运行
      • 前置条件
      • 一键启动
      • 手动添加采集任务
      1. Linux服务器部署
      • systemd 守护进程
      • 常用命令
      1. ClassIn 视频采集全流程
      • ClassIn API 客户端
      • ClassIn 消费者
      • 启动顺序
      • 扩展:如何接入新的采集源
      1. 测试用例
      • 模式1: api --- 仅测试 ClassIn API 连通性
      • 模式2: direct --- 直接入库
      • 模式3: mq --- 完整 RabbitMQ 流程
      • 测试课节配置
      • 端到端验证结果
      1. 完整代码

Go实现大文件异步流式采集引擎

从视频同步实战中提炼的通用大文件采集工具,支持流式下载、可选本地留存/OSS上传、进度日志、监控面板。

1. 为什么要做这个工具

因为项目需求,需要改造一个已有的大存储视频文件同步服务的Go的项目,在排查问题和改造的过程中,我遇到了一系列问题,最主要的报错信息就是panic: flow control update exceeds maximum window size,经过分析,得出以下结论:

  • 内存爆炸 :用 io.ReadAll 一次性读取大文件到内存,400MB 的视频文件直接 OOM
  • 无法追踪进度:上传一个 1GB 的文件,中间只能干等,不知道卡在下载还是上传
  • 并发冲突:多个协程同时处理同一条记录,重复下载上传
  • 服务崩溃无人值守:进程 panic 后不会自动恢复,第二天上班才发现一堆文件没同步

这些问题单独看都不复杂,但组合在一起就形成了一个很尴尬的局面:服务能跑,但跑不稳;能同步,但不知道同步到哪了。每次出问题都要翻日志、查数据库、手动重试,运维成本极高。

优化完成了这个项目之后,我决定把这些踩过的坑全部解决,并提炼成一个通用的文件采集引擎。核心设计理念:

  1. 流式处理 --- 内存占用恒定,不管文件多大
  2. 异步解耦 --- RabbitMQ 消息队列驱动,生产者消费者分离
  3. 可观测性 --- 单文件进度日志 + Web 监控面板,随时掌握状态
  4. 可插拔可扩展 --- 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:521749656632 直观得多。存储层多占 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.yamlstorage_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 权限问题

现象AccessDeniedInvalidAccessKeyId

排查

  1. 登录阿里云 OSS 管理台,确认 AccessKey 是否启用
  2. 确认 RAM 用户有 OSS 读写权限(AliyunOSSFullAccess
  3. 确认 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)
}

完整流程如下:

  1. 解析消息获取 courseId + classId
  2. 调用 classinapi.FetchVideoList 获取视频列表(自动分页,一次调用拿完所有视频)
  3. 查找或创建 information 记录(unique_id = courseId-classId,保证幂等性)
  4. 逐个创建 grab_file 记录(file_status=0source_type=2推送)
  5. 已存在的视频(按 file_url 判重)自动跳过,不会重复入库
  6. 采集引擎自动轮询 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=trueclassin.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=2sync_duration=1分钟18秒

💡 211MB 视频从下载到上传 OSS 总共只用了 1 分 18 秒,内存占用始终在 30MB 以内。这就是流式处理的优势。

13. 完整代码

完整代码请参考:rx-go-grab

相关推荐
霸道流氓气质1 小时前
Spring Boot 大数据量 Excel 导入导出功能实现指南
spring boot·后端·excel
yugi9878381 小时前
基于C#实现数字识别率的OCR方案
开发语言·c#·ocr
星越华夏1 小时前
python中四种获取文件后缀名的方法
开发语言·python
小刘|1 小时前
Spring AI 结构化输出 + 大模型参数全解(含千问调优)
java·后端·spring
copyer_xyf1 小时前
FastAPI 项目骨架搭建
前端·后端·python
javajenius2 小时前
Pixi:用 Rust 重写 Conda 体验的包管理工具
开发语言·其他·rust·conda
l齐天2 小时前
Ubuntu 中编译 Go + PBC 程序为 Windows 11 可运行文件
windows·ubuntu·golang
神明不懂浪漫2 小时前
【第二章】Java中的数据类型,运算符与程序逻辑控制
java·开发语言·经验分享·笔记
laowangpython2 小时前
tokio-rstracing:Rust 可观测性的标准答案
开发语言·后端·其他·rust