问题描述
在 Android 设备上批量上传视频文件时,部分文件上传失败。
现象:
- 选择 6 个视频文件(20MB ~ 60MB)上传
- 只有 2 个成功,4 个失败
- 前端显示
Broken pipe或status code 400错误 - 服务器日志显示
permission denied错误
问题定位过程
第一步:添加调试日志
为了定位问题,在前后端添加详细的上传日志:
前端日志(api_client.dart):
Future<ApiResult<FileMetadata>> uploadFile(...) async {
print('[UPLOAD] 开始上传: $fileName, 路径: $filePath');
print('[UPLOAD] FormData 准备完成, 开始发送请求...');
// ...
print('[UPLOAD] 数据发送完成: $fileName, 等待服务器响应...');
print('[UPLOAD] 服务器响应: ${response.statusCode}');
}后端日志(server.go):
func (s *Server) uploadFile(c *gin.Context) {
log.Printf("[UPLOAD] 收到上传请求")
log.Printf("[UPLOAD] 文件名: %s, 大小: %d bytes", header.Filename, header.Size)
log.Printf("[UPLOAD] 开始加密: %s", header.Filename)
log.Printf("[UPLOAD] 加密完成, 加密后大小: %d bytes", encryptedBuf.Len())
log.Printf("[UPLOAD] 开始上传到 S3: %s", s3Key)
// ...
}第二步:复现问题
运行应用,选择 6 个视频文件上传,观察日志:
I/flutter: [UPLOAD] 开始上传: VID_20251212_185648.mp4I/flutter: [UPLOAD] 开始上传: VID_20251212_185621.mp4I/flutter: [UPLOAD] 开始上传: VID_20251212_185550.mp4I/flutter: [UPLOAD] FormData 准备完成, 开始发送请求...I/flutter: [UPLOAD] FormData 准备完成, 开始发送请求...I/flutter: [UPLOAD] FormData 准备完成, 开始发送请求...I/GoLog: [UPLOAD] 收到上传请求I/GoLog: [UPLOAD] 收到上传请求I/GoLog: [UPLOAD] 收到上传请求I/GoLog: [UPLOAD] 文件名: VID_20251212_185550.mp4, 大小: 27809735 bytesI/GoLog: [UPLOAD] 开始加密: VID_20251212_185550.mp4I/GoLog: [UPLOAD] 加密完成, 加密后大小: 27810729 bytesI/GoLog: [UPLOAD] 开始上传到 S3: files/xxx.encI/GoLog: [UPLOAD] 无文件: open /data/local/tmp/multipart-2734234005: permission denied ← 关键错误I/GoLog: [UPLOAD] 无文件: open /data/local/tmp/multipart-2852574493: permission denied ← 关键错误I/flutter: [UPLOAD] 上传失败: VID_20251212_185648.mp4, 错误: Broken pipeI/flutter: [UPLOAD] 上传失败: VID_20251212_185621.mp4, 错误: Broken pipe第三步:分析错误原因
关键错误信息:
open /data/local/tmp/multipart-2734234005: permission denied根本原因分析:
-
Gin 框架的 multipart 处理机制:
- Gin 默认
MaxMultipartMemory = 32MB - 当上传文件大小超过此限制时,Gin 会将数据写入临时文件
- 默认临时目录由 Go 的
os.TempDir()决定
- Gin 默认
-
Android 的临时目录问题:
- Go 在 Android 上默认使用
/data/local/tmp/作为临时目录 - 但 Android 应用没有此目录的读写权限
- 只有 shell/adb 进程才能访问此目录
- Go 在 Android 上默认使用
-
并发上传放大问题:
- 同时上传 3 个大文件
- 第一个文件可能恰好小于等于内存限制,成功
- 后续文件超限后写入临时目录,失败
┌─────────────────────────────────────────────────────────────┐│ Gin Multipart 处理流程 │├─────────────────────────────────────────────────────────────┤│ ││ 上传请求 ──► 检查文件大小 ││ │ ││ ┌───────┴───────┐ ││ ▼ ▼ ││ ≤ MaxMultipartMemory > MaxMultipartMemory ││ │ │ ││ ▼ ▼ ││ 存储在内存 写入临时文件 ││ │ │ ││ │ ▼ ││ │ /data/local/tmp/multipart-xxx ││ │ │ ││ │ ▼ ││ │ ❌ permission denied ││ │ │ ││ ▼ ▼ ││ 成功 ✓ 失败 ✗ ││ │└─────────────────────────────────────────────────────────────┘解决方案演进
方案一:增大内存限制(不推荐)
最初的想法是增大 MaxMultipartMemory,让所有文件都在内存中处理:
func NewServer(cfg *config.Config) (*Server, error) {
router := gin.New()
router.MaxMultipartMemory = 512 << 20 // 512 MB
// ...
}问题:
- 移动设备内存有限,512MB 可能导致 OOM
- 并发上传多个大文件时仍可能超限
- 治标不治本
方案二:设置正确的临时目录(推荐)
真正的解决方案是让 Go 使用应用有权限的临时目录。
分析:
- Flutter 启动核心时传入了
LogDir参数 - 这个目录是
/data/user/0/com.e2eepan.e2eepan_client/app_flutter/ - 应用对此目录有完全的读写权限
修改 mobile.go:
func Start(cfg *CoreConfig) error {
mu.Lock()
defer mu.Unlock()
if httpSrv != nil {
return nil
}
// 设置日志和临时目录
if cfg != nil && cfg.LogDir != "" {
setupLogFile(cfg.LogDir)
// 关键修复:设置临时目录为应用有权限的目录
tmpDir := filepath.Join(cfg.LogDir, "tmp")
if err := os.MkdirAll(tmpDir, 0755); err == nil {
os.Setenv("TMPDIR", tmpDir)
}
}
// ... 后续初始化
}修改 server.go(移除之前的内存限制):
func NewServer(cfg *config.Config) (*Server, error) {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
router.Use(gin.Recovery())
// 使用默认 MaxMultipartMemory (32MB)
// 超过此限制的文件会写入临时目录(已在 mobile.go 中设置为应用有权限的目录)
server := &Server{
// ...
}
}验证修复
重新编译运行后,上传 5 个视频文件(20MB ~ 60MB)全部成功:
I/flutter: [UPLOAD] 开始上传: VID_20251212_185648.mp4I/flutter: [UPLOAD] 开始上传: VID_20251212_185621.mp4I/flutter: [UPLOAD] 开始上传: VID_20251212_185550.mp4I/GoLog: [UPLOAD] 收到上传请求I/GoLog: [UPLOAD] 收到上传请求I/GoLog: [UPLOAD] 收到上传请求I/GoLog: [UPLOAD] 文件名: VID_20251212_185550.mp4, 大小: 27809735 bytesI/GoLog: [UPLOAD] S3 上传成功: files/xxx.encI/GoLog: [UPLOAD] 文件名: VID_20251212_185648.mp4, 大小: 60129443 bytesI/GoLog: [UPLOAD] S3 上传成功: files/xxx.encI/GoLog: [UPLOAD] 文件名: VID_20251212_185621.mp4, 大小: 62375956 bytesI/GoLog: [UPLOAD] S3 上传成功: files/xxx.encI/GoLog: [UPLOAD] 文件名: VID_20251212_185343.mp4, 大小: 20398691 bytesI/GoLog: [UPLOAD] S3 上传成功: files/xxx.encI/GoLog: [UPLOAD] 文件名: VID_20251212_185245.mp4, 大小: 35762449 bytesI/GoLog: [UPLOAD] S3 上传成功: files/xxx.enc所有 5 个视频文件都成功上传到 S3 并出现在文件列表中。
技术要点总结
Go 临时目录机制
Go 的 os.TempDir() 函数按以下优先级选择临时目录:
$TMPDIR环境变量(如果设置)/tmp(Unix 系统)C:\Users\xxx\AppData\Local\Temp(Windows)
在 Android 上,由于应用沙盒机制,只有以下目录可写:
/data/user/0/<package>/- 应用私有数据目录/data/user/0/<package>/cache/- 应用缓存目录/sdcard/Android/data/<package>/- 外部存储(需要权限)
Gin Multipart 处理
Gin 框架使用 Go 标准库的 multipart 包处理文件上传:
// 默认值
const defaultMaxMultipartMemory = 32 << 20 // 32 MB
// Engine 结构
type Engine struct {
MaxMultipartMemory int64
// ...
}
// 处理 multipart 请求
func (c *Context) FormFile(name string) (*multipart.FileHeader, error) {
if c.Request.MultipartForm == nil {
// 解析 multipart,超过 MaxMultipartMemory 的部分写入临时文件
if err := c.Request.ParseMultipartForm(c.engine.MaxMultipartMemory); err != nil {
return nil, err
}
}
// ...
}gomobile 编译缓存
在调试过程中遇到一个坑:修改 Go 代码后,运行脚本没有生效。
原因是 gomobile bind 可能使用了缓存。解决方法是删除旧的 .aar 文件:
REM run_android.bat
@echo off
setlocal
REM Delete old .aar to force recompile
if exist "%~dp0..\..\client\android\app\libs\e2eepan-mobile.aar" (
del /q "%~dp0..\..\client\android\app\libs\e2eepan-mobile.aar"
)
pushd "%~dp0..\..\core"
gomobile bind -target=android -androidapi=21 -o ..\client\android\app\libs\e2eepan-mobile.aar e2eepan/mobile经验教训
-
不要无脑加大内存限制
- 这只是治标不治本
- 移动设备内存有限,可能导致 OOM
- 更好的做法是找到根本原因
-
理解平台差异
- Go 代码在不同平台上的行为可能不同
- Android 的沙盒机制限制了文件系统访问
- 需要显式配置才能让代码在移动端正常工作
-
添加足够的日志
- 调试分布式/跨平台系统时,日志是最重要的工具
- 在关键路径添加日志可以快速定位问题
- 问题解决后记得清理调试日志
-
gomobile 编译缓存
- 修改 Go 代码后如果没生效,检查是否使用了缓存
- 可以通过删除
.aar文件强制重新编译
相关文件
core/mobile/mobile.go- 设置 TMPDIR 环境变量core/internal/api/server.go- 上传处理逻辑client/lib/core/api/api_client.dart- 前端上传逻辑scripts/bat/run_android.bat- Android 编译运行脚本