logo头像

BUG本天成,妙手偶得之

分布式场景下的定时任务实践

背景

应用中常会需要一些定时执行的任务,在spring中通过@Scheduled注解可以轻松实现。

然鹅现在正儿八经的项目一般不会只部署一个实例,至少也得搞两台支持不中断服务的发布,壕一点的部署个十几台、几十台的问题不大。

这样一来我们写定时任务时就需要考虑到这个任务到了执行的时候会不会所有实例都在执行,这样对业务会不会造成影响。

不造成影响的情况,如:

  • 任务和实例有关,即代码虽然一样,但执行的逻辑不同,或者操作的数据不同,比如各自处理分配给自己的任务
  • 没有修改共享数据
  • 修改了共享数据,但对共享数据的操作是幂等的(多次请求和一次请求影响相同)

造成影响的情况。。。反之。

解决思路

通过一个独占锁控制每个任务的执行权,必须获得了锁的实例才能执行任务,执行完再释放锁。这个锁的资源需要是所有实例都能访问的同一份资源,可以通过MySQL、Redis等实现。

因为所有实例都需要请求这个共享资源,所以需要提供一个服务接收这些请求。

目标

用一个自定义注解@SyncJob代替@Scheduled即可拥有分布式下同步执行的能力(同一时刻只有一台执行),且定时的规则同@Scheudled。

基于这个目标,进行下面的设计。

架构设计

DB作为“资源中心”,需要如下结构:

字段 描述
ID 任务唯一标识,可以确定到具体执行的方法
状态 任务执行状态,待执行,执行中
本次开始执行时间 每次开始执行时更新,和状态一起作为CAS操作的判断条件
本次结束执行时间 每次执行结束时更新,如果需要支持按结束时间间隔则需要
  • register 将定时任务的信息注册到“定时任务服务”,最重要的是一个表示该方法的唯一标识,可以自定义,也可以来自应用名+完整类名+方法名(重载?可以,但没必要)
  • query 查询当前实例上“待执行”的任务
  • lock 获取目标任务的“当前执行轮次”的执行权限(如果另一个实例先一步抢到锁并执行完释放了锁,且当前时间没到下次执行时间,则不应该得到资源)
  • unlock 释放锁

流程设计

  1. 注册任务信息,启动时自动完成
  2. 查询当前实例待执行任务,轮询间隔1s
  3. 获取目标任务执行权限(加锁)
  4. 执行任务(通过反射执行@SyncJob注解的方法)
  5. 释放执行权限(解锁)

技术方案

  • 通过springboot的自动装配实现只需要引入一个maven依赖就能使用功能
  • 应用启动时扫描所有带@SyncJob注解的bean,注册到ScheduleService
  • 应用启动时向schedule-service推送当前应用里的任务信息,持久化(如果不存在)到数据库
  • 轮询待执行任务(向schedule-service请求,1次/s),判断执行条件(cron表达式、指定间隔等规则),抢锁,执行,解锁

自动装配

springboot提供的能力,spring全家桶中各种starter就是基于这个能力实现的。

只需要添加一个maven依赖,应用启动时就会自动扫描该包下的指定类,创建指定bean,让我们不用在自己项目里写一堆重复代码去创建bean。

添加文件:resources/META-INF/spring.factories

1
2
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.xxx.SyncJobConfig

引用了此依赖的项目在启动时会把SyncJobConfig类里面定义的bean创建并管理。

有两个重要的bean需要在SyncJobConfig中创建:

  1. 自定义bean扫描类,实现BeanPostProcessor接口,spring每创建一个bean都会调用它的方法,可以用来扫描含@SyncJob方法的bean,放入“集合”备用,也可以在这里注册作业信息到schedule-service(或者启动后注册)
  2. 定时任务执行类,负责执行核心流程:轮询,加锁,执行,解锁…

防坑指南

应用关闭/重启导致锁未释放

Q:任务执行中如果有人重发怎么办?任务执行到一半应用关闭,锁也没释放,重启后永远查询不到那个作业的记录。

A:给定时任务执行类定义一个bean的销毁方法(@PreDestroy),应用关闭时框架会自动调用,在里面完成善后。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 举个栗子
public class ScheduleService {
// ...省略无关代码
@PreDestroy // 应用关闭会销毁bean,销毁bean会执行此注解修饰的方法
public void shutdown() {
running = false; // 用一个标记让轮询直接跳过简单粗暴
jobExecutor.shutdown();// 不再接收新任务
try {
// 等待所有作业执行完,或者超时
boolean ok = jobExecutor.awaitTermination(120, TimeUnit.SECONDS);
logger.warn("ScheduleService {}", ok ? "完美停止" : "等待超时");
} catch (InterruptedException e) {
logger.warn("ScheduleService 等待线程被中断 {}", e.getMessage());
}
}
}

应用进程被强杀导致锁未释放

Q:有人杀进程,服务器宕机等极端情况应用死的比较干脆,没有那么多时间优雅狗带,这时没释放锁怎么办?
A:没得办,只能人工干预。

可以做一个控制台页面,做更多事情,懒得做可以写个后门,或者直接改数据库。

schedule-service短暂抽风

重启中、挂掉、网络故障、数据库异常等意外出现时,众多业务系统无法和中心交流,也就无法判断能否执行任务,最好也就不要执行了,耐心等待或者告警。

接口 失败影响 对策
注册任务 应用启动失败/无法执行任务 等待服务恢复
请求资源 无法执行任务 等待服务恢复
释放资源 由于锁没释放,服务恢复后也不能执行 人工干预

针对释放资源失败必须人工干预,可以用一些措施偷个懒。

  1. 放入本地队列,隔段时间再试
  2. 放入远程队列(如各种MQ),由专门的服务负责重试

缺点

  • 强依赖schedule-service,如果它挂了,接入的应用将会无法启动,或者启动后无法执行定时任务
    为之奈何?
  • 可能丢掉锁,比如应用进程被kill,正在执行的任务被中断且不会释放锁
    人工干预
    超时自动释放锁减小影响
  • 时间精度不高,因为每秒轮询一次进行筛选、加锁、执行、解锁,可能有秒级的误差
    问题不大

源码