Nest.js向Google Cloud Storage上传大文件失败问题排查
问题原因分析
- Google App Engine 请求体大小限制:GAE默认允许的最大请求体大小为64MB,当上传文件超过这个阈值时,GAE会直接拦截请求,不会将其转发到你的Nest.js应用,因此后端日志中只会看到OPTIONS预检请求,而没有实际的PATCH请求。
- Multer 内存存储瓶颈:Multer默认使用内存存储上传的文件内容,当文件超过32MB时,不仅会占用大量内存,还会触发Node.js的内存限制,同时结合GAE的请求限制,直接导致请求被中断。
- 前端请求配置错误:手动设置
Content-Type: multipart/form-data会覆盖axios自动生成的请求头(包含正确的边界标识),大文件上传时,服务器无法正确解析multipart数据,间接导致请求失败。
解决方案
1. 调整GAE的请求大小限制
在你的GAE项目的app.yaml文件中添加max_request_size配置,允许更大的请求体(根据需求设置,比如200MB):
runtime: nodejs20 # 对应你的Node.js版本 service: default max_request_size: 200M
2. 后端代码优化
2.1 配置Multer使用磁盘存储(避免内存溢出)
修改文件拦截器,配置Multer使用临时磁盘存储,并设置合理的文件大小限制:
@Patch(':id/audio') @Roles(Role.MOD, Role.ADMIN) @UseInterceptors( FileInterceptor('audio', { fileFilter: audioFileFilter, storage: diskStorage({ destination: '/tmp', // 使用GAE的临时目录 filename: (req, file, cb) => { cb(null, file.originalname); }, }), limits: { fileSize: 200 * 1024 * 1024, // 200MB,和GAE配置一致 }, }), ) async addAudio( @Req() req: Express.Request, @UploadedFile() audio: Express.Multer.File, ) { return await this.bookService.uploadFile(req, audio, 'audio'); }
2.2 改用GCS可恢复上传+流传输(避免内存加载整个文件)
修改uploadFile方法,使用文件流直接pipe到GCS,而不是读取整个文件到buffer,同时启用可恢复上传:
import * as fs from 'fs'; async uploadFile( req, file: Express.Multer.File, type: 'audio' | 'lyrics', ): Promise<any> { const id = req.params['id']; const lang = req.body['language']; let fullName: string; if (type === 'audio') { const voice_id = req.body['voice_id']; const audioType = req.body['type']; fullName = this.setDestination(id, lang) + this.audioFileName(voice_id, audioType, file); } else { fullName = this.setDestination(id, lang) + this.lyricsFileName(file); } const blob = this.bucket.file(fullName); if (await blob.exists()) { await this.removeFile(fullName); } return new Promise((resolve, reject) => { const blobStream = blob.createWriteStream({ resumable: true, // 启用可恢复上传,适配大文件 contentType: file.mimetype, // 正确设置文件MIME类型 }); const fileStream = fs.createReadStream(file.path); // 读取本地临时文件流 fileStream.pipe(blobStream); blobStream.on('error', (error) => { console.error('上传错误:', error); fs.unlinkSync(file.path); // 清理临时文件 reject(error); }); blobStream.on('finish', async () => { fs.unlinkSync(file.path); // 上传完成后清理临时文件 try { await blob.makePublic(); resolve({ publicUrl: `https://storage.googleapis.com/${this.bucket.name}/${fullName}`, }); } catch (e) { console.error('设置公开权限错误:', e); reject(e); } }); }); }
3. 前端代码修复
移除手动设置的Content-Type,让axios自动处理multipart请求头:
async function addAudio( this: Server, id: string, token: string, audio: ServerDto.AddAudio ) { let config = { headers: { "x-access-token": token, // 移除手动设置的Content-Type,axios会自动生成包含边界的正确头 }, onUploadProgress: (progressEvent: any) => { const percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); console.log(`Upload progress: ${percentCompleted}%`); }, }; const { data } = await axios.patch( `${this.baseUrl}/books/${id}/audio`, audio, config ); return data; }
确保
audio参数是FormData实例,axios才能正确识别并生成multipart请求。
4. 可选:大文件分片上传(推荐)
如果文件超过100MB以上,建议实现分片上传逻辑:
- 前端将文件分割成多个小块(比如10MB/块)
- 后端接收每个分片,上传到GCS的临时位置
- 所有分片上传完成后,后端调用GCS的合并接口将分片合并为完整文件
这样可以进一步降低单个请求的大小,避免各种服务器限制,同时提升上传的可靠性。
内容的提问来源于stack exchange,提问作者Efim Rubin
相关产品推荐
相关产品推荐

