分块存储模型迁移:从单文件加密到目录分块存储

December 20, 2025
4 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

背景与动机

E2EEPAN 是一个端到端加密的网盘应用,所有文件在上传前加密,存储在 S3 上的都是密文。

初始设计(单文件加密)

最初的加密格式是将整个文件加密为一个文件,结构如下:

┌─────────────────────────────────────────────────────────┐
│ 旧加密文件格式 │
├─────────────────────────────────────────────────────────┤
│ Header (64B) │
│ ├── Magic: "E2EP" (4B) │
│ ├── Version (1B) │
│ ├── ChunkSize (4B) │
│ ├── TotalChunks (4B) │
│ ├── OriginalSize (8B) │
│ ├── IV (12B) │
│ └── Reserved (31B) │
├─────────────────────────────────────────────────────────┤
│ Chunk 0: [长度(4B)][nonce(12B)][ciphertext][tag(16B)] │
│ Chunk 1: [长度(4B)][nonce(12B)][ciphertext][tag(16B)] │
│ ... │
│ Chunk N: [长度(4B)][nonce(12B)][ciphertext][tag(16B)] │
├─────────────────────────────────────────────────────────┤
│ [加密的元数据][元数据长度(4B)] │
└─────────────────────────────────────────────────────────┘

S3 存储路径:files/{uuid}.enc

核心问题:无法真正流式播放

这种设计有一个致命问题:无法实现真正的流式播放

当视频播放器发起 Range: bytes=100000000- 请求时(跳转到某个位置):

  1. 无法直接定位分块:虽然文件头有分块信息,但每个分块的长度是加密后的实际长度,不是固定值
  2. 需要顺序读取:要定位第 N 个分块,必须先读取前 N-1 个分块的长度字段
  3. 本质上是伪流式:虽然解密是流式的,但必须从头开始读取才能定位

实际表现:

  • 拖动进度条时,需要从头下载到目标位置
  • 一个 500MB 视频,跳到 80% 位置需要先下载 400MB
  • 体验极差,本质上不是流式播放

新设计:目录分块存储

设计目标

  1. 真正的流式播放:支持 Range 请求直接定位到任意位置
  2. 按需下载:只下载需要的分块,不浪费带宽
  3. 简化分块结构:固定大小分块,去掉长度前缀

新存储结构

files/
└── {uuid}/
├── meta.enc # 加密的分块元数据
└── chunks/
├── 0.enc # 分块 0
├── 1.enc # 分块 1
├── 2.enc # 分块 2
└── ...

ChunkMeta 结构(存储在 meta.enc 中):

type ChunkMeta struct {
    Version      int    `json:"version"`      // 版本号
    Name         string `json:"name"`         // 原始文件名
    MimeType     string `json:"mimeType"`     // MIME 类型
    OriginalSize int64  `json:"originalSize"` // 原始大小
    ChunkSize    int    `json:"chunkSize"`    // 分块大小(固定 5MB)
    TotalChunks  int    `json:"totalChunks"`  // 总分块数
}

单个分块格式(无长度前缀):

[nonce(12B)][ciphertext][tag(16B)]

关键设计决策

1. 分块大小:5MB

选项优点缺点
64KB细粒度控制,低延迟启动分块数量太多,S3 请求多
1MB平衡仍然较多请求
5MB请求数适中,适合大文件小文件也至少 1 个分块
10MB+减少请求数单次下载数据量大

最终选择 5MB,因为:

  • 视频流式播放通常需要一定的缓冲区
  • 5MB 在网络正常时约 1-2 秒下载完成
  • 500MB 文件只需 100 个分块,请求数可接受

2. 去掉分块长度前缀

旧格式每个分块前有 4 字节长度:

[长度(4B)][nonce(12B)][ciphertext][tag(16B)]

新格式去掉长度前缀:

[nonce(12B)][ciphertext][tag(16B)]

理由

  • 每个分块是独立的 S3 对象,读取时就知道完整大小
  • 不需要长度前缀来分隔分块
  • 简化加解密逻辑

3. 元数据与分块分离

旧格式将文件名等元数据嵌入文件尾部,需要读取整个文件才能获取。

新格式使用独立的 meta.enc 文件:

  • 只需一次小请求即可获取元数据
  • 支持只更新元数据而不重新上传文件(如重命名)
  • 便于扩展更多元数据字段

重构实现细节

上传流程

func (s *Server) uploadFile(c *gin.Context) {
    // 1. 生成文件 ID 和密钥
    fileID := uuid.New().String()
    fileKey := crypto.DeriveFileKey(s.masterKey, fileID)
    
    // 2. 计算分块信息
    chunkSize := s.encryptor.GetChunkSize() // 5MB
    totalChunks := int((header.Size + int64(chunkSize) - 1) / int64(chunkSize))
    
    // 3. 逐块读取、加密、上传
    buffer := make([]byte, chunkSize)
    for i := 0; i < totalChunks; i++ {
        n, _ := io.ReadFull(file, buffer)
        
        // 加密分块(无长度前缀)
        encrypted, _ := s.encryptor.EncryptChunkSimple(buffer[:n], fileKey)
        
        // 上传到独立的 S3 对象
        chunkKey := fmt.Sprintf("files/%s/chunks/%d.enc", fileID, i)
        s.s3.UploadBytes(ctx, chunkKey, encrypted, "application/octet-stream")
    }
    
    // 4. 创建并上传分块元数据
    chunkMeta := &crypto.ChunkMeta{
        Version:      crypto.Version,
        Name:         header.Filename,
        MimeType:     mimeType,
        OriginalSize: header.Size,
        ChunkSize:    chunkSize,
        TotalChunks:  totalChunks,
    }
    encryptedMeta, _ := s.encryptor.EncryptJSON(chunkMeta, fileKey)
    metaKey := fmt.Sprintf("files/%s/meta.enc", fileID)
    s.s3.UploadBytes(ctx, metaKey, encryptedMeta, "application/octet-stream")
    
    // 5. 更新元数据索引
    s.updateMetadataIndex(ctx, &metadata)
}

