☰
Java文件上传下载工具类:流式IO、断点续传与安全校验
2026/10/27 12:41:20 网站建设 项目流程

Java 项目里但凡沾上文件,最后十有八九会沉淀出一个叫 FileUtil、UploadHelper 或者 TransferKit 的类。这东西起初往往只有十几行,两年之后变成八百行,谁也不敢删,谁也不敢改。我前后接手过几个模拟项目,每次打开 util 包都能看到同一个剧本:有人加了中文名下载,有人补了断点续传,还有人默默往里面塞了一个线程池。上传下载听着像是 IO 流外面包一层皮,真动手写才发现,大小校验、文件名清洗、临时文件回收、Range 请求、编码转换、异常兜底,每一项都能单独拉出半天工作量。这篇就围绕 Java 里常用的上传下载工具类,把它拆成接收、校验、写入磁盘、输出、排错和安全几块,讲讲我在实际项目里怎么设计接口、为什么这么选,以及那些文档里基本不会写的坑。读者不需要多深的框架底子,能看懂 InputStream 和 HTTP 头,就能对照着抄。

1. 几乎每个 Java 项目最后都会长出同一个文件工具类

1.1 从 Controller 里三行代码膨胀到八百行的完整轨迹

绝大多数文件工具类的起点都很朴素,就是 Controller 里那三行:拿到 MultipartFile,调 transferTo 写进某个目录,返回一个拼接出来的 URL。这时候没有任何工具类,代码看起来干净得让人心情舒畅。

问题出在第二个需求上。产品说用户传上来的文件名可能是中文,下载回去不能变成一串 %E4%B8%AD 加乱码;于是有人在下载方法里加了 URLEncoder。第三个需求说文件可能有一百多兆,不能一次性读进内存;于是有人把FileCopyUtils.copy换成了手写的 8K 缓冲区循环。第四个需求说网络断了要能续传;于是有人开始研究Range请求头。第五个需求说上传要限制类型,不能让人往服务器上传 exe;于是又加了一段魔数校验。

等到第六个人接手的时候,这段逻辑已经被复制到了三个 Controller 里,其中两处的缓冲区大小是 8K,一处是 1024,还有一处忘了关流。这时候团队终于决定“抽个工具类”,于是把三份代码一合并,取了个并集,顺手把互相冲突的地方用 if 判断包起来,一个八百行的工具类就这么成型了。

我见过的这类工具类里,最常见的堆积点有三个:

  • 参数列表越来越长:从download(String path)一路涨到download(String path, String name, boolean inline, long start, long end, HttpServletResponse response, HttpServletRequest request)。
  • 异常处理五花八门:有的地方吞掉 IOException 返回 null,有的地方包装成 RuntimeException,有的地方直接e.printStackTrace()。
  • 职责不断外溢:开始管起业务来了,比如里面写死“如果是图片就压缩到 800 像素”,这种逻辑一旦进了工具类,后面想改就得先做一次代码考古。

我的判断标准很简单:如果这个方法的签名里出现了 HttpServletRequest 这种和具体容器强绑定的东西,它就不该放进最底层的工具类,而应该单独放在一个 Web 适配层。

1.2 职责边界划定:哪些逻辑必须收进工具类,哪些坚决不放

一个工具类如果什么都想管,最后一定什么都管不好。我一般按“是不是纯粹的字节搬运”来划线。

必须收进工具类的,是那些跨项目都一样的、跟业务无关的动作:

  • 流的复制与关闭,包括缓冲区大小的统一。
  • 文件名的清洗、编码转换、Content-Disposition 头的拼装。
  • 大小上限的判断,以及超限后的中断与清理。
  • 临时文件的创建规范与删除兜底。
  • Range 头解析、分段定位。
  • 路径的安全校验,防止跳出根目录。

坚决不放进工具类的,是这些东西:

  • 文件该存到哪个目录、用不用对象存储,这属于配置和架构决策。
  • 谁有权限下载、下载要不要记日志,这属于业务。
  • 图片压缩、视频转码、PDF 预览这类和具体媒体格式相关的处理,应交给单独的处理器。

按这个分法,工具类的接口会变得非常克制。我常用的核心方法其实就那么几个:

