1. 从一个真实需求说起:图片下载与水印处理要解决什么
有段时间我在做一个内容管理后台,涉及用户上传图片的统一管理。原始图片是第三方存储服务返回的URL,业务上需要把图片下载到本地,再叠加上平台标识水印,最后入库或者提供给前端展示。一开始觉得这事不复杂,无非就是 URL.openStream() 加上 Graphics2D.drawString(),但真正动手之后才发现,下载有下载的坑,水印有水印的坑,串在一起之后还会互相影响。这篇文章就把我完整落地的这套「Java 根据图片URL下载图片并动态添加水印」方案逐步拆开,包含核心代码、参数设计、异常处理和性能优化,希望能给同样在做图片处理模块的人一些参考。
这个功能适合谁?如果你在写内容平台、电商后台、知识付费系统、企业OA,或者任何需要“拿远程图片 → 加工 → 输出”的场景,这篇都值得看一下。我会把每个环节的设计意图讲清楚,不只是给一段能跑的代码,还会解释为什么这么写、哪些地方容易翻车。文章里的代码基于 Java 8 及以上版本,如果你用的是 Spring Boot 3 或者纯 JDK 环境,一样可以直接迁移。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与依赖准备
2.1 JDK版本与图像处理能力
做图片处理,JDK 自带的能力已经足够我们这类的需求。核心就是 java.awt.image.BufferedImage 和 java.awt.Graphics2D,它们从 JDK 1.2 就存在了,稳定性很高,而且不需要额外引入重量级图像处理框架。如果你只是做“读图 → 画字/画图 → 输出”这条链路,完全没必要上 OpenCV 或者第三方图像库。
但要注意一个版本差异:JDK 9 之后模块化系统会把 java.desktop 这个模块拆出来,如果你的项目用的是精简 JRE 或者自定义模块,需要记得加上这个模块。用 Maven 打包普通 Spring Boot 项目时一般不会遇到问题,但如果你搞的是 jlink 定制运行时,少了 java.desktop 模块会导致 Graphics2D 直接不可用,报错类型通常是 NoClassDefFoundError。
2.2 网络层选型:内置客户端还是第三方下载工具
下载图片这一步,实现方式有很多种。JDK 内置的 HttpURLConnection 最简单,零依赖,但控制起来比较原始。如果你项目中已经用了 HttpClient(JDK 11+)或者 OkHttp,我建议直接用现成的 HTTP 客户端,不要自己再造轮子。
我在这套方案里用的是 JDK 11 的 java.net.http.HttpClient,理由很简单:支持连接超时、读取超时、重定向策略配置,而且响应体可以直接拿 InputStream,方便边下载边转图片。如果你的项目还是 Java 8,那用 HttpURLConnection 也没问题,只是超时和连接池管理需要自己多写几行代码。核心差异点我放在第 3 节单独讲。
2.3 水印处理的核心API
水印的本质就是用 Graphics2D 在原图画布上继续绘制。这里有几个关键类:
BufferedImage:内存中的图片对象,可以理解为“画布+像素数据”。Graphics2D:画笔,负责绘制文字、图像、线条,可以设置颜色、字体、透明度、旋转角度。ImageIO:负责读取和写出图片,支持 JPEG、PNG、GIF、BMP、WBMP,JDK 自带不支持 WebP。Font:文字水印的字体定义,包括字体名称、风格、字号。AlphaComposite:透明度合成器,决定水印和原图的叠加方式。
如果你只需要 Java 标准库,Maven 依赖都不用加。但要注意 WebP 图片,如果有 WebP 格式的图片 URL,需要额外引入 webp-imageio 之类的库,否则 ImageIO.read() 直接返回 null,这坑我在第 6 节会再强调。
3. 图片下载:不是简单读个URL那么简单
3.1 基础下载与资源关闭
很多新手写下载图片的代码是这样的:
java复制BufferedImage image = ImageIO.read(new URL("http://example.com/image.jpg"));
这段代码看着省事,实际上隐患很多:没有超时控制、没有连接复用、没有Content-Type校验、遇到网络波动会一直卡着,而且资源关闭不彻底。正确的做法是先把图片字节流完整拉下来,再交给 ImageIO 处理。
我用的是 JDK 11 的 HttpClient,完整的基础下载逻辑这样写:
java复制HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(imageUrl))
.timeout(Duration.ofSeconds(15))
.header("User-Agent", "Mozilla/5.0 (compatible; ImageProcess/1.0)")
.GET()
.build();
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new RuntimeException("图片下载失败,HTTP状态码: " + response.statusCode());
}
try (InputStream inputStream = response.body()) {
BufferedImage image = ImageIO.read(inputStream);
// 后续处理
}
try-with-resources 一定要用,确保 InputStream 能关闭。如果不关闭,连接池里的连接可能一直被占用,高并发场景下没多久就会把连接池打满,这是一个很容易被忽略的问题。
3.2 超时、重定向和防盗链处理
三个参数必须认真设计:
- 连接超时:
connectTimeout,我设置为 5 秒。这个值表示建立 TCP 连接的最大等待时间,设置太短容易在弱网环境误杀,设置太长会拖慢整个请求链路。 - 读取超时:
timeout,我设置为 15 秒。这是从发起请求到响应数据完全到达的整体超时,大图在弱网下可能需要更多时间,15 秒是一个相对稳妥的值。 - 重定向策略:
Redirect.NORMAL,它会自动跟随 HTTP 重定向。有些图片 URL 会先 302 跳转到 CDN 地址,如果不跟随重定向,你拿到的不是图片本身而是跳转提示。
防盗链是另一个高频问题。不少图片服务器会校验 Referer 头,如果识别到请求不是来自白名单站点,会返回 403 或者一张“替代图”。处理方式是在请求头里加上来源:
java复制.header("Referer", "https://your-platform-domain.com/")
这个值要根据你的实际业务场景来定。如果图片服务器是自己的,或者明确不校验 Referer,那可以不加;但加上成本很低,而且能避免相当一部分来源限制问题。
3.3 图片有效性检查
ImageIO.read() 在图片格式不支持或者文件损坏时不会抛异常,而是返回 null。所以下载完图片之后,立刻做一个空值判断:
java复制if (image == null) {
throw new RuntimeException("无法识别图片格式或图片数据已损坏: " + imageUrl);
}
更严格的做法是拿到响应头里的 Content-Type,先做一次白名单校验:
java复制String contentType = response.headers()
.firstValue("Content-Type")
.orElse("");
if (!(contentType.contains("image/jpeg")
|| contentType.contains("image/png")
|| contentType.contains("image/gif")
|| contentType.contains("image/bmp"))) {
throw new RuntimeException("非支持的图片类型: " + contentType);
}
但不要完全信任 Content-Type,有些服务器返回的是 application/octet-stream 甚至空值,此时还得靠 ImageIO.read() 的实际解析结果做兜底。双重校验最稳。
3.4 下载模块完整代码
把上面的逻辑合并成一个方法,返回 BufferedImage:
java复制public BufferedImage downloadImage(String imageUrl) throws IOException, InterruptedException {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(imageUrl))
.timeout(Duration.ofSeconds(15))
.header("User-Agent", "Mozilla/5.0 (compatible; ImageProcess/1.0)")
.header("Referer", "https://your-platform-domain.com/")
.GET()
.build();
HttpResponse<InputStream> response = client.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
throw new RuntimeException("图片下载失败,HTTP状态码: " + response.statusCode());
}
try (InputStream inputStream = response.body()) {
BufferedImage image = ImageIO.read(inputStream);
if (image == null) {
throw new RuntimeException("无法识别图片格式或图片数据已损坏: " + imageUrl);
}
return image;
}
}
如果你还在用 Java 8,把 HttpClient 换成 HttpURLConnection 也很快:
java复制public BufferedImage downloadImageJava8(String imageUrl) throws IOException {
HttpURLConnection connection = (HttpURLConnection) new URL(imageUrl).openConnection();
connection.setConnectTimeout(5000);
connection.setReadTimeout(15000);
connection.setInstanceFollowRedirects(true);
connection.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; ImageProcess/1.0)");
connection.setRequestProperty("Referer", "https://your-platform-domain.com/");
int status = connection.getResponseCode();
if (status != 200) {
connection.disconnect();
throw new RuntimeException("图片下载失败,HTTP状态码: " + status);
}
try (InputStream inputStream = connection.getInputStream()) {
BufferedImage image = ImageIO.read(inputStream);
if (image == null) {
throw new RuntimeException("无法识别图片格式或图片数据已损坏: " + imageUrl);
}
return image;
} finally {
connection.disconnect();
}
}
注意 Java 8 版本里 HttpURLConnection 的 disconnect() 不只是断开连接,还会释放底层 socket,如果解析图片失败,也要确保调用它,否则连接会泄漏。
4. 动态水印绘制的核心细节
4.1 文字水印的布局计算
水印不是随手 drawString 一把梭就行,布局要考虑图片尺寸、字号、边距和文字长度。如果图片很小但水印文字很大,就会喧宾夺主;如果水印文字过长超出图片边界,就会出现文字被截断的尴尬情况。
我的做法是先计算图片宽高,再根据宽高设置字号和位置:
java复制int imageWidth = image.getWidth();
int imageHeight = image.getHeight();
// 字号动态适配:宽度的 1/20 到 1/30 之间,取一个折中值
int fontSize = Math.max(12, imageWidth / 25);
Font font = new Font("Microsoft YaHei", Font.BOLD, fontSize);
同时计算文字在图片上的实际占位宽度,保证不超过图片宽度的 90%:
java复制FontMetrics metrics = graphics.getFontMetrics(font);
int textWidth = metrics.stringWidth(watermarkText);
int textHeight = metrics.getHeight();
// 如果文字太宽,缩小字号直到合适
while (textWidth > imageWidth * 0.9 && fontSize > 8) {
fontSize--;
font = new Font("Microsoft YaHei", Font.BOLD, fontSize);
metrics = graphics.getFontMetrics(font);
textWidth = metrics.stringWidth(watermarkText);
}
这里有个容易忽略的细节:FontMetrics 是基于 Graphics2D 的当前字体计算的,必须在设置字体之后重新获取。如果你在设置字体之前就调用了 getFontMetrics(),拿到的还是老字体的尺寸,缩字号逻辑就完全失效了。
位置方面,最常用的是右下角,右下角符合阅读习惯,而且不会遮挡主体内容太多。坐标计算:
java复制int padding = fontSize; // 边距动态等于字号大小
int x = imageWidth - textWidth - padding;
int y = imageHeight - padding;
要注意 drawString() 的 y 坐标是文字基线(baseline),不是文字底部。也就是说 y 坐标传 imageHeight - padding,实际文字底部会比 padding 略高一点,这个高度差是字体的 descent,一般不用额外处理,看着也协调。
4.2 透明度、旋转与抗锯齿
三个核心属性决定水印观感:
- 透明度:建议 30%~50%。太低起不到标识作用,太高会严重干扰原图。我用 35% 作为默认值。
- 旋转角度:负 30 度左右的倾斜水印比水平水印更难被裁切掉,而且视觉观感更专业。但旋转会导致文字边界和原始坐标对不上,需要先平移坐标轴,再旋转画笔,最后绘制完恢复。
- 抗锯齿:不开启抗锯齿时,文字边缘会有明显锯齿,尤其放大看非常廉价。开启之后文字边缘平滑,观感提升非常明显。
代码组合起来:
java复制// 开启抗锯齿
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
// 设置透明度
AlphaComposite alphaComposite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.35f);
graphics.setComposite(alphaComposite);
// 设置颜色和字体
graphics.setColor(Color.WHITE);
graphics.setFont(font);
// 保存当前画笔状态
Graphics2D graphicsCopy = (Graphics2D) graphics.create();
// 平移到绘制位置
graphicsCopy.translate(x + textWidth / 2.0, y - textHeight / 2.0);
// 旋转角度(负30度 = -PI/6)
graphicsCopy.rotate(-Math.PI / 6);
// 重新计算绘制位置,让旋转中心在文字中央
graphicsCopy.drawString(watermarkText, -textWidth / 2.0, textHeight / 2.0);
// 恢复状态
graphicsCopy.dispose();
create() 和 dispose() 是一对,前者创建一个画笔的副本,后者释放副本资源。这样做的目的是不让旋转状态污染主画笔,否则后续如果有别的绘制操作(比如画 Logo 或者画边框),坐标会全部错乱。
4.3 图片水印(Logo)的处理
除了文字水印,很多平台还会在角落放一张 Logo 小图。图片水印比文字水印稍微复杂一些,核心在于透明通道和缩放。
如果要叠加的 Logo 本身是 PNG 格式(带透明通道),直接用 graphics.drawImage() 绘制时,透明区域会正常叠加,不需要额外处理。但如果你从 ImageIO.read() 读出来的 Logo 是 JPEG,那就没有透明通道,绘制出来是一张带矩形底色的图,很不美观。
缩放是另一个关键点。Logo 原始尺寸可能很大,不能直接平铺上去。按照图片宽度的一定比例缩放比较合理:
java复制int logoWidth = imageWidth / 6;
int logoHeight = (int) ((double) logo.getHeight() / logo.getWidth() * logoWidth);
Image scaledLogo = logo.getScaledInstance(logoWidth, logoHeight, Image.SCALE_SMOOTH);
getScaledInstance() 用 SCALE_SMOOTH 可以保证缩略图质量,但性能比普通缩放慢。如果你对性能要求高,可以改用 Graphics2D.drawImage() 配合 AffineTransform 做缩放,但代码复杂度会高一些。
4.4 输出格式与压缩参数
水印绘制完成后,需要保存成文件或者字节数组。输出格式由原图格式决定,但有一点非常关键:JPEG 不支持透明通道。
如果你在一个 JPEG 原图上绘制了半透明水印,再保存为 JPEG,水印区域可能会发暗发黑,这是因为 JPEG 没有 Alpha 通道,半透明像素会被压到黑色背景上。解决方式有两种:
- 原图是 JPEG 时,水印透明度调到 80% 以上,减少发黑效应;
- 或者输出为 PNG,保留透明通道,代价是文件体积变大。
保存 JPEG 时可以设置压缩质量:
java复制ImageWriter writer = ImageIO.getImageWritersByFormatName("jpg").next();
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(0.85f); // 0f~1f,0.85 是质量与体积比较均衡的值
压缩质量建议设在 0.8~0.9。低于 0.8,图片容易出现色块和噪点;高于 0.95,文件体积会显著增加,但肉眼几乎看不出画质提升。
5. 完整落地:一个可用的水印工具类
5.1 核心类结构
我习惯把整个功能封装成一个独立的工具类,对外暴露一个方法签名,内部细节全部隐藏。类结构如下:
java复制public class ImageWatermarkUtils {
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private ImageWatermarkUtils() {
}
public static byte[] downloadWithWatermark(
String imageUrl,
String watermarkText,
WatermarkConfig config) throws IOException, InterruptedException {
BufferedImage source = downloadImage(imageUrl);
BufferedImage watermarked = addTextWatermark(source, watermarkText, config);
return toBytes(watermarked, config.getOutputFormat(), config.getCompressionQuality());
}
}
WatermarkConfig 用一个内部类管理所有可配置参数,包括透明度、旋转角度、字号比例、边距、输出格式、压缩质量,避免方法参数过多导致调用方晕头转向。
5.2 下载与水印串联起来
完整的水印绘制方法如下:
java复制private static BufferedImage addTextWatermark(BufferedImage source, String text, WatermarkConfig config) {
int imageWidth = source.getWidth();
int imageHeight = source.getHeight();
int fontSize = Math.max(12, imageWidth / config.getFontDivisor());
Font font = new Font(config.getFontName(), Font.BOLD, fontSize);
BufferedImage target = new BufferedImage(imageWidth, imageHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = target.createGraphics();
try {
// 把原图绘制到目标画布
graphics.drawImage(source, 0, 0, null);
// 开启抗锯齿
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
// 设置透明度
graphics.setComposite(AlphaComposite.getInstance(
AlphaComposite.SRC_OVER, config.getAlpha()));
// 计算文字尺寸
FontMetrics metrics = graphics.getFontMetrics(font);
int textWidth;
do {
font = new Font(config.getFontName(), Font.BOLD, fontSize);
graphics.setFont(font);
metrics = graphics.getFontMetrics(font);
textWidth = metrics.stringWidth(text);
fontSize--;
} while (textWidth > imageWidth * 0.9 && fontSize > 8);
// 计算位置(右下角 + 内边距)
int textHeight = metrics.getHeight();
int padding = config.getPadding() > 0 ? config.getPadding() : fontSize;
int x = imageWidth - textWidth - padding;
int y = imageHeight - padding;
// 设置颜色
graphics.setColor(config.getColor());
graphics.setFont(font);
// 旋转配置
Graphics2D copy = (Graphics2D) graphics.create();
double angle = Math.toRadians(config.getRotateAngle());
copy.translate(x + textWidth / 2.0, y - textHeight / 2.0);
copy.rotate(angle);
copy.drawString(text, -textWidth / 2.0, textHeight / 2.0);
copy.dispose();
// 叠加 Logo 水印
if (config.getLogo() != null) {
addImageWatermark(graphics, config.getLogo(), config);
}
} finally {
graphics.dispose();
}
return target;
}
这里有一个特意设计的点:最终输出的 target 是 TYPE_INT_RGB,即使原图是 PNG 带透明通道,输出时透明区域会被填充为黑色底。如果原图需要保留透明背景,创建画布时要用 TYPE_INT_ARGB。
5.3 对外接口与调用示例
封装完,业务侧调用非常清爽:
java复制WatermarkConfig config = WatermarkConfig.builder()
.alpha(0.35f)
.rotateAngle(-30)
.fontDivisor(25)
.outputFormat("jpg")
.compressionQuality(0.85f)
.color(Color.WHITE)
.build();
byte[] result = ImageWatermarkUtils.downloadWithWatermark(
"http://example.com/upload/2024/photo.jpg",
"内部资料 · 请勿外传",
config);
调用方不需要关心图片是怎么下载的、水印怎么绘制的,只需要关注业务参数。这个设计在团队协作时很有价值,其他同事拿到工具类就能直接用。
5.4 线程池与性能优化
如果你的业务是批处理图片(比如每天定时处理几千张),单线程串行下载+处理会非常慢,需要引入并发。
这里要注意,BufferedImage 的操作本身是纯 CPU 计算,适合多线程并行,但 HttpClient 的连接池需要合理配置:
java复制ExecutorService executor = Executors.newFixedThreadPool(8);
List<CompletableFuture<byte[]>> futures = urlList.stream()
.map(url -> CompletableFuture.supplyAsync(() -> {
try {
return ImageWatermarkUtils.downloadWithWatermark(url, "水印", config);
} catch (Exception e) {
// 记录失败日志,防止单个失败影响整个批任务
return null;
}
}, executor))
.toList();
线程数建议设置为 CPU 核心数的 2 倍左右。如果线程数过多,大量线程阻塞在网络 IO 上,反而会增加上下文切换开销;而且每个连接都会占用 socket 文件描述符,线程太多容易把文件描述符耗尽。
6. 真实项目里踩过的坑
6.1 ImageIO读图返回null
这是我遇到频率最高的问题。ImageIO.read() 返回 null 一般有三种原因:
- 图片格式不支持:WebP、TIF、SVG,JDK 原生不支持。WebP 需要引入
webp-imageio,SVG 需要引入batik库。 - 下载数据不完整:网络不稳定导致只下载了半个图片文件。解决方式是先下载到
byte[],校验长度大于 2KB,再转ByteArrayInputStream。 - 输入流被提前关闭:如果用
HttpURLConnection,在getInputStream()后面加disconnect()会导致流关闭,必须先读完数据再断开连接。
6.2 中文乱码与字体方块
文字水印在 Linux 服务器上经常出现中文变方块的问题,原因是服务器缺少中文字体。Windows 上有“微软雅黑”,Mac 上有“苹方”,Linux 默认可能什么都没有。
解决方案有两种:一是安装字体到服务器系统目录,二是把字体文件打包到项目资源目录,通过 Font.createFont() 动态加载。
推荐第二种方案,不依赖服务器环境:
java复制try (InputStream fontStream = YourClass.class.getResourceAsStream("/fonts/AlibabaPuHuiTi-Regular.ttf")) {
Font dynamicFont = Font.createFont(Font.TRUETYPE_FONT, fontStream);
Font realFont = dynamicFont.deriveFont(Font.BOLD, fontSize);
}
注意 Font.createFont() 加载的字体默认是 Font.PLAIN,需要通过 deriveFont() 设置粗体和字号。另外要确认字体文件有相应授权,商用字体法律风险同样不可忽视。
6.3 JPEG保存后水印区域发黑
前文提过,JPEG 没有 Alpha 通道。半透明文字绘制到 JPEG 原图上再保存成 JPEG,水印区域会出现暗色底。尤其是白色文字配合 35% 透明度,发黑效果非常明显。
我实测下来,透明度调到 0.6 以上会好很多,但最稳妥的方案还是输出 PNG。如果你的业务必须输出 JPEG,建议水印颜色用深灰色而不是纯白色,发黑问题会没那么刺眼。
6.4 大图内存溢出与OOM
一张 5000×4000 的图片,在内存里占用的空间远超文件体积。按照每个像素 4 字节计算,5000×4000×4 = 80MB,加上画布复制和水印绘制,一次操作峰值可能到 200MB 以上。如果用 8 个线程并发处理,内存直接爆掉。
处理思路有三个:
- 控制并发数:图片处理线程数不要超过 2~4,下载线程可以多,但处理线程要控。
- 限制图片尺寸:如果不需要保留原图大尺寸,可以先做缩放再加水印。
- 预估内存:在
JVM启动参数里适当调大堆内存,同时监控实际使用量。
6.5 并发下载连接未释放
HttpClient 复用连接池之后,如果业务侧忘记关闭响应体,连接会一直挂在池里。等池满了,新请求就开始排队,整个批处理任务越来越慢,直到超时。
排查方式很简单:处理完的 InputStream 必须关闭。用 try-with-resources 是最不容易出错的方式。同时可以开启 HttpClient 的连接存活检查:
java复制HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.version(HttpClient.Version.HTTP_2)
.executor(Executors.newFixedThreadPool(4))
.build();
6.6 问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 下载图片返回 403 | 服务器校验 Referer | 请求头补充业务来源地址 |
| 图片下载超时 | 图片地址不可达或 CDN 慢 | 调大 timeout,增加重试机制 |
| ImageIO 返回 null | WebP/SVG 格式不支持 | 引入对应格式支持库或转换 |
| 中文水印变方块 | Linux 无中文字体 | 动态加载项目内字体文件 |
| 水印区域发黑 | JPEG 无透明通道 | 提高透明度或输出 PNG |
| 大图 OOM | 同时处理多张大图 | 控制并发数、做尺寸限制 |
| 连接池耗尽 | 未关闭响应流 | try-with-resources 强制关闭 |
最后说一个我自己切身经历过的教训:水印功能上线后,有一段时间日志里频繁出现“图片下载失败”,排查了很久才发现不是代码问题,而是外链图片服务器加了频率限制。单个 IP 短时间请求次数超过阈值就直接拒绝。后来我在下载层加了简单的限速和重试逻辑,失败两次就放弃,问题才算彻底解决。
这个项目后来还做了横向扩展,支持水印模板配置、多图片拼接下载、二维码叠加等。回看整个过程,最核心的经验就一条:图片处理这类功能,80% 的坑都不在图片处理本身,而在网络下载、格式兼容、资源释放这些“外围”环节。把这些边角料处理干净,核心逻辑反而很简单。
