單體JOB向分散式JOB遷移案例

doFix發表於2024-03-16

一、背景

1.1前言

相信大家在工作中多多少少都離不開定時任務吧,每個公司對定時任務的具體實現都不同。在一些體量小的公司或者一些個人獨立專案,服務可能還是單體的,並且在伺服器上只有一臺例項部署,大多數會採用spring原生註解@Scheduled配合 @EnableScheduling 使用,這也足夠了。

稍大一點的專案可能採用分散式部署架構,這時候再使用原來的做法就不合適了,通常一點的做法是將其中一臺伺服器抽出來獨立的作為定時任務部署,這樣來說成本最低,實現也最為簡單。

下面給大家看一個經典的單體job服務案例。

1.2專案介紹

首先。我們job服務使用了quartz作為定時任務框架,接著使用一張schedule_job表,表中記錄了所有我們需要定時任務的相關資訊,包括cron表示式,執行的bean名稱,執行的方法名,是否啟用,備註等相關資訊。

CREATE TABLE `schedule_job` (
  `job_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '任務id',
  `create_time` datetime DEFAULT NULL COMMENT '建立時間',
  `bean_name` varchar(200) DEFAULT NULL COMMENT 'spring bean名稱',
  `method_name` varchar(100) DEFAULT NULL COMMENT '方法名',
  `params` varchar(2000) CHARACTER SET utf8mb4 DEFAULT NULL,
  `cron_expression` varchar(100) DEFAULT NULL COMMENT 'cron表示式',
  `status` tinyint(4) DEFAULT NULL COMMENT '任務狀態  0:正常  1:暫停',
  `remark` varchar(255) DEFAULT NULL COMMENT '備註',
  `test_params` text COMMENT '測試使用者測試條件',
  PRIMARY KEY (`job_id`)
) ENGINE=InnoDB AUTO_INCREMENT=138 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;

接下來,我們在專案啟動後掃描這張表,將所有啟用的定時任務的bean資訊和method資訊透過反射的方式註冊進入quartz。

@Component
@Slf4j
public class ScheduleConfigBean {

    @Autowired
    private Scheduler scheduler;


    @Autowired
    private ScheduleJobService scheduleJobService;

    /**
     * 專案啟動時,初始化定時器
     */
    @PostConstruct
    public void init() {
        List<ScheduleJobEntity> scheduleJobList = scheduleJobService.selectList();
        for (ScheduleJobEntity scheduleJob : scheduleJobList) {
            CronTrigger cronTrigger = getCronTrigger(scheduler, scheduleJob.getJobId());
            log.info("初始化 job " + scheduleJob.getBeanName() + "." + scheduleJob.getMethodName());
            //如果不存在,則建立
            if (cronTrigger == null) {
                ScheduleUtils.createScheduleJob(scheduler, scheduleJob);
            } else {
                ScheduleUtils.updateScheduleJob(scheduler, scheduleJob);
            }
        }
    }
}



public void createScheduleJob(Scheduler scheduler, ScheduleJobEntity scheduleJob) {
        try {
            //構建job資訊
            JobDetail jobDetail = JobBuilder.newJob(ScheduleJob.class).withIdentity(getJobKey(scheduleJob.getJobId())).build();

            //表示式排程構建器
            CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(scheduleJob.getCronExpression())
                    .withMisfireHandlingInstructionDoNothing();

            //按新的cronExpression表示式構建一個新的trigger
            CronTrigger trigger = TriggerBuilder.newTrigger()
                    .withIdentity(getTriggerKey(scheduleJob.getJobId()))
                    .withSchedule(scheduleBuilder).build();

            //放入引數,執行時的方法可以獲取
            jobDetail.getJobDataMap().put(ScheduleJobEntity.JOB_PARAM_KEY, scheduleJob);
            scheduler.deleteJob(getJobKey(scheduleJob.getJobId()));

            scheduler.scheduleJob(jobDetail, trigger);

            //暫停任務
            if (scheduleJob.getStatus() == Constant.ScheduleStatus.PAUSE.getValue()
                    ||scheduleJob.getStatus() == Constant.ScheduleStatus.MIGREATE.getValue()) {
                pauseJob(scheduler, scheduleJob.getJobId());
            }
        } catch (Exception e) {
            throw new EBException("建立定時任務失敗", e);
        }
    }

這樣,我們就可以透過操作表的方式靈活的去控制定時任務的建立和刪除,並且可以靈活修改cron表示式,無需修改專案程式碼,非常適合單體服務,但是同樣也十分適合作為單體式的專案部署。但是,他同樣也存在著許多問題。

首先,任務是獨立執行在job上的,這導致他需要擁有幾乎所有的業務程式碼,而這些往往可以是屬於不同服務的,比如說後臺服務,前臺服務,雖然他們沒有做成微服務的形式,但是他們可以分散式部署,job服務要擁有他們兩個所有的程式碼保證業務的連貫。

還有就是單體風險,分散式部署帶來的一大好處就是避免單節點當機帶來的整個服務崩潰,也能重複利用多個機器的資源。對於一些大任務來說,單臺服務的瓶頸還是很致命的,特別是任務執行不夠透明。實際上我們雖然有一些類似於schedule_job_log表之類的可以觀測,但是畢竟不夠細緻,也不能手動觸發任務。

隨著公司業務開展,原有的job體系已經逐漸不能滿足實際的業務開發需求,我們需要尋找一種新的解決方案。隨著我們的調研,我們選中了xxl-job作為我們新的job框架,至於xxl-job的使用及特性好處,這裡就不展開講了,相關的文章網上也有很多,下面講講我們是如何進行job遷移的。

二、實踐

2.1xxl-job搭建和接入

先附上xxl-job的官網地址,以示尊敬。

https://www.xuxueli.com/xxl-job/

下面快速過一下搭建接入流程,官網講的很詳細。

1.admin搭建

第一步:下載相關原始碼

https://github.com/xuxueli/xxl-job

第二步:初始化相關資料庫指令碼

/xxl-job/doc/db/tables_xxl_job.sql

第三步:根據自己的需求配置編譯並打包,部署到自己的伺服器中

2.服務接入

第一步:引入相關依賴

<dependency>
  <groupId>com.xuxueli</groupId>
  <artifactId>xxl-job-core</artifactId>
  <version>2.4.0</version>
</dependency>

第二步:增加配置檔案配置

### 排程中心部署根地址 [選填]:如排程中心叢集部署存在多個地址則用逗號分隔。執行器將會使用該地址進行"執行器心跳註冊"和"任務結果回撥";為空則關閉自動註冊;
xxl.job.admin.addresses=http://127.0.0.1:8080/xxl-job-admin
### 執行器通訊TOKEN [選填]:非空時啟用;
xxl.job.accessToken=
### 執行器AppName [選填]:執行器心跳註冊分組依據;為空則關閉自動註冊
xxl.job.executor.appname=xxl-job-executor-sample
### 執行器註冊 [選填]:優先使用該配置作為註冊地址,為空時使用內嵌服務 ”IP:PORT“ 作為註冊地址。從而更靈活的支援容器型別執行器動態IP和動態對映埠問題。
xxl.job.executor.address=
### 執行器IP [選填]:預設為空表示自動獲取IP,多網路卡時可手動設定指定IP,該IP不會繫結Host僅作為通訊實用;地址資訊用於 "執行器註冊" 和 "排程中心請求並觸發任務";
xxl.job.executor.ip=
### 執行器埠號 [選填]:小於等於0則自動獲取;預設埠為9999,單機部署多個執行器時,注意要配置不同執行器埠;
xxl.job.executor.port=9999
### 執行器執行日誌檔案儲存磁碟路徑 [選填] :需要對該路徑擁有讀寫許可權;為空則使用預設路徑;
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
### 執行器日誌檔案儲存天數 [選填] : 過期日誌自動清理, 限制值大於等於3時生效; 否則, 如-1, 關閉自動清理功能;
xxl.job.executor.logretentiondays=30

第三步:編寫配置類

@Bean
public XxlJobSpringExecutor xxlJobExecutor() {
    logger.info(">>>>>>>>>>> xxl-job config init.");
    XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
    xxlJobSpringExecutor.setAdminAddresses(adminAddresses);
    xxlJobSpringExecutor.setAppname(appname);
    xxlJobSpringExecutor.setIp(ip);
    xxlJobSpringExecutor.setPort(port);
    xxlJobSpringExecutor.setAccessToken(accessToken);
    xxlJobSpringExecutor.setLogPath(logPath);
    xxlJobSpringExecutor.setLogRetentionDays(logRetentionDays);
    return xxlJobSpringExecutor;
}

第四步:部署專案

至此,我們已經完成了xxl-job的服務端的部署和客戶端的接入工作呢,接下來只要打上@XxlJob註解那我們就能成功使用xxl-job為我們提供的服務了,那這樣的話本文的意義就不大了,因為網上接入使用xxl-job的教程太多了,本文不使用@XxlJob註解,而是採取了另一種最小入侵,開發無感的方式接入,下面,我們來具體看看到底是如何做的。

2.2xxl-job新的接入方式

分析:

考慮到我們公司目前處於業務高速迭代期,程式碼變更迅速且上線週期短,如果採取一個個加註解的形式則會產生大量的衝突,且我們的定時任務數量繁多且散落,不宜找全。一旦遺漏可能短時間難以發現,但是卻會造成嚴重的系統不可知問題。除此之外,我們也要花費很多額外的溝通成本去和每個開發同步,實在是問題多多。這個時候,我們再探索是否能有一種基於目前專案基礎,無侵入性,減少程式碼變更及溝通成本的方法,安全穩定的將我們原有的任務遷移過去呢?

分析我們目前的處境及我們想要完成的目標,核心就在於我們不想使用@XxlJob註解而已,如果不用這個註解,我們是否有替代的方案?為此,我們先研究一下xxl-job客戶端是如何註冊連線上服務端,@XxlJob在其中扮演了什麼角色。

客戶端啟動流程:

在spring環境中,客戶端將XxlJobSpringExecutor注入spring容器之中,他就是我們的任務執行器。這個bean實現了SmartInitializingSingleton介面,當 Spring 容器中的所有單例 Bean都完成了初始化後,容器會回撥實現了 SmartInitializingSingleton 介面的 Bean 的 afterSingletonsInstantiated 方法。

    @Override
    public void afterSingletonsInstantiated() {

        // init JobHandler Repository
        /*initJobHandlerRepository(applicationContext);*/

        // init JobHandler Repository (for method)
        //初始化排程器資源管理器(從spring容器中找出@XxlJob註解的方法,裝載到map裡)
        initJobHandlerMethodRepository(applicationContext);

        // 重新整理Glue工廠
        GlueFactory.refreshInstance(1);

        // super start
        try {
            //啟動服務,接收伺服器請求
            super.start();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

其實他為我們做了三件事

1.初始化排程器資源管理器從(spring容器中找出XxlJob註解的方法,裝載到map裡)

2.重新整理Glue工廠

3.啟動服務,接收伺服器請求

我們著重看一下initJobHandlerMethodRepository(applicationContext)方法,看看XxlJob註解其中扮演了什麼樣的角色。

    private void initJobHandlerMethodRepository(ApplicationContext applicationContext) {
        if (applicationContext == null) {
            return;
        }
        // init job handler from method
        String[] beanDefinitionNames = applicationContext.getBeanNamesForType(Object.class, false, true);
        for (String beanDefinitionName : beanDefinitionNames) {

            // get bean
            Object bean = null;
            Lazy onBean = applicationContext.findAnnotationOnBean(beanDefinitionName, Lazy.class);
            if (onBean!=null){
                logger.debug("xxl-job annotation scan, skip @Lazy Bean:{}", beanDefinitionName);
                continue;
            }else {
                bean = applicationContext.getBean(beanDefinitionName);
            }

            // filter method
            //這就是裝載了被XxlJob註解標記的方法和對應註解的map
            Map<Method, XxlJob> annotatedMethods = null;   // referred to :org.springframework.context.event.EventListenerMethodProcessor.processBean
            try {
                annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(),
                        new MethodIntrospector.MetadataLookup<XxlJob>() {
                            @Override
                            public XxlJob inspect(Method method) {
                                return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);
                            }
                        });
            } catch (Throwable ex) {
                logger.error("xxl-job method-jobhandler resolve error for bean[" + beanDefinitionName + "].", ex);
            }
            if (annotatedMethods==null || annotatedMethods.isEmpty()) {
                continue;
            }

            // generate and regist method job handler
            for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {
                Method executeMethod = methodXxlJobEntry.getKey();
                XxlJob xxlJob = methodXxlJobEntry.getValue();
                // 註冊方法
                registJobHandler(xxlJob, bean, executeMethod);
            }

        }
    }

這個方法就是掃描spring容器,找出所有加上了@xxlJob註解的方法,然後將這個合併後的註解、相關bean、對應的方法註冊進去,我們再跟進去看一下具體的註冊方法,進一步揭露@xxlJob註解的秘密。

private static ConcurrentMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap<String, IJobHandler>();

protected void registJobHandler(XxlJob xxlJob, Object bean, Method executeMethod){
        if (xxlJob == null) {
            return;
        }

        //這個就是jobhandler的名字
        String name = xxlJob.value();
        //make and simplify the variables since they'll be called several times later
        Class<?> clazz = bean.getClass();
        String methodName = executeMethod.getName();
        if (name.trim().length() == 0) {
            throw new RuntimeException("xxl-job method-jobhandler name invalid, for[" + clazz + "#" + methodName + "] .");
        }
        if (loadJobHandler(name) != null) {
            throw new RuntimeException("xxl-job jobhandler[" + name + "] naming conflicts.");
        }

        // execute method
        /*if (!(method.getParameterTypes().length == 1 && method.getParameterTypes()[0].isAssignableFrom(String.class))) {
            throw new RuntimeException("xxl-job method-jobhandler param-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                    "The correct method format like \" public ReturnT<String> execute(String param) \" .");
        }
        if (!method.getReturnType().isAssignableFrom(ReturnT.class)) {
            throw new RuntimeException("xxl-job method-jobhandler return-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                    "The correct method format like \" public ReturnT<String> execute(String param) \" .");
        }*/
        //開啟許可權
        executeMethod.setAccessible(true);

        // init and destroy
        Method initMethod = null;
        Method destroyMethod = null;

        if (xxlJob.init().trim().length() > 0) {
            try {
                initMethod = clazz.getDeclaredMethod(xxlJob.init());
                initMethod.setAccessible(true);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException("xxl-job method-jobhandler initMethod invalid, for[" + clazz + "#" + methodName + "] .");
            }
        }
        if (xxlJob.destroy().trim().length() > 0) {
            try {
                destroyMethod = clazz.getDeclaredMethod(xxlJob.destroy());
                destroyMethod.setAccessible(true);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException("xxl-job method-jobhandler destroyMethod invalid, for[" + clazz + "#" + methodName + "] .");
            }
        }

        // registry jobhandler
        //繼續註冊JobHandler
        registJobHandler(name, new MethodJobHandler(bean, executeMethod, initMethod, destroyMethod));

    }
   public static IJobHandler registJobHandler(String name, IJobHandler jobHandler){
        logger.info(">>>>>>>>>>> xxl-job register jobhandler success, name:{}, jobHandler:{}", name, jobHandler);
        return jobHandlerRepository.put(name, jobHandler);
    }

這段程式碼就是取出對應的註解標記的init方法destroy方法,如果存在,進一步註冊為JobHandler,其實所謂的註冊為JobHandler也就是幫他們包裝為一個MethodJobHandler,然後將其放在key為jobhandler的名字,value為MethodJobHandler的的map中。

自此,@xxlJob註解的所有作用我們都已經找到了,無非就是透過他作為標記去spring中掃描到我們想要的bean和方法罷了。並附帶上相應的資訊,如果說我們這些資訊都能提供呢?是不是我們就不再需要他了,還是以我們自己的方式實現註冊了?再回顧一下我們已有的資料,我們的schedule_job表中已經有了bean和method的相關資訊,至於JobHandler的名字,我們完全可以用bean和method去組合一個不重複的名字。這似乎可行。

但是,問題又來了。@xxlJob註解註解這個問題我們已經解決了。下一個問題是我們要在控制檯上去吧所有的jobhandler新增上,並指定cron表示式,路由策略等。既然已經做到了這個程度,有沒有能一勞永逸的方法能將這個步驟也都實現呢?上面也說了,我們的任務很多,會有新增遺漏的風險,而且一個個配置出錯的可能性也很大,況且cron表示式我們明明表中也有,自己一個個配是不是太麻煩了,能不能從表裡讀呢?

那我們不妨研究一下xxl-job是如何給我們新增任務的?

xxl-job新增任務流程:

透過實驗,發現xxl-job新增任務的介面是xxl-job-admin/jobinfo/add,那我們就看看這個介面做了什麼好了。

	@Override
	public ReturnT<String> add(XxlJobInfo jobInfo) {

		// valid base
		XxlJobGroup group = xxlJobGroupDao.load(jobInfo.getJobGroup());
		if (group == null) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("system_please_choose")+I18nUtil.getString("jobinfo_field_jobgroup")) );
		}
		if (jobInfo.getJobDesc()==null || jobInfo.getJobDesc().trim().length()==0) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("system_please_input")+I18nUtil.getString("jobinfo_field_jobdesc")) );
		}
		if (jobInfo.getAuthor()==null || jobInfo.getAuthor().trim().length()==0) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("system_please_input")+I18nUtil.getString("jobinfo_field_author")) );
		}

		// valid trigger
		ScheduleTypeEnum scheduleTypeEnum = ScheduleTypeEnum.match(jobInfo.getScheduleType(), null);
		if (scheduleTypeEnum == null) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("schedule_type")+I18nUtil.getString("system_unvalid")) );
		}
		if (scheduleTypeEnum == ScheduleTypeEnum.CRON) {
			if (jobInfo.getScheduleConf()==null || !CronExpression.isValidExpression(jobInfo.getScheduleConf())) {
				return new ReturnT<String>(ReturnT.FAIL_CODE, "Cron"+I18nUtil.getString("system_unvalid"));
			}
		} else if (scheduleTypeEnum == ScheduleTypeEnum.FIX_RATE/* || scheduleTypeEnum == ScheduleTypeEnum.FIX_DELAY*/) {
			if (jobInfo.getScheduleConf() == null) {
				return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("schedule_type")) );
			}
			try {
				int fixSecond = Integer.valueOf(jobInfo.getScheduleConf());
				if (fixSecond < 1) {
					return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("schedule_type")+I18nUtil.getString("system_unvalid")) );
				}
			} catch (Exception e) {
				return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("schedule_type")+I18nUtil.getString("system_unvalid")) );
			}
		}

		// valid job
		if (GlueTypeEnum.match(jobInfo.getGlueType()) == null) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("jobinfo_field_gluetype")+I18nUtil.getString("system_unvalid")) );
		}
		if (GlueTypeEnum.BEAN==GlueTypeEnum.match(jobInfo.getGlueType()) && (jobInfo.getExecutorHandler()==null || jobInfo.getExecutorHandler().trim().length()==0) ) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("system_please_input")+"JobHandler") );
		}
		// 》fix "\r" in shell
		if (GlueTypeEnum.GLUE_SHELL==GlueTypeEnum.match(jobInfo.getGlueType()) && jobInfo.getGlueSource()!=null) {
			jobInfo.setGlueSource(jobInfo.getGlueSource().replaceAll("\r", ""));
		}

		// valid advanced
		if (ExecutorRouteStrategyEnum.match(jobInfo.getExecutorRouteStrategy(), null) == null) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("jobinfo_field_executorRouteStrategy")+I18nUtil.getString("system_unvalid")) );
		}
		if (MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), null) == null) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("misfire_strategy")+I18nUtil.getString("system_unvalid")) );
		}
		if (ExecutorBlockStrategyEnum.match(jobInfo.getExecutorBlockStrategy(), null) == null) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("jobinfo_field_executorBlockStrategy")+I18nUtil.getString("system_unvalid")) );
		}

		// 》ChildJobId valid
		if (jobInfo.getChildJobId()!=null && jobInfo.getChildJobId().trim().length()>0) {
			String[] childJobIds = jobInfo.getChildJobId().split(",");
			for (String childJobIdItem: childJobIds) {
				if (childJobIdItem!=null && childJobIdItem.trim().length()>0 && isNumeric(childJobIdItem)) {
					XxlJobInfo childJobInfo = xxlJobInfoDao.loadById(Integer.parseInt(childJobIdItem));
					if (childJobInfo==null) {
						return new ReturnT<String>(ReturnT.FAIL_CODE,
								MessageFormat.format((I18nUtil.getString("jobinfo_field_childJobId")+"({0})"+I18nUtil.getString("system_not_found")), childJobIdItem));
					}
				} else {
					return new ReturnT<String>(ReturnT.FAIL_CODE,
							MessageFormat.format((I18nUtil.getString("jobinfo_field_childJobId")+"({0})"+I18nUtil.getString("system_unvalid")), childJobIdItem));
				}
			}

			// join , avoid "xxx,,"
			String temp = "";
			for (String item:childJobIds) {
				temp += item + ",";
			}
			temp = temp.substring(0, temp.length()-1);

			jobInfo.setChildJobId(temp);
		}

		// add in db
		jobInfo.setAddTime(new Date());
		jobInfo.setUpdateTime(new Date());
		jobInfo.setGlueUpdatetime(new Date());
		xxlJobInfoDao.save(jobInfo);
		if (jobInfo.getId() < 1) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, (I18nUtil.getString("jobinfo_field_add")+I18nUtil.getString("system_fail")) );
		}

		return new ReturnT<String>(String.valueOf(jobInfo.getId()));
	}

簡單來說,他就是給我們插入了一條jobInfo資訊而已,但是他需要我們已經有了XxlJobGroup,大部分情況這個我們都會用預設的,那我們就建立一個預設的分組好了。我們只要在xxljob控制後臺提前建立好一個執行器就算OK了。然後我們只需要根據規則來生成jobInfo,那我們不就可以不用手動一個個建立了,好像該有的資訊我們都可以從schedule_job表中獲取,一些其他的值給個預設的就好了,我們來試試看。

首先我們把依賴、XxlJobConfig配置類、控制後臺部署好,提前將一些xxljob自帶的表匯入我們的工程,主要是xxl_job_info和xxl_job_group,因為我們待會要操作這兩張表,封裝好dao和service即可。我們將一些前置工作都準備好,然後準備開始編寫我們的核心程式碼。

模擬註冊任務新增流程:


@Component("xxlTestJob")
@Slf4j
public class XxlTestJob implements SmartInitializingSingleton {

    @Autowired
    private ApplicationContext context;

    @Autowired
    private ScheduleJobMapper scheduleJobMapper;

    @Autowired
    private XxlJobInfoService jobInfoService;

    @Autowired
    private XxlJobGroupService jobGroupService;

    @Autowired
    private XXlJobHandlerRepository xlJobHandlerRepository;


    public void init() throws Exception {
        LambdaQueryWrapper<ScheduleJobEntity> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ScheduleJobEntity::getStatus,2);

        List<XxlJobInfo> jobInfos = scheduleJobMapper.selectList(wrapper).stream().map(this::convert).toList();
        for (XxlJobInfo jobInfo : jobInfos) {


            String[] split = jobInfo.getExecutorHandler().split("->");
            String beanName = split[0];
            String methodName = split[1];
            try {
                Object handler = context.getBean(beanName);
                Method method = handler.getClass().getMethod(methodName);
                XxlJob xxlJob = AnnotationUtils.synthesizeAnnotation(
                        Collections.singletonMap("value", jobInfo.getExecutorHandler()), XxlJob.class, null);
                registJobHandler(xxlJob, handler, method);
                log.info("{}註冊完成", jobInfo.getExecutorHandler());
            } catch (NoSuchBeanDefinitionException e) {
                log.info("沒有這個bean{}", beanName);
                continue;
            }   catch (NoSuchMethodException e){
                log.info("bean{}方法{}", beanName,methodName);
            }

            XxlJobInfo existInfo = jobInfoService.lambdaQuery()
                    .eq(XxlJobInfo::getExecutorHandler, jobInfo.getExecutorHandler())
                    .one();
            if (existInfo != null) {
                existInfo.setScheduleConf(jobInfo.getScheduleConf());
                existInfo.setTriggerStatus(jobInfo.getTriggerStatus());
                existInfo.setExecutorParam(jobInfo.getExecutorParam());
                jobInfoService.updateById(existInfo);
                log.info("更新job完成->{}", existInfo);
            } else {
                jobInfoService.save(jobInfo);
                log.info("新增job完成->{}", jobInfo);
            }
        }
    }

    private XxlJobInfo convert(ScheduleJobEntity scheduleJob) {
        XxlJobInfo jobInfo = new XxlJobInfo();
        XxlJobGroup xxlJobGroup = jobGroupService.list().get(0);
        jobInfo.setJobGroup(xxlJobGroup.getId());
        jobInfo.setJobDesc(StringUtil.isNotBlank(scheduleJob.getRemark()) ? scheduleJob.getRemark() : "暫無備註");
        jobInfo.setAddTime(new Date());
        jobInfo.setUpdateTime(new Date());
        jobInfo.setAuthor("xxx");
        jobInfo.setScheduleType("CRON");
        jobInfo.setScheduleConf(scheduleJob.getCronExpression());
        jobInfo.setMisfireStrategy("DO_NOTHING");
        jobInfo.setExecutorHandler(scheduleJob.getBeanName() + "->" + scheduleJob.getMethodName());
        jobInfo.setExecutorBlockStrategy("SERIAL_EXECUTION");
        jobInfo.setExecutorTimeout(0);
        jobInfo.setExecutorFailRetryCount(0);
        jobInfo.setGlueType("BEAN");
        jobInfo.setGlueRemark("GLUE程式碼初始化");
        jobInfo.setGlueUpdatetime(new Date());
        jobInfo.setExecutorRouteStrategy("FIRST");

//        if ("notJob".equals(scheduleJob.getCronExpression())
//                || StringUtils.isBlank(scheduleJob.getCronExpression())
//                || scheduleJob.getStatus() == 1) {
//            jobInfo.setTriggerStatus(1);
//        } else {
//
//        }
        //TODO 攔截邏輯在上層完成,這裡全部放行

        jobInfo.setTriggerStatus(1);


        return jobInfo;
    }

   
    protected void registJobHandler(XxlJob xxlJob, Object bean, Method executeMethod) {
        if (xxlJob == null) {
            return;
        }

        String name = xxlJob.value();
        //make and simplify the variables since they'll be called several times later
        Class<?> clazz = bean.getClass();
        String methodName = executeMethod.getName();
        if (name.trim().length() == 0) {
            throw new RuntimeException("xxl-job method-jobhandler name invalid, for[" + clazz + "#" + methodName + "] .");
        }
        if (XxlJobExecutor.loadJobHandler(name) != null) {
            throw new RuntimeException("xxl-job jobhandler[" + name + "] naming conflicts.");
        }

        // execute method
        /*if (!(method.getParameterTypes().length == 1 && method.getParameterTypes()[0].isAssignableFrom(String.class))) {
            throw new RuntimeException("xxl-job method-jobhandler param-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                    "The correct method format like \" public ReturnT<String> execute(String param) \" .");
        }
        if (!method.getReturnType().isAssignableFrom(ReturnT.class)) {
            throw new RuntimeException("xxl-job method-jobhandler return-classtype invalid, for[" + bean.getClass() + "#" + method.getName() + "] , " +
                    "The correct method format like \" public ReturnT<String> execute(String param) \" .");
        }*/

        executeMethod.setAccessible(true);

        // init and destroy
        Method initMethod = null;
        Method destroyMethod = null;

        if (xxlJob.init().trim().length() > 0) {
            try {
                initMethod = clazz.getDeclaredMethod(xxlJob.init());
                initMethod.setAccessible(true);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException("xxl-job method-jobhandler initMethod invalid, for[" + clazz + "#" + methodName + "] .");
            }
        }
        if (xxlJob.destroy().trim().length() > 0) {
            try {
                destroyMethod = clazz.getDeclaredMethod(xxlJob.destroy());
                destroyMethod.setAccessible(true);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException("xxl-job method-jobhandler destroyMethod invalid, for[" + clazz + "#" + methodName + "] .");
            }
        }

        // registry jobhandler
        XxlJobExecutor.registJobHandler(name, new MethodJobHandler(bean, executeMethod, initMethod, destroyMethod));
        xlJobHandlerRepository.put(AopProxyUtils.ultimateTargetClass(bean).getName(),executeMethod.getName());
        log.info("xlJobHandlerRepository beanClsName :{},method :{}",AopProxyUtils.ultimateTargetClass(bean).getName(),executeMethod.getName());
    }

    @Override
    public void afterSingletonsInstantiated() {
        try {
            init();
            log.info("xxl定時任務初始化註冊完成");
        } catch (Exception e) {
            log.error("初始化異常", e);
        }
    }
}

當我們job服務啟動後,他會自動掃描schedule_job表,將符合條件的bean和method都註冊進去,並根據相關引數自動填充xxl_job_info,我們任何操作都不需要做,只需要等待服務啟動完成後檢視控制檯進行測試驗證即可,看一下控制檯。

單體JOB向分散式JOB遷移案例

再看一下服務後臺日誌,任務也在正常跑,OK都完美執行。

三、新的挑戰

3.1日誌追蹤

在我們的springboot專案中,通常都會一個全域性追蹤ID叫做traceId,用來標識一個完整的業務鏈路。但是我們如今的job呼叫是透過xxl-job發起的,這會導致原有的traceId缺失,我們需要將traceId補充進去。

很自然,我們想到了用aop來實現這一需求,在我們每個任務執行執行之前放入traceId,在任務執行完畢後將其移除。還記得我們上面講到的任務註冊嗎,有這樣一行程式碼,為我們接下來的aop打下了基礎。


        // registry jobhandler
        XxlJobExecutor.registJobHandler(name, new MethodJobHandler(bean, executeMethod, initMethod, destroyMethod));
        //將註冊的任務放入任務倉庫
        xlJobHandlerRepository.put(AopProxyUtils.ultimateTargetClass(bean).getName(),executeMethod.getName());
        log.info("xlJobHandlerRepository beanClsName :{},method :{}",AopProxyUtils.ultimateTargetClass(bean).getName(),executeMethod.getName());
    

再看一下xlJobHandlerRepository是什麼?很簡單,就是一個容器。

@Component
public class XXlJobHandlerRepository {
    private static final Map<String, Set<String>> repository = new ConcurrentHashMap();


    public Boolean hasJob(String key,String method) {
        Set<String> methods = repository.get(key);
        return (!CollectionUtils.isEmpty(methods) && methods.contains(method));

    }

    public synchronized void put(String key,String method) {

        Set<String> methods = repository.get(key);
        if (CollectionUtils.isEmpty(methods)){
            methods = new HashSet<>();
        }
        methods.add(method);
        repository.put(key, methods);
    }


}

接下來,我們寫一個切面,判斷是否符合要求,如果符合要求的話,我們手動放入traceId。

@Aspect
@Component
@Slf4j
public class XxlLogAspect {

    @Autowired
    private XXlJobHandlerRepository xlJobHandlerRepository;

    //我們的定時任務包
    @Pointcut("within(com.xxx.data.quartz.*)")
    public void pointcut() {
    }

    @Around("pointcut()")
    public Object doAround(ProceedingJoinPoint point) throws Throwable {

        Signature signature = point.getSignature();
        Object target = point.getTarget();

        String clName = target.getClass().getName();
        Boolean hasJob = xlJobHandlerRepository.hasJob(clName, signature.getName());
        log.info("come aspect clname: " + clName);
        if (hasJob) {
            log.info("Job " + clName + " has job " + signature.getName());
            MDC.put("traceId", UUID.randomUUID().toString());
        }
        Object proceed = point.proceed();
        if (hasJob){
            MDC.remove("traceId");
        }
        return proceed;
    }
}

再次觀察日誌,發現traceId已經放進去了。6e5cd7b2-f260-421d-b393-95059342a57a

2024-03-12 14:10:04.099  INFO [my-job,6e5cd7b2-f260-421d-b393-95059342a57a,] 1749 --- [xxl-job, JobThread-463-1710224250065] c.l.p.d.s.impl.user.ZnsUserServiceImpl   :xxx log test 

3.2日誌整合

xxl-job後臺為我們提供了頁面查詢任務執行日誌的地方,他需要我們使用XxlJobHelper.log()實現,可是我們目前都有任務日誌都是用@Slf4j的log實現,需要在每一個log程式碼下追加XxlJobHelper.log()。

log.info("xxx------執行xxx計劃,處理開始");
XxlJobHelper.log("xxx------執行xxx計劃,處理開始");

這麼做效率低且枯燥,難看且容易錘產生程式碼衝突,我們有沒有別的方式去實現呢,我們不就是想要拿到上面log裡的資料然後用xxljob的方式再打一遍嘛。那下面我們就寫一個日誌的Appender吧。(Appender 是一種用於定義日誌訊息輸出的元件或介面。它定義了將日誌訊息傳送到不同目標(例如控制檯、檔案、資料庫等)的方式和規則)。

@Component
public class XXlLogbackAppender extends UnsynchronizedAppenderBase<ILoggingEvent> {


    @Override
    protected void append(ILoggingEvent iLoggingEvent) {

        String formattedMessage = iLoggingEvent.getFormattedMessage();

        XxlJobHelper.log(MDC.get("traceId")+" "+formattedMessage);
        IThrowableProxy IthrowableProxy = iLoggingEvent.getThrowableProxy();
        Throwable throwable = null;
        if (IthrowableProxy instanceof ThrowableProxy throwableProxy) {
            throwable = throwableProxy.getThrowable();
        }
        if (Objects.nonNull(throwable)) {
            XxlJobHelper.log(throwable);
        }

    }

接著我們再調整一下日誌配置

...
<appender name="XXL" class="com.linzi.pitpat.job.config.XXlLogbackAppender">
  <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
    <level>INFO</level>
  </filter>
</appender>
...
  <root level="INFO">
       ...
      <appender-ref ref="XXL"/>
</root>

至此,大功告成,我們看一下xxljob的控制後臺。發現日誌已經成功列印出來了

單體JOB向分散式JOB遷移案例

3.3多執行緒下日誌問題

1.問題引出

在多執行緒環境下,xxljob無法正確顯示出日誌。我們可以試著挖掘一下原因原因,首先我們看下xxljob的是如何給我們打日誌的,看一下XxlJobHelper.log()的原始碼。

    /**
     * append exception stack
     *
     * @param e
     */
    public static boolean log(Throwable e) {

        StringWriter stringWriter = new StringWriter();
        e.printStackTrace(new PrintWriter(stringWriter));
        String appendLog = stringWriter.toString();

        StackTraceElement callInfo = new Throwable().getStackTrace()[1];
        return logDetail(callInfo, appendLog);
    }

核心程式碼是logDetail,我們看一下logDetail的內容


    /**
     * append log
     *
     * @param callInfo
     * @param appendLog
     */
    private static boolean logDetail(StackTraceElement callInfo, String appendLog) {
        XxlJobContext xxlJobContext = XxlJobContext.getXxlJobContext();
        if (xxlJobContext == null) {
            return false;
        }

        /*// "yyyy-MM-dd HH:mm:ss [ClassName]-[MethodName]-[LineNumber]-[ThreadName] log";
        StackTraceElement[] stackTraceElements = new Throwable().getStackTrace();
        StackTraceElement callInfo = stackTraceElements[1];*/

        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(DateUtil.formatDateTime(new Date())).append(" ")
                .append("["+ callInfo.getClassName() + "#" + callInfo.getMethodName() +"]").append("-")
                .append("["+ callInfo.getLineNumber() +"]").append("-")
                .append("["+ Thread.currentThread().getName() +"]").append(" ")
                .append(appendLog!=null?appendLog:"");
        String formatAppendLog = stringBuffer.toString();

        // appendlog
        //要輸出的日誌檔名
        String logFileName = xxlJobContext.getJobLogFileName();

        if (logFileName!=null && logFileName.trim().length()>0) {
            XxlJobFileAppender.appendLog(logFileName, formatAppendLog);
            return true;
        } else {
            logger.info(">>>>>>>>>>> {}", formatAppendLog);
            return false;
        }
    }

我們可以發現,我們日誌輸出的檔案是透過xxlJobContext.getJobLogFileName()方法獲取的,我們繼續往下看這個方法怎麼獲取檔名。

package com.xxl.job.core.context;

/**
 * xxl-job context
 *
 * @author xuxueli 2020-05-21
 * [Dear hj]
 */
public class XxlJobContext {

    public static final int HANDLE_CODE_SUCCESS = 200;
    public static final int HANDLE_CODE_FAIL = 500;
    public static final int HANDLE_CODE_TIMEOUT = 502;

    // ---------------------- base info ----------------------

    /**
     * job id
     */
    private final long jobId;

    /**
     * job param
     */
    private final String jobParam;

    // ---------------------- for log ----------------------

    /**
     * job log filename
     */
    private final String jobLogFileName;

    // ---------------------- for shard ----------------------

    /**
     * shard index
     */
    private final int shardIndex;

    /**
     * shard total
     */
    private final int shardTotal;

    // ---------------------- for handle ----------------------

    /**
     * handleCode:The result status of job execution
     *
     *      200 : success
     *      500 : fail
     *      502 : timeout
     *
     */
    private int handleCode;

    /**
     * handleMsg:The simple log msg of job execution
     */
    private String handleMsg;


    public XxlJobContext(long jobId, String jobParam, String jobLogFileName, int shardIndex, int shardTotal) {
        this.jobId = jobId;
        this.jobParam = jobParam;
        this.jobLogFileName = jobLogFileName;
        this.shardIndex = shardIndex;
        this.shardTotal = shardTotal;

        this.handleCode = HANDLE_CODE_SUCCESS;  // default success
    }

    public long getJobId() {
        return jobId;
    }

    public String getJobParam() {
        return jobParam;
    }

    public String getJobLogFileName() {
        return jobLogFileName;
    }

    public int getShardIndex() {
        return shardIndex;
    }

    public int getShardTotal() {
        return shardTotal;
    }

    public void setHandleCode(int handleCode) {
        this.handleCode = handleCode;
    }

    public int getHandleCode() {
        return handleCode;
    }

    public void setHandleMsg(String handleMsg) {
        this.handleMsg = handleMsg;
    }

    public String getHandleMsg() {
        return handleMsg;
    }

    // ---------------------- tool ----------------------

    private static InheritableThreadLocal<XxlJobContext> contextHolder = new InheritableThreadLocal<XxlJobContext>(); // support for child thread of job handler)

    public static void setXxlJobContext(XxlJobContext xxlJobContext){
        contextHolder.set(xxlJobContext);
    }

    public static XxlJobContext getXxlJobContext(){
        return contextHolder.get();
    }

}

可以看到,xxlJobContext就是一個上下文類,儲存了xxlJob環境上一些必要資訊。

...
private static InheritableThreadLocal<XxlJobContext> contextHolder = new InheritableThreadLocal<XxlJobContext>(); // support for child thread of job handler)
...
public static void setXxlJobContext(XxlJobContext xxlJobContext){
        contextHolder.set(xxlJobContext);
    }
...

/*
 * handler thread
 * @author xuxueli 2016-1-16 19:52:47
 */
public class JobThread extends Thread{
...
    @Override
	public void run() {
        ...
            try {
                // log filename, like "logPath/yyyy-MM-dd/9999.log"
					String logFileName = XxlJobFileAppender.makeLogFileName(new Date(triggerParam.getLogDateTime()), triggerParam.getLogId());
					XxlJobContext xxlJobContext = new XxlJobContext(
							triggerParam.getJobId(),
							triggerParam.getExecutorParams(),
							logFileName,
							triggerParam.getBroadcastIndex(),
							triggerParam.getBroadcastTotal());

					// xxlJobContext在這裡被建立,並繫結到執行緒上
					XxlJobContext.setXxlJobContext(xxlJobContext);

				...
				}
			} catch (Throwable e) {
				
			} finally {
              
                }
            }
        }

	}
}

到這裡,我們的xxljob日誌輸出到哪裡是不是非常清晰了,是依賴logFileName,而logFileName又是儲存在xxlJobContext上,這個xxlJobContext繫結在InheritableThreadLocal上。簡單介紹一下這個threadlocal跟普通threadlocal區別,普通的 ThreadLocal 只在當前執行緒中起作用,子執行緒無法繼承父執行緒的執行緒本地變數。而 InheritableThreadLocal 則允許子執行緒繼承父執行緒的執行緒本地變數。當子執行緒建立時,它會從父執行緒中繼承父執行緒的 InheritableThreadLocal 變數的副本,使得子執行緒也可以獨立地訪問和修改該變數副本。

所以,當我們的業務是在主執行緒中開闢子執行緒時,我們的logFileName是不會丟失的,日誌也能正常列印,但是一旦我們使用執行緒池方式去執行我們的日誌,那麼日誌列印就會出問題,因為InheritableThreadLocal處理不了執行緒池情況,那麼我們怎麼去解決這一問題呢,我們可以嘗試使用阿里的ttl。

TransmittableThreadLocal 是一個執行緒本地變數(ThreadLocal)的變體,它擴充套件了 InheritableThreadLocal 並提供了更強大的執行緒上下文傳遞能力。

在多執行緒環境中,當建立子執行緒時,父執行緒的上下文資訊(如執行緒本地變數)通常無法自動傳遞給子執行緒。而 TransmittableThreadLocal 解決了這個問題,它允許在父子執行緒之間自動傳遞執行緒本地變數的值。

與 InheritableThreadLocal 不同,TransmittableThreadLocal 提供了更復雜的上下文傳遞語義。它不僅支援父執行緒到子執行緒的上下文傳遞,還支援執行緒池等場景下的執行緒複用,確保正確的上下文傳遞。

TransmittableThreadLocal 的使用方式與 ThreadLocal 和 InheritableThreadLocal 類似,可以透過 set()、get() 方法來設定和獲取執行緒本地變數的值。

他的具體實現原理不是本文重點,就不展開介紹,大概原理是透過攔截執行緒的建立和執行過程來實現執行緒上下文的傳遞,其實就是包裝一層執行緒池。

2.改造手段

第一步:首先我們修改xxljo的原始碼,引入阿里的ttl包,將其重新編譯,上傳到我們的私服。

找到XxlJobContext位置,做如下修改

//private static InheritableThreadLocal<XxlJobContext> contextHolder = new InheritableThreadLocal<XxlJobContext>(); // support for child thread of job handler)


private static TransmittableThreadLocal<XxlJobContext> contextHolder = new TransmittableThreadLocal<XxlJobContext>(); // support for child thread of job handler)

第二步:接著刪除本地jar包,重新整理maven倉庫。

第三步:編寫執行緒裝飾器,包裝需要在xxljob環境下列印日誌的執行緒池。

public class TransmittableDecorator implements TaskDecorator {
    @Override
    public Runnable decorate(Runnable runnable) {
        Runnable decoratedRunnable = TtlRunnable.get(runnable);  // 在任務執行前獲取TTL(ThreadLocal)的值
        return () -> {
            decoratedRunnable.run();  // 執行原始任務
        };
    }
}

 @Bean("taskExecutor")
    public ThreadPoolTaskExecutor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setThreadNamePrefix("taskExecutor-");
        executor.setCorePoolSize(Runtime.getRuntime().availableProcessors() * 2 + 1);
        executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors() * 4 +1);
        executor.setKeepAliveSeconds(60);
        executor.setQueueCapacity(10000);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.setTaskDecorator(new TransmittableDecorator());

        executor.initialize();
        return executor;
    }

第四步:重新啟動服務,觀察結果

單體JOB向分散式JOB遷移案例

自此,我們的job服務遷移告一段落。其實,在這期間還有許多問題,但是重要的是我們最終完成了我們的目標,並在其中獲得了成長,這是難能可貴的。

相關文章