方法签名用途为什么这么设计
long copy(InputStream in, OutputStream out, long limit)带上限的流复制返回实际字节数,方便调用方核对;超限抛异常而不是返回 -1
Path save(Path baseDir, InputStream in, String rawName, long limit)写入磁盘并返回最终路径把路径校验和写入磁盘合并,调用方拿不到不安全的路径
void send(Path file, RangeSpec range, OutputStream out)按区间输出把断点续传拆成独立的 RangeSpec,便于单测
String contentDisposition(String name, boolean inline)拼装下载头编码逻辑集中,避免每个 Controller 各写一遍

接口收窄之后,代码量反而下来了。之前那个八百行的工具类,拆完剩下不到两百行,剩下的都进了业务层或者独立处理器。这不是为了代码整洁而整洁,而是因为一旦字节搬运和业务决策混在一起,任何一个业务改动都可能引入一个流没关的 bug。

2. 上传链路上真正需要处理的环节

2.1 MultipartFile 的生命周期与临时文件丢失问题

很多人在上传这块踩的第一个坑,是把 MultipartFile 存进一个集合或者丢给异步线程,等真正用的时候报“文件不存在”。原因在于 MultipartFile 在多数实现里背后是一个临时文件,请求处理完成后容器会把它清掉。框架帮你省去了手动读流的麻烦,代价是你必须在这段生命周期内把内容取走。

我一般遵循两条规则:

  1. 同步取流,异步存字节。如果后面要做耗时处理,先把内容落到自己的目录或者内存里,再交给异步任务。
  2. 优先用流,而不是 transferTo。transferTo在不同实现下行为不一致,有的直接 rename 临时文件,有的走一次复制。如果目标目录和临时目录不在同一个文件系统上,rename 会失败然后退化成复制,性能差异很大,而且中途失败时目标文件可能是半个。

一个容易忽略的细节是,当请求体很大的时候,容器在解析 multipart 时就已经把数据写到了磁盘上,磁盘空间不足会直接抛异常,而这个异常往往发生在你的代码执行之前。所以上传接口的监控不能只盯业务异常,还要盯容器层的错误日志。

2.2 大小、类型、文件名三处校验,最容易被打穿的地方

先说大小。MultipartFile.getSize()返回的是声明的大小,某些客户端可以伪造 Content-Length。真正可靠的判断必须在读流的过程中累加计数,一旦超过上限立刻中断。只信 getSize 而不做流式计数,等于把上限交给了请求方。

再说类型。getContentType()取的是请求头里的 Content-Type,同样由客户端决定,随便写。想真正判断类型,只能读文件头的魔数。常见的几个:

  • JPEG 以FF D8 FF开头。
  • PNG 以89 50 4E 47 0D 0A 1A 0A开头。
  • PDF 以25 50 44 46开头。
  • ZIP 及其衍生格式以50 4B 03 04开头。

读魔数有个前提:流必须支持标记和回退,或者你先读前若干字节再拼回去。我通常用PushbackInputStream,读完前 16 字节后 unread 回去,后面的写入磁盘逻辑完全不用改。

最后是文件名。这是最危险的一项,后面第 5 节会专门展开,这里先给结论:任何来自客户端的文件名都必须经过清洗,去掉路径分隔符、控制字符、以及.和..这种特殊值,才能参与路径拼接。

2.3 本地目录、对象存储与分片上传的选型逻辑

同样是把文件存下来,三种方案的成本差别很大,选错了后期迁移很麻烦。

方案适用场景主要成本迁移难度
单机本地目录单实例部署、文件量小、内网访问磁盘扩容和备份高,路径写死在代码里
对象存储多实例、需要外链、文件量大网络延迟、SDK 依赖低,换个实现类即可
分片上传单文件超过几百兆、网络不稳定实现复杂度、分片清理中,需要额外管理分片状态

工具类的设计要能容纳这三种方案的切换,做法是把“存到哪里去”抽象成一个接口,比如只有store(InputStream, name)和load(String key)两个方法,本地实现和对象存储实现各自实现一份。工具类本身只负责字节搬运和校验,不关心最终位置。

分片上传是另一个维度的事,重点不在工具类,而在分片状态的管理:每个分片写到哪里、怎么合并、失败了怎么重试、多久没上传完就清理。这块如果硬塞进工具类,会立刻把类撑爆,我的建议是单独建一个分片服务,工具类只提供“把某个分片流写到指定位置”这样的小能力。

2.4 一个可以直接复制的上传工具类骨架

下面这段是我目前常用的一版,做了精简,但关键点都在。

