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

文章目录

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

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 小时无人值守运行,崩溃自动重启,每个文件的下载/上传进度实时可查,出问题自动重试。

推送/手动

消费

流式下载

流式上传

更新状态

查询

查询

信息源

RabbitMQ

采集引擎

本地存储

阿里云 OSS

MySQL

监控面板

2. 项目概览

技术栈

选型原则:够用就行,不追新不炫技。每个组件都是经过生产验证的成熟方案,避免引入不必要的复杂度。

组件选型说明
HTTP 框架Gin轻量高效,生态丰富,团队熟悉度高
ORMGORM简洁的 Go ORM,AutoMigrate 省去手动建表
消息队列RabbitMQDocker 本地部署,生产可用阿里云 AMQP,支持消息确认和重试
对象存储阿里云 OSS官方 SDK,支持 CNAME 自定义域名和内网上传
数据库MySQL 8.0存储采集状态和元数据,DATETIME 时间格式可读优先
进程守护systemdLinux 原生,崩溃自动重启,零额外依赖

💡 为什么不用 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 表 — 基础信息

字段类型说明
idINT UNSIGNED主键
unique_idVARCHAR(100)业务唯一标识(UNIQUE)
nameVARCHAR(255)名称
extra_nameVARCHAR(255)附加名称
source_urlVARCHAR(500)来源URL
statusTINYINT0待处理 1处理中 2已完成
remarkTEXT备注
created_at / updated_atDATETIME自动维护

grab_file 表 — 采集文件

这是整个系统的核心表,每个文件从“待采集”到“已完成”的全生命周期都记录在这一行里。

字段类型说明
idINT UNSIGNED主键
info_idINT UNSIGNED关联 information.id
file_urlVARCHAR(1000)源文件URL
file_nameVARCHAR(255)文件名
file_statusTINYINT0待采集 1采集中 2已完成 3失败
file_size / file_size_mbBIGINT / DECIMAL文件大小
local_pathVARCHAR(500)本地存储路径
oss_object_keyVARCHAR(500)OSS 对象键
oss_urlVARCHAR(500)OSS 完整访问URL
sync_start_time / sync_end_timeDATETIME同步时间
sync_durationVARCHAR(50)耗时描述
error_msgTEXT失败原因
source_typeTINYINT1手动添加 2推送
sequence_noINT序号

完整 DDL 见 sql/init.sql,包含所有字段的 COMMENT。

3. RabbitMQ 异步任务队列

为什么需要消息队列?因为文件采集是一个耗时操作(几分钟到几十分钟不等),如果同步处理,HTTP 请求早就超时了。通过 MQ 解耦后,生产者只管发布任务,采集引擎按自己的节奏消费,互不干扰。

同时 RabbitMQ 的 ACK/NACK 机制 提供了天然的重试保障:消息处理失败时 Nack 回队列,不会丢失任务。

Docker 本地启动

开发环境用 Docker 一行命令启动,无需额外配置:

# 拉取带有管理插件的版本
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,适合临时采集单个文件:

{
  "fileId": 123,
  "fileUrl": "https://example.com/video/abc.mp4",
  "fileName": "abc.mp4",
  "objectKey": "123/abc.mp4"
}

2. 同步消息(ClassInSyncMessage) :指定课节,由消费者自动调 API 获取视频列表,适合批量同步:

{
  "courseId": 284880687,
  "classId": 1029404489,
  "courseName": "雅思课程",
  "className": "雅思阅读 (10)"
}

完整数据流

下图展示了从消息发布到文件上传完成的完整链路,每个环节都是异步解耦的:

PublishClassInSync

Consume

调用 getClassVodList

返回视频列表

写入

写入

轮询 file_status=0

流式下载

流式上传

更新状态

全部完成

测试用例/API

RabbitMQ Queue

ClassIn消费者

ClassIn API

information 表

grab_file 表

采集引擎

本地存储

阿里云 OSS

information.status=2

生产者/消费者

生产者发布消息、消费者接收消息的代码非常简洁,所有复杂性(重试、确认、错误处理)都封装在 rabbitmq.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

先看一个反面教材。这个项目早期的实现方式是这样的:

// 错误做法:一次性读取整个文件到内存
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,内存占用恒定:

// 正确做法:流式写入本地文件,内存占用恒定
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 核心代码:

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)
}

实现原理:

32KB buffer

Write

AddBytes

HTTP Response Body

progressWriter

本地文件

进度日志

流式传输有一个副作用:因为数据是分块传输的,所以无法直接知道当前进度。解决方案是用装饰器模式包装 Writer/Reader,在每次 Write/Read 时累加字节数,顺便记录进度日志:

// 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,原理完全一样:

file, _ := os.Open(localPath)
pr := &ProgressReader{reader: file, progress: progressLogger}
bucket.PutObject(objectKey, pr)  // 流式上传,不加载到内存

本地文件名来源

这是一个容易被忽视但很实用的细节。StreamDownloadAndUpload 接受 fileName 参数,优先使用数据库的 file_name 字段,为空时才从 URL 尾部提取:

// 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,但在大文件场景下会出问题:

// 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 自定义域名):

oss:
  endpoint: "test.oss-cn-beijing-internal.aliyuncs.com"  # 内网,上传用
  file_host: "https://oss-test.xxxx.com"          			 # 外网,访问用
  use_cname: true

代码实现:

// 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 作为信号量,简洁高效,不需要引入额外的并发库:

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,否则会重复认领。因此,我设计了一个三步占位法,利用数据库的原子性保证每个文件只被一个协程处理:

// 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(采集中)的残留记录。如果不处理,这些文件永远不会被重新采集。解决方案:启动时统一重置:

db.Model(&GrabFile{}).Where("file_status = ?", 1).Update("file_status", 0)

7. 日志和监控

日志文件

“到底卡在哪了?”——这是运维最常问的问题。进度日志系统就是为了解决这个问题。每个采集任务独立一个日志文件,记录从开始到完成的每一个阶段,包括速度、剩余时间等关键指标。

// 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,方便快速定位问题:

image-20260612134127727

日志内容示例:

========== 文件采集进度日志 ==========
标签: [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

image-20260612134253015

8. 踩坑和故障排查

踩坑过程

这部分记录的都是真实生产环境遇到的问题,每个都导致了服务异常或数据错误。个人感悟:最好的代码不是写出来的,是改出来的。

1. HTTP/2 flow control 导致 panic

现象:下载大文件时偶尔 panic: connection reset by peer

根因:Go 的 HTTP/2 实现要求 response.Body 被完整消费。如果提前关闭(如状态码非 200),flow control 计数器不一致导致 panic。

修复

// 非成功响应也必须消费完 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% 的问题都能快速定位:

卡住

服务异常

进程是否存活?

检查 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 分钟内即可跑起来:

# 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 添加(推荐,适合临时采集):

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":"测试采集"}'

方式二:直接往数据库插入(适合批量导入):

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 原生的进程管理工具,零额外依赖。配置后服务崩溃会自动重启,服务器重启后自动启动。

# 首次安装(只需一次)
sudo bash script/install_service.sh

# 之后发布
bash script/restart.sh

核心配置(script/rx-go-grab.service):

Restart=always          # 崩溃自动重启
RestartSec=5            # 5秒后重启
WantedBy=multi-user.target  # 开机自启

常用命令

部署完成后,以下命令是日常运维最常用的:

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 去除等细节:

// 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 获取视频列表,然后写入数据库,采集引擎自动接管后续工作。

// 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 测试用例 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 过期,消费者不会启动,但其他功能(手动添加、监控面板)不受影响:

# 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 接口,其余的流式下载、进度追踪、并发控制全部复用。

// internal/grabber/adapter.go
type Adapter interface {
    Name() string
    FetchFiles() ([]FileItem, error)
}

示例:接入一个 ZIP 文件采集源

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. 测试用例

为了保证全流程的可靠性,我编写了端到端测试用例。每次修改代码后跑一遍测试,确保不会引入回归问题。

// 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 后先跑这个模式:

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 并写入数据库。适合调试消费者逻辑,或者临时批量导入视频:

./test/test_classin_flow -mode=direct

流程:调用API → 创建 information → 创建 grab_file → 打印统计

模式3: mq — 完整 RabbitMQ 流程

这是最完整的端到端测试,验证从消息发布到文件上传的全链路。需要 rx-go-grab 服务已在运行

# 先确保服务运行中
./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 变量。建议至少包含一个单视频课节和一个多视频课节,验证分页逻辑:

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
流式上传 OSS42秒完成,~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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

码农兴哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值