☰
Java HttpServletResponse多文件下载:流式ZIP与响应头避坑
2026/11/6 2:52:32 网站建设 项目流程

多文件下载这个需求,几乎每个做 Java Web 的人都接过。表面看它就是个"循环读文件、写流"的活,真正上线之后才发现坑全在细节里:中文名在别人的电脑上变成一串问号,浏览器把第二个下载请求直接拦掉,文件下到 90% 连接断了服务器报了一屏红色堆栈,几百兆的包一打内存就飙到几个 G。我前后在三四个项目里做过这项功能,从最早的前端循环点链接,到后来的 Java HttpServletResponse 流式 ZIP 打包,方案换了两轮,代码重写了三遍。这篇就把"Java HttpServletResponse 多文件下载"这件事从头到尾拆开讲清楚,包括怎么选路线、响应头每一个参数为什么这么设、ZipOutputStream 怎么用才不炸、出问题去哪儿找。适合刚接手这类需求的同学直接抄,也适合已经写过一版但被线上问题教育过的老手对照检查。

1. 从需求本身出发:多文件下载的路线选型与整体设计

1.1 四条可选路线,以及它们各自的真实成本

"多文件下载"这四个字在不同人嘴里意思完全不同。产品说的是"用户勾选十个文件,点一下全部下下来";后端听到的是"一次请求返回多个文件";浏览器理解的是"你要触发多次下载"。这三者之间的落差,就是选型要解决的问题。

我把见过的实现归成四类,每条路线我都实际用过至少一次,下面这张表是踩完之后的结果:

路线实现位置用户体验服务端成本适用规模主要风险
前端循环触发 N 次请求前端差,浏览器会拦截或弹授权低3~5 个小文件现代浏览器对多文件连续下载有明确限制
前端 JSZip 客户端打包前端中,需要等待极低十几张小图全量字节过浏览器内存,大文件必崩
服务端流式 ZIP 单次响应后端好,一次点击一个包中(CPU 压缩)几十到几百个文件不支持断点续传,看不到单文件进度
异步任务生成文件 + 通知下载后端中,需要等中高(磁盘占用)上千文件 / GB 级临时文件清理、任务状态管理

前端循环那条路,早期最省事,写个 for 循环 createElement('a') 一顿 click 就完事。问题是浏览器这几年越管越严,连续触发多个下载会弹"此网站尝试下载多个文件"的授权提示,用户点拒绝就只下来第一个。而且每个文件都是一次独立的鉴权、连接、响应,十个文件就是十次往返,弱网下体验非常糟。

前端 JSZip 看起来优雅,但本质是把服务端该干的活挪到了用户的浏览器里。文件字节要先通过接口全部读到内存,再在 JS 里压成 ZIP。我试过 200 张 3MB 的图片,Chrome 标签页直接白屏。这条路只适合"十来个不超过几百 KB 的文本或小图"。

服务端流式打包是我现在默认的选择。一次请求、一次鉴权、一个响应流,内存占用恒定在缓冲区大小,无论文件多大都吃得住。代价是压缩消耗 CPU,以及响应一旦开始写就没法回头改状态码。

1.2 为什么我把流式 ZIP 当成默认答案

选它不是因为"先进",而是因为它把复杂度收敛到了一个地方。用户点一次,服务端做一次权限判断、做一次配额检查、出一次审计日志,出问题的时候日志里只有一条记录,排查成本极低。前端那边也干净,一个 a 标签或者一次 fetch 就结束了。

另一个很现实的原因是权限。批量下载的文件往往来自不同归属:有的是自己的,有的是共享的,有的是某个项目下的。如果走前端循环,每一个文件的鉴权都要单独做一遍,一旦某个文件没权限,用户看到的是"下到一半失败了",具体哪个失败还得自己数。服务端打包就能在写第一个字节之前把所有权限校验跑完,任何一个不通过就直接返回一个明确的错误,用户体验干净得多。

它的短板我也认:流式响应意味着 Content-Length 未知(除非先落临时文件),浏览器只能显示"正在下载,剩余时间未知";也不支持 HTTP Range 断点续传,网断了就得从头再来。这两个短板在"几十兆以内"的场景完全可以接受,超过这个量级就得换异步任务那条路,后面第 4 章会讲怎么过渡。

1.3 整条链路的顺序,顺序错了后面全是白工