public final class UploadKit { private static final int BUF = 64 * 1024; private UploadKit() { } public static Path saveTo(Path baseDir, InputStream in, String rawName, long limit) throws IOException { String safeName = sanitize(rawName); Path root = baseDir.toAbsolutePath().normalize(); Path target = root.resolve(safeName).normalize(); if (!target.startsWith(root)) { throw new IllegalArgumentException("目标路径越界"); } Files.createDirectories(target.getParent()); long total = 0; try (OutputStream out = Files.newOutputStream(target)) { byte[] buf = new byte[BUF]; int n; while ((n = in.read(buf)) != -1) { total += n; if (total > limit) { throw new IOException("文件超出大小上限: " + limit); } out.write(buf, 0, n); } } catch (IOException e) { Files.deleteIfExists(target); throw e; } return target; } public static String sanitize(String raw) { if (raw == null || raw.isBlank()) { throw new IllegalArgumentException("文件名为空"); } String name = raw.replace('\\', '/'); int slash = name.lastIndexOf('/'); if (slash >= 0) { name = name.substring(slash + 1); } name = name.replaceAll("\\p{Cntrl}", "_").trim(); if (name.isEmpty() || ".".equals(name) || "..".equals(name)) { throw new IllegalArgumentException("非法文件名"); } return name; } }

几处刻意的设计说明一下。缓冲区取 64K 不是随手写的,8K 在老机械盘上确实够用,但现在的场景多是 SSD 加网络流,64K 能明显减少系统调用次数,再往上提升就不明显了,还会增加单次内存占用。写入过程中一旦超限,不光要抛异常,还要把已经写了一半的文件删掉,否则磁盘上会残留一堆半截文件,时间长了占满空间还查不出是谁写的。

sanitize里先替换反斜杠再取最后一段,是为了兼容 Windows 风格路径。\\p{Cntrl}的正则会把\u0000之类的控制字符统一替换掉,这类字符在某些文件系统上会导致创建失败,而且日志里打印出来是看不见的,排查起来极其费劲。

3. 下载远没有看起来那么简单

3.1 流式写出与一次性读入内存的分水岭

下载的第一个决策点是:这个文件到底该多大程度上依赖内存。我给自己定的界线很死:超过 8MB 一律流式,绝不整块读。

整块读的写法通常是Files.readAllBytes(path)然后写进响应,代码短,看着舒服。问题是它把文件大小直接变成了堆内存占用,十个并发下载 100MB 的文件,堆上就是 1GB,GC 会立刻教做人。而且这种写法拿不到任何进度信息,一旦卡住只能干等。

流式写出的标准写法是这样的:

public static void send(Path file, OutputStream out) throws IOException { try (InputStream in = Files.newInputStream(file)) { byte[] buf = new byte[64 * 1024]; int n; while ((n = in.read(buf)) != -1) { out.write(buf, 0, n); } out.flush(); } }

这里有个细节需要注意:不能随便 catch IOException 之后吞掉。客户端中途断开连接时,写响应流会抛异常,这是正常现象,但如果吞掉异常继续往下走,可能会触发后续的重复响应写入,日志里就会出现莫名其妙的 IllegalStateException。我的处理方式是捕获后判断异常类型,连接重置相关的直接结束方法,其余照常抛出。

另外,out.flush()在 Servlet 环境里其实不必须,容器在请求结束时会把缓冲刷出去,但显式调用一次成本几乎为零,还能让“数据确实发出去了”这件事变得确定。

3.2 中文文件名乱码的三种成因与对应写法

中文名下载乱码是经典问题,但原因其实分三种,很多人一上来就加 URLEncoder,结果在某些浏览器上还是不对。

第一种是响应头用了默认字符集编码。HTTP 头按规定是 ISO-8859-1,直接把中文塞进去会被替换成问号。

第二种是用了 URLEncoder 但没处理空格。URLEncoder.encode会把空格编成+,而在 Content-Disposition 里+会被当成字面量,最终文件名里出现加号。

第三种是只写了filename=,没写filename*=。老浏览器认前者,新浏览器优先认后者,只写一个就有一边出问题。

我现在的写法是两者都写,并且把空格单独替换回来:

public static String contentDisposition(String name, boolean inline) { String encoded = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20"); String type = inline ? "inline" : "attachment"; return type + "; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded; }

filename*的格式是字符集''编码后的值,中间是两个单引号,注意不要写成两个双引号或者一个单引号。语言参数可以省略,但两个单引号不能省。

