上周有个做运营的朋友找我,说要从一个旧图片系统里把三千多张图导出来,旧系统里的文件名是一串带时间戳的随机码,新系统要求按商品 SKU 加序号命名。这活儿看着简单,但真正上手才发现,比想象中麻烦:图片 URL 不规律、命名规则要灵活、三千张图片还得并发下载不能太慢。这篇文章就把我在 Spring Boot 里做批量下载图片并重命名时踩过的坑和最终落地的方案完整分享一下,适合给老系统做数据迁移、整理已授权素材文件、归档第三方接口返回的图片临时链接这类场景,Java 后端开发尤其是 Spring Boot 使用者可以直接参考。
1. 批量图片下载的本质:先拆需求再写代码
很多第一次接到这种需求的人,第一反应是“下载图片嘛,一个 HTTP 请求加一个 FileOutputStream 不就完了”。确实,单张图片的下载逻辑其实就是这么简单,但一旦套上“批量”和“重命名”这两个条件,事情就变了。批量意味着你要考虑并发上限、失败重试、重复下载、日志可观测;重命名意味着你要先搞清楚“目标文件名从哪来”,而不是下载完再手动改。
1.1 这个任务到底卡在哪里
我把这个需求拆成三层来看:
第一层是网络请求层。你要下载的图片可能来自不同域名、不同协议,有些图片服务器没有开放外链,有些会做防盗链校验,有些 URL 本身是动态生成的,比如 /image?id=123 这种,根本没有带文件名信息。这一层的核心问题不是“怎么下载”,而是“怎么判断下载到的东西到底是不是一张图片”。
第二层是文件命名层。这是整个需求里最容易翻车的地方。目标文件名可能来自 URL 的最后一段,也可能来自业务数据里的某个字段,还需要按序号补零、按日期分目录、按分类拼接前缀。如果一开始没有把命名规则配置化,后面需求方说“名字前面加个日期”,你就得改代码重新跑一遍,三千张图再重新下载一次。
第三层是任务调度层。批量任务跑起来之后,哪些下载成功了,哪些失败了,失败的原因是 404、超时还是磁盘写入失败,这些信息必须能查得到。不然三千张图跑完,你只知道“好像完成了”,但根本不知道少了哪些,也不知道为什么少。
1.2 技术选型:RestTemplate、Hutool 还是 OkHttp
Spring Boot 项目里做 HTTP 请求,大家最熟悉的肯定是 RestTemplate,但做文件下载这个场景,它并不是最顺手的选择。我对比过几种常见方案,直接给结论:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| RestTemplate | Spring 自带,无需额外依赖 | 下载大文件需要手动处理 InputStream,代码偏啰嗦 | 只想用 Spring 原生能力 |
| Hutool HttpUtil | API 简洁,一行代码下载文件 | 底层封装程度高,特殊请求头定制稍显繁琐 | 快速实现,中小规模下载 |
| OkHttp | 性能好,连接池和拦截器机制完善 | 需要额外引入依赖,需要自己封装工具类 | 大规模、高并发、需要复杂拦截逻辑 |
| WebClient | 响应式,高并发能力强 | 调试复杂度高,学习成本高 | 整个项目已经是响应式架构 |
我最终选择的是 Hutool 作为主实现,因为它是国内用得比较多的工具包,很多 Spring Boot 项目里已经引入了,HttpRequest 和 HttpResponse 的 API 设计得很直接,拿响应流、读响应头都很方便。如果你项目里没有 Hutool,用 RestTemplate 的 execute 方法也能实现同样的逻辑,核心思路是一样的。
1.3 先把命名规则定成配置文件
这里想强调一个经验:写代码之前,先花半小时和需求方把命名规则确认清楚,然后塞进配置文件。 不要写死在 Java 代码里。我见过太多项目,一开始规则是“按 URL 最后一段命名”,跑了三天之后需求方说“图片太多了,按日期分一下目录”,又过一周说“同一商品的多张图要加序号”。
用配置或者模板表达式来管理命名规则,后续改动只需要改配置,不需要动代码。比如我把规则放在 application.yml 里:
yaml复制download:
save-root: /data/images
rename-template: "{date}/{category}_{bizId}_{seq}.{ext}"
illegal-char-replacement: "_"
max-file-name-length: 120
等真正写下载逻辑的时候,只要解析这个模板就够了。规则改了,重新启动或者刷新配置即可,代码不用变。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 重命名策略:让“下载什么就叫什么”落地
重命名是整个需求的核心,也是看起来最简单、实际最容易出问题的地方。我梳理了三种最常见的命名来源,分别做了处理。
2.1 从 URL 里直接提取文件名
这是最直观的做法:URL 是 https://example.com/images/product/12345.jpg,那么文件名就叫 12345.jpg。用 Java 实现很简单:
java复制String fileName = url.substring(url.lastIndexOf('/') + 1);
但这里有两个坑。第一个坑是 URL 末尾可能带查询参数,比如 https://example.com/image?file=67890.jpg&size=large,你直接 lastIndexOf('/') 截出来的是 image?file=67890.jpg&size=large,这显然不能当文件名。第二个坑是中文文件名会被 URL 编码,比如 https://example.com/images/商品图.jpg,实际拿到的是 %E5%95%86%E5%93%81%E5%9B%BE.jpg,如果不做反转义,保存下来的文件名就是一堆百分号编码。
我处理这块的逻辑是:
java复制public String extractFileNameFromUrl(String url) {
String path = url;
int queryIndex = path.indexOf('?');
if (queryIndex != -1) {
path = path.substring(0, queryIndex);
}
String fileName = path.substring(path.lastIndexOf('/') + 1);
if (fileName.isEmpty()) {
fileName = "image_" + System.currentTimeMillis();
}
return URLDecoder.decode(fileName, StandardCharsets.UTF_8.name());
}
注意 URLDecoder.decode 是把 %E4%B8%AD 之类的中文编码还原成可读字符,如果 URL 本身没有编码,这段代码不会产生副作用。
2.2 响应头里藏着的文件名
有一些图片服务器会在响应头里返回 Content-Disposition,里面带了真实的文件描述,常见格式是:
text复制Content-Disposition: attachment; filename="real_name.jpg"
这个头比 URL 解析更可靠,因为它是文件提供方明确指定的名字。我建议优先级是:业务字段指定名称 > Content-Disposition > URL 末段。也就是说,如果调用方在任务里传了目标名称,就用传进来的;没有传,就看响应头;响应头也没有,再回退到 URL 解析。
从响应头解析文件名的代码:
java复制public String extractFileNameFromDisposition(HttpResponse response) {
String disposition = response.header("Content-Disposition");
if (disposition == null || disposition.isEmpty()) {
return null;
}
String fileName = null;
if (disposition.contains("filename*=UTF-8''")) {
fileName = disposition.substring(disposition.indexOf("filename*=UTF-8''") + "filename*=UTF-8''".length());
} else if (disposition.contains("filename=")) {
fileName = disposition.substring(disposition.indexOf("filename=") + "filename=".length());
}
if (fileName != null) {
fileName = fileName.replace("\"", "").trim();
}
return fileName;
}
2.3 用模板规则生成文件名
当单个文件名的来源比较复杂,比如要“按日期分目录 + 商品分类前缀 + 序号补零”,我会定义一个简单的模板引擎,支持几个关键占位符:
| 占位符 | 说明 | 示例 |
|---|---|---|
{date} |
当天日期 | 20250614 |
{category} |
业务分类 / 来源标记 | product / material |
{bizId} |
业务 ID | SKU 号或商品 ID |
{seq} |
同批次内序号,自动补零 | 0001 |
{fileName} |
从 URL 或响应头提取的原文件名 | 12345 |
{ext} |
文件扩展名 | jpg |
模板替换的核心逻辑不复杂,但要注意扩展名不要写死。我之前犯过错误:默认所有图片都是 .jpg,结果一批 PNG 图片全部成了 .jpg 扩展名,虽然图片内容没变,但文件头信息和扩展名不一致,后续做图片处理的时候报了一堆错。正确的做法是优先从 Content-Type 拿扩展名映射,拿不到再用 URL 后缀。
java复制public String resolveExtension(HttpResponse response, String url) {
String contentType = response.header("Content-Type");
if (contentType != null) {
if (contentType.contains("image/png")) return "png";
if (contentType.contains("image/jpeg") || contentType.contains("image/jpg")) return "jpg";
if (contentType.contains("image/gif")) return "gif";
if (contentType.contains("image/webp")) return "webp";
if (contentType.contains("image/bmp")) return "bmp";
}
// 从 URL 后缀兜底
String path = url.split("\\?")[0];
int dotIndex = path.lastIndexOf('.');
if (dotIndex != -1) {
String ext = path.substring(dotIndex + 1);
if (ext.matches("[a-zA-Z0-9]{2,5}")) {
return ext.toLowerCase();
}
}
return "jpg";
}
2.4 去重与防覆盖
批量下载最大的隐患之一就是重复下载。同一个 URL 因为数据源重复,可能被提交两次;目标文件名重名,可能后下载的覆盖先下载的。我处理重复的思路是双保险,内存里去重 + 磁盘上检查。
内存里用 ConcurrentHashMap.newKeySet() 保存已提交的 URL,只有首次出现的 URL 才创建下载任务。磁盘上则是目标文件保存前先判断文件是否存在,存在就直接跳过并记录下来,除非调用方明确传了覆盖标记。
3. 核心代码实现:异步任务 + 可配置线程池
这一节直接上能跑的代码。我的工程结构分成了 Controller、Service、Task DTO 和配置类四块,这里只贴和下载重命名最相关的核心部分,完整项目结构看代码注释就能还原。
3.1 任务数据结构
java复制public class DownloadTask {
// 图片 URL,必填
private String url;
// 目标文件名(不含扩展名),可选
private String targetName;
// 业务分类,用于占位符 {category}
private String category;
// 业务 ID,用于占位符 {bizId}
private String bizId;
// 是否覆盖已存在的文件,默认 false
private boolean overwrite;
// getter / setter 省略
}
这个 DTO 的好处是把“下载什么”和“命名成什么”解耦了。如果调用方能直接给出 targetName,就用它作为最终文件名;如果不能,就由命名策略动态生成。
3.2 下载单张图片
单张下载是批量的基础,我把这一步写得比较细。核心逻辑是:发起请求 -> 校验状态码 -> 校验 Content-Type -> 生成文件名 -> 写入临时文件 -> 重命名为最终文件。
java复制public DownloadResult downloadOne(DownloadTask task) {
long startTime = System.currentTimeMillis();
String targetPath = null;
HttpResponse response = null;
// 临时文件用 UUID 命名,避免多线程并发写同名文件
String tempFilePath = saveRoot + "/.tmp/" + UUID.randomUUID() + ".tmp";
try {
response = HttpRequest.get(task.getUrl())
.timeout(5000)
.execute();
if (!response.isOk()) {
return DownloadResult.failed(task, "HTTP状态码异常: " + response.getStatus());
}
String contentType = response.header("Content-Type");
if (contentType == null || !contentType.startsWith("image/")) {
return DownloadResult.failed(task, "非图片响应,Content-Type=" + contentType);
}
String ext = resolveExtension(response, task.getUrl());
String fileName = resolveFileName(task, ext);
targetPath = saveRoot + "/" + fileName;
File tempFile = new File(tempFilePath);
File parentDir = tempFile.getParentFile();
if (!parentDir.exists()) {
parentDir.mkdirs();
}
// 用 try-with-resources 确保流关闭
try (InputStream inputStream = response.bodyStream();
FileOutputStream outputStream = new FileOutputStream(tempFile)) {
byte[] buffer = new byte[8192];
int len;
while ((len = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, len);
}
outputStream.flush();
}
File destFile = new File(targetPath);
if (destFile.exists() && !task.isOverwrite()) {
tempFile.delete();
return DownloadResult.skipped(task, "目标文件已存在: " + targetPath);
}
File destParent = destFile.getParentFile();
if (!destParent.exists()) {
destParent.mkdirs();
}
boolean renamed = tempFile.renameTo(destFile);
if (!renamed) {
// 跨目录 rename 在某些环境下会失败,改用 Files.move
Files.move(tempFile.toPath(), destFile.toPath(),
StandardCopyOption.REPLACE_EXISTING);
}
return DownloadResult.success(task, targetPath,
System.currentTimeMillis() - startTime);
} catch (Exception e) {
return DownloadResult.failed(task, e.getMessage());
} finally {
if (response != null) {
response.close();
}
// 清理残留临时文件
File tmp = new File(tempFilePath);
if (tmp.exists()) {
tmp.delete();
}
}
}
有几个细节值得展开说一下。
第一,临时文件方案。为什么不直接把流写到最终路径?因为如果下载过程中网络中断,磁盘上会留下一个半截文件,下次跑批的时候这个文件已经存在,会被“跳过”逻辑误判为已下载。先把流写到 .tmp 目录,等完整下载完再 rename 到最终目录,可以保证目标目录里要么没有这个文件,要么是一个完整的文件。
第二,tempFile.renameTo(destFile) 在跨文件系统或某些操作系统上会返回 false。我在代码里做了 Files.move 作为兜底,这比直接用 Files.move 更稳,因为有些场景下 rename 是原子操作,性能更好。
第三,所有资源都要记得关闭。HttpResponse 如果不关,连接池里的连接会一直不释放,下载几百张图之后就能看到大量 TIME_WAIT 或者连接池耗尽。bodyStream() 用 try-with-resources 包住,确保异常情况也能关闭。
3.3 批量下载:线程池 + 结果收集
批量下载的核心是把任务提交给线程池,然后收集每个任务的结果。我在 Spring Boot 里没有用 @Async 注解,而是选择手动定义 ThreadPoolTaskExecutor,因为这样线程池参数完全可控,也方便在运行时拿到队列大小、活跃线程数这些指标。
java复制@Configuration
public class DownloadThreadPoolConfig {
@Bean("downloadExecutor")
public ThreadPoolTaskExecutor downloadExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(1000);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("img-download-");
// 拒绝策略:由调用线程执行,避免任务丢失
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
关于线程池参数,图片下载是典型的 IO 密集型任务,线程大部分时间都花在等待网络响应上,所以线程数可以比 CPU 核数多不少。8 到 16 这个区间是我在多数场景下的经验值,如果网络带宽大、目标服务器不设防,可以适当调高;如果目标服务器有并发限制,就要往下调。
批量调度的核心代码:
java复制public BatchDownloadResult batchDownload(List<DownloadTask> tasks) {
// 内存去重
Set<String> seenUrls = ConcurrentHashMap.newKeySet();
List<DownloadTask> validTasks = tasks.stream()
.filter(t -> seenUrls.add(t.getUrl()))
.collect(Collectors.toList());
CountDownLatch latch = new CountDownLatch(validTasks.size());
List<DownloadResult> results = Collections.synchronizedList(new ArrayList<>());
for (DownloadTask task : validTasks) {
downloadExecutor.submit(() -> {
try {
DownloadResult result = downloadWithRetry(task);
results.add(result);
} finally {
latch.countDown();
}
});
}
try {
// 等待全部任务完成,超时时间给足
latch.await(30, TimeUnit.MINUTES);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("批量下载被中断", e);
}
return new BatchDownloadResult(results);
}
3.4 失败重试策略
批量任务里,下载失败是常态。网络抖动、服务器 5xx、连接超时,任何一个环节出问题都会导致单张下载失败。我实现的 downloadWithRetry 逻辑是:最多重试 3 次,每次重试之间等待递增的时间,避免请求风暴。
java复制public DownloadResult downloadWithRetry(DownloadTask task) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
DownloadResult result = downloadOne(task);
if (result.isSuccess() || result.isSkipped()) {
return result;
}
if (i < maxRetry - 1) {
try {
Thread.sleep(1000L * (i + 1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
return DownloadResult.failed(task, "重试3次仍失败");
}
这里有个容易被忽视的点:重试不应该对“HTTP 404”这种确定性失败做。文件不存在,重试一百次结果都一样。所以要区分失败类型,只有超时、5xx、网络异常这些瞬时错误才值得重试。我在 DownloadResult 里加了一个 retryable 字段:
java复制public static DownloadResult failed(DownloadTask task, String errorMsg, boolean retryable) {
// ...
}
HTTP 状态码 >= 500 或者抛出网络异常,retryable 为 true;4xx 的请求错误,retryable 为 false。
4. 实际操作中遇到的坑与排查方法
这一节写的是我实际跑批过程中遇到的真实问题,每一个都花了不少时间去定位。对于想直接上手跑批的读者,建议把这节当成 checklist 过一遍。
4.1 下载下来的“图片”全是 HTML 页面
这是新手最容易踩的坑。现象是批量跑完,发现所有文件都能打开,但内容全是网页源代码。原因很简单:有些图片服务器做了防盗链或者需要登录,未带合法 Referer 或 Cookie 的请求会被重定向到一个 HTML 提示页,而且这个页面返回的 HTTP 状态码是 200。代码只判断了 response.isOk(),没有校验 Content-Type,于是把 HTML 内容写进了 .jpg 文件。
排查思路:
- 随便拿一个失败的 URL,用浏览器直接访问,能打开图片。
- 用 curl 带同样的头访问,发现返回的是 HTML。
- 打印响应头,看到
Content-Type: text/html。 - 回到代码,发现没有对 Content-Type 做拦截。
修复方法就是我在 downloadOne 里加的 contentType.startsWith("image/") 判断。这一步一定要在写文件之前做,别等文件落盘了才想起来校验。
4.2 中文文件名变成一串百分号编码
某次跑批,下载完发现文件名是 %E5%95%86%E5%93%81%E5%9B%BE_0001.jpg,明显是 URL 编码没还原。原因是对外链接的图片 URL 本身做了 URL 编码,我在提取文件名后没有解码。
解决方法是提取文件名后调用 URLDecoder.decode(fileName, "UTF-8")。但这里有一个隐蔽的坑:如果 URL 里带 + 号,URLDecoder 会把 + 解码成空格。比如文件名 code+A.jpg,解码后变成 code A.jpg。所以更稳妥的做法是先处理查询参数再解码整个路径,或者用 URI 类的 getPath() 先拿到解码后的路径,再从路径里取最后一段:
java复制URI uri = new URI(url);
String path = uri.getPath();
String fileName = path.substring(path.lastIndexOf('/') + 1);
URI.getPath() 返回的是已经解码过的路径,不会被 + 号问题干扰。这个细节花了我不少时间才意识到。
4.3 并发拉满,结果被服务器限流
最开始我把线程池的 maxPoolSize 配到 50,想着三千张图可以很快跑完。结果跑了五分钟,日志里开始大量出现 503、429,重试三次之后仍然失败,最终成功率只有六成。原因是目标图片服务器的并发能力有限,顶不住 50 个线程同时打过来。
排查思路:
- 看日志,失败的请求几乎都是连接超时和 HTTP 503。
- 单独拿一个失败的 URL 手动访问,又是正常的。
- 确认是并发过高导致服务端限流。
解决办法:调低线程池到 8,并且在批量循环里加入 Thread.sleep(10) 做简单限速。下载速度虽然慢了一点,但成功率从六成提到了 99% 以上。跑批类任务的核心指标不应该是“多快跑完”,而是“成功率多高、失败了能不能自动恢复”。
4.4 Windows 上重命名提示文件被占用
这个问题是在 Windows 环境部署时遇到的。批量下载过程中,偶尔出现某个文件无法重命名,提示文件被占用。排查发现,HttpResponse 的流没有完全关闭,导致文件句柄一直未释放。Windows 对文件占用特别敏感,Linux 下不明显,到了 Windows 就暴露了。
修复方式就是代码里的 try-with-resources 写法,确保 bodyStream() 和 FileOutputStream 都自动关闭。另外,临时文件的清理要放在 finally 块里,即使下载失败也要把残留的 .tmp 清掉,不然 .tmp 文件越积越多。
4.5 用 debug 模式看请求头
跑批遇到问题,最快的定位办法是把请求和响应的关键信息打出来。我给 downloadOne 加了一个 debug 开关,开启后会打印:
text复制URL: https://example.com/images/12345.jpg
Status: 200
Content-Type: image/jpeg
Content-Length: 345678
Resolved File: product/20250614/12345.jpg
这样一眼就能看出状态码、类型、目标文件名是否符合预期。实际跑批的时候,不要只看“跑完了”这个结果,重点检查 debug 日志里的 Content-Type 和目标文件名两个字段,这两个字段最容易出问题。
5. 从 Demo 到生产可用的几个扩展点
如果只是跑一次临时任务,前面三章已经够用了。但如果你想让这个工具变成团队内部可复用的服务,还需要考虑持久化、定时调度、文件校验这几个扩展点。
5.1 用数据库表记录下载任务
内存任务列表有个问题:服务重启后任务状态全丢了,三千张图跑到两千张的时候意外宕机,重启后从零开始,等于白跑。生产环境建议加一张任务表:
sql复制CREATE TABLE image_download_task (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
source_url VARCHAR(512) NOT NULL,
target_name VARCHAR(255),
category VARCHAR(64),
biz_id VARCHAR(64),
status TINYINT NOT NULL DEFAULT 0 COMMENT '0=待处理 1=成功 2=失败 3=跳过',
retry_count INT NOT NULL DEFAULT 0,
error_msg VARCHAR(512),
created_at DATETIME NOT NULL,
finished_at DATETIME,
KEY idx_status (status)
) COMMENT '图片下载任务表';
有了这张表,配合 @Scheduled 定时任务,每 30 秒扫描一次 status=0 的记录,把任务捞出来丢给线程池,就能实现“掉线自动续跑”。source_url 字段建议建唯一索引,防止重复插入。
5.2 下载完做文件完整性校验
HTTP 下载的另一个坑是“看起来下载成功了,文件实际损坏”。有一种情况是响应头里的 Content-Length 和实际下载字节数不一致,网络断开时连接被重置,但代码没感知到。我在写完文件后会做一步校验:
java复制long fileLength = tempFile.length();
String contentLengthHeader = response.header("Content-Length");
if (contentLengthHeader != null) {
long expectedLength = Long.parseLong(contentLengthHeader);
if (fileLength != expectedLength) {
return DownloadResult.failed(task, "文件大小不匹配, 期望=" + expectedLength + ", 实际=" + fileLength);
}
}
如果 Content-Length 不存在,比如 chunked 传输,可以用 ImageIO.read 尝试读取图片尺寸,读不到就视为损坏。这一步对后续使用的图片质量很有保障。
5.3 按日期归档目录结构
当图片量级到几万张之后,单目录存储会变得非常难维护。我在命名模板里放了一个 {date} 占位符,生成的路径形如 /data/images/20250614/product_A_0001.jpg。这样后续清理过期素材、按日期追溯、做存储分层都很方便。归档策略可以和命名模板解耦开,只改配置就能生效。
5.4 记录下载来源和用途
最后一个小建议,在这个工具里加一个 source 字段,记录任务是谁发起的、用途是什么。下载图片这类操作,如果做得好,是在为业务提供资产沉淀;但如果信息缺失,后续可能分不清哪些图片有授权、哪些图片来源不明。无论是做数据迁移还是素材归档,保持一份清晰的下载来源记录,长期来看都是非常值得的。
最后再分享一个小技巧
写这类批量下载工具,我的习惯是永远先跑小批量验证,再跑全量。具体操作是:先写一个能循环下载的单线程版本,拿 50 张图把命名规则、扩展名、目录结构验一遍,确认没问题之后,再加线程池上全量。同时,每次跑完批量任务,先看失败列表而不是成功列表,因为很多问题在成功文件里根本看不出来。这个习惯帮我避免过不少次“三千张图跑完才发现命名规则错了”的尴尬。
