背景与动机
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- 请求时(跳转到某个位置):
- 无法直接定位分块:虽然文件头有分块信息,但每个分块的长度是加密后的实际长度,不是固定值
- 需要顺序读取:要定位第 N 个分块,必须先读取前 N-1 个分块的长度字段
- 本质上是伪流式:虽然解密是流式的,但必须从头开始读取才能定位
实际表现:
- 拖动进度条时,需要从头下载到目标位置
- 一个 500MB 视频,跳到 80% 位置需要先下载 400MB
- 体验极差,本质上不是流式播放
新设计:目录分块存储
设计目标
- 真正的流式播放:支持 Range 请求直接定位到任意位置
- 按需下载:只下载需要的分块,不浪费带宽
- 简化分块结构:固定大小分块,去掉长度前缀
新存储结构
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 添加文件11T4 保存 (11个文件)T5 添加文件12T6 保存 (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) |
迁移策略
由于处于开发阶段,采用”不兼容迁移”:
- 旧数据全部删除
- 新上传使用新格式
- 简化代码,无需兼容逻辑
后续优化方向
- 并行分块下载:多个分块同时下载,提高大文件下载速度
- 断点续传:记录已上传分块,支持中断后继续
- 客户端缓存:缓存常用分块,减少重复下载