这套东西最容易出错的地方不是代码写不出来,而是步骤顺序排错。我把它固定成下面这条流水线,顺序不能调换:

  1. 解析并校验参数:文件 ID 列表是否为空、是否超长、格式是否合法。
  2. 逐个做权限与存在性校验:注意是"全部校验完"再往下走,不是边写边校验。
  3. 配额校验:文件总数上限、总字节上限。
  4. 设置响应头:Content-Type、Content-Disposition、缓存控制。
  5. 获取输出流,包装 ZipOutputStream。
  6. 逐文件写 entry:putNextEntry → 写字节 → closeEntry。
  7. finish() 收尾,flushBuffer() 刷出。

第 2 步和第 4 步的顺序是硬约束。一旦第 4 步执行完、第 5 步拿到了输出流并写入了哪怕一个字节,Servlet 容器就会把响应标记为已提交(committed)。此后再调response.sendError(404)或者改状态码,全都不生效,客户端收到的会是一个残缺的 ZIP 文件——打开报错,用户以为系统坏了。这个坑我在第一版里踩过,日志里明明打了"文件不存在",前端却只看到一个 200 的空包。

提示:判断响应是否已经提交,用response.isCommitted()。在任何可能失败的分支前都可以打一条日志确认,调试期非常有用。

2. 响应头是下载的地基:HttpServletResponse 关键参数逐项拆解

2.1 Content-Type、Content-Disposition 与中文文件名的编码

浏览器怎么知道一个响应是"下载"而不是"展示",全看响应头。这两个头设不对,后面代码写得再漂亮也是白搭。

Content-Type我给application/zip。有人习惯用application/octet-stream,两者的区别在于前者明确告诉浏览器"这是 ZIP",有些浏览器会据此在下载完成后直接调用系统解压工具;后者是万金油,一律当二进制处理。我倾向application/zip,语义准确,也方便网关和日志按类型统计。如果打包出来的不是 ZIP 而是别的东西,就换成对应的 MIME。

Content-Disposition是真正的主角,格式是attachment; filename="xxx"; filename*=UTF-8''xxx。attachment表示下载而非内联展示。文件名要写两份:filename放 ASCII 兜底值,filename*放 RFC 5987 规定的编码值。现代浏览器优先读filename*,老一点的读filename。

中文名乱码的根源在于filename这个参数在 HTTP 头里只能放 ASCII,直接塞中文会被容器按 ISO-8859-1 处理成乱码。所以必须编码:

private static String buildContentDisposition(String fileName) { String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8) .replace("+", "%20"); String fallback = fileName.replaceAll("[^\\x20-\\x7E]", "_") .replace("\"", "") .replace("\\", ""); return "attachment; filename=\"" + fallback + "\"; filename*=UTF-8''" + encoded; }

三个细节值得单独说。第一,URLEncoder.encode会把空格编成+,而+在 RFC 5987 里不是空格的合法表示,必须替换成%20,否则文件名里带空格的会变成一个加号。第二,fallback里必须把双引号和反斜杠剔掉,否则用户可以构造一个带引号的文件名把响应头截断,这是标准的响应头注入。第三,文件名里如果混进了\r或\n,也必须清掉,道理一样。这三条不是洁癖,是安全底线。

2.2 响应提交时机:committed 之后你什么都改不了

我见过不止一个团队在这上面翻车。典型写法是:先response.setContentType(...),然后进循环,循环里if (!file.exists()) { response.sendError(404); return; }。看起来没问题,实际上如果前面几个文件都正常写进去了,到第五个才发现不存在,这时候sendError只会往已经写了一半的 ZIP 里再塞一段 HTML 错误页,最终产物是个彻底损坏的压缩包。

正确的做法是把所有"可能失败"的判断全部前置。我在写这类接口时会先把文件元信息查出来,形成一个不可变的列表,包含路径、大小、修改时间、展示名,全部校验通过后才碰response.getOutputStream()。这个过程额外多一次数据库或文件系统查询,但换来的是"要么完整成功,要么干净失败"。

那如果确实在写流的中途出错了怎么办?比如磁盘 IO 异常。这时候状态码已经发不出去了,唯一的做法是立刻停止写入、不再finish()、直接让连接断掉。客户端拿到的会是一个不完整的 ZIP,至少解压工具会明确报"文件损坏",比拿到一个看似正常但内容错位的包要好。同时服务端要记一条 error 日志带上下载批次号,方便对账。