还有一点容易被忽略:文件名里如果本身带双引号,直接拼进去会破坏头部结构,虽然经过 URLEncoder 之后双引号会变成 %22,但有些旧代码是先拼再编码的,顺序搞反就出问题。所以顺序永远是先编码再拼装。

3.3 断点续传:Range 头解析与分段输出的完整实现

断点续传的核心就三件事:解析请求里的 Range,返回 206 状态码和 Content-Range 头,只输出请求的那一段字节。

Range 头的格式是bytes=start-end,几种常见变体:

请求头含义处理方式
bytes=0-499前 500 字节start=0, end=499
bytes=500-从 500 到结尾start=500, end=length-1
bytes=-500最后 500 字节start=length-500, end=length-1
bytes=0-1,5-6多段只取第一段,其余忽略
无 Range完整下载返回 200

解析代码要小心两处。第一处是bytes=-500这种后缀写法,破折号前面的部分是空的,不能直接把空串解析成 0。第二处是 end 超过文件长度时要截断到 length-1,否则会读越界。

public static long[] parseRange(String header, long length) { if (header == null || !header.startsWith("bytes=")) { return new long[]{0, length - 1}; } String spec = header.substring(6); int comma = spec.indexOf(','); if (comma >= 0) { spec = spec.substring(0, comma); } int dash = spec.indexOf('-'); if (dash < 0) { return new long[]{0, length - 1}; } String s = spec.substring(0, dash).trim(); String e = spec.substring(dash + 1).trim(); long start; long end; if (s.isEmpty()) { long suffix = Long.parseLong(e); start = Math.max(0, length - suffix); end = length - 1; } else { start = Long.parseLong(s); end = e.isEmpty() ? length - 1 : Math.min(Long.parseLong(e), length - 1); } if (start > end || start >= length) { return null; } return new long[]{start, end}; }

返回 null 表示区间无效,调用方应该回 416 状态码并带Content-Range: bytes */加总长度。这个细节很多人漏掉,结果客户端拿到一个空响应,一直重试。

输出分段时用RandomAccessFile或者FileChannel定位到 start,读end - start + 1个字节就行:

try (RandomAccessFile raf = new RandomAccessFile(file.toFile(), "r")) { raf.seek(start); byte[] buf = new byte[64 * 1024]; long remain = end - start + 1; while (remain > 0) { int want = (int) Math.min(buf.length, remain); int n = raf.read(buf, 0, want); if (n == -1) { break; } out.write(buf, 0, n); remain -= n; } }

响应头必须同时设置Content-Range: bytes start-end/length、Content-Length: end-start+1、Accept-Ranges: bytes。少了 Accept-Ranges,客户端可能就不会尝试续传了。

4. 大文件场景下的内存与超时问题排查实录

4.1 一次 OOM 的完整排查链路

之前在一个模拟项目里遇到过一次,下载接口在压测时把服务打挂了,日志里是经典的一片 OutOfMemoryError。整个过程我记的比较清楚,因为排查路径挺典型。

第一步,先看堆大小和 GC 日志。堆配的是 2G,Full GC 后老年代仍然下不来,说明是对象真的活着,而不是回收不及时。

第二步,抓堆转储。用jmap -dump:live,format=b,file=heap.hprof <pid>导出来,工具打开之后按对象大小排序,排在最前面的是一堆byte[],单个大小在 30MB 到 120MB 之间,而且数量不少。

第三步,看引用链。这些 byte 数组的引用来自一个 InputStream 的包装对象,顺着往上找,定位到下载方法里的Files.readAllBytes。代码是两年前写的,当时文件都很小,没人觉得有问题。

第四步,复盘触发条件。压测脚本里并发下载的都是 100MB 左右的测试文件,十几个并发就把堆填满了。生产上因为文件小,一直没暴露。

第五步,改法。换成流式写出,同时把响应缓冲区调小。改完之后同样压力下堆占用稳定在 200MB 以内。

这次排查给我留下的经验是:OOM 现场一定要先看对象分布,而不是猜代码。很多人一看到 OOM 就去调大堆,那只是把爆炸时间往后推。堆转储里排第一的对象类型,基本就直接指向问题代码了。

另外补一句,Files.readAllBytes还有个更隐蔽的问题:它会先按文件大小申请一个精确大小的数组,如果文件超过 2G,直接抛 OutOfMemoryError,而不是 IOException,异常类型对不上,上层的兜底逻辑根本没接住。

4.2 超时配置与连接复用里藏着的坑

