Java HttpServletResponse多文件下载:流式ZIP与响应头避坑

发布时间:2026/11/6 2:52:41
Java HttpServletResponse多文件下载:流式ZIP与响应头避坑 多文件下载这个需求几乎每个做 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 整条链路的顺序顺序错了后面全是白工这套东西最容易出错的地方不是代码写不出来而是步骤顺序排错。我把它固定成下面这条流水线顺序不能调换解析并校验参数文件 ID 列表是否为空、是否超长、格式是否合法。逐个做权限与存在性校验注意是全部校验完再往下走不是边写边校验。配额校验文件总数上限、总字节上限。设置响应头Content-Type、Content-Disposition、缓存控制。获取输出流包装 ZipOutputStream。逐文件写 entryputNextEntry → 写字节 → closeEntry。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; filenamexxx; filename*UTF-8xxx。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。如果前端用fetchblob的方式拿文件想从响应里读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); ListDownloadItem items; try { items downloadService.resolve(ids, currentUser(req)); } catch (BizException e) { resp.setStatus(HttpServletResponse.SC_BAD_REQUEST); resp.setContentType(application/json;charsetUTF-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, ListDownloadItem items) throws IOException { ZipOutputStream zos new ZipOutputStream( new BufferedOutputStream(resp.getOutputStream(), BUFFER_SIZE), StandardCharsets.UTF_8); zos.setLevel(Deflater.DEFAULT_COMPRESSION); SetString 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 使用 DEFLATEDDeflate 算法压缩。对文本、日志、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 SetString 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, SetString 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 pipeTomcat或者EofExceptionJetty。原因很简单用户在下载过程中点了取消、关了标签页、网络切换、或者浏览器认为这个下载太慢主动断开。这时候服务端的写操作会抛 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里删结果某次进程被 killfinally没执行临时目录堆了几十 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 且参数多fetchblobURL.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 我调了半个下午因为现象是偶发下载失败非常有迷惑性。另外fetchblob会把整个 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 之后就非常平了为那一点点体积付出的时间成本用户是能直接感知到的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询