还有一种折中方案值得推荐:给前端提供一个预检接口。用户点"批量下载"时,前端先调/api/files/batch-check传同样的 ID 列表,后端返回{ok: true, totalSize: 12345, count: 8}或者{ok: false, reason: "..."}。预检通过再去真正下载。多一次请求,但错误提示可以做得非常友好,用户不会下载到一个坏包才发现问题。

2.3 缓存、嗅探防护与跨域暴露头

这几个头看着琐碎,缺了会在特定场景下出问题。

Cache-Control: no-store是必须的。下载类接口的响应内容跟用户身份强相关,一旦被中间层缓存,A 用户可能拿到 B 用户的文件。这不是理论风险,某些企业内网的代理就是这么配的。

X-Content-Type-Options: nosniff也建议加上。它能阻止浏览器忽略 Content-Type 去猜内容类型,避免某些老版本浏览器把 ZIP 当 HTML 渲染从而触发脚本执行。

跨域场景要特别注意Access-Control-Expose-Headers。如果前端用fetch+blob的方式拿文件,想从响应里读Content-Disposition来解析文件名,那么在同源情况下没问题,跨域情况下浏览器默认只暴露六个简单响应头,Content-Disposition不在其中,resp.headers.get('Content-Disposition')会返回null。必须在服务端显式加上:

Access-Control-Expose-Headers: Content-Disposition

这一条我调了快一个小时才定位到,因为控制台里响应头明明看得见,JS 就是读不到,很容易误以为是前端解析代码写错了。

3. 打包下载的核心实现:ZipOutputStream 怎么用才不炸

3.1 最小可用版本:一个能直接跑的批量下载 Servlet

先把完整骨架放出来,后面所有讨论都基于这段代码展开。假设文件元信息已经通过一个服务层查好,DownloadItem里存着实际路径和对外展示名。

