跳到主要内容

设计文件存储系统

问题

如何用 Go 设计一个文件存储服务?支持大文件上传、断点续传和 CDN 分发。

答案

核心架构

分片上传

// 初始化上传:返回 uploadID
func InitUpload(c *gin.Context) {
var req struct {
Filename string `json:"filename"`
FileSize int64 `json:"file_size"`
ChunkSize int64 `json:"chunk_size"` // 每片大小(默认 5MB)
}
c.ShouldBindJSON(&req)

uploadID := uuid.NewString()
totalChunks := int(math.Ceil(float64(req.FileSize) / float64(req.ChunkSize)))

// 保存上传元数据
db.Create(&UploadTask{
UploadID: uploadID,
Filename: req.Filename,
FileSize: req.FileSize,
TotalChunks: totalChunks,
Status: "uploading",
})

c.JSON(200, gin.H{"upload_id": uploadID, "total_chunks": totalChunks})
}

// 上传分片
func UploadChunk(c *gin.Context) {
uploadID := c.Param("upload_id")
chunkIndex, _ := strconv.Atoi(c.PostForm("chunk_index"))
file, _ := c.FormFile("chunk")

// 存储分片到临时目录
chunkPath := fmt.Sprintf("tmp/%s/chunk_%d", uploadID, chunkIndex)
c.SaveUploadedFile(file, chunkPath)

// 记录分片状态
db.Create(&ChunkRecord{
UploadID: uploadID,
ChunkIndex: chunkIndex,
Status: "uploaded",
})

c.JSON(200, gin.H{"chunk_index": chunkIndex, "status": "ok"})
}

// 合并分片
func MergeChunks(c *gin.Context) {
uploadID := c.Param("upload_id")
var task UploadTask
db.Where("upload_id = ?", uploadID).First(&task)

// 按序合并所有分片
outFile, _ := os.Create("uploads/" + task.Filename)
defer outFile.Close()

for i := 0; i < task.TotalChunks; i++ {
chunkPath := fmt.Sprintf("tmp/%s/chunk_%d", uploadID, i)
chunk, _ := os.Open(chunkPath)
io.Copy(outFile, chunk)
chunk.Close()
os.Remove(chunkPath) // 清理分片
}

// 上传到 OSS
ossURL := uploadToOSS(outFile.Name())

task.Status = "completed"
task.URL = ossURL
db.Save(&task)

c.JSON(200, gin.H{"url": ossURL})
}

断点续传

// 查询已上传分片(客户端重连后调用)
func GetUploadedChunks(c *gin.Context) {
uploadID := c.Param("upload_id")
var chunks []ChunkRecord
db.Where("upload_id = ? AND status = ?", uploadID, "uploaded").Find(&chunks)

uploaded := make([]int, len(chunks))
for i, ch := range chunks {
uploaded[i] = ch.ChunkIndex
}

c.JSON(200, gin.H{"uploaded_chunks": uploaded})
}

客户端流程:

  1. 请求已上传分片列表
  2. 跳过已上传的分片
  3. 只上传缺失的分片

秒传(文件去重)

// 客户端计算文件 MD5/SHA256,上传前检查
func CheckFileExists(c *gin.Context) {
hash := c.Query("hash")
var file FileRecord
if err := db.Where("hash = ?", hash).First(&file).Error; err == nil {
// 文件已存在,直接返回 URL(秒传)
c.JSON(200, gin.H{"exists": true, "url": file.URL})
return
}
c.JSON(200, gin.H{"exists": false})
}

MinIO 对象存储集成

import "github.com/minio/minio-go/v7"

func UploadToMinIO(filePath, objectName string) (string, error) {
client, _ := minio.New("localhost:9000", &minio.Options{
Creds: credentials.NewStaticV4("minioadmin", "minioadmin", ""),
Secure: false,
})

_, err := client.FPutObject(context.Background(), "files", objectName, filePath, minio.PutObjectOptions{
ContentType: "application/octet-stream",
})
if err != nil {
return "", err
}

return fmt.Sprintf("http://cdn.example.com/files/%s", objectName), nil
}

// 生成预签名 URL(客户端直传 OSS)
func GetPresignedURL(c *gin.Context) {
objectName := c.Query("filename")
url, _ := minioClient.PresignedPutObject(
context.Background(), "files", objectName, 15*time.Minute,
)
c.JSON(200, gin.H{"upload_url": url.String()})
}

访问控制

场景方案
公开文件CDN 直接访问
私有文件预签名 URL(限时有效)
防盗链Referer / Token 校验
图片处理OSS 图片参数(缩放、裁剪、水印)

常见面试问题

Q1: 大文件上传为什么要分片?

答案

  • 支持断点续传,网络中断不用全部重传
  • 并发上传多个分片,提高速度
  • 避免单个请求过大导致超时
  • 绕过一些代理/网关的请求体大小限制

Q2: 客户端直传 OSS 和服务端中转有什么区别?

答案

  • 客户端直传:通过预签名 URL 直传 OSS,不占用服务端带宽(推荐大文件)
  • 服务端中转:文件经服务端转存,可做校验和处理,但消耗服务端资源
  • 最佳实践:客户端直传 + 回调通知服务端记录元数据

Q3: 如何保证文件完整性?

答案

  • 每个分片传 MD5,服务端校验
  • 合并后校验整个文件的 SHA256
  • 数据库记录每个分片状态,缺失则要求重传

相关链接