最近要做一个定时任务处理的需求,在分页处理上。发现了大家容易遇到的一些"坑",特此分析记录一下。
场景
现在想象一下这个场景,你有一个定时处理任务,需要查询数据库任务表中的所有待处理任务,然后进行处理。
举个例子:生成用户的月度账单,并且要尽可能确保每个用户都能生成自己的账单,推送到用户的邮箱中。
分析
拿到这样一个任务之后,我们很自然的就想到了加一个定时任务,每隔一段时候处理这些任务。
任务肯定是先查询,再处理。处理完成之后,再更新任务状态。
关于查询
一般开始一个任务时,都是要有一个范围的,比如特定时间或特定用户。如果不界定范围,由于产线上的数据不断更新,我们的程序就会变得不可控!因此我们先要界定一个范围,然后再进行处理。
由于任务基数可能比较大,所以查询任务的时候,不能一次性全部读取到内存中,因此需要进行分页处理。
关于更新
任务更新的时候,考虑到并发,我们一般都要进行待状态更新,这样才能确定更新结果符合预期。如果更新结果不符合预期,还可以适当告警。
分页1.0
根据上面的需求,我们很容易就写出了如下v1.0代码(使用了PageHelper进行分页)。
Date startTime = getStartTime();
Date endTime = getEndTime();
Integer pageNum = 1;
while (true) {
PageHelper.startPage(pageNum, 1000);
List<TaskDTO> taskDTOList = taskService.queryTask(TaskStatusEnum.INIT.getCode(), startTime, endTime);
if (CollectionUtils.isEmpty(taskDTOList)) {
break;
}
for (TaskDTO taskDTO : taskDTOList) {
taskService.updateTaskStatus(taskDTO.getTaskId(), TaskStatusEnum.INIT.getCode(), TaskStatusEnum.SUCCESS.getCode());
}
pageNum++;
}
程序没问题?拉出来跑一跑
乍一看,这样的处理代码没什么问题。但是如果跑起来,你就会发现出现了“跳读”现象,即一个调度处理完成后,数据库中仍然会存在一些待处理的任务,这些数据被 跳读 了!
分页2.0 —— 解决跳读问题
问题发生在哪里呢?
问题出在分页查询的逻辑错了。
PageHelper固然能够帮助我们简化分页的处理,但是它的应用场景是原始数据不变
的场景。
前面,我们虽然根据起止时间界定了任务的范围。但是,当我们一边根据任务状态查询遍历,一边更新任务状态时,实际上待处理的任务总量是实时变化的!不可避免的,会跳过部分待处理的任务。这就是上面那段代码存在的问题。
如何解决这个问题?
其实,解决这个问题,最简单的方式只需要修改一行代码。就是将上面循环体内的pageNum++
去掉,即一直只查第一页。
因为我们默认每次处理完成之后,都是会更新任务状态为成功。这样,我们只要一直查待处理的第一页,这样就不会有 跳读 的问题了。
Date startTime = getStartTime();
Date endTime = getEndTime();
while (true) {
PageHelper.startPage(1, 1000);
List<TaskDTO> taskDTOList = taskService.queryTask(TaskStatusEnum.INIT.getCode(), startTime, endTime);
if (CollectionUtils.isEmpty(taskDTOList)) {
break;
}
for (TaskDTO taskDTO : taskDTOList) {
taskService.updateTaskStatus(taskDTO.getTaskId(), TaskStatusEnum.INIT.getCode(), TaskStatusEnum.SUCCESS.getCode());
}
}
这样就没问题了?其实还有坑
上面解决问题的代码方案,其实是基于正常的场景下的。
现在考虑一个异常的场景:假如我们在处理任务的时候,发生 异常 了(如调用外部系统异常,网络抖动,某些数据有问题),导致某些任务失败。
如果出现这样的问题,当前面积攒的失败任务过多时,程序就会一直重复处理某些数据。极端场景下,第一页的1000条数据全部失败,程序就一直无法进行处理下去了,而且循环还无法停止。。。
那么针对这种场景,该如何处理呢?
分页3.0 —— 异常处理
添加失败态
一种思路是添加失败态。我们可以将处理异常的任务给一个失败的终止态,这样下次查询的时候就不会查出这个失败的任务了。
当一个任务执行异常之后,马上就置为失败可能会有点过于激进。为了减少失败次数,我们可以在task表中添加一个重试次数的字段。每次处理失败,重试次数都+1。当达到我们预期的一个值时,例如3次,就置为失败态,后续可以告警或人工处理。
代码如下:
Date startTime = getStartTime();
Date endTime = getEndTime();
Integer maxRetryTimes = 3;
while (true) {
PageHelper.startPage(1, 1000);
List<TaskDTO> taskDTOList = taskService.queryTask(TaskStatusEnum.INIT.getCode(), startTime, endTime);
if (CollectionUtils.isEmpty(taskDTOList)) {
break;
}
for (TaskDTO taskDTO : taskDTOList) {
try {
taskService.updateTaskStatus(taskDTO.getTaskId(), TaskStatusEnum.INIT.getCode(), TaskStatusEnum.SUCCESS.getCode());
} catch (Exception e) {
taskService.updateRetryTimes(taskDTO.getTaskId(), taskDTO.getRetryTimes() + 1);
if (taskDTO.getRetryTimes() + 1 >= maxRetryTimes) {
taskService.updateTaskStatus(taskDTO.getTaskId(), TaskStatusEnum.INIT.getCode(), TaskStatusEnum.FAILED.getCode());
}
}
}
}
但是仔细想想上面这种写法,重试是在一次任务执行调度期间发生的。一般来说,当一个异常发生后,马上再次调用时,大概率依然还是会发生异常的,因此多数场景下,该任务只会做失败处理。
添加定位标识
另一种思路是添加定位标识。这种思路并不更改任务状态,而是通过添加定位标识的方式,来完成全部遍历的要求。
简单来说,就是每次查询出待处理任务时,都带出对应的taskId,并按照正序排序。一个批次处理结束后,获取上一个批次处理的最大taskId。下一次查询的时候,带上大于此taskId的条件,这样循环处理,就能完成全部待处理任务的遍历。
代码如下:
Date startTime = getStartTime();
Date endTime = getEndTime();
Integer startId = 0;
while (true) {
PageHelper.startPage(1, 1000);
List<TaskDTO> taskDTOList = taskService.queryTask(TaskStatusEnum.INIT.getCode(), startTime, endTime, startId);
if (CollectionUtils.isEmpty(taskDTOList)) {
break;
}
for (TaskDTO taskDTO : taskDTOList) {
taskService.updateTaskStatus(taskDTO.getTaskId(), TaskStatusEnum.INIT.getCode(), TaskStatusEnum.SUCCESS.getCode());
startId = taskDTO.getTaskId();
}
}
使用定位标识进行定位,解决了可能无法遍历全部任务的问题。并且对于处理异常的任务,在下一次调度拉起的时候,又能够重新进行执行。(对于像网络抖动、系统调用异常这样的问题,可以待网络或下游系统恢复后,在下一次调度执行时自动执行完成,这是其优点)
综合使用两种方案
上述两种方式都可以解决异常处理问题。在实际问题中,往往两者综合起来进行使用。即:
- 使用定位标识来进行遍历,从而在一个调度执行期间,能够对所有待处理任务进行处理;并且对于处理异常的任务,能够在下一次调度启动时自动拉起执行。
- 另一方面,任务实体中添加一个重试次数字段。当达到最大重试次数后,任务翻为失败,由人工进行处理。
具体的重试次数和调度执行时间间隔,可以由具体的业务场景来决定。这样就能尽可能减少人工干预的次数,减少人力成本。
总结
上面所说的一些场景,是我所遇到过的一些“坑”。在真正的业务场景中,可能还会有更多更复杂的分页问题。一般来说,具体问题还是需要具体看待,不能照搬照抄,但是可以借鉴参考。
总结一下,在我看来,遇到这种定时任务处理场景下的分页问题,需要:
- 以不变应万变。控制数据总量不变,这样才能准确分页。
- 考虑异常场景。防止异常场景下,程序不断重试,阻塞后续正常任务执行。
- 添加监控。线上问题层出不穷,做好监控可以及时发现并处理。