@WebServlet("/api/files/batch-download") public class BatchDownloadServlet extends HttpServlet { private static final int MAX_FILE_COUNT = 50; private static final long MAX_TOTAL_BYTES = 2L * 1024 * 1024 * 1024; private static final int BUFFER_SIZE = 16 * 1024; @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String[] ids = req.getParameterValues("id"); List<DownloadItem> items; try { items = downloadService.resolve(ids, currentUser(req)); } catch (BizException e) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.setContentType("application/json;charset=UTF-8"); resp.getWriter().write("{\"code\":\"" + e.getCode() + "\"}"); return; // 注意:此处响应尚未 committed } if (items.isEmpty()) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "no file"); return; } String zipName = "batch_" + System.currentTimeMillis() + ".zip"; resp.setContentType("application/zip"); resp.setHeader("Content-Disposition", buildContentDisposition(zipName)); resp.setHeader("Cache-Control", "no-store"); resp.setHeader("X-Content-Type-Options", "nosniff"); resp.setHeader("X-Batch-Count", String.valueOf(items.size())); writeZip(resp, items); } private void writeZip(HttpServletResponse resp, List<DownloadItem> items) throws IOException { ZipOutputStream zos = new ZipOutputStream( new BufferedOutputStream(resp.getOutputStream(), BUFFER_SIZE), StandardCharsets.UTF_8); zos.setLevel(Deflater.DEFAULT_COMPRESSION); Set<String> usedNames = new HashSet<>(); boolean finished = false; try { for (DownloadItem item : items) { ZipEntry entry = new ZipEntry( uniqueEntryName(item.getDisplayName(), usedNames)); entry.setTime(item.getLastModified()); if (isAlreadyCompressed(item.getDisplayName())) { long size = item.getSize(); entry.setMethod(ZipEntry.STORED); entry.setSize(size); entry.setCompressedSize(size); entry.setCrc(crc32(item.getPath())); } zos.putNextEntry(entry); Files.copy(item.getPath(), zos); // 不会关闭 zos zos.closeEntry(); } zos.finish(); finished = true; resp.flushBuffer(); } finally { if (!finished) { // 中途失败:不调用 finish,让容器直接断开,产物标记为损坏 log.warn("batch zip aborted, items={}", items.size()); } zos.close(); // 关闭的是包装流,Tomcat 会忽略对底层流的重复 close } } }

几个关键点对应到代码里。Files.copy(Path, OutputStream)这个重载不会关闭输出流,可以放心用,比手写 while 循环读字节简洁且默认带缓冲。zos.close()放在 finally 里,虽然容器会接管response.getOutputStream()的关闭,但显式关闭能让ZipOutputStream有机会把中央目录补齐——不过在失败路径上我们并不希望它补齐,所以才有了finished这个标志,用来区分"正常收尾"和"半途而废"。

entry.setTime()容易被忽略。不设的话,ZIP 里每个条目的时间戳都是 1970 或当前时刻,用户解压出来一看全是同一天,会觉得系统有问题。设成文件的真实修改时间是基本礼貌。

3.2 压缩策略:STORED 还是 DEFLATED,省的是 CPU 还是带宽

ZipOutputStream默认对每个 entry 使用 DEFLATED(Deflate 算法压缩)。对文本、日志、CSV、JSON 这些内容,压缩比能到 5:1 甚至 10:1,非常划算。但如果打包的是已经压缩过的格式——jpg、png、mp4、pdf 里内嵌的图片流、以及本身就是 .zip/.jar/.gz 的文件——再压一遍基本没有收益,纯烧 CPU。

我做过一个粗略的实测:打包 200 张平均 2MB 的 JPG,总计约 400MB。

压缩方式耗时输出体积CPU 占用
DEFLATED(默认级别)约 21 秒398MB单核跑满
STORED(不压缩)约 2.3 秒400MB很低
DEFLATED 级别 1约 6 秒399MB中等

体积几乎没变,时间差了近十倍。所以按扩展名判断是很值的一步优化:

private static final Set<String> COMPRESSED_EXT = Set.of( "jpg", "jpeg", "png", "gif", "webp", "mp4", "mov", "mp3", "zip", "gz", "7z", "rar", "jar", "docx", "xlsx", "pptx"); private static boolean isAlreadyCompressed(String name) { int dot = name.lastIndexOf('.'); if (dot < 0) return false; return COMPRESSED_EXT.contains(name.substring(dot + 1).toLowerCase(Locale.ROOT)); }

但 STORED 有个隐藏成本:ZIP 规范要求 STORED 条目必须在头部声明 CRC32 和原始大小,这意味着你得先把文件完整读一遍算 CRC,再读第二遍写数据。对于大文件,这是两次磁盘 IO。所以这里的判断要看场景:如果文件在本地 SSD 且不大,两次读无所谓;如果文件在对象存储上需要走网络拉取,那第二次读的代价可能比压缩还高,这时候反而应该老老实实 DEFLATED。

注意:如果你的文件元数据表里本来就存了 CRC32 和文件大小(很多内容管理系统会存),那 STORED 就是纯赚,直接读一次写一次。

压缩级别也别一味调高。setLevel(9)相比默认的 6,体积可能只小 2%,时间却翻倍。我一般用默认级别,或者在对延迟敏感的场景调到 1。

3.3 中文文件名乱码的真正原因与两种解法

ZIP 格式诞生于 1989 年,那时候没人考虑 Unicode。文件名在 ZIP 里就是一段字节,用哪种编码解释完全由解压软件自己猜。这就导致同一个 ZIP,在 Windows 自带解压、7-Zip、macOS 归档工具里可能显示三种不同的乱码形态。

后来 ZIP 规范加了一个"通用标志位第 11 位",置位表示"文件名是 UTF-8"。Java 7 之后,ZipOutputStream的构造函数可以传Charset,传 UTF-8 时会自动置这个位:

new ZipOutputStream(out, StandardCharsets.UTF_8);

传 GBK 则不会置位,字节按 GBK 写进去,靠解压端的本地编码去猜。

实测下来的兼容矩阵是这样的:

解压环境UTF-8 编码GBK 编码
Windows 10/11 资源管理器正常正常
Windows 7 资源管理器乱码正常
7-Zip正常部分版本乱码
macOS 归档实用工具正常乱码
Linux unzip正常乱码

结论很清楚:除非你的用户里还有大量 Windows 7,否则 UTF-8 是唯一正确选择。我现在的项目统一用 UTF-8,在下载页面上加一行小字"如遇文件名乱码,请使用 7-Zip 或系统自带解压工具打开",基本没有投诉。如果确实要兼容老环境,那就做一个配置开关,按用户来源决定编码,而不是把两种混在一个包里。

还有一个更隐蔽的坑:ZIP 里的条目名必须用正斜杠/分隔目录层级,不能以/开头,不能包含..。你的展示名如果来自用户上传的原始文件名,完全可能带这些字符。写 entry 之前必须清洗:

private static String sanitize(String raw) { String name = raw.replace('\\', '/'); int slash = name.lastIndexOf('/'); if (slash >= 0) name = name.substring(slash + 1); name = name.replaceAll("[\\x00-\\x1F<>:\"|?*]", "_"); if (name.isBlank() || ".".equals(name) || "..".equals(name)) { name = "unnamed"; } return name.length() > 120 ? name.substring(name.length() - 120) : name; }

重名也要处理。两个不同目录下的report.pdf塞进同一个 ZIP,很多解压工具会报duplicate entry直接拒绝解压,或者静默覆盖掉一个。用Set记录已用名,重复的追加序号:

private static String uniqueEntryName(String raw, Set<String> used) { String name = sanitize(raw); if (used.add(name)) return name; int dot = name.lastIndexOf('.'); String base = dot > 0 ? name.substring(0, dot) : name; String ext = dot > 0 ? name.substring(dot) : ""; for (int i = 1; ; i++) { String candidate = base + "(" + i + ")" + ext; if (used.add(candidate)) return candidate; } }

4. 生产环境才暴露的问题:中断、大文件与资源保护

4.1 客户端中途断开:那堆红色堆栈该怎么对待

本地开发永远碰不到,一上线日志里就开始刷屏:ClientAbortException: java.io.IOException: Broken pipe(Tomcat),或者EofException(Jetty)。原因很简单,用户在下载过程中点了取消、关了标签页、网络切换、或者浏览器认为这个下载太慢主动断开。

这时候服务端的写操作会抛 IOException。业务上这不算错误,是正常现象。但你如果不去管它,框架的全局异常处理器会把它当成 500 记一条 error 日志,一天下来几十万条,真正的故障反而被淹没。

处理方式是在写流的循环外层单独捕获:

try { for (DownloadItem item : items) { // ... 写 entry } zos.finish(); finished = true; } catch (IOException e) { if (isClientAbort(e)) { log.debug("client aborted during batch download, sent={}", sent); } else { log.error("batch download failed, sent={}", sent, e); throw e; } }

isClientAbort就是一路遍历getCause(),看类名或消息里有没有ClientAbortException、Broken pipe、Connection reset。不依赖具体容器类,反射判断类名即可,这样换容器不用改代码。

顺带说一句,客户端断开之后就别再"努力"往下写了。有些实现在 catch 里继续循环剩下的文件,等于把几百兆数据往一个已经关闭的管道里灌,纯浪费 CPU 和 IO。捕获到就直接跳出。

4.2 临时文件方案:什么时候该牺牲流式换 Content-Length

流式 ZIP 最大的体验缺陷是没有Content-Length。浏览器不显示总大小,进度条是"未知剩余时间"的转圈,用户不知道要等多久,很容易中途放弃。而对于几百兆的包,等待时间可能是好几分钟。

解决办法是先在服务端把 ZIP 生成到一个临时文件,拿到准确大小,然后再把文件内容输出给客户端。这时候响应头可以带Content-Length,浏览器就能显示百分比进度。

Path tmp = Files.createTempFile("batch-", ".zip"); try { try (ZipOutputStream zos = new ZipOutputStream( new BufferedOutputStream(Files.newOutputStream(tmp), BUFFER_SIZE), StandardCharsets.UTF_8)) { // ... 写 entry } long size = Files.size(tmp); resp.setContentLengthLong(size); resp.setContentType("application/zip"); resp.setHeader("Content-Disposition", buildContentDisposition(zipName)); try (InputStream in = Files.newInputStream(tmp)) { in.transferTo(resp.getOutputStream()); } } finally { Files.deleteIfExists(tmp); }

代价很直接:磁盘要多占一份完整 ZIP 的空间,生成期间用户只能干等(没有任何字节下发),并且临时文件必须保证清理。清理这块我吃过教训——早期写在finally里删,结果某次进程被 kill,finally没执行,临时目录堆了几十 G。后来改成两层保险:finally删一次,再加一个定时任务扫描超过 2 小时的残留文件兜底。

这条路线还有一个额外好处:有了临时文件,就能支持 HTTP Range 断点续传了。解析请求里的Range头,用RandomAccessFile定位,返回 206 状态码和Content-Range。对于 GB 级的包,这个能力的价值远超流式方案那点内存优势。

我的实际取舍是这样:

场景方案理由
总大小 < 50MB 且预计 < 3 秒纯流式简单,无磁盘占用,用户感知不到差别
总大小 50MB ~ 2GB临时文件 + Content-Length进度条对体验影响巨大
总大小 > 2GB 或文件数 > 500异步任务 + 通知下载同步请求大概率超时

4.3 数量上限、并发限流与那些必须写死的护栏

一个下载接口如果不加限制,它就是一个天然的放大攻击面。用户传 5000 个文件 ID,你的服务端就开始疯狂读盘、压缩、吐字节,一台机器的带宽和 CPU 瞬间被一个请求吃干。

护栏至少要三层。第一层是数量与体积上限,在解析参数之后立刻判断,超了直接 400,一个字节都不写:

if (items.size() > MAX_FILE_COUNT) { throw new BizException("TOO_MANY", "单次最多下载 " + MAX_FILE_COUNT + " 个文件"); } long total = items.stream().mapToLong(DownloadItem::getSize).sum(); if (total > MAX_TOTAL_BYTES) { throw new BizException("TOO_LARGE", "单次下载总量请控制在 2GB 以内"); }

注意MAX_TOTAL_BYTES是拿元数据里的 size 求和,不要真去读文件算,不然限制本身就成了攻击点。

第二层是并发控制。用一个Semaphore限制同时进行的重打包任务数,比如 CPU 核数的两倍。拿不到信号量的请求直接返回 429 并带上Retry-After,让前端提示"当前下载人数较多,请稍后再试"。这比让所有请求排队最后集体超时要体面得多。

第三层是超时。Servlet 容器一般有connectionTimeout,但那只管连接建立。真正要管的是单次请求的处理时长,可以在业务层记录开始时间,超过阈值主动中断写入。更省事的办法是把整套东西放到一个独立的线程池里跑,配合动态调整的队列长度,队列满了就降级。

日志这块也不能省。每批下载至少记四样:用户标识、文件数量和总字节数、耗时、是否被中断。这些数据在排查"用户说下载不下来"的时候是唯一的线索。我给日志加了一个批次号,同时通过自定义响应头X-Batch-Id回传给前端,用户报障时报这个号,我直接能在日志里定位到那一次请求。

5. 前端配合与常见问题排查实录

5.1 前端三种触发方式,选错了会很别扭

后端把响应头设对之后,前端怎么发起请求也有讲究,三种方式差别不小:

触发方式代码量能否带请求头错误处理适用场景
location.href/a[download]最少不能(只能靠 URL 参数)无法感知,只能靠预检接口简单 GET 下载
隐藏form提交少不能(只能 POST 表单字段)同上需要 POST 且参数多
fetch+blob+URL.createObjectURL多能完整,可读状态码和错误体需要鉴权头、需要精细错误提示

我最常用的是第三种,因为它能解决两个实际问题:一是携带 Authorization 头(a 标签做不到),二是能在失败时拿到 JSON 错误信息展示给用户。

async function batchDownload(ids) { const resp = await fetch('/api/files/batch-download', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }); if (!resp.ok) { const err = await resp.json().catch(() => ({ message: '下载失败' })); alert(err.message); return; } const blob = await resp.blob(); const cd = resp.headers.get('Content-Disposition') || ''; let name = 'batch.zip'; const star = /filename\*=UTF-8''([^;]+)/i.exec(cd); const plain = /filename="?([^";]+)"?/i.exec(cd); if (star) name = decodeURIComponent(star[1]); else if (plain) name = plain[1]; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; a.click(); URL.revokeObjectURL(url); }

这里有个必须记住的点:URL.revokeObjectURL要延后一点调用。有些浏览器在 a.click() 之后同步撤销会导致下载被取消,尤其是在 Safari 上。稳妥的做法是setTimeout(() => URL.revokeObjectURL(url), 1000)。这个 bug 我调了半个下午,因为现象是"偶发下载失败",非常有迷惑性。

另外,fetch+blob会把整个 ZIP 读进浏览器内存。2GB 的包在 8G 内存的机器上大概率崩。这种情况下应该退回到 form 提交或者 a 标签,让浏览器自己接管流式落盘。

5.2 常见问题速查表

下面这张表是我和同事这几年攒下来的,基本都是真实发生过的,按现象归类:

现象最可能的原因排查方向处理办法
下载下来是 0 字节响应流没 flush,或异常被吞看isCommitted,看有没有 error 日志补flushBuffer(),异常向上抛
ZIP 打不开,提示损坏中途抛异常但状态码已提交搜日志里的批次号所有校验前置,中途失败不 finish
中文文件名乱码没设 UTF-8 或没写filename*看响应头原始字节双写 filename,构造函数传 UTF-8
文件名里带+号URLEncoder的空格编码未替换检查编码代码.replace("+", "%20")
解压报 duplicate entry不同目录同名文件直接塞进去看 ZIP 内条目列表用 Set 去重追加序号
只下到第一个文件前端循环触发被浏览器拦截看是否有授权提示改服务端打包
服务端内存飙高 / OOM文件读进 byte[] 再写看堆 dump改Files.copy流式
跨域拿不到文件名缺Access-Control-Expose-Headers看控制台响应头 vs JS 读到的补暴露头
前端拿到 blob 打开是 JSON后端出错但返回 200打印 blob 文本看内容错误分支返回非 200 状态码
日志被 ClientAbort 刷屏未识别客户端断开grep 异常类名单独 catch,降级为 debug
大包下载到 90% 断了要重来流式响应不支持 Range检查是否有 Accept-Ranges改临时文件 + 206
偶发下载被取消revokeObjectURL调用太早复现频率与浏览器相关延后 1 秒撤销

5.3 几个印象深刻的坑,都是花钱买的

第一个坑跟异常处理有关。某次上线后用户反馈"下载的压缩包解压到一半报错"。查了两天才发现,是文件服务那边偶发超时抛了异常,被一个上层的 catch 吞掉打了个 warn,然后循环继续往下走。结果 ZIP 里某个 entry 的头部声明了大小,实际写入的字节数不够,整个包结构就歪了。修复很简单,任何写入异常一律中断整个响应,绝不允许"跳过失败的文件继续下一个"。后来我在代码里加了一条硬规则:写流的循环体内不允许出现 try-catch,异常一律抛到最外层统一处理。

第二个坑跟权限有关。早期版本为了"性能",把权限校验放在了写 entry 的循环里,边写边查。压测的时候没发现问题,因为测试账号有全部权限。生产环境有个用户下载 20 个文件,写第 15 个的时候发现没权限,抛了异常。结果就是他拿到一个包含 14 个文件的坏包,而且完全不知道发生了什么。改成前置校验之后,这类问题彻底消失——虽然多了一次批量查询,但接口 P99 只涨了 8 毫秒,完全值得。

第三个坑是编码。我们服务端用的是 UTF-8,测试同事在 Mac 上验证全部通过,上线之后 Windows 用户反馈文件名全是乱码。查下来是那位用户用的是公司统一配发的旧系统,自带解压工具不认 UTF-8 标志位。最后没有改代码,而是在下载页面上加了一句提示,并在文件名里避免了生僻字,投诉量直接降到零。这件事让我意识到,技术上的"正确"和用户实际的"可用"之间,有时候差的是一句说明文字。

第四个坑比较隐蔽:ZipOutputStream在putNextEntry之后如果忘了closeEntry,下一个putNextEntry会自动把上一个关掉,看起来能正常工作。但如果中间用了try-with-resources或者在异常路径里跳出了,就可能出现条目头写了但数据没写完的情况。我现在统一用"一次 entry 一个明确的闭合"的写法,宁可代码啰嗦一点也不省略。

最后一个不算坑,是个体感上的经验。这类接口的默认压缩级别千万别乱调。有次为了"减小带宽",把setLevel调到 9,结果打包时间从 4 秒涨到 17 秒,用户投诉"点下载之后要愣半天"。改回默认级别,体积只大了 1.5%,体验立刻正常。压缩这件事,收益曲线在级别 6 之后就非常平了,为那一点点体积付出的时间成本,用户是能直接感知到的。

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

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

立即咨询