下载流程

func (s *Server) downloadFile(c *gin.Context) {
    // 1. 加载分块元数据
    chunkMeta, _ := s.loadChunkMeta(ctx, fileID, fileKey)
    
    // 2. 逐块下载并解密
    var result bytes.Buffer
    for i := 0; i < chunkMeta.TotalChunks; i++ {
        chunkKey := fmt.Sprintf("files/%s/chunks/%d.enc", fileID, i)
        chunkData, _ := s.s3.DownloadBytes(ctx, chunkKey)
        
        decrypted, _ := s.encryptor.DecryptChunkSimple(chunkData, fileKey)
        result.Write(decrypted)
    }
    
    c.Data(http.StatusOK, chunkMeta.MimeType, result.Bytes())
}

删除流程

func (s *Server) deleteFile(c *gin.Context) {
    // 使用前缀删除整个目录
    filePrefix := fmt.Sprintf("files/%s/", fileID)
    s.s3.DeletePrefix(ctx, filePrefix)
    
    // 更新元数据索引
    s.removeFromMetadataIndex(ctx, fileID)
}

重命名优化

旧设计需要:下载 → 解密 → 修改文件名 → 重新加密 → 上传

新设计只需:

func (s *Server) renameFile(c *gin.Context) {
    // 加载 ChunkMeta
    chunkMeta, _ := s.loadChunkMeta(ctx, fileID, fileKey)
    
    // 修改文件名
    chunkMeta.Name = req.Name
    chunkMeta.MimeType = detectMimeType(req.Name)
    
    // 重新加密并上传 ChunkMeta(分块数据不变)
    encryptedMeta, _ := s.encryptor.EncryptJSON(chunkMeta, fileKey)
    metaKey := fmt.Sprintf("files/%s/meta.enc", fileID)
    s.s3.UploadBytes(ctx, metaKey, encryptedMeta, "application/octet-stream")
}

性能提升:1GB 文件重命名从分钟级降为毫秒级。


并发问题与解决

问题发现:批量上传产生游离文件

测试批量上传时发现:部分文件上传成功但在文件列表中不可见。

根因:元数据更新竞态条件

原始实现:

func (s *Server) updateMetadataIndex(ctx context.Context, file *FileMetadata) error {
    meta, _ := s.loadMetadataIndex(ctx)   // 1. 读取
    meta.Files[file.ID] = file             // 2. 修改
    return s.saveMetadataIndex(ctx, meta)  // 3. 保存
}

竞态场景:

时间 请求A 请求B
─────────────────────────────────────────────
T1 读取元数据 (10个文件)
T2 读取元数据 (10个文件)
T3 添加文件11
T4 保存 (11个文件)
T5 添加文件12
T6 保存 (11个文件,覆盖A的结果!)

解决方案:异步批量处理

引入 channel + worker 模式:

type Server struct {
    metaUpdateChan chan *metaUpdateRequest
}
 
type metaUpdateRequest struct {
    file *FileMetadata
    done chan error
}
 
// 元数据批量写入 worker
func (s *Server) metaWriterWorker() {
    const (
        maxBatchSize = 20
        batchTimeout = 10 * time.Millisecond
    )
    
    var pending []*metaUpdateRequest
    var timer <-chan time.Time
    
    for {
        select {
        case req := <-s.metaUpdateChan:
            pending = append(pending, req)
            if len(pending) == 1 {
                timer = time.After(batchTimeout)
            }
            if len(pending) >= maxBatchSize {
                s.flushMetaUpdates(pending)
                pending = nil
                timer = nil
            }
        case <-timer:
            if len(pending) > 0 {
                s.flushMetaUpdates(pending)
                pending = nil
            }
            timer = nil
        }
    }
}
 
func (s *Server) flushMetaUpdates(requests []*metaUpdateRequest) {
    meta, _ := s.loadMetadataIndex(ctx)
    
    // 批量添加所有文件
    for _, req := range requests {
        meta.Files[req.file.ID] = req.file
    }
    
    err := s.saveMetadataIndex(ctx, meta)
    
    // 通知所有等待者
    for _, req := range requests {
        req.done <- err
    }
}

优势

  • 避免竞态:所有更新通过单一 worker 处理
  • 批量写入:多个请求合并为一次 S3 写入
  • 减少 I/O:10ms 内的请求合并处理

总结

存储格式对比

特性旧格式新格式
存储结构单文件 files/{uuid}.enc目录 files/{uuid}/
分块格式长度前缀 + 加密数据纯加密数据
元数据位置嵌入文件尾部独立 meta.enc 文件
流式定位需顺序读取直接计算
Range 请求不支持真正随机访问支持任意位置访问
重命名性能O(文件大小)O(1)

迁移策略

由于处于开发阶段,采用”不兼容迁移”:

  • 旧数据全部删除
  • 新上传使用新格式
  • 简化代码,无需兼容逻辑

后续优化方向

  1. 并行分块下载:多个分块同时下载,提高大文件下载速度
  2. 断点续传:记录已上传分块,支持中断后继续
  3. 客户端缓存:缓存常用分块,减少重复下载