文件传输的超时配置和普通接口不一样,默认值往往太小。上传 100MB 文件在慢网络下可能需要几分钟,如果读超时是 30 秒,连接会在传到一半时被掐断,客户端看到的现象是“进度条卡住然后失败”,服务端日志里什么都没有,因为连接是在网络层被关的。

我的经验配置大致是这样:

参数建议值说明
连接超时5 秒只针对建立连接,不要调太大
读超时60 到 300 秒按最大文件大小和最低带宽估算
写超时同上上传方向更容易被忽略
最大请求体按业务定要在容器层和框架层同时配

读超时怎么估?假设最低带宽按 1Mbps 算,100MB 文件需要 800 秒,那就得配到 900 秒以上,或者干脆按业务上限设一个固定值。这个数不是拍脑袋,是要算出来的。

还有一个坑在连接复用上。反向代理层如果开启了连接复用,而服务端返回的响应没有正确设置 Content-Length,代理可能会一直等下一个响应,最终两边都超时。所以流式下载时必须显式设置 Content-Length,用setContentLengthLong,不要依赖容器自动推断。分块传输编码虽然能解决长度未知的问题,但在需要断点续传的场景下是没法用的。

5. 工具类里的安全红线

5.1 文件名清洗不是可选项,而是最后一道防线

路径穿越的原理很简单:如果用户传的文件名是../../etc/passwd,而你直接拿它去拼接路径,写入位置就跑到了预期目录之外。这个问题在文件上传里出现频率极高,因为很多人觉得“文件名是浏览器传的,应该没问题”。

防护的完整做法是三步,缺一不可:

  1. 剥离路径部分,只保留最后一段文件名。
  2. 规范化目标路径,用normalize()消掉..和.。
  3. 校验前缀,确认规范化后的路径仍然以根目录开头。

三步里第三步最关键,因为前两步都有绕过方式。比如某些系统上..%2f解码之后会变成../,又比如符号链接可以指向目录之外。normalize()加上startsWith能挡住绝大多数情况,剩下的符号链接问题需要在目录权限层面解决,工具类管不了。

Path root = baseDir.toAbsolutePath().normalize(); Path target = root.resolve(safeName).normalize(); if (!target.startsWith(root)) { throw new IllegalArgumentException("路径越界"); }

注意startsWith比较的是 Path 对象而不是字符串。用字符串比较的话,/data/upload2会以/data/upload开头,判定通过,但实际上已经跑到隔壁目录去了。这个坑我见过至少两次。

5.2 压缩包解压的层数与总大小上限

上传 zip 然后在线解压,是另一个高危操作。风险有两个:一是压缩炸弹,几十 KB 的压缩包解出来几十 GB;二是目录穿越,压缩包里的条目名带../,解压时写到别处。

防护思路是给解压过程加三重限制:

  • 条目数上限,比如不超过 10000 个。
  • 解压后总大小上限,每写一个条目累加,超过就中止。
  • 单层目录深度上限,条目路径里../出现次数超过阈值直接拒绝。
long total = 0; int count = 0; try (ZipInputStream zis = new ZipInputStream(in)) { ZipEntry entry; while ((entry = zis.getNextEntry()) != null) { if (++count > MAX_COUNT) { throw new IOException("条目数超限"); } if (entry.isDirectory()) { continue; } Path target = root.resolve(entry.getName()).normalize(); if (!target.startsWith(root)) { throw new IOException("条目路径越界"); } long n = copyWithLimit(zis, Files.newOutputStream(target), MAX_PER_ENTRY); total += n; if (total > MAX_TOTAL) { throw new IOException("解压总量超限"); } } }

这里copyWithLimit必须在读到上限时立刻中断,而不是先写完再判断,否则炸弹已经落地了才报警,等于没防。

5.3 临时文件的回收与磁盘打满的预防

工具类里但凡创建了临时文件,就必须有对应的清理逻辑,而且要在 finally 里做,不能只写在正常路径上。File.deleteOnExit这个名字很会误导人,它只在 JVM 正常退出时生效,进程被 kill 或者容器被强制重启时完全不执行。

我的做法是给临时文件统一加前缀,比如tmp_upload_,然后用一个定时任务扫描目录,删除超过一定时间还没被移走的文件。阈值设两小时比较稳妥,太短会误删正在处理的大文件,太长则磁盘压力大。

