定时任务处理中的分页问题

最近要做一个定时任务处理的需求,在分页处理上。发现了大家容易遇到的一些"坑",特此分析记录一下。

场景

现在想象一下这个场景,你有一个定时处理任务,需要查询数据库任务表中的所有待处理任务,然后进行处理。

举个例子:生成用户的月度账单,并且要尽可能确保每个用户都能生成自己的账单,推送到用户的邮箱中。

分析

拿到这样一个任务之后,我们很自然的就想到了加一个定时任务,每隔一段时候处理这些任务。

任务肯定是先查询,再处理。处理完成之后,再更新任务状态。

关于查询

一般开始一个任务时,都是要有一个范围的,比如特定时间或特定用户。如果不界定范围,由于产线上的数据不断更新,我们的程序就会变得不可控!因此我们先要界定一个范围,然后再进行处理。

由于任务基数可能比较大,所以查询任务的时候,不能一次性全部读取到内存中,因此需要进行分页处理。

关于更新

任务更新的时候,考虑到并发,我们一般都要进行待状态更新,这样才能确定更新结果符合预期。如果更新结果不符合预期,还可以适当告警。

分页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) {
    
    
            // 重试次数+1,超过3次置为失败
            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();
    }
}

使用定位标识进行定位,解决了可能无法遍历全部任务的问题。并且对于处理异常的任务,在下一次调度拉起的时候,又能够重新进行执行。(对于像网络抖动、系统调用异常这样的问题,可以待网络或下游系统恢复后,在下一次调度执行时自动执行完成,这是其优点)

综合使用两种方案

上述两种方式都可以解决异常处理问题。在实际问题中,往往两者综合起来进行使用。即:

  1. 使用定位标识来进行遍历,从而在一个调度执行期间,能够对所有待处理任务进行处理;并且对于处理异常的任务,能够在下一次调度启动时自动拉起执行。
  2. 另一方面,任务实体中添加一个重试次数字段。当达到最大重试次数后,任务翻为失败,由人工进行处理。

具体的重试次数和调度执行时间间隔,可以由具体的业务场景来决定。这样就能尽可能减少人工干预的次数,减少人力成本。

总结

上面所说的一些场景,是我所遇到过的一些“坑”。在真正的业务场景中,可能还会有更多更复杂的分页问题。一般来说,具体问题还是需要具体看待,不能照搬照抄,但是可以借鉴参考。

总结一下,在我看来,遇到这种定时任务处理场景下的分页问题,需要:

  1. 以不变应万变。控制数据总量不变,这样才能准确分页。
  2. 考虑异常场景。防止异常场景下,程序不断重试,阻塞后续正常任务执行。
  3. 添加监控。线上问题层出不穷,做好监控可以及时发现并处理。

猜你喜欢

转载自blog.csdn.net/somehow1002/article/details/107624318