☰
为什么你的断点续传总是从 0 开始:Hutool 大文件下载避坑实战
2026/10/28 10:05:45 网站建设 项目流程

为什么你的断点续传总是从 0 开始:Hutool 大文件下载避坑实战

【免费下载链接】hutool🍬A set of tools that keep Java sweet.项目地址: https://gitcode.com/gh_mirrors/hu/hutool

用 Hutool 做文件下载,几 MB 的包一行HttpUtil.downloadFile就完事。可一旦换成 8GB 的镜像,网抖一下就回到 0——断点续传和大文件下载才是真战场。这两个需求,Hutool 的 hutool-http 模块 铺好了地基,但没帮你全包,有几处坑得自己绕开。

HttpUtil.downloadFile 为什么续传不了

先说结论,也是本文的题眼:Hutool 内置的downloadFile系列方法不发Range头,永远是裸 GET。

long size = HttpUtil.downloadFile(url, new File("d:/dl/linux.iso")); // 内部走 createGet + writeBody,不碰 Range // 网络中断后再调一次:还是从 0 开始,不是从 60% 继续

所以"续传"这件事,你没法靠downloadFile一行搞定,得自己构造HttpRequest、自己带Range头、自己判断服务器给的是 206 还是 200。下面这段是续传的全部骨架,先把"为什么"讲透,代码反而少。

Range 请求被服务器打回 200 怎么办

续传的前提是服务器愿意按Range切片返回。请求侧只需两行:

HttpRequest req = HttpRequest.get(url) .header("Range", "bytes=" + offset + "-") // offset 读本地 .part 的当前长度 .timeout(30000); // 异步执行:不先把整个 body 读进内存,8GB 也不会 OOM HttpResponse resp = req.executeAsync();

关键是拿到响应后,别急着写,先看状态码。这就是那个不太明显的坑:不少 CDN、代理会直接忽略Range,回你一个200加完整文件体。如果你以为在续传、直接用追加模式写,前半段旧数据拼上新数据,文件就废了。

三种状态码的分支处理:

状态码常量含义你该做什么
206HttpStatus.HTTP_PARTIAL服务器认账,只给了剩余部分追加写本地.part,offset继续累加
200HttpStatus.HTTP_OKRange被忽略,返回完整文件删掉.part,覆盖从头写,别 append
416HttpStatus.HTTP_REQUESTED_RANGE_NOT_SATISFIABLE偏移越界(多半已下完或文件被改)报错或校验完整性,别盲目写
switch (resp.getStatus()) { case HttpStatus.HTTP_PARTIAL: // 206:认账 try (FileOutputStream out = new FileOutputStream(file, true)) { resp.writeBody(out, false, overall); // isCloseOut=false,流由上面管 } break; case HttpStatus.HTTP_OK: // 200:被打回,重下 resp.writeBody(file, overall); // 覆盖写目标文件 break; case HttpStatus.HTTP_REQUESTED_RANGE_NOT_SATISFIABLE: throw new HttpException("offset 越界:" + offset); }

writeBody内部走的是IoUtil.copy的流式拷贝,边收边落盘,这正是你要的"流式写盘"。整个交互长这样:

进度条的 total 为什么总差一截

StreamProgress.progress(long total, long progressSize)里的total取自响应的Content-Length。普通下载它是整个文件大小,可206 的Content-Length只是"剩余"那段的长度,不是你文件的全长。

不处理的话,进度条永远在 0~100% 之间来回跳,用户就会以为程序卡死。包一层做偏移换算即可:

StreamProgress overall = new StreamProgress() { @Override public void progress(long total, long done) { long t = total + offset, d = done + offset; Console.log("已下 {}/{} ({}%)", t, d, d * 100 / t); } @Override public void start() {} @Override public void finish() {} };

想先拿到分母也可以单独发个 HEAD:long total = HttpRequest.head(url).execute().contentLength();

临时文件为什么多一层 .part 后缀

生产里最丑的事故,往往是磁盘上躺着一个"看起来下载完、其实是半截"的文件。两种解法:

  • 单次下载防半截:Hutool 内置了"先写临时文件、成功后改名、失败自动删"的能力,tempFileSuffix默认就是.temp。
    // 不想自己处理?内置版:成功才改名成正式文件 HttpDownloader.downloadFile(url, file, ".download", 30000, overall);
  • 续传:不能每次都改名,那状态就丢了。这里用固定的.part文件本身当断点状态——它的length()就是下次请求的offset,不需要额外的 state 文件,天然原子、重启也不丢。

一句话:.part既当缓冲又当进度记录,下载完成前对外只暴露这个中间态,完成才FileUtil.rename成正式名。

大文件:别让 body 先进内存

前面那句"用executeAsync()"不是可有可无。Hutool 里downloadBytes、resp.bodyBytes()这类方法会先把整个 body 读进byte[]——1GB 还好,8GB 直接OutOfMemoryError。大文件务必走executeAsync()(异步、只持流)再writeBody(流)边收边写,这才是"大文件下载"的内存底线。

先别急着上多线程分片

🧵 看到"单线程太慢"就掏线程池,是个坑。分片加速有个硬前提:服务器得支持多段Range(Accept-Ranges允许且真按多段返回),否则你分 10 片也是从 0 重下 10 遍。

真要上,思路是:每个线程发独立的Range请求,各自seek到预分配好长度的文件对应偏移写自己那片,用RandomAccessFile落盘,主线程Future.get()汇总——但 Hutool 没现成封装这块,得自己搭。对多数业务,先把单连接 + 断点续传做稳,往往比上多线程更值,也更容易排查。


如果你只想记住一件事:断点续传能不能成立,取决于服务器愿不愿意回 206;Hutool 帮你把流式写盘、进度回调和防半截文件铺好了路,但Range头、200/206 的分支、以及那个.part状态文件,这三样得自己攥在手里。更多细节可对照 官方文档 与HttpResponse源码。

【免费下载链接】hutool🍬A set of tools that keep Java sweet.项目地址: https://gitcode.com/gh_mirrors/hu/hutool

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询