运营提需求那天的原话是:订单下面三十多个附件,一个个点开另存为太蠢了,能不能一次性打个包给我。这就是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> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.10</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-compress</artifactId> <version>1.26.1</version> </dependency>MinIO 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的返回值是Iterable<Result<Item>>,这个概念很多人第一次用会误解——它不是一次把所有结果拉回来的列表,而是一个懒加载的迭代器,背后按页发请求。
Iterable<Result<Item>> results = client.listObjects( ListObjectsArgs.builder() .bucket("biz-file") .prefix("order/20240612/" + orderId + "/") .recursive(true) .maxKeys(1000) .build()); List<Item> items = new ArrayList<>(); for (Result<Item> 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. 核心实现:把多个对象写进一个 ZIP
3.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. 先把条目全部收集好,避免边写边列举 List<Item> items = new ArrayList<>(); for (Result<Item> 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;charset=UTF-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 上跑出来是 GBK,Linux 上跑出来是 UTF-8,解压时很容易出现乱码。显式设成 UTF-8 能覆盖绝大多数现代解压工具,但要注意老版本的 Windows 资源管理器对 UTF-8 标记的支持也有差异,如果用户群体里还有很老的系统,可以考虑把中文名转成拼音,或者干脆用业务 ID 加原始扩展名。
第三个问题是重名。MinIO 的对象名在整个 bucket 里唯一,但去掉前缀之后很可能撞车,比如两个不同子目录下都有合同.pdf。处理方式有两种,一种是保留相对路径里的目录层级,另一种是发现重复时加序号:
Map<String, 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 和 crc:
ZipArchiveEntry 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 缓冲区在流转。但有两个地方会悄悄把内存吃掉。
一个是List<Item> 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 | 用于权限校验,防止越权下载 |
| status | PENDING / RUNNING / SUCCESS / FAILED |
| total / 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 要好得多。
查询接口返回进度和结果,成功时生成预签名 URL:
@GetMapping("/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 限制" | 未启用 Zip64 | zos.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 必须顺序写入,不能简单地多线程同时往同一个流里写。
我的做法是"并发下载到有界队列,单线程顺序写入 zip":
BlockingQueue<Fetched> queue = new ArrayBlockingQueue<>(16); List<Future<?>> 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。上线前至少要在预发环境跑一次真实的下载,看看实际耗时再决定用哪种方案。
最后分享一个我觉得很实用的小设计:把打包过程的关键节点打上耗时日志。列举耗时、拉取总耗时、压缩总耗时、上传耗时,四个数字分开打出来。出问题时一眼就能看出瓶颈在哪一层,不用再猜。这个日志在排查"为什么这次特别慢"的时候,比任何监控指标都直观。