监控上要单独盯临时目录的使用率,而不是只看整个磁盘。临时目录被打满的时候,整个磁盘往往还有富余空间,但上传接口会开始报“设备上没有空间”,这个错误信息和磁盘使用率对不上,第一次遇到的人容易查半天。

6. 给工具类补一套真正有用的单测

6.1 用内存流和临时目录覆盖边界情况

文件工具类的测试很多人写得比较敷衍,通常只测“正常上传一个文件能存下来”。这种测试的价值几乎为零,因为出问题的从来不是正常路径。

我会重点覆盖这几类边界:

  • 空流上传,应该得到一个 0 字节文件或者直接拒绝。
  • 恰好等于上限和上限加一,用来验证边界判断是>还是>=。
  • 文件名包含../、..\\、控制字符、超长字符串。
  • Range 头的各种变体,包括非法值和超出长度的值。
  • 写入过程中途抛异常,验证半截文件是否被清理。

测试用的流不需要真的读文件,用new ByteArrayInputStream就够了,临时目录用 JUnit 的@TempDir,每个用例独立,互不影响。

@Test void 超限时应清理残留文件(@TempDir Path dir) throws Exception { InputStream in = new ByteArrayInputStream(new byte[2048]); assertThrows(IOException.class, () -> UploadKit.saveTo(dir, in, "a.bin", 1024)); try (Stream<Path> s = Files.list(dir)) { assertEquals(0, s.count()); } }

这个用例看着简单,但它验证的是一个真实踩过的坑:最初版本超限时直接抛异常,没删文件,结果压测之后临时目录里留了几千个半截文件,磁盘在两天后满了。

6.2 那些只有压测才会暴露的问题

单测能覆盖逻辑,但覆盖不了并发和资源。有几类问题我基本都是在压测阶段才发现的:

第一类是文件句柄泄漏。Files.newInputStream忘了关,单次调用看不出来,几千次之后就开始报“打开的文件过多”。排查方式是压测前后对比/proc/<pid>/fd目录下的文件数,涨了就是没关。

第二类是并发写同一个目标路径。两个请求同时上传同名文件,如果写入逻辑是“先判断存在再创建”,中间存在竞态窗口,结果可能是两份内容交错写进同一个文件。解决办法是用CREATE_NEW选项,让文件系统来保证原子性,冲突时抛异常由上层决定改名还是拒绝。

第三类是内存增长不平滑。压测时用 jstat 观察老年代增长曲线,如果看到阶梯式上涨后不回落,说明有对象被意外持有,常见原因是把 InputStream 或者字节数组塞进了某个缓存。

我给工具类定的验收标准是:用 200 个并发跑 10 分钟上传下载,堆内存稳定、文件句柄数稳定、临时目录不增长。三条都满足,才算能上线。

7. 我在实际项目里沉淀下来的几条使用习惯

写这类工具类时间长了,会形成一些不太写在文档里的习惯,我觉得比代码本身更值钱。

第一条,工具类里的每个 public 方法都要能单独回答“失败了会怎样”。是抛异常、返回 null 还是返回空文件,三种行为对调用方的影响完全不同。我倾向于一律抛受检异常或者明确的运行时异常,坚决不返回 null,因为返回 null 的代价是每个调用点都要判空,总有人会忘。

第二条,永远不信任传入的流是“干净”的。调用方可能传来一个已经被读过一部分的流,也可能传来一个关闭后调用 read 会抛异常的流。工具类里该做的判断要做,比如对available()不要有任何期待,对read返回 -1 的处理要写对。

第三条,日志要能定位到具体文件。上传下载出问题时,最有用的信息是文件名、大小、耗时这三项。我通常会在方法入口和出口各打一条 debug 日志,中间出错时带上已写入的字节数。这些日志平时关着,出问题临时打开,比事后猜要快得多。

第四条,版本升级时要重新验证一次大文件。JDK 小版本升级偶尔会改变某些 IO 类的内部缓冲策略,平时传小文件完全无感,一传大文件就暴露。我在一个模拟项目里遇到过一次,升级后同样的下载接口吞吐掉了三成,最后查到是某个流的默认缓冲区变了。

最后再分享一个小技巧:如果你的项目里已经有不止一个上传下载工具类,别急着合并。先把两边的调用点列出来,看看哪些方法实际没人调用,删掉之后再合并,工作量往往能少一半。我见过太多合并到最后变成“把两个类的所有方法都塞进一个新类”的情况,那不是重构,那是把技术债搬了个家。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询