先来看开启多个定时任务:
Spring Boot 里用@Scheduled做定时任务,只要你加了@EnableScheduling开启定时功能,默认只会创建一个只有 1 个核心线程的调度线程池。
- 所有你写的
@Scheduled方法、所有动态注册的用户定时任务,都会被提交到这个只有 1 个线程的池子里; - 这个唯一的线程,一次只能执行一个任务,剩下的所有任务(哪怕到了触发时间),都会被丢到等待队列里排队,必须等前一个任务完全执行完,才会取下一个任务执行。
这就是你说的 "无形的锁":它不是真的加了锁,而是单线程天然的串行执行特性,强制所有任务必须排队,同一时间永远只能跑一个任务,效果和加了一把全局的串行锁完全一样。
意思就是假如说有A、B、C三个用户,A用户在用B用户得等A用户定时任务到期也就是定时任务执行完可是我们知道定时任务是有可能很长的,意思就是这段时间只能有A用户一个人使用这个定时任务,其它用户不管有多少个人成千上百也好都要等着它执行完。
它不像其它接口一样可以多线程同时进行,假如说一个接口同时被两百个请求访问因为tomact最多分配这么多,那么此时就有200个线程同时执行那个接口的方法。
而定时任务就这么奇特只允许一个线程它就分一个线程,只允许一个用户执行定时任务。
- 你给用户 A 做了一个定时任务,每天凌晨 1 点触发,要给 A 的 10 个应用更新截图,完整执行完需要 5 分钟;
- 用户 B 也设置了一个每天凌晨 1 点触发的定时任务,只需要 10 秒就能跑完;
- 到了凌晨 1 点,Spring 的单线程先拿到了 A 的任务,开始执行;
- 同一时间,B 的任务也到了触发时间,但因为唯一的线程被 A 的任务占了,B 的任务只能进队列等着;
- 必须等 A 的任务整整 5 分钟跑完、线程释放了,B 的任务才会开始执行 ------ 明明设置的 1 点整执行,结果 1 点 05 分才跑,定时完全不准。
更致命的极端情况:如果 A 的任务里,浏览器加载页面卡住了、代码死循环了,这个唯一的线程就会被永远占住。后续所有用户的定时任务,哪怕是 1 点半、2 点触发的,都会永远卡在队列里,永远不会执行,整个定时任务体系直接瘫痪。
补充一个你可能会遇到的坑:同一个任务的重复触发也会排队
哪怕是同一个定时任务,也会出现这个问题。比如你给某个任务设置了「每分钟执行一次」,但某次执行因为页面加载慢,花了 90 秒才跑完。那原本第 2 分钟、第 3 分钟该触发的任务,都会在队列里排队,等第一次的任务跑完,才会依次执行,完全不会并发跑,也不会跳过,最终导致任务执行时间完全错乱。
解决方法:
只需要加一个配置类,给定时任务设置一个多线程的调度池,就能让不同用户的定时任务,到点就用独立的线程并发执行,互不阻塞,完全符合你的业务需求:
java
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import java.util.concurrent.Executors;
@Configuration
@EnableScheduling
public class ScheduledConfig implements SchedulingConfigurer {
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
// 核心:设置一个20线程的调度池,最多支持20个定时任务同时触发执行
// 这个数字根据你的用户量、服务器配置调整,比如预计同时触发的任务最多20个,就设20
taskRegistrar.setScheduler(Executors.newScheduledThreadPool(20));
}
}
- 用户 A 和 B 的任务同时 1 点触发,会分配两个独立的线程,同时执行,互不等待、互不阻塞;
- 哪怕 A 的任务跑 5 分钟,B 的任务到点就立刻执行,10 秒就跑完了,完全不受影响;
- 一个任务报错、卡住,只会影响它自己,不会拖累其他用户的定时任务。
这个设置成20就是同时允许那个方法在同一时间最多有20个线程执行执行定时任务而不是那孤零零的一个。
我们解决并发可以开启一个线程池。
比如说我们想要开启多个定时任务,那么如果是没有线程
下面看对上一期WebDriver的补充:
因为我们之前虽然说线程之间是隔离的对吧,但是他们操纵的都是同一个WebDriver就像那种操作同一个数据库一样,当高并发的时候肯定会出现问题,下面看解决方法。
方案一:WebDriver 连接池(对象池模式)【生产环境首选,企业级标准方案】
这是和数据库连接池(Druid、HikariCP)完全一致的设计思想,也是你项目上线的最优选择。
1. 核心原理
通俗理解:你提前创建好一批固定数量的 Chrome 浏览器(WebDriver 实例),放到一个线程安全的公共池子里。
- 有截图任务要执行,就从池子里借一个空闲的浏览器实例 ,这个实例在你归还之前,只能被当前这一个线程独占使用,其他线程绝对拿不到、改不了;
- 截图任务完成后,清空浏览器的状态(跳空白页、重置配置),把实例还回池子里,下一个任务可以接着复用;
- 池子里的实例全被借光了,新的任务就排队等待,直到有实例归还,全程严格控制 Chrome 的最大数量,服务器资源完全可控。
它解决问题的本质:让每个并发任务,都使用独立的、独占的 WebDriver 实例,彻底打破多线程共享资源的问题。
2. 生产级完整可落地代码(适配 Spring Boot,可直接复制)
我会把生产环境必须的「生命周期管理、有效性校验、异常销毁、并发控制、空闲过期」都做全,你直接用就行。
第一步:先在 application.yml 加可配置项
java
# 自定义WebDriver连接池配置
webdriver:
pool:
max-size: 10 # 最大实例数,和你的截图业务线程池数量对齐(比如业务池10个线程,这里就设10)
idle-timeout-seconds: 300 # 实例空闲多久自动销毁,避免闲置占用资源
第二步:实现连接池核心类
java
import io.github.bonigarcia.wdm.WebDriverManager;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.Dimension;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.time.Duration;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/**
* 生产级WebDriver连接池,解决并发串图、资源不可控问题
* 适配Spring生命周期,项目启动初始化,关闭自动销毁所有实例
*/
@Slf4j
@Component
public class WebDriverPool {
// 从配置文件读取参数
@Value("${webdriver.pool.max-size:10}")
private int maxPoolSize;
@Value("${webdriver.pool.idle-timeout-seconds:300}")
private long idleTimeoutSeconds;
// 核心1:线程安全的空闲队列,存可用的WebDriver实例
private final Queue<PooledDriver> idleDriverQueue = new ConcurrentLinkedQueue<>();
// 核心2:信号量,严格控制同时能借的实例总数,绝不超过maxPoolSize
private Semaphore semaphore;
// 包装类:给WebDriver加上时间标记,用于空闲过期销毁
private static class PooledDriver {
final WebDriver driver;
final long createTime;
long lastReturnTime; // 上次归还到池子的时间
public PooledDriver(WebDriver driver) {
this.driver = driver;
this.createTime = System.currentTimeMillis();
this.lastReturnTime = System.currentTimeMillis();
}
}
// 项目启动时,初始化连接池
@PostConstruct
public void initPool() {
semaphore = new Semaphore(maxPoolSize);
log.info("WebDriver连接池初始化完成,最大实例数:{}", maxPoolSize);
}
// ====================== 核心对外方法 ======================
/**
* 借取WebDriver实例,业务代码调用这个方法拿浏览器
* @return 可用的WebDriver实例
*/
public WebDriver borrowDriver() {
try {
// 1. 先拿许可,最多同时有maxPoolSize个线程能拿到,超出就排队等待
boolean getPermit = semaphore.tryAcquire(60, TimeUnit.SECONDS);
if (!getPermit) {
throw new RuntimeException("截图服务繁忙,排队超时,请稍后重试");
}
// 2. 先从空闲队列里找可用的、没过期的实例
PooledDriver pooledDriver;
while ((pooledDriver = idleDriverQueue.poll()) != null) {
// 校验:空闲太久的实例,直接销毁,避免内存泄漏或浏览器崩了
if (System.currentTimeMillis() - pooledDriver.lastReturnTime > idleTimeoutSeconds * 1000) {
destroyDriver(pooledDriver.driver);
continue;
}
// 校验:浏览器实例是不是还活着,有没有崩掉
if (isDriverAlive(pooledDriver.driver)) {
log.info("从连接池借到空闲WebDriver,剩余空闲数:{}", idleDriverQueue.size());
return pooledDriver.driver;
} else {
// 实例已经失效,销毁
destroyDriver(pooledDriver.driver);
}
}
// 3. 没有空闲实例,创建新的(受信号量控制,最多创建maxPoolSize个)
log.info("无空闲WebDriver,创建新实例,当前已创建实例数:{}", (maxPoolSize - semaphore.availablePermits()));
return createNewDriver();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取截图服务被中断,请重试");
}
}
/**
* 归还WebDriver实例,截图完成后必须调用!!!
* @param driver 借出去的实例
*/
public void returnDriver(WebDriver driver) {
if (driver == null) {
semaphore.release(); // 一定要归还许可,否则池子会被占死
return;
}
try {
// 归还前必须清空状态!!避免上一个任务的页面、缓存污染下一个任务
driver.get("about:blank"); // 跳空白页,释放上一个页面的资源
driver.manage().window().setSize(new Dimension(1600, 900)); // 重置窗口大小
} catch (Exception e) {
log.warn("归还前清空WebDriver状态失败,销毁该实例", e);
destroyDriver(driver);
semaphore.release();
return;
}
// 把实例放回空闲队列
PooledDriver pooledDriver = new PooledDriver(driver);
idleDriverQueue.offer(pooledDriver);
log.info("WebDriver实例归还完成,当前空闲数:{}", idleDriverQueue.size());
semaphore.release(); // 归还许可
}
/**
* 销毁失效的实例,截图过程中报错/浏览器崩了,调用这个方法,不要归还到池子
* @param driver 失效的实例
*/
public void invalidateDriver(WebDriver driver) {
if (driver != null) {
destroyDriver(driver);
}
semaphore.release(); // 必须释放许可
log.info("已销毁失效的WebDriver实例");
}
// ====================== 内部工具方法 ======================
// 创建新的WebDriver实例,统一配置,避免重复代码
private WebDriver createNewDriver() {
// 自动管理ChromeDriver版本,不用手动下载对应版本的驱动
WebDriverManager.chromedriver().setup();
ChromeOptions options = new ChromeOptions();
// 无头模式核心配置(必须加,服务器没有图形界面)
options.addArguments("--headless=new"); // 新版无头模式,比老的--headless更稳定
options.addArguments("--disable-gpu"); // 禁用GPU,服务器环境必加
options.addArguments("--no-sandbox"); // 禁用沙箱,Linux/Docker环境必加
options.addArguments("--disable-dev-shm-usage"); // 解决Linux共享内存不足导致的崩溃
options.addArguments("--window-size=1600,900"); // 固定窗口大小,截图尺寸统一
// 性能&安全优化配置
options.addArguments("--disable-extensions"); // 禁用扩展,减少资源占用
options.addArguments("--disable-images"); // 可选:禁用图片加载,加快页面渲染
options.addArguments("--disable-javascript"); // 可选:如果你的页面不需要JS渲染,开启更安全
options.addArguments("--user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
// 创建驱动实例
ChromeDriver driver = new ChromeDriver(options);
// 设置全局超时时间,避免页面卡住无限等待
driver.manage().timeouts().pageLoadTimeout(Duration.ofSeconds(30)); // 页面加载超时30秒
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(10)); // 元素查找超时10秒
return driver;
}
// 彻底销毁浏览器实例,必须用quit(),不能用close()
private void destroyDriver(WebDriver driver) {
try {
driver.quit(); // quit()会关闭整个Chrome进程,close()只会关标签页,进程会残留
} catch (Exception e) {
log.error("销毁WebDriver实例失败", e);
}
}
// 校验浏览器实例是否还存活、可用
private boolean isDriverAlive(WebDriver driver) {
try {
driver.getCurrentUrl(); // 能正常执行命令,说明浏览器没崩
return true;
} catch (Exception e) {
return false;
}
}
// 项目关闭时,销毁池子里所有的浏览器实例,释放资源,避免进程残留
@PreDestroy
public void destroyPool() {
log.info("项目关闭,开始销毁WebDriver连接池所有实例");
PooledDriver pooledDriver;
while ((pooledDriver = idleDriverQueue.poll()) != null) {
destroyDriver(pooledDriver.driver);
}
log.info("WebDriver连接池销毁完成");
}
}
第三步:在你的截图业务里使用(规范写法,绝对安全)
java
import cn.hutool.core.io.FileUtil;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.OutputType;
import org.openqa.selenium.TakesScreenshot;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.File;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
public class AppScreenshotService {
@Autowired
private WebDriverPool webDriverPool;
/**
* 生成应用封面截图
* @param appDeployUrl 应用的部署地址
* @param appId 应用ID,用于区分存储路径
* @return 截图保存路径
*/
@Async("screenshotExecutor") // 用我们之前创建的截图专属业务线程池
public CompletableFuture<String> generateAppCover(String appDeployUrl, Long appId) {
// 必须在try外面声明,finally里才能拿到
WebDriver driver = null;
try {
log.info("开始生成应用{}封面截图,地址:{}", appId, appDeployUrl);
// 1. 从连接池借一个专属的WebDriver实例
driver = webDriverPool.borrowDriver();
// 2. 执行截图核心逻辑
driver.get(appDeployUrl); // 访问应用页面
waitForPageLoadComplete(driver); // 等待页面完全渲染
// 截图并保存
byte[] screenshotBytes = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
String savePath = System.getProperty("user.dir") + "/app-cover/" + appId + "/" + System.currentTimeMillis() + "_cover.jpg";
FileUtil.writeBytes(screenshotBytes, new File(savePath));
log.info("应用{}封面截图生成完成,保存路径:{}", appId, savePath);
return CompletableFuture.completedFuture(savePath);
} catch (Exception e) {
log.error("应用{}封面截图生成失败", appId, e);
// 【关键】异常的实例不要归还,直接销毁,避免把有问题的实例放回池子
if (driver != null) {
webDriverPool.invalidateDriver(driver);
driver = null; // 标记为null,避免finally里重复归还
}
return CompletableFuture.completedFuture(null);
} finally {
// 【重中之重!!!】不管成功还是失败,只要实例没被销毁,必须归还
if (driver != null) {
webDriverPool.returnDriver(driver);
}
}
}
// 等待页面完全加载,避免截到空白页
private void waitForPageLoadComplete(WebDriver driver) {
try {
// 等待页面加载完成(document.readyState == complete)
new WebDriverWait(driver, Duration.ofSeconds(15))
.until(webDriver -> ((org.openqa.selenium.JavascriptExecutor) webDriver)
.executeScript("return document.readyState").equals("complete"));
Thread.sleep(500); // 额外等待500ms,确保动态内容渲染完成
} catch (Exception e) {
log.warn("等待页面加载完成异常,继续截图", e);
}
}
}
3. 完整执行流程(结合你的业务场景)
以用户在前端点击「生成封面」按钮为例,整个链路完全透明,你能清楚知道为什么不会串图:
- 用户的请求进入 Spring Boot,Tomcat 的请求线程接收请求,做参数校验、查询应用信息;
- 调用截图 Service 的方法,因为加了
@Async,会把截图任务提交给我们设置的10 线程专属业务池; - 业务池分配一个空闲线程,执行截图方法,第一步就调用
webDriverPool.borrowDriver(); - 连接池给这个线程分配一个独立的、独占的 WebDriver 实例,这个实例从空闲队列里移除,其他线程绝对拿不到;
- 线程用这个专属的浏览器实例,访问当前应用的地址、等待渲染、截图、保存文件,全程只操作自己的浏览器,和其他任务完全隔离;
- 任务完成 / 报错,在
finally里归还 / 销毁实例,释放连接池的资源,线程回到业务池,等待下一个任务。
4. 核心优缺点
✅ 优点(生产环境核心优势):
- 资源完全可控:最大实例数固定,哪怕同时来 1000 个请求,最多也只会开 10 个 Chrome 进程,服务器 CPU、内存占用完全稳定,不会被打崩;
- 性能优秀:浏览器实例一次创建,多次复用,避免了频繁启动 / 销毁 Chrome 的高额开销,响应速度更快;
- 隔离性绝对安全:一个实例同一时间只给一个任务用,彻底杜绝多线程抢同一个浏览器导致的串图、截错图问题;
- 容错性强:有实例有效性校验,坏的、过期的实例会被自动销毁,不会污染池子,不会影响后续任务;
- 适配所有场景:不管是用户实时截图请求,还是定时任务批量截图,都能完美适配,资源全局统一管控。
❌ 缺点:
- 实现复杂度相对高,需要处理借还逻辑、异常处理、生命周期管理、有效性校验等细节;
- 对代码规范要求高,必须严格遵守「借了必须还、异常必须销毁」的规则,否则会导致池子被占死。
5. 生产环境必避的 5 个致命坑
- 必须在 finally 里归还 / 销毁实例:不管截图成功还是失败,哪怕代码抛了异常,一定要走归还 / 销毁逻辑,否则信号量的许可不会释放,池子很快就会被占死,所有请求都排队超时。
- 归还前必须清空实例状态 :一定要跳转到
about:blank,否则上一个任务的页面内容、Cookie、内存资源会残留,轻则导致下一个任务截到残留页面,重则导致浏览器内存泄漏崩溃。 - 异常的实例绝对不能归还 :如果截图过程中浏览器崩了、报错了,一定要调用
invalidateDriver销毁,不要放回池子,否则下一个任务拿到一个坏的实例,直接报错。 - 最大实例数必须和业务线程池对齐:比如你的截图业务池设了 10 个线程,连接池的 maxSize 就设 10-12,设多了也是闲置浪费,设少了会导致线程空闲但拿不到浏览器实例,浪费资源。
- 销毁实例必须用 quit (),不能用 close () :
close()只会关闭当前标签页,Chrome 的后台进程会一直残留,越积越多,最终把服务器内存占满,必须用quit()彻底关闭进程。