源码背景: xxl-job 2.3.0 activity 7.1.0
先了解一下架构背景,activity工作流引擎,引入jar通过配置即可在你的业务服务中使用。而xxl-job为分布式任务调度器,分为server端(调度器)和执行器端,server端并提供web服务可以通过页面配置调度逻辑,执行器端通过引入xxl-job-core的jar包并通过配置+bean形式注册到server端,由server端触发定时规则再通过不同的路由策略,失败策略通过调用执行器对应的bean/直接实例化的代码来执行业务逻辑。虽然这两个框架用处不同,架构不同,这里比较和分析都处在的定时触发逻辑
先来看xxl-job的逻辑
1.保存任务数据
这里只关心触发逻辑,所以新的调度逻辑分为 corn表达式和 固定速度 (以固定的时间间隔来调度)
使用的同一个字段存入 通过type区分不同的调度策略详见 XxlJobInfo类
private String scheduleType; // 调度类型
private String scheduleConf; // 调度配置,值含义取决于调度类型
private String misfireStrategy; // 调度过期策略
现在还有过期策略,即如果配置的调度策略完全不能触发,时间都在当前时间点以前,会有一次补偿调度策略,这个也可以设计到业务系统的消息发送中,例如触发消息发送的时间小于当前时间可以配置补偿发。
现在已经把要调度的任务配置存进去了,下面我们开始查询他做调度了。。。看类XxlJobInfoDao#scheduleJobQuery方法
只有JobScheduleHelper 类用到,直接贴出所有逻辑代码
2.调度逻辑
调度的源头,任务查询及触发
//这里是一个一直进行调度逻辑的线程,JobScheduleHelper#toStop方法销毁只有在spring DisposableBean#destroy卸载bean时调用
public void start(){
// schedule thread
scheduleThread = new Thread(new Runnable() {
@Override
public void run() {
try {
//使用sleep方式进行循环执行调度逻辑,activity为wait方式会释放锁资源,可能节省一点消耗, 以5秒一个周期轮询执行,
// 会补偿一下当前毫秒数,使其之后执行时间为整秒
TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis()%1000 );
} catch (InterruptedException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
// 这里是取任务配置数量的最大数量,也就是任务管理菜单中最多配置多少个任务,会取快慢任务线程数相加*20 默认6000个,所以我们配置的任务
//数量上线默认是6000个,当然也可以通过提高线程数量来提高任务数量。但是阅读下面代码会发现这里不是配置的任务总数,而是当前可以触发的任
//务的上限,也就是任务配置会存下次期待调度时间,每隔5秒左右调度一次,这次调度的满足当前触发时间的所有任务,也就是一次调度的并发调度
//数量上限,那这个就很多了...
// pre-read count: treadpool-size * trigger-qps (each trigger cost 50ms, qps = 1000/50 = 20)
int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;
while (!scheduleThreadToStop) {
// Scan Job
省略部分代码...
// db锁,这里相当于提供了 server端 HA模式的基础,同时多个server调度只会一个调度成功,同时上面的时间修正逻辑,保证调度时间为整秒
preparedStatement = conn.prepareStatement( "select * from xxl_job_lock where lock_name = 'schedule_lock' for update" );
preparedStatement.execute();
// tx start
// 1、pre read
long nowTime = System.currentTimeMillis();
//获取 dao 并去db里获取任务配置,这里XxlJobAdminConfig实现了一个保证配置加载后才调度的逻辑,通过spring的InitializingBean#afterPropertiesSet 接口方法来暴露自己的一个静态方法,如果配置没有加载成功调用dao方法会NPE,当然调度逻辑等初始化动作也是在这里完成的,所以顺序上决定了调度逻辑等一定等配置加载成功后才开始
// PRE_READ_MS 预取时间间隔,也就是当前 调度时间会向前取5秒,期待调度的时间提前5秒触发。
List scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
if (scheduleList!=null && scheduleList.size()>0) {
// 2、push time-ring
for (XxlJobInfo jobInfo: scheduleList) {
// time-ring jump
//如果服务宕机了,或者重启等等,导致超过了调度周期(5秒的调度周期),也就是本来由时间上的上一次或上很多次调度触发的数据被本次调度查到了,这就可能代表着可能中间存在多次调度未触发,而按照周期性一次一次计算下次预期调度时间,那这次调度完了计算出来的下次调度还是在当前时间以前,例如调度周期1分钟调度一次,宕机5分钟了,现在查到的预期调度时间为5分钟前,如果直接调度成功会重复调度5次当前时间以前的任务,这里直接pass并计算下一次调度时间,但是计算下一次调度时间也是传入当前时间,直接修正预期下次调度时间为当前时间之后,因为调度时间周期为 5秒,所以会+ PRE_READ_MS 判断,如果是一次性的调度则会补偿这次调度
if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
// 2.1、trigger-expire > 5s:pass && make next-trigger-time
logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());
// 1、misfire match
MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
// 一次性调度补偿
if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) {
// FIRE_ONCE_NOW 》 trigger
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.MISFIRE, -1, null, null, null);
}
// 2、fresh next
refreshNextValidTime(jobInfo, new Date());
} else if (nowTime > jobInfo.getTriggerNextTime()) {
// 2.2、trigger-expire < 5s:direct-trigger && make next-trigger-time
// 1、trigger
JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId() );
// 2、fresh next
refreshNextValidTime(jobInfo, new Date());
// next-trigger-time in 5s, pre-read again
//如果下次发送时间在当前时间之后5秒内,会进行第二次触发,放到另一个线程中执行触发逻辑
if (jobInfo.getTriggerStatus()==1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring
//放到map中另一个线程触发
pushTimeRing(ringSecond, jobInfo.getId());
// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
} else {//同理上边的逻辑 进行预取5秒的直接触发 但是区别于上面是第一次触发
// 2.3、trigger-pre-read:time-ring trigger && make next-trigger-time
// 1、make ring second
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);
// 2、push time ring
//放到map中另一个线程触发
pushTimeRing(ringSecond, jobInfo.getId());
// 3、fresh next
refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
}
}
// 3、update trigger info
for (XxlJobInfo jobInfo: scheduleList) {
//循环更新数据库 任务表
XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleUpdate(jobInfo);
}
} else {
preReadSuc = false;
}
// tx stop
} catch (Exception e) {
if (!scheduleThreadToStop) {
logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread error:{}", e);
}
} finally {
省略部分代码...
}
long cost = System.currentTimeMillis()-start;
// Wait seconds, align second
if (cost < 1000) { // scan-overtime, not wait
//如果循环调度执行时间耗时小于 1秒,如果查到了执行的任务数据则再sleep 到下一整秒,如果没有查到数据则sleep到之后第5整秒,所以到这里发现了,实际的 循环周期取决于执行时间,如果没有数据查到,则10秒的循环周期,如果有数据 && 执行时间小于1秒 则6秒/5秒周期,执行时间大于1秒则 5 + (执行时间 向前取整到整秒)
try {
// pre-read period: success > scan each second; fail > skip this period;
TimeUnit.MILLISECONDS.sleep((preReadSuc?1000:PRE_READ_MS) - System.currentTimeMillis()%1000);
} catch (InterruptedException e) {
if (!scheduleThreadToStop) {
logger.error(e.getMessage(), e);
}
}
}
}
logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread stop");
}
});
scheduleThread.setDaemon(true);
scheduleThread.setName("xxl-job, admin JobScheduleHelper#scheduleThread");
scheduleThread.start();
// ring thread
// 上面 pushTimeRing 方法放入的直接预先5秒触发逻辑的线程
ringThread = new Thread(new Runnable() {
@Override
public void run() {
while (!ringThreadToStop) {
// align second
try {
// sleep 1秒 ,1秒循环周期触发,同样也会取整秒
TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis() % 1000);
} catch (InterruptedException e) {
if (!ringThreadToStop) {
logger.error(e.getMessage(), e);
}
}
try {
// second data
List ringItemData = new ArrayList<>();
int nowSecond = Calendar.getInstance().get(Calendar.SECOND); // 避免处理耗时太长,跨过刻度,向前校验一个刻度;
for (int i = 0; i < 2; i++) {
//这里也是一个循环周期的预取逻辑
List tmpData = ringData.remove( (nowSecond+60-i)%60 );
if (tmpData != null) {
ringItemData.addAll(tmpData);
}
}
// ring trigger
logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData) );
if (ringItemData.size() > 0) {
// do trigger
for (int jobId: ringItemData) {
// do trigger
JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
}
// clear
ringItemData.clear();
}
} catch (Exception e) {
if (!ringThreadToStop) {
logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}", e);
}
}
}
logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread stop");
}
});
ringThread.setDaemon(true);
ringThread.setName("xxl-job, admin JobScheduleHelper#ringThread");
ringThread.start();
}
// 刷新下次触发时间
private void refreshNextValidTime(XxlJobInfo jobInfo, Date fromTime) throws Exception {
Date nextValidTime = generateNextValidTime(jobInfo, fromTime);
if (nextValidTime != null) {
jobInfo.setTriggerLastTime(jobInfo.getTriggerNextTime());
jobInfo.setTriggerNextTime(nextValidTime.getTime());
} else {
jobInfo.setTriggerStatus(0);
jobInfo.setTriggerLastTime(0);
jobInfo.setTriggerNextTime(0);
logger.warn(">>>>>>>>>>> xxl-job, refreshNextValidTime fail for job: jobId={}, scheduleType={}, scheduleConf={}",
jobInfo.getId(), jobInfo.getScheduleType(), jobInfo.getScheduleConf());
}
}
//放入 立即触发下次(预先)调度的map中由另一个线程调度
private void pushTimeRing(int ringSecond, int jobId){
// push async ring
List ringItemData = ringData.get(ringSecond);
if (ringItemData == null) {
ringItemData = new ArrayList();
ringData.put(ringSecond, ringItemData);
}
ringItemData.add(jobId);
logger.debug(">>>>>>>>>>> xxl-job, schedule push time-ring : " + ringSecond + " = " + Arrays.asList(ringItemData) );
}
// spring bean的destory触发
public void toStop(){
// 1、stop schedule
//基于数据库的 调度会立即停止
scheduleThreadToStop = true;
try {
TimeUnit.SECONDS.sleep(1); // wait
} catch (InterruptedException e) {
logger.error(e.getMessage(), e);
}
if (scheduleThread.getState() != Thread.State.TERMINATED){
// interrupt and wait
scheduleThread.interrupt();
try {
scheduleThread.join();
} catch (InterruptedException e) {
logger.error(e.getMessage(), e);
}
}
// if has ring data
//基于内存中的5秒内的预取调度会使其调度线程再调度 8 + 1秒
boolean hasRingData = false;
if (!ringData.isEmpty()) {
for (int second : ringData.keySet()) {
List tmpData = ringData.get(second);
if (tmpData!=null && tmpData.size()>0) {
hasRingData = true;
break;
}
}
}
if (hasRingData) {
try {
TimeUnit.SECONDS.sleep(8);
} catch (InterruptedException e) {
logger.error(e.getMessage(), e);
}
}
// stop ring (wait job-in-memory stop)
ringThreadToStop = true;
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
logger.error(e.getMessage(), e);
}
if (ringThread.getState() != Thread.State.TERMINATED){
// interrupt and wait
ringThread.interrupt();
try {
ringThread.join();
} catch (InterruptedException e) {
logger.error(e.getMessage(), e);
}
}
}
// ---------------------- tools ----------------------
public static Date generateNextValidTime(XxlJobInfo jobInfo, Date fromTime) throws Exception {
ScheduleTypeEnum scheduleTypeEnum = ScheduleTypeEnum.match(jobInfo.getScheduleType(), null);
if (ScheduleTypeEnum.CRON == scheduleTypeEnum) {
Date nextValidTime = new CronExpression(jobInfo.getScheduleConf()).getNextValidTimeAfter(fromTime);
return nextValidTime;
} else if (ScheduleTypeEnum.FIX_RATE == scheduleTypeEnum /*|| ScheduleTypeEnum.FIX_DELAY == scheduleTypeEnum*/) {
return new Date(fromTime.getTime() + Integer.valueOf(jobInfo.getScheduleConf())*1000 );
}
return null;
}
触发任务
JobTriggerPoolHelper 类 有两个触发线程池,一个快速一个慢速
public void addTrigger(final int jobId,
final TriggerTypeEnum triggerType,
final int failRetryCount,
final String executorShardingParam,
final String executorParam,
final String addressList) {
// choose thread pool
ThreadPoolExecutor triggerPool_ = fastTriggerPool;
AtomicInteger jobTimeoutCount = jobTimeoutCountMap.get(jobId);
if (jobTimeoutCount!=null && jobTimeoutCount.get() > 10) { // job-timeout 10 times in 1 min
triggerPool_ = slowTriggerPool;
}
// trigger
triggerPool_.execute(new Runnable() {
@Override
public void run() {
long start = System.currentTimeMillis();
try {
// do trigger
XxlJobTrigger.trigger(jobId, triggerType, failRetryCount, executorShardingParam, executorParam, addressList);
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
// check timeout-count-map
long minTim_now = System.currentTimeMillis()/60000;
if (minTim != minTim_now) {
minTim = minTim_now;
jobTimeoutCountMap.clear();
}
// incr timeout-count-map
long cost = System.currentTimeMillis()-start;
// 耗时大于 500毫秒的会放入到本地缓存统计慢调度次数,如果大于10次则会将任务id的任务放入慢调度线程触发调度,当然如果是HA模式的server端这个次数不太确定
if (cost > 500) { // ob-timeout threshold 500ms
AtomicInteger timeoutCount = jobTimeoutCountMap.putIfAbsent(jobId, new AtomicInteger(1));
if (timeoutCount != null) {
timeoutCount.incrementAndGet();
}
}
}
}
});
}
接下来 XxlJobTrigger#trigger 方法,会有一些调度策略例如广播,轮询等等和一些阻塞处理策略
继续走到 XxlJobTrigger#runExecutor 方法
会走到XxlJobScheduler#getExecutorBiz 这是 admin调用 执行器端的api,这里把心跳和业务执行调用抽象为一个接口,server端直拿到的是ExecutorBizClient 然后通过不同地址注册的执行器调用 ExecutorBizImpl类的run方法,这里可以理解为 聚合了执行器端和server端同一个动作到一个接口。实际server端永远都是ExecutorBizClient。具体执行器端执行逻辑和路由策略阻塞策略这里略过。
再来看 activity逻辑
任务job
activity任务分 无法执行的任务、循环定时任务、普通即时任务,中断挂起的任务 ,包括失败重试的任务也复用了定时任务和无法执行的任务,这里只讨论循环定时任务的逻辑,即act_ru_timer_job 表
所有任务基本逻辑 都在类 JobEntityManager
先从 JobEntityManager#findJobsToExecute 看起,这里会查表,会查出 LOCK_EXP_TIME_ 字段为null,也就是没有锁住的数据,
并且activity的定时job表有 RETRIES_ 具体业务不太清楚,应该是可以设置定时循环执行几次,并且兼容了失败重试次数的逻辑
activity 通过两个线程一个执行任务,一个解锁任务线程),activity整体架构使用命令模式,通过抽象出Command 每一个动作都是一个子类,通过CommandExecutor执行,同时嵌入CommandInterceptor 命令拦截器做日志,事务等,而多个命令和任务的贯穿通过责任链模式,而任务的执行逻辑通过锁抢占方式,任务锁逻辑根据 挂起任务表, 定时循环任务表 ,普通任务 的不同逻辑稍有不同 这里不做详细探讨。先看重置逻辑
任务重置并解锁
解锁任务可能会被重新拾起执行,或者最终状态不被执行
// 到处都是命令,包括查询等等
List expiredJobs = asyncExecutor.getProcessEngineConfiguration().getCommandExecutor()
.execute(new FindExpiredJobsCmd(asyncExecutor.getResetExpiredJobsPageSize()));
List expiredJobIds = new ArrayList(expiredJobs.size());
for (JobEntity expiredJob : expiredJobs) {
expiredJobIds.add(expiredJob.getId());
}
//单独的命令处理
if (expiredJobIds.size() > 0) {
asyncExecutor.getProcessEngineConfiguration().getCommandExecutor()
.execute(new ResetExpiredJobsCmd(expiredJobIds));
}
//-----------------ResetExpiredJobsCmd---------------------------
//如果是基于消息队列的任务,相当于一个异步任务,进入这个命令的可能是消息队列或者直接就是要解锁的任务
boolean messageQueueMode = commandContext.getProcessEngineConfiguration().isAsyncExecutorIsMessageQueueMode();
for (String jobId : jobIds) {
if (!messageQueueMode) {
Job job = commandContext.getJobEntityManager().findById(jobId);
// 里面的方法不看了,分为如果是普通任务删除一条任务重新插入,如果是挂起任务,定时任务直接解锁
commandContext.getJobManager().unacquire(job);
} else {
commandContext.getJobEntityManager().resetExpiredJob(jobId);
}
}
循环逻辑
while (!isInterrupted) {
省略代码...
try {
synchronized (MONITOR) {
if (!isInterrupted) {
isWaiting.set(true);
// 通过 wait指定时间 可以释放线程资源
MONITOR.wait(asyncExecutor.getResetExpiredJobsInterval());
}
}
} catch (InterruptedException e) {
if (log.isDebugEnabled()) {
log.debug("async reset expired jobs wait interrupted");
}
} finally {
isWaiting.set(false);
}
//-----------------销毁逻辑-------------
public void stop() {
synchronized (MONITOR) {
isInterrupted = true;
if (isWaiting.compareAndSet(true, false)) {
MONITOR.notifyAll();
}
}
}
上边的销毁逻辑和 线程循环逻辑和 循环查询待执行任务的逻辑相同,这里不做db锁抢占所以不会补偿到整秒逻辑,这里允许并发情况,锁的粒度是在每一条数据上,job表每条数据都通过一个字段维护锁,锁的释放通过一个单独的线程来做,而释放的逻辑
通过lock_exp_time锁的释放时间来维护.而 这个锁时间逻辑这里暂时不做讨论
// if all jobs were executed
millisToWait = asyncExecutor.getDefaultTimerJobAcquireWaitTimeInMillis();
// 这里是分页查询待执行的任务或者定时任务,然后决定wait 的时间
int jobsAcquired = acquiredJobs.size();
if (jobsAcquired >= asyncExecutor.getMaxTimerJobsPerAcquisition()) {
millisToWait = 0;
}
final AcquiredTimerJobEntities acquiredJobs = commandExecutor.execute(new AcquireTimerJobsCmd(asyncExecutor));
commandExecutor.execute(new Command() {
// 这里拿到定时任务数据,会把数据删除,带上锁时间插入到正常任务表中,再由正常任务表的轮询调度执行 这里只做中转
@Override
public Void execute(CommandContext commandContext) {
for (TimerJobEntity job : acquiredJobs.getJobs()) {
jobManager.moveTimerJobToExecutableJob(job);
}
return null;
}
});
@Override
public JobEntity moveTimerJobToExecutableJob(TimerJobEntity timerJob) {
if (timerJob == null) {
throw new ActivitiException("Empty timer job can not be scheduled");
}
JobEntity executableJob = createExecutableJobFromOtherJob(timerJob);
boolean insertSuccesful = processEngineConfiguration.getJobEntityManager().insertJobEntity(executableJob);
if (insertSuccesful) {
processEngineConfiguration.getTimerJobEntityManager().delete(timerJob);
// 将当前任务的触发事件传播给 异步执行线程,但是如果不是异步,那么就由普通任务轮询线程触发?有点奇怪的逻辑
triggerExecutorIfNeeded(executableJob);
return executableJob;
}
return null;
}
普通任务的轮询逻辑
AcquiredJobEntities acquiredJobs = commandExecutor.execute(new AcquireJobsCmd(asyncExecutor));
boolean allJobsSuccessfullyOffered = true;
for (JobEntity job : acquiredJobs.getJobs()) {
// 查到了 直接执行 没啥好说的
boolean jobSuccessFullyOffered = asyncExecutor.executeAsyncJob(job);
// 这里返回一个 任务执行队列是否满员了,下面做了判断,如果队列满员了会强制让当前线程等待一定时间 默认是0,应该可以配置,当然前提也是还有未查询出来的任务
if (!jobSuccessFullyOffered) {
allJobsSuccessfullyOffered = false;
}
}
// If all jobs are executed, we check if we got back the amount we expected
// If not, we will wait, as to not query the database needlessly.
// Otherwise, we set the wait time to 0, as to query again immediately.
millisToWait = asyncExecutor.getDefaultAsyncJobAcquireWaitTimeInMillis();
int jobsAcquired = acquiredJobs.size();
if (jobsAcquired >= asyncExecutor.getMaxAsyncJobsDuePerAcquisition()) {
millisToWait = 0;
}
// If the queue was full, we wait too (even if we got enough jobs back), as not overload the queue
if (millisToWait == 0 && !allJobsSuccessfullyOffered) {
millisToWait = asyncExecutor.getDefaultQueueSizeFullWaitTimeInMillis();
}
activity 的源码比较繁多,主要是抽象的command将所有动作都更细粒度的用子类实现,并且数据流转逻辑各司其职,接下来的任务执行逻辑大概也是根据任务类型重试次数下次执行时间等逻辑再决定是否重新分配到timer_job表,这里不做详细研究。
简单总结
xxl-job 用单个job_info表,并记录下次执行时间来做定时,调度触发逻辑线程用sleep + 修正到整秒,并结合db锁实现调度逻辑可以在多个服务执行。以循环周期维度并做了过期略过逻辑,也就是可能宕机等造成上次执行到当前时间还有多次要执行时直接以当前时间计算下次预期执行,防止一下多次执行。
区分了快速慢速执行任务的线程池(虽然是远程执行,但是提供了日志回显)
锁维度为全局
任务的时间逻辑在一条job_info上(相当于一个配置),数据量有一定上限,没有做分页处理
activity 用多个表区分任务类型,要执行的任务一定在普通任务表,一条数据逻辑会做多个表流转操作。
锁粒度更细到单条数据,并由专门的线程解锁
wait 方式会释放线程内的资源
不同业务完全解耦不同的cmd 并结合职责链做业务流转,通过 拦截器做通用逻辑。
任务的时间逻辑在单条数据上,配置带到当前业务数据上(类似于快照逻辑),每条数据的之后也可以修改逻辑更灵活,数据分页处理。