提到SpringBoot实现文件上传和下载,很多朋友第一反应是“这不就是加一个MultipartFile参数、调一个transferTo吗”。我曾经带过一位刚入行的同事,照着网上的三行代码半天就把上传写完了,本地测试也一切正常,结果一到测试环境就翻车:文件名带着中文和空格直接乱码、文件稍大一点就报临时目录满、传完发现文件不知道落到哪个路径、项目一重启上传的内容全部蒸发。从那以后我养成了一个习惯:凡是涉及文件上传下载的需求,先不急着写代码,先把整条链路的边界捋清楚。
这篇内容就是把这些年在实际项目里做SpringBoot文件上传下载的工程经验整理一遍。不只有Controller层怎么写,还会把存储选型、参数配置、文件名安全、下载响应头、断点续传、元数据管理、僵尸文件清理,以及最容易在上线前夕坑到你的环境问题都过一遍。无论你是刚接触SpringBoot的初学者,还是被上传下载坑过几次的开发者,这都应该是一份能直接照着落地的参考。
1. 别急着写代码:文件上传下载的工程边界先拉清楚
1.1 为什么“一个Controller方法加MultipartFile”远远不够
SpringBoot确实把文件上传的入口做得非常简单。定义一个@PostMapping方法,参数里放一个MultipartFile,然后调用transferTo(目标路径),代码就完成了。这种写法在教科书和博客里到处都是,但它默认了一个非常重要的前提:你的应用只部署在一台机器上、文件都放本地、软件永不重启、上传量也不大。
真实业务里,上传下载涉及的范围要宽得多:
- 文件存到哪里:本地磁盘、NFS共享目录、还是对象存储服务;
- 文件名怎么处理:原始文件名能不能直接当存储名,中文、空格、特殊字符会不会出问题;
- 文件类型怎么判断:是靠扩展名、MIME类型,还是读取文件头;
- 上传过程怎么控制:大小限制、并发限制、超时处理、进度反馈;
- 下载怎么响应:直接走接口输出流,还是重定向到静态资源映射;
- 文件生命周期:用户删了记录,磁盘文件谁负责清理;上传了一版新文件,旧文件什么时候回收。
任何一个环节没想清楚,后续都要花比写代码多几倍的时间去补坑。我记得有一次线上排查半天找不到用户上传的图片,最后发现配置的路径是相对路径,进程工作目录变了,文件就散落到不同位置。这种问题不是代码逻辑错,而是工程边界没划好。
1.2 上传下载背后的三个典型问题域
如果把这个需求拆开看,可以做三层划分,这样不管是设计还是评审都容易对齐:
第一层是“存取能力”。核心是文件从哪里来、到哪里去、怎么保证数据不丢不坏。对应到SpringBoot里,就是MultipartFile的接收、存储目录的读写、文件流的完整传输。
第二层是“业务规则”。比如附件必须和某个业务单据关联、单文件不能超过10MB、图片类型只允许jpg/png/webp、文件名必须保留给用户下载时展示。这些规则属于业务方关心的问题,必须放到上传接口里统一校验。
第三层是“运维治理”。磁盘占用会不会一直涨、需要不需要定时清理、文件数量大了目录怎么分片、备份怎么处理。这些通常是部署上线后才会暴露的,但如果早期不做一点预留,后面很被动。
我在设计接口方案时习惯先做这一层划分,再决定哪些能力放SpringBoot里做,哪些交给外部设施,比如Nginx直传、对象存储预签名URL。这样写出来的代码才不是“能跑就行”,而是“换环境也能跑、出问题好定位”。
2. 存储方案与项目骨架:先决定文件去哪,再决定代码怎么写
2.1 本地磁盘存储:开发与内网部署最稳妥的起点
做SpringBoot文件上传下载,绝大多数学着练手的项目,包括不少中小型内部系统,最开始都是本地磁盘存储。这个选择不丢人,反而是最务实的:实现简单、读写快、排查方便、不用额外依赖。只要提前做好目录规划,后面想迁移到对象存储也是可以平滑替换的。
先规划目录结构。我常用的可以分成三个区域:
/data/app-file/ ├── upload/ # 正式上传目录 │ ├── 2025/01/ # 按年月分目录,避免单目录文件过多 │ └── ... ├── temp/ # 临时目录,用于分片合并、临时转换 └── backup/ # 定期备份或清理前转移区按年月分目录是我强烈建议的做法。如果所有文件都平铺在一个目录里,文件数量到一定程度后,列表操作、备份、清理都会变慢,而且人工排查非常痛苦。按时间分目录以后,定期清理“三个月前的目录”也只是一个批量删除操作,逻辑非常清晰。
2.2 SpringBoot工程的基础配置:路径、上限与静态资源映射
确定存储方案后,先把application.yml里的相关配置配好。下面这份是单机本地存储最常用的配置模板:
spring: servlet: multipart: enabled: true max-file-size: 50MB max-request-size: 200MB file-size-threshold: 1MB location: ${app.file.temp-dir} web: resources: static-locations: file:${app.file.upload-dir} app: file: upload-dir: /data/app-file/upload temp-dir: /data/app-file/temp max-size: 50MB这里几个参数的作用要说清楚:
max-file-size:单个文件最大体积,超过会抛MaxUploadSizeExceededException;max-request-size:一次请求的总体积上限,多文件批量上传时主要看这个;file-size-threshold:文件小于这个阈值时内容留内存,超过则先落临时文件,避免大文件把堆内存拖垮;static-locations:把上传目录映射成静态资源地址,图片、PDF这类可以走URL直接访问。
有个细节值得注意:spring.servlet.multipart.location如果不设置,SpringBoot默认使用Servlet容器的临时目录。Linux服务器上通常是/tmp,有些环境会定时清理/tmp下的内容,或者目录挂载空间很小,这会导致大文件上传失败。我建议一定要显式指定这个临时目录,并且放在空间充裕的挂载盘里。
2.3 为什么我建议把上传根目录抽成配置项而不是写死
很多初学代码的朋友喜欢在Service里写/data/myapp/upload/文件名。这样做在单机上没问题,但一旦需要换机器部署、挂NAS、做数据迁移,你就得去代码里找所有写死的路径,改完还要重新编译打包。更隐蔽的问题是,不同环境(开发、测试、生产)目录结构可能不一致,写死等于埋雷。
我的习惯是:应用里永远不出现绝对路径的硬编码。通过@ConfigurationProperties把路径前缀读进来:
@Component @ConfigurationProperties(prefix = "app.file") public class FileStorageProperties { private String uploadDir; private String tempDir; private long maxSize; // getter / setter 略 }然后在Service里统一拼路径。这样换环境只需改配置,代码逻辑完全不用动。做公网部署的人还会顺便把上传目录放在独立数据盘上,应用程序、系统盘、文件盘分离,出了问题互不拖累。
3. 单文件上传实战:从MultipartFile到磁盘落地的每一步
3.1 控制方法的正确姿势:参数顺序、注解与返回结构
上传接口的Controller方法,看起来简单,实际写的时候有几个容易被忽略的点。先看一个完整版本:
@PostMapping("/api/files/upload") public ResponseEntity<UploadResult> upload( @RequestParam("file") MultipartFile file, @RequestParam(value = "bizType", required = true) String bizType, @RequestParam(value = "bizId", required = false) String bizId) { if (file.isEmpty()) { return ResponseEntity.badRequest().body(UploadResult.fail("文件内容为空")); } FileRecord record = fileService.store(file, bizType, bizId); return ResponseEntity.ok(UploadResult.ok(record)); }第一,MultipartFile名字要和前端提交的字段名一致,否则会直接报Required request part 'file' is not present。前端用FormData时键名通常是file,保持一致即可。第二,除了文件本身,业务参数不要放在请求体JSON里,因为multipart/form-data格式和JSON不能混用。业务参数用@RequestParam,和文件一起混在FormData里提交。第三,接口统一返回一个简单的JSON包装对象,前面和后端约定好code/message/data结构,方便前端统一处理,别一个接口一个返回风格。
3.2 文件名校验与安全过滤:别让文件名直接拼路径
文件名处理是整个上传环节里最容易被低估的部分。很多人直接file.getOriginalFilename(),然后拼到目标路径里。如果文件名是../../etc/passwd或者..\\..\\tmp\\xxx,就会触发路径穿越问题。尤其是部署在公网的系统,这个漏洞属于高危问题。
我建议的处理顺序是:
- 用
Objects.requireNonNull(file.getOriginalFilename())拿原始文件名; - 剥离掉路径信息,很多浏览器或客户端会带上路径前缀;
- 检查扩展名,放进白名单,而不是黑名单;
- 存储到磁盘时,改用服务端生成的唯一文件名,而不是用户原始名。
生成唯一文件名,我常用UUID.randomUUID().toString().replace("-", ""),再把扩展名保留。用户原始名则单独存到数据库的original_name字段里,下载时通过该字段重置文件名。这样磁盘上的文件名彻底和用户输入隔离,路径穿越问题从根上就被杜绝了。
private String generateStoreName(String originalFilename) { String ext = extractExtension(originalFilename); String uuid = UUID.randomUUID().toString().replace("-", ""); return uuid + "." + ext; }3.3 流式保存的细节:为什么用transferTo会有隐患
MultipartFile.transferTo(File dest)确实可以一句代码把上传的文件保存到磁盘,但用之前要理解它的实现:它底层先判断文件是否已经落临时目录,如果还在内存里就强制落盘,然后通过FileCopyUtils执行复制。在实际使用中它有几点要注意:
dest的父目录必须已经存在,否则会抛IOException,所以保存前要先mkdirs();- 保存过程中一旦发生异常,临时文件可能残留,需要finally或异常处理里清理;
- 大型文件流式复制时,直接
transferTo没有提供对中间状态的感知能力。
所以我更习惯用手动流式保存,既能控制缓冲区,也能处理异常,代码反而更透明:
public FileRecord store(MultipartFile file, String bizType, String bizId) { String originalName = sanitizeFileName(file.getOriginalFilename()); String ext = extractExtension(originalName); String storeName = generateStoreName(originalName); Path dir = Paths.get(this.properties.getUploadDir(), LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM"))); Files.createDirectories(dir); Path target = dir.resolve(storeName); try (InputStream in = file.getInputStream(); OutputStream out = Files.newOutputStream(target)) { byte[] buffer = new byte[8192]; int len; long total = 0; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); total += len; } // 校验总字节数,防止写一半 if (total != file.getSize()) { throw new IOException("文件写入字节数不一致"); } } catch (IOException e) { Files.deleteIfExists(target); throw new RuntimeException("文件保存失败", e); } // 记录元数据并返回 }手动流式写入的另一个好处是,将来想接入“上传进度条”或者增加值钩子(比如文件入库前压缩、图片压缩、敏感词扫描)都很方便,在流循环里加逻辑即可。transferTo在这类场景下反而不灵活。
3.4 入库前先做真实的磁盘写入测试
单文件上传写完之后,我建议把代码在本地跑起来,做一次完整的“真文件测试”,不要拿一个几KB的txt糊弄。测试内容至少包括:
- 上传一个5MB以上的随机二进制文件,验证流式写入没有损坏;
- 上传一个中文文件名、带空格的文件,验证存储和元数据分离正确;
- 上传一个超过
max-file-size的文件,验证异常被捕获并返回友好的错误提示; - 上传一个扩展名伪装的文件(比如把exe改成png),验证类型校验是否能识别出来。
是不是读懂了这段代码不重要,重要的是让异常在测试环境先炸一遍。我曾经遇到过RAID卡读写异常导致写出的文件和原始字节不一致,因为业务代码里没有总字节数校验,问题一直没发现,直到用户下载下来文件打不开才暴露。所以“写入后校验字节数”这个习惯是实打实救过我的。
4. 多文件上传、批量处理与前端联调的真实配合方式
4.1 多文件请求的两种格式:多字段与单字段数组
实际业务中,多文件上传常常是“可能传1张图,也可能传5张图”。前端实现方式有差异,后端参数接收也要跟着匹配,否则会出现“文件传上来了但我一个都没收到”的诡异现象。
两种常见格式:
- 多个不同字段名,比如
file1、file2,后端用多个@RequestParam("file")参数接收。 - 同一个字段名下传多个文件,比如前端循环
formData.append("files", file),后端用@RequestParam("files") List<MultipartFile> files接收。
第二种更通用,我推荐批量接口统一使用。注意Spring接收List<MultipartFile>时,如果请求里一个文件都没有,参数可能绑定一个空List,也可能直接报错,稳妥的做法是在方法里判断files == null || files.isEmpty(),同时给前端补一个@RequestParam(value = "files", required = false)。
4.2 后端批量保存的幂等与事务边界
多文件上传时,每个文件都要生成记录并保存磁盘,这涉及到“全部成功”还是“部分成功”的选择。假设一次传5个文件,第3个写失败,前两个已经保存了,怎么办?具体要分两种存储介质看:
- 数据库记录:可以用
@Transactional,让记录要么全部插入,要么全部回滚; - 磁盘文件:事务回滚管不到文件系统。已经落盘的文件如果回滚了,就成了“孤儿文件”,后续必须靠定时任务清理。
所以我的做法是:批量上传时先循环把所有文件写到临时文件区域,全部写入成功后再统一移动到正式目录并写数据库记录。整个过程如果中途失败,就把写好的临时文件和已写的正式文件一并删除。这样能最大程度避免“数据库里没有记录、磁盘上却堆着一堆文件”的状态。
List<Path> written = new ArrayList<>(); try { for (MultipartFile file : files) { Path p = writeToTemp(file); written.add(p); } // 全部写入成功后再转入正式目录并落库 for (Path p : written) { Path target = moveToStore(p); saveRecord(target, metadata); } } catch (Exception e) { written.forEach(this::deleteQuietly); throw e; }需要意识到,这里并没有把“文件写入的原子性”做得很完美,但基本满足日常业务要求。如果要更严谨,就得引入消息队列对账或补偿任务,那属于另一层架构话题了。
4.3 前端FormData与async/await的配合要点
后端再怎么处理,最后都要和前端联调。上传接口最常见的问题就是前端提交格式不对。下面这段示例基本可以直接拿去用:
async function uploadFiles(fileList, bizType, bizId) { const formData = new FormData(); for (const file of fileList) { formData.append('files', file); } formData.append('bizType', bizType); formData.append('bizId', bizId); const resp = await fetch('/api/files/upload', { method: 'POST', body: formData // 注意不要手动设置 Content-Type,浏览器会自动加 multipart/form-data; boundary=... }); const result = await resp.json(); if (result.code !== 0) { throw new Error(result.message); } return result.data; }一个高频坑是很多人会手动写Content-Type: application/json然后把文件转成base64,这样一来文件体积膨胀约33%,后端也不容易处理大文件。正确做法是交给浏览器用multipart/form-data编码。如果在axios里看到Content-Type已经被设置,用delete headers['Content-Type']让它自动生成也可以。
5. 文件下载的两种主流实现:简单响应与断点续传
5.1 ResponseEntity下载:适合中小文件的写法
下载接口的原理其实不复杂:把磁盘文件读出来,通过HTTP响应输出流写给客户端。最简单清晰的方式是用ResponseEntity<Resource>:
@GetMapping("/api/files/download/{id}") public ResponseEntity<Resource> download(@PathVariable String id) { FileRecord record = fileRecordService.getById(id); if (record == null) { return ResponseEntity.notFound().build(); } Path path = Paths.get(record.getStorePath()); if (!Files.exists(path)) { return ResponseEntity.status(HttpStatus.GONE).build(); } Resource resource = new FileSystemResource(path); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + encodeFileName(record.getOriginalName()) + "\"") .header(HttpHeaders.CONTENT_TYPE, record.getContentType()) .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(record.getSize())) .body(resource); }这种方式适合中小文件,整体逻辑清晰,SpringBoot会帮你处理输入流关闭等细节。需要注意Content-Disposition里的文件名,如果直接塞中文,浏览器可能无法识别。服务端应该先按RFC 2231做编码,通常是filename*=UTF-8''加百分号编码,前面filename可以留一个ASCII的兜底值。
5.2 流式下载与Range请求:为大文件和下载管理体验准备
ResponseEntity<Resource>里的FileSystemResource虽然也是流式的,但它不原生支持Range请求头。如果你希望实现对下载进度的断点续传、浏览器内视频拖拽播放,或者下载中途网络断开后重试只下载剩余部分,就必须自己处理Range。
Spring框架其实提供了一个现成类:org.springframework.web.servlet.support.ResourceRegion。不过更直接的做法是自己读取请求头,截取字节范围再输出。网上讨论很多的是实现ResourceHttpRequestHandler,但配置成本偏高。我个人更常用的方式是写一个通用方法,核心逻辑是:
- 解析请求头
Range: bytes=start-end; - 根据文件大小计算实际返回的起始位置和长度;
- 响应头加上
Accept-Ranges: bytes、Content-Range、Content-Length; - 用
InputStream.skip(start)跳过起始字节,再按长度输出。
@GetMapping("/api/files/download/range/{id}") public void downloadRange(@PathVariable String id, @RequestHeader(value = "Range", required = false) String range, HttpServletResponse response) throws IOException { FileRecord record = fileRecordService.getById(id); Path path = Paths.get(record.getStorePath()); long fileSize = Files.size(path); long start = 0; long end = fileSize - 1; if (range != null && range.startsWith("bytes=")) { String[] parts = range.substring(6).split("-"); start = Long.parseLong(parts[0]); if (parts.length > 1 && !parts[1].isEmpty()) { end = Math.min(Long.parseLong(parts[1]), fileSize - 1); } } long length = end - start + 1; response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); response.setHeader("Accept-Ranges", "bytes"); response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize); response.setHeader("Content-Length", String.valueOf(length)); try (InputStream in = Files.newInputStream(path)) { in.skip(start); byte[] buffer = new byte[8192]; long toRead = length; int len; while (toRead > 0 && (len = in.read(buffer, 0, (int) Math.min(buffer.length, toRead))) != -1) { response.getOutputStream().write(buffer, 0, len); toRead -= len; } } }这段代码已经是很多下载工具的体验基础。没有它,用户在弱网环境下载大文件很容易半途而废。
5.3 中文文件名与Content-Disposition的编码处理
下载时常遇到“文件名是中文,下载后变成乱码”。原因在于历史遗留的filename="中文.txt"已经被浏览器普遍不信任。现代浏览器推荐的长这样:
Content-Disposition: attachment; filename="fallback.txt"; filename*=UTF-8''%E6%B5%8B%E8%AF%95.txtJava里生成这段需要把中文名做百分号编码。用URLEncoder.encode(name, StandardCharsets.UTF_8.name())会把空格也转成+,需要再替换成%20。下面是一个通用的编码方法:
private String encodeFileName(String fileName) { try { String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()) .replaceAll("\\+", "%20"); return "attachment; filename=\"download\"; filename*=UTF-8''" + encoded; } catch (UnsupportedEncodingException e) { return "attachment; filename=download"; } }下载接口还有个小经验:如果你把文件当附件处理,就用attachment;如果只是想在浏览器里直接预览PDF或图片,可以改成inline。很多项目同时存在“下载”和“预览”两种需求,建议拆成两个接口,而不是共用一个下载URL再用参数控制,因为响应头处理逻辑差异较大,混在一起很难维护。
6. 上传之外的三件收尾事:元数据、防重复与清理策略
6.1 打通附件表:文件记录与业务ID的关联方式
文件上传到这里,磁盘上的文件是落好了,服务端的文件记录也必须落库。我一直强调文件上传不能只是“把文件写进去”,还要有一个附件表去记录文件的业务归属和生命周期。一张简单的附件表可以长这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| biz_type | varchar | 业务类型:avatar/order/invoice |
| biz_id | varchar | 业务单据ID |
| original_name | varchar | 用户原始文件名 |
| store_name | varchar | 存储文件名 |
| store_path | varchar | 存储相对路径 |
| content_type | varchar | MIME类型 |
| size | bigint | 字节数 |
| md5 | varchar | 文件内容指纹 |
| created_at | datetime | 上传时间 |
biz_type和biz_id这两个字段建议一开始就留好。如果没有它们,附件表就只是一个文件堆,后面要根据用户、订单、工单去查附件的时候,你只能全表扫,非常痛苦。就算第一版用不上“按业务查附件”,也应该预留。
6.2 文件指纹与秒传判断
做文件上传的进阶功能时,最值得优先考虑的优化就是“秒传”。思路是:文件上传前先计算MD5,服务端查一下这个MD5是否已经存在,如果存在就不重复保存,直接把已有的记录关联到当前业务ID上即可。
实现时要注意两点。第一,大文件算MD5可能比较慢,前端可以在文件选择后、分片上传前先算整体MD5;第二,MD5碰撞在普通业务下概率极低,如果对安全性要求极高,可以用SHA-256。后端保存时顺手把MD5落库,也为后面的重复文件清理提供了依据。
private String computeMd5(Path path) throws IOException { MessageDigest md = MessageDigest.getInstance("MD5"); try (InputStream in = Files.newInputStream(path)) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { md.update(buffer, 0, len); } } return DatatypeConverter.printHexBinary(md.digest()).toUpperCase(); }“秒传”逻辑不复杂,但能给用户带来非常明显的体验提升,尤其在你一圈图片素材反复使用的场景里。磁盘占用也能得到有效控制。
6.3 定时清理与孤立文件回收
文件上传系统运行一段时间后,最头疼的就是磁盘空间被占满。文件删起来比想象中麻烦,因为它涉及两个状态:数据表里还有记录、但磁盘文件已被删;或者磁盘文件还在、数据表记录已经删除。前者会造成下载404,后者叫“孤儿文件”。
我的做法是双管齐下:
- 在删除接口里:先删数据库记录,再删磁盘文件,最后再兜底扫描。
- 用定时任务做每日孤儿清理:扫描附件表里所有
store_path,逐个验证磁盘文件是否存在;同时扫磁盘目录,反向检查所有文件是否都在数据库里有记录,没有就移到回收目录,超过保留期彻底删除。
@Scheduled(cron = "0 30 2 * * ?") public void cleanupDiscardedFiles() { // 1. 查询附件表中已标记删除但磁盘未删的记录 // 2. 查询磁盘目录中早于48小时的临时文件并删除 // 3. 将无数据库记录的正式文件移动到backup目录 }这里要特别提醒的是,千万不要先删磁盘文件再删数据库记录。一旦数据库删除失败,那条记录指向一个不存在的文件,比多一个孤儿文件更麻烦,因为用户侧会立刻感知到下载失败。
7. 上线前隐患清单与我的调试心得
7.1 临时目录、磁盘空间、权限这一类环境问题
我在润色这篇文章时又回想了一遍历年遇到的上传下载故障,排在前列的几乎都不是Java代码问题,而是环境因素。最常见的有三类:
第一是临时目录空间不足。前面提过,spring.servlet.multipart.location不配置时用的是容器临时目录,而/tmp通常不是独立大分区,上传文件一旦超过分区容量,接口会报错,而且错误信息比较隐晦。第二是目录权限。部署时用的用户和上传目录属主不一致,导致运行时无写权限。第三是磁盘满。绝大多数内网服务器没有自动清理机制,日志和上传文件不停增长,磁盘满了之后应用会变得异常卡顿。
上线前我建议把这些检查写进部署文档:上传目录、临时目录是否已创建且属主正确;df -h确认空间评估过;ulimit -n文件句柄数是否够用。这些检查看着琐碎,但每一条都避免过几次“半夜被叫起来”。
7.2 本地能跑放在容器里报错的常见原因
很多人的项目最后是丢进容器里跑的。同一套SpringBoot代码,本地正常,容器里出问题,最典型的就是路径和重启数据丢失:
- 容器文件系统是临时的,文件写进工作目录,容器重建后全没了;
- 容器用户是
nobody或非root用户,挂载目录没授权,写到外部卷时报权限不足; - 环境变量没传,上传目录配置读的是默认值,文件写到了镜像层里。
我的建议是把上传目录作为外部挂载卷,路径通过环境变量注入配置文件:
app: file: upload-dir: ${UPLOAD_DIR:/data/app-file/upload}容器编排里再挂载/data/app-file到宿主机的数据盘。这样应用的重启、迁移、备份都围绕挂载卷进行,不会把数据丢在容器层里。很多人问“文件到底存哪里”,我的回答总会多一句:不管存哪,先保证重启用完数据还在。
7.3 我看接口日志时重点盯的几个字段
做文件上传下载的线上排查,日志里我重点盯这么几个字段:文件大小、上传耗时、目标路径、客户端IP、HTTP状态码。有一次用户反映“上传失败”,我打开日志看到请求在multipart解析阶段卡了很久,最后超时。原因是客户网络带宽很低,一个200MB的文件要传很久,网关层的请求超时设置比应用层短,连接直接被断掉了。
这类问题不是改Java代码能解决的,而是需要在网关层放宽上传请求的超时时间,或者改走分片上传/预签名直传对象存储。这里也能看出,文件上传下载从来不只是SpringBoot一个应用内部的问题,前面经过了什么样的代理、网关,都对行为有影响。上线前把全链路超时参数拉通检查一遍,比临时排查要省力得多。
7.4 说说我做文件功能这几年沉淀下来的小习惯
本来这篇文章写到这就该收了,但既然聊到调试和排查,就把几个贯穿始终的小习惯一并分享出来。这些习惯不复杂,但每次都帮我省了时间。
第一,接口文档里必须明确写出“文件大小限制”“支持的文件类型”“是否支持多文件”“下载是否走附件”。前端不用反复问,测试环境也能照着写用例。第二,调用保存文件的方法返回后,立刻用Files.size()或数据库记录字段做一次校验,别只检查“文件不为空”。第三,规划预留“文件预览”和“图片缩略图”的口子,即使当前用不到,也不要设计成只能下载不能预览的结构。很多项目最后都会回过头来加预览功能,到时候再改存储结构就比较难受了。
最后还想说一句,文件上传下载在SpringBoot里入门确实不难,但生产环境给你的容错空间非常小。多花二十分钟把存储路径、文件名策略、异常处理、清理任务这些基本功打好,后面能省出好几个不眠夜。希望这篇里的踩坑记录和落地步骤,能让你在接到类似需求时,少走几段我走过的弯路。