在Web开发中,大文件上传是常见的需求场景(如视频、压缩包、大型数据集等),但传统的单文件上传方式存在诸多问题:网络中断导致上传失败需从头重传、单次请求体过大引发超时、服务器内存占用过高易触发OOM等。为解决这些问题,分片上传+断点续传 成为主流解决方案。本文将基于Go语言的Gin Web框架,从零实现大文件分片上传与断点续传功能,并结合实际场景拓展相关优化思路。
一、核心原理剖析
在编写代码前,先理清分片上传和断点续传的核心逻辑,这是实现的基础:
1. 分片上传(Chunked Upload)
将一个大文件按照固定大小(如5MB)拆分成多个小分片(Chunk),客户端逐个将分片上传至服务端;服务端接收所有分片后,按照分片的索引顺序将其合并为完整的原始文件。
2. 断点续传(Resumable Upload)
基于分片上传的基础,为每个文件生成唯一标识(通常是文件内容的MD5值,避免文件名重复导致的冲突)。客户端上传前先向服务端查询该文件已上传的分片列表,仅上传未完成的分片;服务端则记录每个文件的分片上传状态,确保断点续传的可行性。
3. 核心流程
- 客户端:计算文件MD5 → 拆分文件为分片 → 查询服务端已上传分片 → 上传未完成分片 → 全部上传完成后请求服务端合并分片。
- 服务端:提供「分片查询接口」→ 提供「分片接收接口」→ 提供「分片合并接口」→ 合并完成后清理临时分片文件。
二、技术选型与环境准备
1. 技术栈
- 服务端:Go 1.20+ + Gin框架(轻量、高性能,适合Web API开发)。
- 客户端:Go编写简易客户端(也可替换为前端JS/TS,原理一致)。
- 存储:本地文件系统(临时存储分片,合并后存储完整文件;生产环境可替换为MinIO、S3等对象存储)。
2. 环境初始化
首先创建项目并安装Gin依赖:
bash
# 创建项目目录
mkdir gin-large-file-upload && cd gin-large-file-upload
# 初始化Go模块
go mod init gin-large-file-upload
# 安装Gin
go get github.com/gin-gonic/gin
三、服务端实现
服务端需要实现三个核心接口:分片查询、分片上传、分片合并。同时需处理临时文件存储、分片状态记录、文件合并等逻辑。
1. 全局配置与工具函数
先定义全局配置(如分片大小、临时目录、目标文件目录)和通用工具函数(如生成文件MD5、检查目录是否存在):
go
// main.go
package main
import (
"crypto/md5"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// 全局配置
const (
ChunkSize = 5 * 1024 * 1024 // 分片大小:5MB
TempDir = "./temp_chunks" // 分片临时存储目录
UploadDir = "./upload_files" // 最终文件存储目录
MaxFileSize = 100 * 1024 * 1024 // 最大文件限制:100MB
)
// 初始化目录(确保临时目录和上传目录存在)
func initDir() error {
if err := os.MkdirAll(TempDir, 0755); err != nil {
return fmt.Errorf("创建临时目录失败:%v", err)
}
if err := os.MkdirAll(UploadDir, 0755); err != nil {
return fmt.Errorf("创建上传目录失败:%v", err)
}
return nil
}
// 计算文件MD5(用于文件唯一标识)
func calculateFileMD5(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// 获取分片文件的存储路径
func getChunkPath(fileMD5 string, chunkIndex int) string {
return filepath.Join(TempDir, fmt.Sprintf("%s_%d", fileMD5, chunkIndex))
}
// 检查分片是否已上传
func isChunkExist(fileMD5 string, chunkIndex int) bool {
chunkPath := getChunkPath(fileMD5, chunkIndex)
_, err := os.Stat(chunkPath)
return err == nil // 无错误则表示文件存在
}
// 检查所有分片是否上传完成
func isAllChunksUploaded(fileMD5 string, totalChunks int) bool {
for i := 0; i < totalChunks; i++ {
if !isChunkExist(fileMD5, i) {
return false
}
}
return true
}
2. 核心接口实现
(1)分片查询接口
客户端上传前调用该接口,查询指定文件的已上传分片列表,返回未上传的分片索引。
go
// 查询已上传分片
func checkChunkHandler(c *gin.Context) {
// 获取请求参数
fileMD5 := c.Query("file_md5")
totalChunksStr := c.Query("total_chunks")
if fileMD5 == "" || totalChunksStr == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "file_md5和total_chunks为必传参数",
})
return
}
// 转换总分片数为整数
totalChunks, err := strconv.Atoi(totalChunksStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "total_chunks必须为整数",
})
return
}
// 收集已上传的分片索引
uploadedChunks := make([]int, 0)
for i := 0; i < totalChunks; i++ {
if isChunkExist(fileMD5, i) {
uploadedChunks = append(uploadedChunks, i)
}
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"message": "查询成功",
"uploaded_chunks": uploadedChunks,
})
}
(2)分片上传接口
接收客户端上传的分片文件,保存至临时目录,并返回上传结果。
go
// 上传分片
func uploadChunkHandler(c *gin.Context) {
// 解析表单(分片文件+参数)
file, fileHeader, err := c.Request.FormFile("chunk")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "获取分片文件失败:" + err.Error(),
})
return
}
defer file.Close()
// 获取其他参数
fileMD5 := c.PostForm("file_md5")
chunkIndexStr := c.PostForm("chunk_index")
fileName := c.PostForm("file_name")
if fileMD5 == "" || chunkIndexStr == "" || fileName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "file_md5、chunk_index、file_name为必传参数",
})
return
}
// 转换分片索引为整数
chunkIndex, err := strconv.Atoi(chunkIndexStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "chunk_index必须为整数",
})
return
}
// 检查分片大小(可选,防止客户端上传过大分片)
if fileHeader.Size > ChunkSize {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": fmt.Sprintf("分片大小不能超过%dMB", ChunkSize/1024/1024),
})
return
}
// 创建分片文件并写入内容
chunkPath := getChunkPath(fileMD5, chunkIndex)
chunkFile, err := os.Create(chunkPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "创建分片文件失败:" + err.Error(),
})
return
}
defer chunkFile.Close()
// 读取分片内容并写入文件(使用缓冲区,避免内存溢出)
buf := make([]byte, 1024*1024) // 1MB缓冲区
_, err = io.CopyBuffer(chunkFile, file, buf)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "写入分片文件失败:" + err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"message": "分片上传成功",
"data": gin.H{
"file_md5": fileMD5,
"chunk_index": chunkIndex,
},
})
}
(3)分片合并接口
客户端确认所有分片上传完成后,调用该接口,服务端将所有分片按索引合并为完整文件。
go
// 合并分片
func mergeChunkHandler(c *gin.Context) {
// 获取请求参数
fileMD5 := c.PostForm("file_md5")
totalChunksStr := c.PostForm("total_chunks")
fileName := c.PostForm("file_name")
if fileMD5 == "" || totalChunksStr == "" || fileName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "file_md5、total_chunks、file_name为必传参数",
})
return
}
// 转换总分片数为整数
totalChunks, err := strconv.Atoi(totalChunksStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "total_chunks必须为整数",
})
return
}
// 检查所有分片是否上传完成
if !isAllChunksUploaded(fileMD5, totalChunks) {
c.JSON(http.StatusBadRequest, gin.H{
"code": 400,
"message": "存在未上传的分片,无法合并",
})
return
}
// 生成最终文件路径(避免文件名重复,拼接MD5前缀)
ext := filepath.Ext(fileName)
baseName := strings.TrimSuffix(fileName, ext)
finalFileName := fmt.Sprintf("%s_%s%s", baseName, fileMD5, ext)
finalFilePath := filepath.Join(UploadDir, finalFileName)
// 创建最终文件
finalFile, err := os.Create(finalFilePath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": "创建最终文件失败:" + err.Error(),
})
return
}
defer finalFile.Close()
// 按索引顺序合并所有分片
buf := make([]byte, 1024*1024) // 1MB缓冲区
for i := 0; i < totalChunks; i++ {
chunkPath := getChunkPath(fileMD5, i)
chunkFile, err := os.Open(chunkPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": fmt.Sprintf("打开分片%d失败:%v", i, err),
})
return
}
// 将分片内容写入最终文件
_, err = io.CopyBuffer(finalFile, chunkFile, buf)
chunkFile.Close() // 及时关闭分片文件
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"code": 500,
"message": fmt.Sprintf("合并分片%d失败:%v", i, err),
})
return
}
// 删除已合并的分片文件(可选,也可定时清理)
os.Remove(chunkPath)
}
c.JSON(http.StatusOK, gin.H{
"code": 200,
"message": "文件合并成功",
"data": gin.H{
"file_name": finalFileName,
"file_path": finalFilePath,
},
})
}
3. 注册路由并启动服务
将上述接口注册到Gin路由,并启动服务:
go
func main() {
// 初始化目录
if err := initDir(); err != nil {
fmt.Printf("目录初始化失败:%v\n", err)
return
}
// 创建Gin引擎
r := gin.Default()
// 注册路由
r.GET("/check-chunk", checkChunkHandler) // 分片查询
r.POST("/upload-chunk", uploadChunkHandler) // 分片上传
r.POST("/merge-chunk", mergeChunkHandler) // 分片合并
// 启动服务
fmt.Println("服务启动成功,监听端口:8080")
if err := r.Run(":8080"); err != nil {
fmt.Printf("服务启动失败:%v\n", err)
}
}
四、客户端实现
为了验证服务端功能,编写一个简易的Go客户端,实现文件分片、断点续传、合并请求的逻辑:
go
// client.go
package main
import (
"bytes"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
)
const (
ServerAddr = "http://127.0.0.1:8080" // 服务端地址
ClientChunkSize = 5 * 1024 * 1024 // 分片大小(与服务端一致)
)
// 计算文件MD5(同服务端逻辑)
func calcFileMD5(filePath string) (string, error) {
file, err := os.Open(filePath)
if err != nil {
return "", err
}
defer file.Close()
hash := md5.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
// 查询已上传分片
func checkUploadedChunks(fileMD5 string, totalChunks int) ([]int, error) {
// 构造请求URL
url := fmt.Sprintf("%s/check-chunk?file_md5=%s&total_chunks=%d", ServerAddr, fileMD5, totalChunks)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 解析响应
var result struct {
Code int `json:"code"`
Message string `json:"message"`
UploadedChunks []int `json:"uploaded_chunks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
if result.Code != 200 {
return nil, fmt.Errorf("查询失败:%s", result.Message)
}
return result.UploadedChunks, nil
}
// 上传单个分片
func uploadChunk(filePath string, fileMD5 string, chunkIndex int, chunkData []byte, fileName string) error {
// 构造multipart/form-data请求
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// 添加分片文件
part, err := writer.CreateFormFile("chunk", fmt.Sprintf("chunk_%d", chunkIndex))
if err != nil {
return err
}
_, err = part.Write(chunkData)
if err != nil {
return err
}
// 添加其他参数
_ = writer.WriteField("file_md5", fileMD5)
_ = writer.WriteField("chunk_index", strconv.Itoa(chunkIndex))
_ = writer.WriteField("file_name", fileName)
// 关闭writer,完成表单构造
writer.Close()
// 发送POST请求
url := fmt.Sprintf("%s/upload-chunk", ServerAddr)
req, err := http.NewRequest("POST", url, body)
if err != nil {
return err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 解析响应
var result struct {
Code int `json:"code"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if result.Code != 200 {
return fmt.Errorf("分片%d上传失败:%s", chunkIndex, result.Message)
}
fmt.Printf("分片%d上传成功\n", chunkIndex)
return nil
}
// 请求合并分片
func mergeChunks(fileMD5 string, totalChunks int, fileName string) error {
// 构造请求参数
data := strings.NewReader(fmt.Sprintf("file_md5=%s&total_chunks=%d&file_name=%s", fileMD5, totalChunks, fileName))
url := fmt.Sprintf("%s/merge-chunk", ServerAddr)
req, err := http.NewRequest("POST", url, data)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// 解析响应
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
FileName string `json:"file_name"`
FilePath string `json:"file_path"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
}
if result.Code != 200 {
return fmt.Errorf("合并分片失败:%s", result.Message)
}
fmt.Printf("文件合并成功!最终文件:%s,路径:%s\n", result.Data.FileName, result.Data.FilePath)
return nil
}
// 大文件上传主逻辑
func uploadLargeFile(filePath string) error {
// 1. 打开文件并获取基本信息
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return err
}
fileSize := fileInfo.Size()
fileName := filepath.Base(filePath)
// 2. 计算文件MD5
fmt.Println("正在计算文件MD5...")
fileMD5, err := calcFileMD5(filePath)
if err != nil {
return fmt.Errorf("计算MD5失败:%v", err)
}
fmt.Printf("文件MD5:%s,大小:%dMB\n", fileMD5, fileSize/1024/1024)
// 3. 计算总分片数
totalChunks := int(fileSize / ClientChunkSize)
if fileSize%ClientChunkSize != 0 {
totalChunks += 1
}
fmt.Printf("文件将拆分为%d个分片,每个分片%dMB\n", totalChunks, ClientChunkSize/1024/1024)
// 4. 查询已上传分片
fmt.Println("查询已上传分片...")
uploadedChunks, err := checkUploadedChunks(fileMD5, totalChunks)
if err != nil {
return fmt.Errorf("查询已上传分片失败:%v", err)
}
fmt.Printf("已上传分片:%v\n", uploadedChunks)
// 5. 遍历所有分片,上传未完成的分片
buf := make([]byte, ClientChunkSize)
for i := 0; i < totalChunks; i++ {
// 跳过已上传的分片
skip := false
for _, idx := range uploadedChunks {
if idx == i {
skip = true
break
}
}
if skip {
fmt.Printf("分片%d已上传,跳过\n", i)
continue
}
// 读取分片数据
offset := int64(i) * ClientChunkSize
_, err := file.Seek(offset, io.SeekStart)
if err != nil {
return fmt.Errorf("读取分片%d失败:%v", i, err)
}
n, err := file.Read(buf)
if err != nil && err != io.EOF {
return fmt.Errorf("读取分片%d失败:%v", i, err)
}
// 上传分片
if err := uploadChunk(filePath, fileMD5, i, buf[:n], fileName); err != nil {
return err
}
}
// 6. 所有分片上传完成,请求合并
fmt.Println("所有分片上传完成,请求合并...")
if err := mergeChunks(fileMD5, totalChunks, fileName); err != nil {
return err
}
return nil
}
func main() {
// 替换为你要上传的大文件路径
filePath := "./test_large_file.mp4"
if err := uploadLargeFile(filePath); err != nil {
fmt.Printf("文件上传失败:%v\n", err)
}
}
五、功能测试
1. 启动服务端
bash
go run main.go
输出「服务启动成功,监听端口:8080」表示服务端启动正常。
2. 运行客户端
将client.go中的filePath替换为本地的大文件路径(如视频、压缩包),然后运行:
bash
go run client.go
客户端会输出如下日志,表明上传流程正常:
正在计算文件MD5...
文件MD5:xxxxxx,大小:20MB
文件将拆分为4个分片,每个分片5MB
查询已上传分片...
已上传分片:[]
分片0上传成功
分片1上传成功
分片2上传成功
分片3上传成功
所有分片上传完成,请求合并...
文件合并成功!最终文件:test_large_file_xxxxxx.mp4,路径:./upload_files/test_large_file_xxxxxx.mp4
3. 断点续传测试
手动中断客户端上传(如按Ctrl+C),重新运行客户端,会看到客户端跳过已上传的分片,仅上传未完成的分片:
正在计算文件MD5...
文件MD5:xxxxxx,大小:20MB
文件将拆分为4个分片,每个分片5MB
查询已上传分片...
已上传分片:[0 1]
分片0已上传,跳过
分片1已上传,跳过
分片2上传成功
分片3上传成功
所有分片上传完成,请求合并...
文件合并成功!最终文件:test_large_file_xxxxxx.mp4,路径:./upload_files/test_large_file_xxxxxx.mp4
六、进阶拓展与优化
上述实现满足了基础的分片上传和断点续传需求,但在生产环境中,还需要考虑以下优化方向:
1. 分片大小动态调整
固定5MB分片大小并非适用于所有场景:
- 分片过小:增加HTTP请求次数,加重服务端负担;
- 分片过大:单分片上传超时风险高。
可根据文件大小动态调整分片大小(如100MB以内用5MB分片,1GB以内用10MB分片),或允许客户端自定义分片大小(服务端做上限限制)。
2. 并发上传分片
客户端可通过goroutine并发上传多个分片,提升上传速度。需注意控制并发数(如5-10个),避免请求过多导致服务端压力过大:
go
// 并发上传示例(客户端)
var wg sync.WaitGroup
sem := make(chan struct{}, 5) // 限制5个并发
for i := 0; i < totalChunks; i++ {
// 跳过已上传分片...
wg.Add(1)
sem <- struct{}{}
go func(idx int) {
defer wg.Done()
defer func() { <-sem }()
// 读取分片并上传...
}(i)
}
wg.Wait()
3. 临时分片文件清理
服务端的临时分片文件如果长时间未合并(如客户端中断上传),会占用磁盘空间。可添加定时任务(如每小时)清理超过24小时未合并的分片文件:
go
// 定时清理临时分片文件
func cleanExpiredChunks() {
ticker := time.NewTicker(1 * time.Hour) // 每小时执行一次
for range ticker.C {
// 遍历临时目录
entries, err := os.ReadDir(TempDir)
if err != nil {
fmt.Printf("读取临时目录失败:%v\n", err)
continue
}
// 清理超过24小时的文件
expireTime := time.Now().Add(-24 * time.Hour)
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
if info.ModTime().Before(expireTime) {
os.Remove(filepath.Join(TempDir, entry.Name()))
fmt.Printf("清理过期分片:%s\n", entry.Name())
}
}
}
}
// 在main函数中启动定时任务
go cleanExpiredChunks()
4. 分片校验机制
为避免分片传输过程中数据损坏,可在客户端计算每个分片的MD5,上传时携带分片MD5;服务端接收后校验分片MD5,确保数据完整性:
go
// 客户端:计算分片MD5
chunkMD5 := md5.Sum(buf[:n])
chunkMD5Str := hex.EncodeToString(chunkMD5[:])
// 上传时添加分片MD5参数
_ = writer.WriteField("chunk_md5", chunkMD5Str)
// 服务端:校验分片MD5
chunkMD5 := c.PostForm("chunk_md5")
// 计算接收到的分片内容的MD5,与客户端传入的对比
5. 分布式场景适配
如果服务端部署在多节点,本地文件系统无法共享分片文件,需将分片存储至分布式对象存储(如MinIO、AWS S3),并将分片状态记录至Redis:
- 分片存储:将分片上传至MinIO,而非本地目录;
- 状态记录:Redis的Set结构存储已上传的分片索引(key为文件MD5,value为分片索引集合);
- 合并逻辑:从MinIO下载所有分片,合并后再上传至MinIO。
6. 上传进度反馈
服务端可新增接口,根据文件MD5返回已上传分片数/总分片数,客户端据此计算上传进度并展示:
go
// 服务端进度查询接口
func uploadProgressHandler(c *gin.Context) {
fileMD5 := c.Query("file_md5")
totalChunksStr := c.Query("total_chunks")
// ... 校验参数 ...
uploadedCount := len(uploadedChunks)
progress := float64(uploadedCount) / float64(totalChunks) * 100
c.JSON(http.StatusOK, gin.H{
"code": 200,
"progress": progress, // 百分比
})
}
七、注意事项
- 目录权限 :确保服务端的
TempDir和UploadDir有读写权限,避免文件创建/写入失败。 - 文件命名冲突:通过文件MD5+原文件名的方式命名最终文件,避免不同文件重名覆盖。
- 内存占用:读写文件时使用缓冲区(如1MB),避免一次性加载大文件/分片到内存,引发OOM。
- 超时设置:客户端和服务端都需设置合理的HTTP超时时间,避免大分片上传超时。
- 异常处理:增加文件锁、重试机制,避免并发上传同一分片导致的文件损坏。
八、总结
本文基于Gin框架实现了大文件分片上传与断点续传的核心功能,从原理剖析到代码实现,再到进阶优化,覆盖了从基础到生产级别的关键要点。该方案解决了传统单文件上传的痛点,具备高可用性和可扩展性。
在实际项目中,可根据业务需求灵活调整:前端可替换为WebUploader、Resumable.js等成熟的分片上传库,服务端可对接分布式存储和缓存,进一步提升系统的稳定性和性能。Go语言的高性能特性结合Gin框架的轻量优势,使得该方案能够轻松应对高并发的大文件上传场景。