版本
基于4.3.9版本
架构
PowerJob由调度中心(powerjob-server)和执行器(powerjob-worker)构成,server负责任务的获取与调度,worker负责任务的执行和具体执行逻辑,调度中心是一个基于 SpringBoot 的 Web 应用,而worker是一个普通Jar包,我们作为依赖导入项目进而定义任务逻辑,worker和server之间维护着心跳并且均支持集群部署
总流程
Worker启动
我们先从Worker的启动入手,看看worker如何通过配置的server地址与server进行通讯
// tech.powerjob.worker.PowerJobWorker
public void init() throws Exception {
// 获取配置文件的信息,一些配置文件的信息和运行时的动态信息放在WorkerRunTime对象里面维护和传递
try {
// 在发第一个请求之前,完成真正 IP 的解析
int localBindPort = config.getPort();
String localBindIp = WorkerNetUtils.parseLocalBindIp(localBindPort, config.getServerAddress());
// 校验 appName
WorkerAppInfo appInfo = serverDiscoveryService.assertApp();
workerRuntime.setAppInfo(appInfo);
// 初始化本地的ip地址并保存到WorkerRunTime
// 初始化线程池
final ExecutorManager executorManager = new ExecutorManager(workerRuntime.getWorkerConfig());
workerRuntime.setExecutorManager(executorManager);
// 初始化 ProcessorLoader
ProcessorLoader processorLoader = buildProcessorLoader(workerRuntime);
workerRuntime.setProcessorLoader(processorLoader);
// 初始化 actor和通讯引擎
// 连接 server
serverDiscoveryService.timingCheck(workerRuntime.getExecutorManager().getCoreExecutor());
log.info("[PowerJobWorker] PowerJobRemoteEngine initialized successfully.");
// 初始化日志系统
// 初始化存储
TaskPersistenceService taskPersistenceService = new DbTaskPersistenceService(workerRuntime.getWorkerConfig().getStoreStrategy());
taskPersistenceService.init();
workerRuntime.setTaskPersistenceService(taskPersistenceService);
log.info("[PowerJobWorker] local storage initialized successfully.");
// 初始化定时任务
workerRuntime.getExecutorManager().getCoreExecutor().scheduleAtFixedRate(new WorkerHealthReporter(workerRuntime), 0, config.getHealthReportInterval(), TimeUnit.SECONDS);
workerRuntime.getExecutorManager().getCoreExecutor().scheduleWithFixedDelay(omsLogHandler.logSubmitter, 0, 5, TimeUnit.SECONDS);
log.info("[PowerJobWorker] PowerJobWorker initialized successfully, using time: {}, congratulations!", stopwatch);
}catch (Exception e) {
log.error("[PowerJobWorker] initialize PowerJobWorker failed, using {}.", stopwatch, e);
throw e;
}
}
校验appName
这里主要是通过我们在配置文件里面配置的AppName获取一个appID,所以我们需要提前在powerjob的控制台界面新建一个app应用,并且worker设置与其相同的app。我们跟进assertApp
,会发现里面主要是通过以下的http请求,向server获取该name对应的appID,如果获取不到则报错
String resultDTOStr = CommonUtils.executeWithRetry0(() -> HttpUtils.get(realUrl));
初始化 ProcessorLoader
对于任务的执行逻辑,我们需要规定当定时任务被调度时,执行哪一个方法,这个就是由ProcessorLoader来完成,我们的每一个任务的执行,即为Processor,在控制台填写任务的processorinfo后,任务就会交由对应的Processor来执行,这些Processor是由ProcessorFactory来加载的,而ProcessorLoader就存有不同种类的ProcessorFactory,通过server传递的处理器类型,获取对应的工厂,进行processor的创建
server调度时,传递到worker层的是一个ProcessorDefinition(类名称和对应方法等),worker执行任务时会首先根据ProcessorLoader来找到这个类和方法,如果有缓存则直接获取,否则通过对应的ProcessorFactory,反射拿到这个类和方法
所以这里会进行一个ProcessorLoader以及内部的几个ProcessorFactory的初始化,如BuiltInDefaultProcessorFactory就是以全限定类名的处理器工厂,而BuildInSpringMethodProcessorFactory就是通过@PowerJobHandler来识别处理器
// 初始化 ProcessorLoader
private ProcessorLoader buildProcessorLoader(WorkerRuntime runtime) {
List<ProcessorFactory> customPF = Optional.ofNullable(runtime.getWorkerConfig().getProcessorFactoryList()).orElse(Collections.emptyList());
List<ProcessorFactory> finalPF = Lists.newArrayList(customPF);
// 后置添加2个系统 ProcessorLoader
finalPF.add(new BuiltInDefaultProcessorFactory());
finalPF.add(new JarContainerProcessorFactory(runtime));
return new PowerJobProcessorLoader(finalPF);
}
// 实际上除了以上的默认的工厂tech.powerjob.worker.PowerJobSpringWorker#setApplicationContext中初始化了与Spring相关的工厂,工厂内部build时,会经由spring的context的getBean获取对应的处理器
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
// 通过Bean获取
BuiltInSpringProcessorFactory springProcessorFactory = new BuiltInSpringProcessorFactory(applicationContext);
// 通过@PowerJobHandler获取
BuildInSpringMethodProcessorFactory springMethodProcessorFactory = new BuildInSpringMethodProcessorFactory(applicationContext);
// append BuiltInSpringProcessorFactory
List<ProcessorFactory> processorFactories = Lists.newArrayList(
Optional.ofNullable(config.getProcessorFactoryList())
.orElse(Collections.emptyList()));
processorFactories.add(springProcessorFactory);
processorFactories.add(springMethodProcessorFactory);
config.setProcessorFactoryList(processorFactories);
}
连接Server
public void timingCheck(ScheduledExecutorService timingPool) {
this.currentServerAddress = discovery();
if (StringUtils.isEmpty(this.currentServerAddress) && !config.isAllowLazyConnectServer()) {
throw new PowerJobException("can't find any available server, this worker has been quarantined.");
}
// 这里必须保证成功
timingPool.scheduleAtFixedRate(() -> {
try {
this.currentServerAddress = discovery();
} catch (Exception e) {
log.error("[PowerDiscovery] fail to discovery server!", e);
}dsd
}
, 10, 10, TimeUnit.SECONDS);
}
这里我们可以看到,先是调用了discovery来初始化currentServerAddress,后续通过定时循环调用discovery,来不断更新currentServerAddress的值,实际上就是进行维护更新一个可用的serverIP,这个serverIP可能不是我们目前所发向的server的IP(后续Server启动会详细解释),以便后续心跳汇报worker状态,我们再跟进discovery
// 这里是对于懒加载的,前面我们已经获取了appID,所以正常不会进入这个if
if (appInfo.getAppId() == null || appInfo.getAppId() < 0) {
try {
assertApp0();
} catch (Exception e) {
log.warn("[PowerDiscovery] assertAppName in discovery stage failed, msg: {}", e.getMessage());
return null;
}
}
// 获取配置文件里面的所有serverIP
if (ip2Address.isEmpty()) {
config.getServerAddress().forEach(x -> ip2Address.put(x.split(":")[0], x));
}
String result = null;
// 先对当前机器发起请求,这个是在定时循环的时候,优先向目前的server进行确认
// 这里的result实际上是currentServerAddress,即这个worker连接的severIP
// acquire即实际向server发起请求,不再跟进
String currentServer = currentServerAddress;
if (!StringUtils.isEmpty(currentServer)) {
String ip = currentServer.split(":")[0];
// 直接请求当前Server的HTTP服务,可以少一次网络开销,减轻Server负担
String firstServerAddress = ip2Address.get(ip);
if (firstServerAddress != null) {
result = acquire(firstServerAddress);
}
}
// 如果前面一步拿到的是null,这里向所有的serverIP发起心跳
for (String httpServerAddress : config.getServerAddress()) {
if (StringUtils.isEmpty(result)) {
result = acquire(httpServerAddress);
}else {
break;
}
}
初始化定时任务
这里主要是进行worker向currentServer的定时汇报健康状态,告知server自己的ip地址(server一开始并不知道worker的信息,这个信息是动态的)和其他信息,如自身是否超载。
以上Worker的启动流程就结束了,接下来就是等待currentServer来调度自己
总览
Server的启动流程
server在启动的时候会开启多个线程去定时执行任务,如调度,清理等,由于本篇只分析启动流程,通过以上分析,我们重点关注worker的acquire如何到达server,并获取对应的serverIP,通过server的启动流程,就能理解PowerJob的“无锁化设计”
// tech.powerjob.server.web.controller.ServerController#acquireServer
@GetMapping("/acquire")
public ResultDTO<String> acquireServer(ServerDiscoveryRequest request) {
return ResultDTO.success(serverElectionService.elect(request));
}
// 跟进elect
public String elect(ServerDiscoveryRequest request) {
if (!accurate()) {
final String currentServer = request.getCurrentServer();
// 如果是本机,就不需要查数据库那么复杂的操作了,直接返回成功
Optional<ProtocolInfo> localProtocolInfoOpt = Optional.ofNullable(transportService.allProtocols().get(request.getProtocol()));
if (localProtocolInfoOpt.isPresent()) {
if (localProtocolInfoOpt.get().getExternalAddress().equals(currentServer) || localProtocolInfoOpt.get().getAddress().equals(currentServer)) {
log.info("[ServerElection] this server[{}] is worker[appId={}]'s current server, skip check", currentServer, request.getAppId());
return currentServer;
}
}
}
// 如果不是本机
return getServer0(request);
}
为什么会出现不是本机的情况?
我们考虑这样一个场景,我们有一台server1,在控制台注册了一个App1,那么就会在数据库记录一条appid1 -> server1IP, 假如现在一个worker第一次对server1进行一个注册,即worker的发现,携带的的server1的IP到达server1,那么server1当然发现这个ip就是本机,直接返回
如果现在worker突然对server1失去了连接,即之前提到的discover失败了,那么就会对其他的server如server2发起discover请求,这个时候携带的就是server1的IP,server2就会检测到ip的不一致了,我们跟进getServer0看PowerJob如何解决这个问题
private String getServer0(ServerDiscoveryRequest discoveryRequest) {
final Long appId = discoveryRequest.getAppId();
final String protocol = discoveryRequest.getProtocol();
Set<String> downServerCache = Sets.newHashSet();
for (int i = 0; i < RETRY_TIMES; i++) {
// 1. 无锁获取当前数据库中的Server
Optional<AppInfoDO> appInfoOpt = appInfoRepository.findById(appId);
if (!appInfoOpt.isPresent()) {
throw new PowerJobException(appId + " is not registered!");
}
String appName = appInfoOpt.get().getAppName();
String originServer = appInfoOpt.get().getCurrentServer();
// activeAddress即向目标server发起一次ping,如果成功说明server仍然存活有效
String activeAddress = activeAddress(originServer, downServerCache, protocol);
if (StringUtils.isNotEmpty(activeAddress)) {
return activeAddress;
}
// 2. 无可用Server,重新进行Server选举,需要加锁
String lockName = String.format(SERVER_ELECT_LOCK, appId);
boolean lockStatus = lockService.tryLock(lockName, 30000);
if (!lockStatus) {
try {
Thread.sleep(500);
}catch (Exception ignore) {
}
continue;
}
try {
// 3. 可能上一台机器已经完成了Server选举,需要再次判断
AppInfoDO appInfo = appInfoRepository.findById(appId).orElseThrow(() -> new RuntimeException("impossible, unless we just lost our database."));
String address = activeAddress(appInfo.getCurrentServer(), downServerCache, protocol);
if (StringUtils.isNotEmpty(address)) {
return address;
}
// 4. 篡位,如果本机存在协议,则作为Server调度该 worker
final ProtocolInfo targetProtocolInfo = transportService.allProtocols().get(protocol);
if (targetProtocolInfo != null) {
// 注意,写入 AppInfoDO#currentServer 的永远是 default 的绑定地址,仅在返回的时候特殊处理为协议地址
appInfo.setCurrentServer(transportService.defaultProtocol().getAddress());
appInfo.setGmtModified(new Date());
appInfoRepository.saveAndFlush(appInfo);
log.info("[ServerElection] this server({}) become the new server for app(appId={}).", appInfo.getCurrentServer(), appId);
return targetProtocolInfo.getExternalAddress();
}
}catch (Exception e) {
log.error("[ServerElection] write new server to db failed for app {}.", appName, e);
} finally {
lockService.unlock(lockName);
}
}
throw new PowerJobException("server elect failed for app " + appId);
}
- 首先是无锁获取当前数据库中的Server。还是之前的例子,我们到达server2节点,但是目前携带的是server1的ip,server2去数据库查询这个对于server1的appid以及ip的记录,并且对server1进行一次ping,如果成功,说明server1仍然有效的,worker之前的找不到server1只是暂时的网络问题,于是返回server1的ip给worker
- 无可用Server,重新进行Server选举,需要加锁,如果前面的ping操作还是没有响应,server2这个时候会认为server1下线了,尝试篡位成为这个worker新的server,需要加锁,防止篡位过程中,worker选择了其他的server
- 可能上一台机器已经完成了Server选举,需要再次判断,类似于单例模式的双重锁校验
- 篡位,如果本机存在协议,则作为Server调度该 worker,这个时候就是改数据库记录了,把之前的appid1 -> server1IP,改成appid1 -> server2IP,返回server2的ip
总览
通过以上分析,我们发现PowerJob通过分组隔离的方式,实现了同一App分组只依附于同一个server,因为worker集群下,其他worker的注册(同一app)请求到了server上,会优先查找是否存在该appId的记录,若有且ping通的情况下则直接返回。所以保证了同一个appId分组,只会被同一server调度,这就多server下的任务重复调度的问题
反观xxljob,xxljob的服务发现机制没有篡位的能力,即worker不断的发起register请求,无需维护目前的serverAddress,日志也是遍历address给server。也无需关注在哪一台上完成了注册,因为后续调度都是全表扫描,没有分组的概念,所以xxljob为了解决多server下任务重复调度的问题,server获取任务需要全局加锁