
运营提需求那天的原话是订单下面三十多个附件一个个点开另存为太蠢了能不能一次性打个包给我。这就是java 批量下载 MinIO 中存储的多个文件并压缩成一个 zip 包这个需求的起点。听起来没什么技术含量——把对象存储里的若干对象读出来顺序塞进一个 zip 流浏览器接住就完事。真动手写才发现坑藏在细节里对象数量上千时列表分页会把数据漏掉、getObject拿到的流忘了关闭会把连接池耗干、图片视频这类已经压过一遍的内容再压一遍纯属浪费 CPU、包体积超过 4GB 还要处理 Zip64 的额外字段前端超时了用户以为失败其实后台还在跑。这些坑没有一个是靠看文档能躲过去的基本都是线上出问题之后回头补的。这篇内容我把从第一版原型写到线上稳定运行的完整过程摊开讲包括每一处参数为什么这么定、代码怎么写、哪种方案适合哪个量级、以及哪些地方是我实际踩过之后才改的。读者最好是已经写过一点 Java Web、对对象存储有基本概念的开发但哪怕你只是刚接触 MinIO跟着走一遍也能把这个功能落地。整个过程涉及三个独立的技术动作批量枚举对象、流式读取对象内容、打包成 zip 输出难点从来不在单个动作而在它们串起来之后的资源管理和边界处理。1. 方案怎么定批量下载与 ZIP 打包的整体设计1.1 先把需求拆开三个独立动作别混着写很多人第一版代码会写成一个大方法从查数据库拿文件 ID 一路写到response.getOutputStream()中间不留任何缝。这么写在 demo 阶段没问题一旦文件数量上来就会出各种诡异问题原因就是三个动作的失败模式和资源模型完全不一样。第一个动作是确定要下载哪些对象。这一层的输入通常是业务侧的一组 ID 或者一个前缀输出是对象名的集合。它的风险点是列表分页、权限过滤、以及对象在列举之后被删除导致后续读不到。第二个动作是把对象内容读出来。MinIO 的 Java SDK 返回的是一个基于 OkHttp 的响应流这个流背后占着一条 HTTP 连接。什么时候关、关几次、异常时关不关直接决定了你的应用在并发上来之后会不会卡死。第三个动作是写 zip。zip 是一种有中心目录的格式条目必须顺序写入最后要写 End of Central Directory 记录。这意味着你没法先并行写完再拼起来除非用临时文件分段。这个特性决定了整个流程是顺序写入、边读边写的形态。把这三个动作在代码里分成三层后面所有优化和排查都会轻松很多。我在项目里的分层是ObjectLister负责列举并解析出条目名ZipStreamWriter负责 zip 格式和命名中间用一层很薄的协调逻辑串起来。看起来多写了几个类但后面换压缩策略、改异步导出、加进度上报时改动都被限制在单层里。1.2 三种落地方案对比直连流式、本地落盘、异步导出同一个需求有三条实现路线选错路线比写错代码更麻烦。方案适用文件量首字节延迟服务端内存/磁盘用户体验直连流式边读边写响应单次几十到几百个、总体积 1GB低几百毫秒几乎无额外占用好点完就开始下本地落盘再返回几百到几千个、体积可控高要等全部打完需要临时磁盘等于包体积差中间没有任何反馈异步导出 预签名链接上千个、体积几 GB提交后立即返回需要临时磁盘或中转对象中需要轮询状态直连流式是最自然的做法HttpServletResponse的输出流本身就是可写的ZipOutputStream套上去就完事。它的致命弱点是一旦开始写响应头就不能再改状态码。也就是说如果第 500 个对象读失败了你没法返回 500只能让这个包残缺地传完用户拿到一个损坏的 zip。这个体验非常糟糕用户不会知道是坏了还是本来就少文件。本地落盘方案解决的就是这个问题先在服务端把完整的 zip 写成一个临时文件全部成功之后才开始把文件流给客户端。代价是要等而且临时文件必须能删干净否则几轮下来磁盘就满了。异步导出是把落盘再往前推一步任务提交给线程池接口立刻返回一个任务 ID前端轮询打包完成后返回一个指向 zip 对象的预签名链接。这个方案适合文件特别多、打包要跑几分钟的场景因为用户不需要一直挂着那个 HTTP 连接。我的判断标准很简单预估打包时间超过 30 秒或者对象数量超过 300 个就走异步导出。低于这个量级直连流式完全够用别为了架构好看把简单事做复杂。1.3 依赖与 MinIO 客户端初始化依赖其实不多两个就够。dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.10/version /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-compress/artifactId version1.26.1/version /dependencyMinIO SDK 8.x 的版本号建议跟服务端保持一个不太落后的差距8.5.x 对于新的服务端版本兼容性比较稳。commons-compress的作用是显式控制 Zip64 和编码比 JDK 自带的java.util.zip可控性强不少。客户端初始化这里有个必须注意的点默认的 OkHttp 客户端超时和连接池配置在大批量下载场景下不够用。ConnectionPool pool new ConnectionPool(64, 5, TimeUnit.MINUTES); OkHttpClient httpClient new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .readTimeout(10, TimeUnit.MINUTES) .connectionPool(pool) .build(); MinioClient client MinioClient.builder() .endpoint(http://10.0.0.12:9000) .credentials(accessKey, secretKey) .httpClient(httpClient) .build();readTimeout要放大是因为单个大对象的读取时间可能远超默认的 10 秒。connectionPool的 64 是给并发读取预留的如果顺序读取20 就够了。这里最容易犯的错误是把MinioClient做成局部变量每次请求都 new 一个——每次 new 都会创建新的 OkHttpClient 和连接池连接完全复用不起来压测时能看到大量 TIME_WAIT。注意MinioClient是线程安全的全局单例即可。如果要用多套凭证访问不同租户的 bucket才需要多个实例。2. MinIO 侧必须搞清楚的几个细节2.1 对象列表与 max-keys 分页listObjects的返回值是IterableResultItem这个概念很多人第一次用会误解——它不是一次把所有结果拉回来的列表而是一个懒加载的迭代器背后按页发请求。IterableResultItem results client.listObjects( ListObjectsArgs.builder() .bucket(biz-file) .prefix(order/20240612/ orderId /) .recursive(true) .maxKeys(1000) .build()); ListItem items new ArrayList(); for (ResultItem result : results) { Item item result.get(); if (item.isDir()) { continue; } items.add(item); }maxKeys控制的是每页返回多少条服务端有一次请求最多返回 1000 条的默认限制。recursive(true)表示递归列举前缀下的所有对象如果设成 false返回结果里会包含目录占位对象item.isDir()会是 true需要过滤掉。这里有个隐蔽的坑迭代过程中如果发生异常迭代会中断你拿到的 items 是一个不完整的集合。如果后面直接拿这个集合去打包用户会收到一个静悄悄少了文件的 zip。稳妥的做法是在迭代结束后校验数量或者对关键场景用listObjects加上一次全量比对。另外如果在遍历迭代器的循环里做了耗时的网络操作比如逐个 stat 对象大小整个遍历会被拖得非常慢因为下一页请求必须等上一页处理完才发。正确做法是先把Item收集到内存列表再去处理内容。2.2 getObject 的流与连接泄漏client.getObject()返回的是GetObjectResponse它继承自FilterInputStream。这个流的背后是一条活着的 HTTP 连接只要你没读完也没关闭这条连接就一直占着。连接池的容量是有限的泄漏几十次之后新的请求就会开始排队表现是接口越来越慢最后直接超时。try (InputStream in client.getObject( GetObjectArgs.builder() .bucket(bucket) .object(objectName) .build())) { copy(in, zipOut); }一定要用 try-with-resources不要手动在 finally 里判断 null。更危险的一种写法是把InputStream存到集合里跨方法传递最后忘了关。我在排查一次线上问题时最后定位到的就是某个分支里statObject失败之后直接continue跳过了流的关闭逻辑。还有一种情况需要留意你提前跳出读取循环但没关流。比如为了限制单文件最大体积读到 100MB 就 break这时候流里还有剩余数据没读。OkHttp 对这种情况会尝试把连接丢弃而不是复用虽然不会泄漏但会造成连接重建性能会掉。所以如果确实要截断break 之后也要显式 close。2.3 预签名 URL 的适用边界与有效期异步导出方案最后要把 zip 交给用户最省事的做法是把打包好的 zip 上传到 MinIO 的某个临时目录然后生成一个预签名 URL。String url client.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucket) .object(export/ userId / taskId .zip) .expiry(2, TimeUnit.HOURS) .build());预签名 URL 的有效期上限是 7 天这是个硬限制写代码时不要试图传更大的值。另外要注意预签名 URL 是用当前客户端的凭证签的如果这个凭证是通过 STS 拿到的临时凭证URL 的失效时间会受临时凭证本身的过期时间影响可能出现URL 还没到期但已经 403的情况。关于匿名访问很多人想通过关闭 bucket 的匿名策略来控制访问这个方向是对的临时导出目录务必保持私有只通过预签名 URL 出流量。如果只是为了图省事把 bucket 设成公开可读等于把所有人的文件都暴露在公网上这个口子千万别开。3. 核心实现把多个对象写进一个 ZIP3.1 最小可用版本Servlet 直写响应流先给出能跑的版本把所有细节都摊开。GetMapping(/orders/{orderId}/attachments/zip) public void downloadZip(PathVariable String orderId, HttpServletResponse response) throws Exception { String prefix order/ orderId /; MinioClient client MinioClientHolder.get(); String bucket biz-file; // 1. 先把条目全部收集好避免边写边列举 ListItem items new ArrayList(); for (ResultItem r : client.listObjects(ListObjectsArgs.builder() .bucket(bucket) .prefix(prefix) .recursive(true) .build())) { Item item r.get(); if (!item.isDir()) { items.add(item); } } if (items.isEmpty()) { response.setStatus(HttpServletResponse.SC_NOT_FOUND); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\msg\:\该订单没有可下载的附件\}); return; } // 2. 准备响应头这一步必须在写流之前 String zipName 订单附件- orderId .zip; String encoded URLEncoder.encode(zipName, StandardCharsets.UTF_8).replace(, %20); response.setContentType(application/zip); response.setCharacterEncoding(UTF-8); response.setHeader(Content-Disposition, attachment; filename\ encoded \; filename*UTF-8 encoded); // 3. 顺序写 zip try (ZipArchiveOutputStream zos new ZipArchiveOutputStream(response.getOutputStream())) { zos.setEncoding(UTF-8); zos.setUseZip64(Zip64Mode.AsNeeded); for (Item item : items) { String entryName resolveEntryName(prefix, item.object()); ZipArchiveEntry entry new ZipArchiveEntry(entryName); entry.setSize(item.size()); zos.putArchiveEntry(entry); try (InputStream in client.getObject(GetObjectArgs.builder() .bucket(bucket) .object(item.object()) .build())) { copy(in, zos); } zos.closeArchiveEntry(); } zos.finish(); } }copy就是最朴素的缓冲区拷贝private static void copy(InputStream in, OutputStream out) throws IOException { byte[] buf new byte[8192]; int n; while ((n in.read(buf)) ! -1) { out.write(buf, 0, n); } }缓冲区 8KB 是个经验值。调到 64KB 在千兆内网环境下提升有限但内存占用会上去尤其是并发请求多的时候。如果你的服务端和 MinIO 之间跨机房可以调到 32KB减少往返次数。3.2 条目命名、中文乱码与重名处理zip 里的条目名就是用户解压后看到的文件名这一层处理不好会直接影响体验。第一个问题是条目名带上前缀目录。如果直接用item.object()用户解压出来会是一串order/20240612/12345/xxx.pdf多套了好几层没有意义的目录。我的做法是把列表用的 prefix 去掉只保留相对路径private static String resolveEntryName(String prefix, String objectName) { String relative objectName.startsWith(prefix) ? objectName.substring(prefix.length()) : objectName; if (relative.isEmpty() || relative.endsWith(/)) { relative relative 未命名文件; } return relative; }第二个问题是中文乱码。zip 的条目名编码在不同工具里的处理不一致ZipArchiveOutputStream默认使用平台编码Windows 上跑出来是 GBKLinux 上跑出来是 UTF-8解压时很容易出现乱码。显式设成 UTF-8 能覆盖绝大多数现代解压工具但要注意老版本的 Windows 资源管理器对 UTF-8 标记的支持也有差异如果用户群体里还有很老的系统可以考虑把中文名转成拼音或者干脆用业务 ID 加原始扩展名。第三个问题是重名。MinIO 的对象名在整个 bucket 里唯一但去掉前缀之后很可能撞车比如两个不同子目录下都有合同.pdf。处理方式有两种一种是保留相对路径里的目录层级另一种是发现重复时加序号MapString, Integer counter new HashMap(); private String dedup(String name) { int n counter.merge(name, 1, Integer::sum); if (n 1) { return name; } int dot name.lastIndexOf(.); if (dot 0) { return name.substring(0, dot) ( (n - 1) ) name.substring(dot); } return name ( (n - 1) ); }用哪种取决于业务。附件场景我倾向于保留目录层级并且加序号因为用户能看出文件来自哪个子目录。3.3 压缩级别与 Zip64两个最容易被忽略的参数压缩级别这块有个很值钱的优化。MinIO 里存的东西大部分是 JPG、PNG、PDF、MP4、docx 这类已经是压缩格式的文件再走一遍 Deflate 压缩基本压不下去CPU 却实实在在地烧掉了。我实测过一批 200 个 PDF 的订单附件默认级别打包耗时 43 秒把级别降到 0 之后只要 11 秒包体积只涨了不到 2%。zos.setLevel(Deflater.NO_COMPRESSION);严格来说setLevel(0)走的是 Deflate 但不做压缩如果要用真正的 STORED 存储模式需要额外设置 method、size 和 crcZipArchiveEntry entry new ZipArchiveEntry(entryName); entry.setMethod(ZipEntry.STORED); entry.setSize(item.size()); entry.setCrc(crc32); // 需要自己先算一遍STORED 模式要求提前知道 CRC32意味着你得先把整个对象读一遍算校验再读一遍写入对网络流量是双倍消耗。除非是本地文件否则不划算。所以我的选择是统一用setLevel(0)兼顾速度和实现复杂度。如果附件里混有纯文本、CSV、JSON 这类高压缩比的内容可以按扩展名分流文本类用默认级别压缩媒体类用 0 级。这个判断很便宜收益却很直接。Zip64是另一个必须配置的参数。zip 格式的经典结构里条目数上限是 65535单个文件大小和总包大小上限都是 4GB超过之后必须写 Zip64 扩展记录。commons-compress的Zip64Mode.AsNeeded会在需要时自动切换这是最稳的配置。zos.setUseZip64(Zip64Mode.AsNeeded);如果提前知道包会很大也可以用Zip64Mode.Always代价是包体积会多几十字节的固定开销但能避开某些老解压工具的兼容性问题。真正的坑在于如果用了不支持的 Zip64 的解压工具用户下载完打不开所以包超过门槛时最好在前端提示一下或者干脆拆包。3.4 大文件与超多文件的内存控制直连流式方案的内存占用和文件数量几乎无关因为每次只有一个 8KB 缓冲区在流转。但有两个地方会悄悄把内存吃掉。一个是ListItem items。Item对象本身不大但几千个对象名加起来也有几 MB如果单次列出几万个对象这个集合会很可观。解决办法是分页处理不用一次拉完而是按 500 个一批处理处理完一批释放一批。但这样就没法预知总条目数进度上报会不准。折中方案是先做一次轻量的列举只统计数量可以通过listObjects迭代计数不保存 Item再重新遍历一遍取实际内容。另一个是响应流没被消费完。如果客户端在下载途中断开连接Servlet 容器会抛ClientAbortException这时候 zip 流会中断。如果不捕获这个异常日志里会刷一堆堆栈看起来像故障其实只是用户点了取消。捕获之后直接静默返回即可。try { // 写 zip } catch (ClientAbortException e) { log.info(客户端中断下载orderId{}, orderId); } catch (IOException e) { log.error(打包失败orderId{}, orderId, e); }还有一个容易被忽略的点磁盘不是问题但临时文件的清理是问题。直连流式不落盘所以没这个烦恼落盘和异步导出方案必须有清理机制无论是finally里 delete还是定时任务扫过期文件。我在项目里用的是临时文件写到系统临时目录任务完成后立刻删另外加一个每小时扫一次的兜底任务清理超过 2 小时的残留文件双保险。4. 完整落地异步导出 进度查询 下载链接4.1 任务模型设计当对象数量到千级、包体积到 GB 级时直连流式会遇到三个现实障碍网关的连接超时、用户不能关页面、失败无法重试。异步导出就是为这三件事准备的。任务模型需要记录的状态不多但要完整字段说明taskId全局唯一返回给前端userId用于权限校验防止越权下载statusPENDING / RUNNING / SUCCESS / FAILEDtotal / finished进度分子分母用于前端展示百分比zipObject打包完成后在 MinIO 中的对象路径errorMsg失败原因给用户一个可读的提示createdAt创建时间用于过期清理状态存储第一版可以放本地内存的ConcurrentHashMap配合定时清理。一旦服务多实例部署就要换成 Redis否则轮询请求打到另一个实例会查不到任务。这个切换在项目里我踩过一次本地测试完全正常压测时上了两个实例立刻出问题。4.2 关键代码任务提交、状态缓存、预签名返回任务提交接口只做两件事校验参数、丢给线程池。PostMapping(/export/tasks) public TaskVO createExportTask(RequestBody ExportRequest req) { String taskId UUID.randomUUID().toString().replace(-, ); String zipObject export/ req.getUserId() / taskId .zip; taskStore.put(taskId, TaskStatus.pending(req.getUserId(), zipObject)); exportExecutor.submit(() - { try { taskStore.update(taskId, TaskStatus.running()); File tmp File.createTempFile(export-, .zip); try { // 打包到本地临时文件中途更新进度 try (OutputStream fileOut new BufferedOutputStream( new FileOutputStream(tmp), 64 * 1024)) { writeZip(client, req.getBucket(), req.getObjectNames(), fileOut, taskId); } // 上传到 MinIO 的导出目录 try (InputStream in new FileInputStream(tmp)) { client.putObject(PutObjectArgs.builder() .bucket(req.getBucket()) .object(zipObject) .stream(in, tmp.length(), -1) .contentType(application/zip) .build()); } } finally { Files.deleteIfExists(tmp.toPath()); } taskStore.update(taskId, TaskStatus.success(zipObject)); } catch (Exception e) { taskStore.update(taskId, TaskStatus.failed(e.getMessage())); } }); return new TaskVO(taskId); }线程池的配置要根据机器规格来定不能拍脑袋。打包任务的瓶颈是 CPU压缩和网络 IO拉取对象如果用了 0 级压缩瓶颈基本在网络侧线程数可以设成核数的两倍。但要注意每个打包任务都可能占用几十 MB 的堆和临时的磁盘写入带宽无限制提交会让机器直接雪崩。所以队列必须有界并且给一个拒绝策略。ThreadPoolExecutor exportExecutor new ThreadPoolExecutor( 4, 8, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(32), new ThreadFactoryBuilder().setNameFormat(zip-export-%d).build(), new ThreadPoolExecutor.AbortPolicy());AbortPolicy会在队列满时直接抛异常我把它捕获之后返回给前端一个当前排队人数较多请稍后再试比无限堆积到 OOM 要好得多。查询接口返回进度和结果成功时生成预签名 URLGetMapping(/export/tasks/{taskId}) public TaskStatusVO query(PathVariable String taskId) { TaskStatus st taskStore.get(taskId); if (st null) { return TaskStatusVO.notFound(); } TaskStatusVO vo new TaskStatusVO(); vo.setStatus(st.getStatus()); vo.setTotal(st.getTotal()); vo.setFinished(st.getFinished()); if (st.getStatus() Status.SUCCESS) { vo.setDownloadUrl(client.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(bucket) .object(st.getZipObject()) .expiry(2, TimeUnit.HOURS) .build())); } return vo; }前端拿到downloadUrl之后直接用window.open或者a标签触发下载即可这样下载流量完全不经过你的应用服务带宽压力被转移到对象存储侧。这是异步方案除了不占连接之外的另一大好处。4.3 临时文件与过期清理导出目录会随着时间越堆越多必须有清理策略。最省事的是用 MinIO 自带的对象生命周期规则针对export/前缀配置一条超过 3 天自动删除的规则配置一次就不用管了。如果没有权限配置生命周期就写一个定时任务列举export/前缀下的对象按最后修改时间判断删除。这里有个细节值得注意清理的间隔要大于预签名 URL 的最长有效期否则用户刚拿到链接文件就被删了点开是 404。我一般是 URL 有效期 2 小时清理阈值 72 小时留足余量。另外如果导出目录和业务数据在同一个 bucket 里建议用一个独立的前缀比如_export/方便批量操作时不会误伤业务对象。用独立 bucket 更好不存在前缀冲突的可能。5. 常见问题排查与性能调优实录5.1 症状对照表下载失败、包损坏、卡住不动线上跑了一年多遇到的典型问题基本都在这张表里。症状大概率原因处理方式下载下来的 zip 解压报文件已损坏写入过程中抛了异常包结构不完整改用落盘后整体返回或在写响应前先做一次校验读包能打开但里面文件数少了listObjects迭代中断或异常被吞迭代结束后校验数量异常必须抛出而不是 catch 后 continue中文文件名解压后乱码未显式设置 zip 条目编码zos.setEncoding(UTF-8)或考虑用非中文文件名下载一开始就卡住不开始nginx 的proxy_buffering在等整个响应体关闭proxy_buffering或改用异步导出并发十几个请求后接口超时GetObjectResponse未关闭连接池耗尽检查是否有分支漏了 close解压提示超出 4GB 限制未启用 Zip64zos.setUseZip64(Zip64Mode.AsNeeded)接口长时间无响应后返回 504网关超时时间短于打包时间改异步导出或调大网关超时用户点了取消日志刷一堆异常客户端断开触发了ClientAbortException单独捕获该异常降级为 info 日志其中下载一开始就卡住这条最坑因为它看起来像是后端慢实际上是反向代理在缓冲。默认情况下 nginx 会把后端的响应缓存到磁盘再转发对普通接口没影响对流式下载就是灾难——用户要等整个包打完才开始收到第一个字节。配置项是location /api/export/ { proxy_pass http://backend; proxy_buffering off; proxy_read_timeout 300s; proxy_send_timeout 300s; }proxy_buffering off让数据边到边转发proxy_read_timeout要覆盖最长的打包时间。这两个不改流式方案基本没法用。5.2 速度调优并发拉取与压缩策略顺序从 MinIO 拉取对象如果每个对象都要等 HTTP 往返网络延迟会被放大 N 倍。在一批 500 个文件的测试里顺序拉取耗时 6 分 20 秒把拉取并发度提到 8 之后降到了 1 分 10 秒提升非常明显。难点在于 zip 必须顺序写入不能简单地多线程同时往同一个流里写。我的做法是并发下载到有界队列单线程顺序写入 zipBlockingQueueFetched queue new ArrayBlockingQueue(16); ListFuture? futures new ArrayList(); for (Item item : items) { futures.add(ioExecutor.submit(() - { byte[] data readAllFully(client, bucket, item.object()); queue.put(new Fetched(item.object(), data)); // put 会阻塞天然形成背压 return null; })); }但这个写法有个致命问题readAllFully把整个对象读进内存。单个 2GB 的视频直接把堆打爆。所以并发方案只适合小文件场景比如每个对象都不超过 20MB 的附件。实现时要加一道判断超过阈值的对象走顺序流式写入低于阈值的走并发预读。更稳妥的替代方案是用临时文件做中转并发线程把对象下载到临时文件写入线程按顺序读临时文件写进 zip写完删掉。这样内存占用可控代价是多一轮磁盘 IO。如果对象存储和应用的网络带宽是瓶颈磁盘中转的成本可以忽略。还有一个常被忽略的优化点是复用压缩流。ZipArchiveOutputStream的putArchiveEntry和closeArchiveEntry之间不能复用其他对象但同一个 zip 流可以跨条目持续使用不需要每个文件新建。有些实现会为每个文件新建一个流这样不仅慢还会导致 zip 结构错误。5.3 踩过的坑经验合集第一条别在写响应头之前忘了检查空集合。我第一次上线就遇到这个问题某个订单的所有附件被用户删光了接口进了打包分支ZipArchiveOutputStream因为没有任何条目在 finish 时报了这个错——ZIP file must have at least one entry。异常抛在响应流已经部分写入之后前端收到的是一个 200 加一个空文件。改法是打包前先判断items.isEmpty()直接返回 404 加提示。第二条statObject和listObjects拿到的大小可能不一致。极端情况下对象在列举之后被替换你写进去的 size 和实际内容长度对不上用 STORED 模式会直接报错。所以除非你有严格的并发控制否则不建议用 STORED老老实实走setLevel(0)。第三条别把对象名当文件名直接拼进 zip。对象名里可能包含/、\、..这类字符某些解压工具会把它当成路径穿越产生安全风险。写入之前做一次规整去掉开头的/把反斜杠替换掉截断..序列。第四条给前端一个明确的打包中状态。哪怕走的是直连流式也要在前端点击后立刻显示 loading因为服务端列举对象和建立连接需要时间用户看到的就是点了没反应。直连流式还有个问题Content-Length未知浏览器只能显示已下载未知大小用户体验一般。如果包体积可以预估把所有Item.size()加起来设置Content-Length浏览器就能显示进度条。注意这个值只是解压后的大小不等于压缩包大小展示进度会有偏差但比没有强。第五条压测要在真实的网络环境下做不要在本地和 MinIO 同机测试。本地测试的耗时和跨机房完全不是一个量级我见过本地 2 秒搞定、线上 3 分钟的案例就是因为跨机房带宽只有几十兆而包体积有 800MB。上线前至少要在预发环境跑一次真实的下载看看实际耗时再决定用哪种方案。最后分享一个我觉得很实用的小设计把打包过程的关键节点打上耗时日志。列举耗时、拉取总耗时、压缩总耗时、上传耗时四个数字分开打出来。出问题时一眼就能看出瓶颈在哪一层不用再猜。这个日志在排查为什么这次特别慢的时候比任何监控指标都直观。