学习目标
读完本章后,你将能够:
- 说出文件上传到文件服务器与业务服务器的架构区别,理解为什么大文件应该直传文件服务器而非经过业务服务器。
- 区分 FormData(multipart/form-data)和 Binary(application/octet-stream)两种上传方式的原理与适用场景,并用 Go Gin 实现两种方式。
- 画出分块上传的完整流程图,说出大文件切分、并行上传、服务端合并的每个步骤。
- 说出断点续传的核心原理(MD5 校验 + 已传块查询 + 缺失块上传),并用 Go 实现完整的服务端逻辑(分块接收、查询已传、合并文件)。
- 说出秒传的原理(文件 MD5 预检)和并发上传优化的方法(worker pool、失败重试、进度回调)。
前置知识: 熟悉 Go 语言基本语法和 Gin 框架,了解 HTTP 协议中 multipart/form-data 的概念,了解 Redis 基本操作。
动手做 3 件事:
- 用 Go Gin 实现一个简单的 FormData 文件上传接口,用 curl 或 Postman 测试上传一个小文件。
- 在上述基础上实现分块上传:客户端把文件切成 1MB 的块上传,服务端合并还原。
- 加入断点续传:上传前查询已传块,只上传缺失的块;加入秒传:文件 MD5 已存在则直接返回成功。
一、文件服务器 vs 业务服务器
1.1 用生活类比先建立直觉
想象一个快递分拣中心:
- 方案一(经业务服务器中转):所有包裹先送到前台接待(业务服务器),前台登记信息后再亲自搬到仓库(文件服务器)。前台既要做登记又要搬货,一旦包裹太大太多,前台就被压垮了——这就是把文件上传请求打到业务服务器的痛点。
- 方案二(直传文件服务器):前台只给访客一张"通行证"(签名 URL/Token),访客拿着通行证直接把包裹送到仓库(文件服务器)。前台只做登记(存元数据),不碰包裹本身。这样前台永远不会被大文件压垮。
graph TD
subgraph 方案一 经业务服务器中转
A1[客户端] --> B1[业务服务器
接收文件+存元数据]
B1 --> C1[文件服务器
存储文件]
B1 --> D1[数据库
存元数据]
end
subgraph 方案二 直传文件服务器
A2[客户端] --> B2[业务服务器
申请上传凭证]
B2 --> D2[数据库
存元数据]
A2 --> C2[文件服务器
直接上传文件]
C2 --> B2
end桥接: “前台搬货"对应业务服务器接收并转发文件——文件内容经过业务服务器的内存和网络,大文件会占用大量带宽和内存。“直传仓库"对应客户端直接上传到文件服务器(Nginx/对象存储),业务服务器只负责签发凭证和存储元数据。这就是为什么生产环境几乎都采用方案二。
1.2 工程要点
知识点 1:视频上传到文件服务器 vs 业务服务器
两种架构对比:
| 对比维度 | 经业务服务器中转 | 直传文件服务器 |
|---|---|---|
| 文件流经 | 客户端 → 业务服务器 → 文件服务器 | 客户端 → 文件服务器(直连) |
| 业务服务器压力 | 高(占用带宽+内存) | 低(只处理元数据) |
| 实现复杂度 | 简单 | 需要签名/Token 机制 |
| 适用场景 | 小文件、内网环境 | 大文件、生产环境 |
| 典型实现 | Gin c.FormFile | 预签名 URL、Nginx 直传 |
文件服务器的常见形态:
| 类型 | 说明 | 典型方案 |
|---|---|---|
| 独立部署 | 单独的文件服务进程 | MinIO、FastDFS |
| Nginx 直传 | Nginx 接收文件存储到磁盘 | client_body_temp_path |
| 对象存储 | 云厂商提供的海量存储 | AWS S3、阿里云 OSS |
| CDN 回源 | 边缘节点缓存+回源存储 | Cloudflare、阿里云 CDN |
⚠️ 新手必踩的坑: 如果用业务服务器中转文件,一定要设置
r.MaxMultipartMemory限制内存使用。Gin 默认是 32MB,超过的部分会写入临时文件。如果不限制,攻击者上传超大文件可以直接打满内存导致 OOM。生产环境大文件必须直传文件服务器或对象存储。
二、FormData vs Binary 上传
2.1 用生活类比先建立直觉
想象两种寄快递的方式:
- FormData(multipart/form-data):你把物品装进一个标准快递箱,箱子上贴一张运单,运单上写着"收件人、物品名称、重量"等元数据信息。快递公司打开箱子就能看到物品和运单。这是 HTTP 上传文件的标准方式。
- Binary(application/octet-stream):你直接把物品递给快递员,什么运单都不贴。快递员收到的是一坨原始数据,没有附加信息。更轻量,但服务端需要自己解析。
桥接: FormData 是 HTTP 标准,一个请求里可以同时带文件和文本字段(如"文件名=report.pdf, 用户ID=123”)。Binary 只传输原始字节流,更轻量但没有元数据。Gin 中 c.FormFile 解析 FormData,c.GetRawData 读取 Binary 原始数据。
2.2 工程要点
知识点 2:FormData vs Binary 上传
两种上传方式对比:
| 对比维度 | FormData (multipart/form-data) | Binary (application/octet-stream) |
|---|---|---|
| 格式 | 标准 HTTP multipart 格式 | 纯二进制流 |
| 元数据 | 可携带文件名、自定义字段 | 无附加信息 |
| 多文件 | 支持一次传多个 | 一次只传一个流 |
| 解析方式 | Gin: c.FormFile | Gin: c.GetRawData |
| 适用场景 | 表单上传、带元数据 | 纯文件流、分块上传 |
FormData 上传(Gin 实现):
package main
import (
"fmt"
"log"
"path/filepath"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// 步骤1:限制multipart内存为8MB
r.MaxMultipartMemory = 8 << 20
// 步骤2:FormData文件上传接口
r.POST("/upload/formdata", func(c *gin.Context) {
// 步骤3:获取上传的文件
file, err := c.FormFile("file")
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 步骤4:获取附加的文本字段
userId := c.PostForm("userId")
description := c.PostForm("description")
log.Printf("用户%s上传文件: %s, 描述: %s", userId, file.Filename, description)
// 步骤5:保存文件到指定目录
savePath := filepath.Join("./uploads", file.Filename)
if err := c.SaveUploadedFile(file, savePath); err != nil {
c.JSON(500, gin.H{"error": "保存失败"})
return
}
c.JSON(200, gin.H{
"code": 0,
"message": "上传成功",
"filename": file.Filename,
"size": file.Size,
})
})
r.Run(":8080")
}
Binary 上传(Gin 实现):
package main
import (
"crypto/md5"
"fmt"
"io"
"os"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// 步骤1:Binary文件上传接口
r.POST("/upload/binary", func(c *gin.Context) {
// 步骤2:从Header获取文件名
filename := c.GetHeader("X-File-Name")
if filename == "" {
filename = "unnamed.bin"
}
// 步骤3:读取原始二进制数据
data, err := c.GetRawData()
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 步骤4:计算MD5用于后续校验
hash := fmt.Sprintf("%x", md5.Sum(data))
// 步骤5:写入文件
savePath := fmt.Sprintf("./uploads/%s", filename)
if err := os.WriteFile(savePath, data, 0644); err != nil {
c.JSON(500, gin.H{"error": "保存失败"})
return
}
c.JSON(200, gin.H{
"code": 0,
"message": "上传成功",
"filename": filename,
"size": len(data),
"md5": hash,
})
})
r.Run(":8080")
}
⚠️ 新手必踩的坑: Binary 上传用
c.GetRawData()会把整个文件读进内存。对于大文件(如视频),这会直接导致 OOM。大文件应该用c.Request.Body流式读取,边读边写入磁盘,不要一次性读完。分块上传时也应该用流式处理。
三、分块上传原理
3.1 用生活类比先建立直觉
想象你要搬一套大型家具(大文件)进电梯:
- 家具太大,电梯装不下(单个 HTTP 请求超时或超限)。
- 于是你把家具拆成几个小部件(分块),每个部件都能轻松进电梯。
- 你叫了几个搬运工同时搬(并行上传),大大加快了速度。
- 所有部件搬到新家后,再组装还原(服务端合并)。
分块上传的核心思想就是:把大文件切分成多个小块,并行上传,最后在服务端合并。
flowchart TD
A[客户端选择大文件] --> B[计算文件MD5]
B --> C[按固定大小切分文件块]
C --> D[并行上传各文件块]
D --> E[服务端接收并存储各块]
E --> F{所有块上传完成?}
F -->| 否 | G[继续上传缺失块]
F -->| 是 | H[通知服务端合并]
H --> I[服务端按序号合并各块]
I --> J[MD5校验合并后文件]
J --> K{校验通过?}
K -->| 是 | L[上传完成]
K -->| 否 | M[返回错误 重新上传]桥接: “拆家具进电梯"对应把大文件切成小块分别上传——每块大小通常 1-5MB,单块上传时间短不容易超时。“几个搬运工同时搬"对应并行上传——多个 HTTP 请求并发发送,利用带宽。“组装还原"对应服务端按块序号合并还原原始文件。
3.2 工程要点
知识点 3:分块上传原理
分块上传的关键参数:
| 参数 | 说明 | 典型值 |
|---|---|---|
| chunkSize | 每块大小 | 1-5MB |
| concurrency | 并发上传数 | 3-5 |
| chunkHash | 每块MD5 | 用于校验完整性 |
| fileHash | 整个文件MD5 | 用于秒传和最终校验 |
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"github.com/gin-gonic/gin"
)
// 步骤1:定义分块上传存储目录
const chunkDir = "./chunks"
func main() {
r := gin.Default()
os.MkdirAll(chunkDir, 0755)
os.MkdirAll("./uploads", 0755)
// 步骤2:接收单个分块
r.POST("/upload/chunk", func(c *gin.Context) {
fileHash := c.PostForm("fileHash")
chunkIndex := c.PostForm("chunkIndex")
chunkTotal := c.PostForm("chunkTotal")
// 步骤3:为每个文件创建独立的分块目录
fileChunkDir := filepath.Join(chunkDir, fileHash)
os.MkdirAll(fileChunkDir, 0755)
// 步骤4:接收分块文件
file, err := c.FormFile("chunk")
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 步骤5:保存分块,文件名用序号命名
chunkPath := filepath.Join(fileChunkDir, chunkIndex)
if err := c.SaveUploadedFile(file, chunkPath); err != nil {
c.JSON(500, gin.H{"error": "保存分块失败"})
return
}
log.Printf("文件%s的分块%s/%s上传成功", fileHash, chunkIndex, chunkTotal)
c.JSON(200, gin.H{
"code": 0,
"message": "分块上传成功",
"chunkIndex": chunkIndex,
"chunkTotal": chunkTotal,
})
})
// 步骤6:合并分块
r.POST("/upload/merge", func(c *gin.Context) {
fileHash := c.PostForm("fileHash")
fileName := c.PostForm("fileName")
chunkTotal, _ := strconv.Atoi(c.PostForm("chunkTotal"))
fileChunkDir := filepath.Join(chunkDir, fileHash)
outputPath := filepath.Join("./uploads", fileName)
// 步骤7:创建合并后的输出文件
outFile, err := os.Create(outputPath)
if err != nil {
c.JSON(500, gin.H{"error": "创建输出文件失败"})
return
}
defer outFile.Close()
// 步骤8:按序号顺序合并所有分块
for i := 0; i < chunkTotal; i++ {
chunkPath := filepath.Join(fileChunkDir, strconv.Itoa(i))
chunkData, err := os.ReadFile(chunkPath)
if err != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("读取分块%d失败", i)})
return
}
outFile.Write(chunkData)
}
log.Printf("文件%s合并完成,共%d块", fileName, chunkTotal)
c.JSON(200, gin.H{
"code": 0,
"message": "合并成功",
"fileName": fileName,
"fileHash": fileHash,
})
})
r.Run(":8080")
}
⚠️ 新手必踩的坑: 合并分块时必须严格按序号顺序写入(0, 1, 2, 3…),否则合并后的文件内容会错乱。常见错误是遍历目录时使用
os.ReadDir返回的顺序——这个顺序不是按文件名数字排序的,而是按字母排序(10 排在 2 前面)。正确做法是用for i := 0; i < chunkTotal; i++按数字顺序读取。
四、断点续传原理与完整实现
4.1 用生活类比先建立直觉
想象你在下载一个大型游戏,下载到 80% 时网断了:
- 如果不支持断点续传,你只能从头重新下载——80% 的进度白费了。
- 如果支持断点续传,客户端先问服务器"你那边已经有了哪些块?",服务器回答"块 0-7 已经有了”,客户端只需继续上传块 8 和块 9。
断点续传的关键是记录已上传的块和校验块的完整性(MD5)。每次上传前先查询已传块,只上传缺失的块。
flowchart TD
A[客户端计算文件MD5] --> B[请求服务端查询已传块]
B --> C{文件已存在?}
C -->| 是 | D[秒传成功 直接返回]
C -->| 否 | E[服务端返回已传块列表]
E --> F[客户端对比本地块列表]
F --> G[只上传缺失的块]
G --> H{所有块上传完成?}
H -->| 否 | G
H -->| 是 | I[通知服务端合并]
I --> J[服务端合并并校验MD5]
J --> K{校验通过?}
K -->| 是 | L[上传完成]
K -->| 否 | M[清除分块 重新上传]桥接: “问服务器已有哪些块"对应上传前查询已传块列表。“只上传缺失的块"对应跳过已传块。服务端用 Redis 记录每个文件的已传块,键是文件 MD5,值是已传块序号集合。每次上传一块就更新 Redis,即使中途断网,下次查询时也能知道哪些块已经有了。
4.2 工程要点
知识点 4 & 5:断点续传原理与完整实现
断点续传完整实现需要三个接口:
| 接口 | 路径 | 作用 |
|---|---|---|
| 查询已传块 | GET /upload/status | 返回已上传的块列表 |
| 上传分块 | POST /upload/chunk | 接收单个分块并记录到 Redis |
| 合并文件 | POST /upload/merge | 合并所有分块并校验 MD5 |
package main
import (
"crypto/md5"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sort"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"golang.org/x/net/context"
)
// 步骤1:定义全局变量
var (
rdb *redis.Client
chunkDir = "./chunks"
uploadDir = "./uploads"
)
func main() {
// 步骤2:初始化Redis
rdb = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
os.MkdirAll(chunkDir, 0755)
os.MkdirAll(uploadDir, 0755)
r := gin.Default()
// 步骤3:注册三个核心接口
r.GET("/upload/status", handleChunkStatus)
r.POST("/upload/chunk", handleChunkUpload)
r.POST("/upload/merge", handleMergeChunks)
r.Run(":8080")
}
// 步骤4:查询已上传的分块
func handleChunkStatus(c *gin.Context) {
fileHash := c.Query("fileHash")
ctx := context.Background()
// 步骤5:检查文件是否已存在(秒传检查)
fileKey := fmt.Sprintf("file:%s", fileHash)
if exists, _ := rdb.Exists(ctx, fileKey).Result(); exists > 0 {
filePath, _ := rdb.Get(ctx, fileKey).Result()
c.JSON(200, gin.H{
"code": 0,
"uploaded": true,
"url": filePath,
"message": "文件已存在,秒传成功",
})
return
}
// 步骤6:查询已上传的分块列表
chunkKey := fmt.Sprintf("chunks:%s", fileHash)
uploadedChunks, err := rdb.SMembers(ctx, chunkKey).Result()
if err != nil {
c.JSON(500, gin.H{"error": "查询失败"})
return
}
// 步骤7:同时检查磁盘上实际存在的分块
fileChunkDir := filepath.Join(chunkDir, fileHash)
diskChunks := []string{}
if entries, err := os.ReadDir(fileChunkDir); err == nil {
for _, entry := range entries {
diskChunks = append(diskChunks, entry.Name())
}
}
// 步骤8:合并Redis和磁盘的记录,返回已传块列表
chunkSet := make(map[string]bool)
for _, ch := range uploadedChunks {
chunkSet[ch] = true
}
for _, ch := range diskChunks {
chunkSet[ch] = true
}
var result []string
for ch := range chunkSet {
result = append(result, ch)
}
sort.Strings(result)
c.JSON(200, gin.H{
"code": 0,
"uploaded": false,
"uploadedChunks": result,
})
}
// 步骤9:接收单个分块
func handleChunkUpload(c *gin.Context) {
fileHash := c.PostForm("fileHash")
chunkIndex := c.PostForm("chunkIndex")
chunkTotal := c.PostForm("chunkTotal")
// 步骤10:为每个文件创建独立的分块目录
fileChunkDir := filepath.Join(chunkDir, fileHash)
os.MkdirAll(fileChunkDir, 0755)
// 步骤11:接收分块文件
file, err := c.FormFile("chunk")
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 步骤12:保存分块到磁盘
chunkPath := filepath.Join(fileChunkDir, chunkIndex)
if err := c.SaveUploadedFile(file, chunkPath); err != nil {
c.JSON(500, gin.H{"error": "保存分块失败"})
return
}
// 步骤13:将已传块记录到Redis
ctx := context.Background()
chunkKey := fmt.Sprintf("chunks:%s", fileHash)
rdb.SAdd(ctx, chunkKey, chunkIndex)
// 步骤14:设置过期时间,7天后自动清理
rdb.Expire(ctx, chunkKey, 7*24*3600*time.Second)
c.JSON(200, gin.H{
"code": 0,
"message": "分块上传成功",
"chunkIndex": chunkIndex,
"chunkTotal": chunkTotal,
})
}
// 步骤15:合并所有分块
func handleMergeChunks(c *gin.Context) {
fileHash := c.PostForm("fileHash")
fileName := c.PostForm("fileName")
chunkTotal, _ := strconv.Atoi(c.PostForm("chunkTotal"))
fileChunkDir := filepath.Join(chunkDir, fileHash)
outputPath := filepath.Join(uploadDir, fileName)
// 步骤16:创建合并后的输出文件
outFile, err := os.Create(outputPath)
if err != nil {
c.JSON(500, gin.H{"error": "创建输出文件失败"})
return
}
defer outFile.Close()
// 步骤17:按序号顺序合并所有分块
for i := 0; i < chunkTotal; i++ {
chunkPath := filepath.Join(fileChunkDir, strconv.Itoa(i))
chunkData, err := os.ReadFile(chunkPath)
if err != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("读取分块%d失败", i)})
return
}
outFile.Write(chunkData)
}
// 步骤18:计算合并后文件的MD5,校验完整性
mergedHash, err := calcFileMD5(outputPath)
if err != nil {
c.JSON(500, gin.H{"error": "计算MD5失败"})
return
}
if mergedHash != fileHash {
// 步骤19:MD5不匹配,删除合并文件和分块
os.Remove(outputPath)
os.RemoveAll(fileChunkDir)
c.JSON(400, gin.H{"error": "文件MD5校验失败,请重新上传"})
return
}
// 步骤20:MD5校验通过,记录到Redis(供后续秒传)
ctx := context.Background()
fileKey := fmt.Sprintf("file:%s", fileHash)
rdb.Set(ctx, fileKey, outputPath, 0)
// 步骤21:清理分块文件
os.RemoveAll(fileChunkDir)
rdb.Del(ctx, fmt.Sprintf("chunks:%s", fileHash))
log.Printf("文件%s合并并校验成功", fileName)
c.JSON(200, gin.H{
"code": 0,
"message": "合并成功",
"fileName": fileName,
"fileHash": fileHash,
"url": outputPath,
})
}
// 步骤22:计算文件的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 fmt.Sprintf("%x", hash.Sum(nil)), nil
}
⚠️ 新手必踩的坑: 断点续传的 MD5 校验有两个层面:(1) 每个分块上传时可以校验分块 MD5,确保传输过程没有损坏;(2) 合并完成后必须校验整个文件的 MD5,确保分块顺序正确且内容完整。很多人只做了分块校验没做整体校验,结果分块顺序错误导致合并后的文件损坏。
五、秒传与并发上传优化
5.1 用生活类比先建立直觉
想象你搬家时发现新家已经有了一个一模一样的书架:
- 秒传:你还没开始搬,先问一句"你们那边有没有一个一模一样的书架?“如果有,就不用搬了——直接标记为"已搬完”。核心是上传前先算文件的 MD5(指纹),问服务端"这个指纹的文件你有了吗?",有就直接返回成功。
- 并发上传优化:你一个人搬太慢了,于是雇了一个施工队(worker pool)。队长(调度器)把任务分成小块分给工人,工人同时干活。有人摔了一跤(网络失败)就重试一次。每搬完一块就汇报进度(进度回调)。
flowchart TD
A[客户端选择文件] --> B[计算整个文件的MD5]
B --> C[发送MD5到服务端预检]
C --> D{文件已存在?}
D -->| 是 | E[秒传成功
不实际上传任何数据]
D -->| 否 | F[开始分块上传]
F --> G[worker pool分配任务]
G --> H[worker1上传块0]
G --> I[worker2上传块1]
G --> J[worker3上传块2]
H --> K{上传成功?}
I --> K
J --> K
K -->| 否 | L[失败重试
最多3次]
L --> K
K -->| 是 | M[回调更新进度]
M --> N{所有块完成?}
N -->| 否 | G
N -->| 是 | O[通知服务端合并]桥接: “问有没有一模一样的书架"对应秒传——用文件 MD5 做唯一标识,服务端有就直接返回。“施工队同时干活"对应 worker pool——控制并发数避免开太多 goroutine 压垮客户端或服务端。“摔了一跤重试"对应失败重试——网络不稳定时自动重传失败的块。“汇报进度"对应进度回调——让用户看到上传进度条。
5.2 工程要点
知识点 6 & 7:秒传与并发上传优化
秒传的实现:
秒传的核心逻辑非常简单——上传前先检查文件 MD5 是否已存在于服务端:
| 步骤 | 客户端 | 服务端 |
|---|---|---|
| 1 | 计算整个文件的 MD5 | - |
| 2 | 发送 MD5 到 /upload/status | - |
| 3 | - | 查询 Redis:file:MD5 是否存在 |
| 4 | - | 存在则返回 URL,不存在则返回已传块列表 |
| 5 | 收到"已存在"则秒传成功 | - |
| 6 | 收到"不存在"则走分块上传流程 | - |
package main
import (
"github.com/gin-gonic/gin"
"golang.org/x/net/context"
"fmt"
)
// 步骤1:秒传预检接口
func handleInstantUpload(c *gin.Context) {
fileHash := c.PostForm("fileHash")
fileName := c.PostForm("fileName")
fileSize := c.PostForm("fileSize")
ctx := context.Background()
// 步骤2:查Redis,文件MD5是否已存在
fileKey := fmt.Sprintf("file:%s", fileHash)
if filePath, err := rdb.Get(ctx, fileKey).Result(); err == nil {
// 步骤3:文件已存在,直接返回成功(秒传)
c.JSON(200, gin.H{
"code": 0,
"message": "秒传成功,文件已存在",
"fileName": fileName,
"fileHash": fileHash,
"fileSize": fileSize,
"url": filePath,
})
return
}
// 步骤4:文件不存在,需要正常上传
c.JSON(200, gin.H{
"code": 1,
"message": "文件不存在,请上传",
})
}
并发上传优化(worker pool):
package main
import (
"crypto/md5"
"fmt"
"io"
"log"
"os"
"sync"
"time"
)
// 步骤1:定义分块任务
type ChunkTask struct {
Index int
Data []byte
FileHash string
ChunkHash string
Retries int
}
// 步骤2:定义上传结果
type UploadResult struct {
Index int
Success bool
Error error
}
// 步骤3:并发上传函数
func concurrentUpload(filePath string, chunkSize int64, concurrency int) error {
// 步骤4:打开文件并计算MD5
file, err := os.Open(filePath)
if err != nil {
return err
}
defer file.Close()
// 步骤5:读取文件并分块
stat, _ := file.Stat()
totalChunks := int(stat.Size()/chunkSize) + 1
if stat.Size()%chunkSize == 0 {
totalChunks = int(stat.Size() / chunkSize)
}
// 步骤6:创建任务通道和结果通道
taskCh := make(chan ChunkTask, totalChunks)
resultCh := make(chan UploadResult, totalChunks)
// 步骤7:启动worker pool
var wg sync.WaitGroup
for w := 0; w < concurrency; w++ {
wg.Add(1)
go func(workerId int) {
defer wg.Done()
for task := range taskCh {
// 步骤8:上传分块(带重试)
err := uploadChunkWithRetry(task, 3)
if err != nil {
resultCh <- UploadResult{Index: task.Index, Success: false, Error: err}
} else {
resultCh <- UploadResult{Index: task.Index, Success: true}
}
}
}(w)
}
// 步骤9:分配任务——读取文件并分块
go func() {
for i := 0; i < totalChunks; i++ {
offset := int64(i) * chunkSize
chunkData := make([]byte, chunkSize)
file.ReadAt(chunkData, offset)
// 步骤10:计算分块MD5
chunkHash := fmt.Sprintf("%x", md5.Sum(chunkData))
taskCh <- ChunkTask{
Index: i,
Data: chunkData,
ChunkHash: chunkHash,
}
}
close(taskCh)
}()
// 步骤11:等待所有worker完成
go func() {
wg.Wait()
close(resultCh)
}()
// 步骤12:收集结果并更新进度
uploaded := 0
failed := 0
for result := range resultCh {
if result.Success {
uploaded++
} else {
failed++
log.Printf("分块%d上传失败: %v", result.Index, result.Error)
}
// 步骤13:进度回调
progress := float64(uploaded+failed) / float64(totalChunks) * 100
log.Printf("进度: %.1f%% (%d/%d)", progress, uploaded+failed, totalChunks)
}
if failed > 0 {
return fmt.Errorf("%d个分块上传失败", failed)
}
log.Printf("全部%d个分块上传完成", uploaded)
return nil
}
// 步骤14:带重试的上传函数
func uploadChunkWithRetry(task ChunkTask, maxRetries int) error {
var lastErr error
for retry := 0; retry <= maxRetries; retry++ {
if retry > 0 {
// 步骤15:指数退避等待
time.Sleep(time.Duration(retry*retry) * time.Second)
}
// 步骤16:模拟上传分块
err := doUploadChunk(task)
if err == nil {
return nil
}
lastErr = err
log.Printf("分块%d第%d次上传失败: %v", task.Index, retry+1, err)
}
return lastErr
}
// 步骤17:实际上传分块的HTTP请求(简化示例)
func doUploadChunk(task ChunkTask) error {
// 实际项目中这里发送HTTP POST请求到服务端
// 包含fileHash, chunkIndex, chunkData等信息
return nil
}
⚠️ 新手必踩的坑: 并发上传时不要把所有分块都读进内存再上传。上面的示例代码为了简洁用了
make([]byte, chunkSize)一次性读取,实际项目中应该用io.Reader流式读取——否则一个 1GB 的文件分成 100 块,每块 10MB,100 个[]byte就是 1GB 内存。正确做法是 worker 从 channel 取到任务后,用file.ReadAt按需读取对应位置的数据,读完即释放。
六、文件服务器如何选型
6.1 用生活类比先建立直觉
想象你要为公司找一个"放东西的地方”,可选方案和文件服务器选型几乎一一对应:
- 租公共云仓(OSS / S3):直接找现成的云厂商仓库,按月付费、容量无限、随用随扩,但得遵守它的规矩(API、计费,数据出了自家机房)。适合不想自己运维、追求弹性扩容的团队。
- 自己买地建仓(MinIO / FastDFS 自建):在自己机房搭一套分布式存储,数据完全自己掌控、不花云厂商的钱,但得自己招人运维、做容灾。适合数据合规要求高、长期成本敏感的场景。
- 用小区公共储物架(NFS / 共享文件系统):挂一块网络磁盘,简单粗暴、所有机器都能直接读写,但容量和并发都有限,挂了就全挂。适合内网小项目、临时共享。
- 让快递柜代收(Nginx 直传磁盘):最朴素——Nginx 收到文件直接落盘,零额外组件,但功能弱(没元数据管理、没分块、难扩展)。适合 Demo 或纯小文件。
桥接: “租云仓 vs 自建仓 vs 共享架子"对应公有云对象存储 / 自建对象存储 / 共享文件系统三条路线。选型本质是在运维成本、扩展能力、数据掌控力、价格四件事之间做取舍——没有"最好”,只有"最合适”。
flowchart TD
A[我要存文件] --> B{团队有专职运维?}
B -->| 否 | C{数据可出公网?}
C -->| 否 内网小项目 | D[共享磁盘 NFS
或 Nginx 直传]
C -->| 是 想省心 | E[公有云对象存储
OSS / S3]
B -->| 是 | F{强合规/控成本?}
F -->| 是 | G[自建对象存储
MinIO / FastDFS]
F -->| 否 弹性优先 | E桥接(决策图解读): 没运维人力又想省心 → 直接上 OSS/S3;有 SRE 团队且数据不能出内网/要控成本 → MinIO 自建;只是内网小项目临时共享 → NFS 或 Nginx 直传。下面用一张对比表把维度说清楚。
6.2 工程要点
知识点:五种文件服务器形态对比与选型
| 形态 | 代表方案 | 部署成本 | 扩展能力 | 运维负担 | 数据掌控 | 典型适用场景 |
|---|---|---|---|---|---|---|
| 共享文件系统 | NFS | 低(挂盘即用) | 弱(单点) | 低 | 完全本地 | 内网小项目、临时共享 |
| Nginx 直传 | client_body_temp_path | 极低(零组件) | 弱 | 低 | 完全本地 | Demo、纯小文件 |
| 公有云对象存储 | AWS S3、阿里云 OSS | 中(按量付费) | 极强(无限扩容) | 极低(厂商运维) | 数据在云上 | 生产环境、弹性业务 |
| 自建对象存储 | MinIO、FastDFS | 中高(机器+人力) | 强(可横向扩) | 高(自己运维) | 完全自有 | 合规要求、成本敏感 |
| CDN 回源 | 边缘缓存 + 对象存储 | 中(流量费) | 强(边缘加速) | 低 | 依赖源站 | 静态资源加速、下载分发 |
选型决策速记:
- 先问运维:没人运维 → 闭眼选 OSS/S3;有 SRE 团队 → 考虑 MinIO 自建。
- 再问数据:数据不能出内网、强合规 → 必须自建(MinIO / 私有云);可接受上云 → 对象存储最省心。
- 后问规模:小文件、低频、内网 → NFS / Nginx 直传够用;大文件、高并发、要断点续传 → 对象存储 + 预签名直传。
- 提速场景:面向大量用户分发(视频、安装包)→ 对象存储 + CDN 回源,边缘节点扛流量。
⚠️ 新手必踩的坑: “文件服务器"和"业务服务器"混用一台机器是大忌。哪怕选了 NFS,也建议单独挂载一块盘甚至单独一台机器,避免上传流量打满磁盘 IO、把业务接口的数据库查询一起拖垮。另外,选了对象存储后,业务服务器只签发预签名 URL,文件流直传对象存储——这正好呼应第一章"直传文件服务器"的架构,文件服务器选型决定了上传链路怎么搭。
七、自测题与动手练习
自测题
1. 为什么大文件上传应该直传文件服务器,而不是经过业务服务器中转?
查看答案
经过业务服务器中转时,文件内容会流经业务服务器的内存和网络。大文件(如视频)会占用大量带宽和内存,多个并发上传可能直接导致业务服务器 OOM 或影响其他业务逻辑。直传文件服务器(Nginx/对象存储)让文件流量不经过业务服务器,业务服务器只负责签发上传凭证和存储元数据(文件名、大小、MD5),压力大大降低。
2. FormData 和 Binary 两种上传方式的区别是什么?各自适用什么场景?
查看答案
FormData(multipart/form-data)是 HTTP 标准格式,一个请求中可以同时包含文件和文本字段(如文件名、用户ID),用 Gin 的 c.FormFile 解析。适用于表单上传、需要携带元数据的场景。
Binary(application/octet-stream)只传输纯二进制流,没有附加信息,文件名等需要通过 Header 传递。用 Gin 的 c.GetRawData 读取。适用于分块上传(每块是纯二进制流)、需要更轻量传输的场景。
3. 分块上传合并时,为什么要按序号顺序合并?如果乱序合并会发生什么?
查看答案
分块是按文件偏移量切分的,块 0 对应文件开头,块 1 对应第二段,依此类推。合并时必须按 0, 1, 2, 3… 的顺序写入,才能还原原始文件。如果乱序合并,文件内容会错乱——比如把块 3 的内容写到了块 0 的位置,整个文件就损坏了。常见错误是用 os.ReadDir 遍历分块目录,返回的顺序是字母序(10 排在 2 前面),导致合并顺序错误。
4. 断点续传是如何实现的?服务端如何知道哪些块已经上传了?
查看答案
断点续传通过"上传前查询已传块"实现:(1) 客户端上传前先用文件 MD5 查询服务端 /upload/status 接口;(2) 服务端从 Redis 中查询该文件 MD5 对应的已传块集合(chunks:MD5 这个 key 的 SMembers 结果);(3) 同时检查磁盘上实际存在的分块文件;(4) 返回已传块列表给客户端;(5) 客户端对比本地分块列表,只上传缺失的块。每上传成功一块,服务端就用 SAdd 把块序号加入 Redis 集合。
5. 秒传的原理是什么?为什么秒传不需要实际上传文件?
查看答案
秒传的原理是:每个文件的 MD5 是唯一的(不考虑哈希碰撞),如果服务端已经有一个相同 MD5 的文件,就说明文件内容完全一样,不需要重复上传。客户端在上传前先计算文件 MD5,发送到服务端预检。服务端查 Redis(file:MD5 这个 key),如果存在就直接返回文件 URL,客户端标记为上传成功。整个过程只传了一个 MD5 字符串,没有传输任何文件数据。
动手练习
练习 1: 用 Go Gin 实现一个支持 FormData 上传的接口。要求:(1) 限制上传文件大小不超过 10MB;(2) 保存到 ./uploads 目录;(3) 返回文件的 MD5 和大小。用 curl 命令 curl -F "file=@test.txt" -F "userId=123" http://localhost:8080/upload/formdata 测试。
练习 2: 在练习 1 的基础上实现分块上传。要求:(1) 客户端把文件切成 1MB 的块;(2) 服务端接收并存储分块到 ./chunks/{fileHash}/ 目录;(3) 提供合并接口按序号合并分块;(4) 合并后校验 MD5。用一个大文件(如 50MB)测试。
练习 3: 在练习 2 的基础上加入断点续传和秒传。要求:(1) 上传前查询已传块,只上传缺失块;(2) 用 Redis 记录已传块和已完成文件;(3) 实现秒传预检接口;(4) 实现并发上传(worker pool,并发数 3)。测试:上传到一半 kill 进程,重启后继续上传。
八、本章小结
本章围绕文件上传的进阶技术展开,核心要点如下:
- 文件服务器 vs 业务服务器:大文件应直传文件服务器(Nginx/对象存储),不经过业务服务器。业务服务器只负责签发凭证和存储元数据,避免大文件占用带宽和内存。
- FormData vs Binary:FormData(multipart/form-data)是标准格式,可携带元数据字段,用
c.FormFile解析。Binary(application/octet-stream)是纯二进制流,用c.GetRawData读取,更轻量但无元数据。大文件必须流式读取,不能用GetRawData一次性读进内存。 - 分块上传:把大文件切成 1-5MB 的小块,并行上传,服务端按序号合并。关键是合并时必须按数字顺序(0, 1, 2…),不能用
ReadDir的字母序。 - 断点续传:上传前查询已传块(Redis + 磁盘双重检查),只上传缺失块。每上传一块用
SAdd记录到 Redis。合并后用 MD5 校验整体完整性。 - 秒传:上传前计算文件 MD5,查 Redis
file:MD5是否存在。存在则直接返回 URL,不传任何文件数据。核心是"MD5 相同则文件内容相同”。 - 并发上传优化:用 worker pool 控制并发数(通常 3-5),避免开太多 goroutine。失败重试用指数退避(1s, 4s, 9s)。进度回调让用户看到上传进度。注意按需读取文件数据,不要把所有分块都读进内存。
掌握这些技术后,你能够实现生产级的文件上传系统,支持大文件、断点续传、秒传等核心功能。这是构建网盘、视频平台等应用的基础能力。
- 文件服务器 vs 业务服务器分离:大文件直传对象存储(OSS/S3),业务服务器只处理元数据和凭证签发,避免带宽瓶颈。
- 分片上传关键:序号排序用数字序而非字母序;每片独立校验MD5确保完整性;合并时按正确顺序组装。
- 断点续传核心:Redis记录已上传分片位图,重启后查询已传部分跳过重复上传,提升用户体验。
- 秒传实现:计算文件哈希(MD5/SHA256)作为唯一标识,相同内容直接复用已有文件URL,节省存储空间。