浅析后端微服务涉及到定时任务时如何解决多集群定时任务重复执行并发的方案对比

  在开发的过程中,项目中使用定时器已经不是一个新鲜的事情了,但是如果你的项目后期部署到集群环境下,如果不做处理,就会出现意想不到的问题,原因:由于我们项目同时部署在多台集群机器上,因此到达指定的定时时间时,多台机器上的定时器可能会同时启动,造成重复数据或者程序异常等问题。

  服务器采用了负载均衡,有两台服务器,部署的代码一样,所以里面的定时任务在某一时间会被同时执行,这就导致了很多其他意外的发生,想要解决的问题基本就三个:单点执行,故障转移,服务状态。这里对比一下网上找的几种方案:

方案一:固定执行定时任务的机器

  方法:在多台机器中选择一台执行定时任务,每次执行的时候回判断当前机器和指定的机器是否一致或者启动时就指定好执行机器。

  优缺点:这种方法是可以有效避免多次执行的情况,但是最明显的缺点就是单点故障问题,如果你指定的机器出现了宕机,任务就不会执行了,业务逻辑就会奔溃。

  有下面常见 2 种处理方案:

1、方式一:只在一台服务器上部署该定时任务代码。

  优点:解决方法容易理解。缺点:部署麻烦,需要多套代码,且当这台服务器出问题时就没定时任务了。

2、方式二:在定时任务代码上加上某个特定的ip限制,仅某个ip的服务器能运行该定时任务

  优点:解决方法容易理解,部署简单,不需要多套代码。  缺点:同上,只能规定一台服务器运行,发送故障时就没办法了。

  常见面试题:SpringCloud架构中如何保证定时任务只有一个服务在执行?

  有时候我们在开发过程中,很容易犯这样一个错误,就是在服务中写一个定时任务,然后也没做其它的处理就上线了。然后微服务架构为了保证高可用,一般都会部署多个服务,这个时候问题就来了,时间一到定时任务一启动,发现你部署多少个服务,就会跑多少个定时任务。如果服务器性能一般,定时任务占用内存又多,服务器跑死都有可能。

  问题:那基于SpringCloud的架构中,这种情况我们应该如何处理呢?这边我们先来简单概述一下,我们先来看一下任务执行的时序图。简单的来说,我们可以分为以下步骤:
  • 第一步先获取当前服务ip
  • 第二步获取springcloud集群ip信息
  • 最后将当前ip和集群的ip进行对比,如果当前ip是集群中最小的ip则执行定时任务业务,如果不是则return掉。
  通过这样的方法,就可以保证SpringCloud架构中定时任务只在一个服务在执行了,这边可能童鞋们会有一些疑问,为什么不用分布式调度框架来解决这个问题呢?当然我们也可以用分布式调度框架来解决这个问题,比如用elastic-job这个框架来就可以。但是引入第三方框架有时候会增加系统的复杂程度,学习成本也会相应的变大,最重要的是有些定时任务没必要进行分片,一个单点服务就可以搞定,就没必要耗费资源进行分片跑任务服务了。

  具体代码逻辑可以看这篇文章:https://blog.csdn.net/linzhiqiang0316/article/details/88047138

方案二:利用数据库的共享锁事务管理机制来运行定时任务

  原理:由于MySQL存在表锁和行锁,每次执行定时任务的时候从数据库表中读取记录,只有读取到的记录标识当前任务状态为未执行时,当前机器才会去触发任务,并且更新数据库状态(先更新,再执行),由于存在表锁和行锁,因此同一时刻只能有一个事务操作,可以保证只执行一次。

  方法:在数据库中新建一张表 - 定时任务表,存储了上次执行定时任务的 ip地址(ip),任务名称(task_name),是否正在执行(execute)。集群中的所有服务器都是走以下流程:

1、第一步:查找数据库的定时任务表。

2、第二步:检查是否有机器在运行定时任务。

  检查方法:update定时任务表的excute字段为1(1为执行中,0为未执行)、ip为自己的ip,如果update失败,则证明有机器在执行该定时任务,该机器的定时任务就不执行了,成功则进行第三步。

3、第三步:执行定时任务的具体内容。

4、第四步:还原excute字段为0。

  以上是该方案的流程,利用了 mysql 的共享锁机制判断,通过是否更新成功来判断是否有机器正在执行定时任务,这种方案可以保证任务只执行一次,且只要集群中有一台服务器是好的,就会执行任务。方案挺好,暂时想不到有啥缺点,可能增加了数据库的负担算一个吧。

方案三:利用 redis 数据库

  原理:和第三种差不多,只是通过 redis 的 key-value 来存储 任务名-执行ip。

  执行定时任务前先查询 redis 是否有改任务的值,没有就自己 执行,并插入新的 key-value。有的话就查看ip是否是自己,是的话就执行,不是的话就证明有其他机器在执行,自己就不执行啦。

  过期时间可以自己设置,方便有机器出故障时候可以转移机器执行任务。

  优点:利用了redis的自动过期机制实现了转移故障机器的问题,比较简单,而且redis的访问速度也很快。

  缺点:这里没有事务管理机制,访问redis的时候,一定会出现高并发的情况,所以得自己实现redis的共享锁机制。

1、通过redis实现任务调度思路

  实现功能之前,回顾下之前遇到的三个问题:单点执行,故障转移,服务状态。结合着redis的一些接口特性,解决思路如下:

(1)使用redis作为任务调度中心,采用了redis的自动过期与分布式锁特性

(2)每个服务的ip加项目名作为每台服务的唯一别名

(3)通过redis中对应key值中的value来判定执行的是哪台服务: 如redis中key为 schedular_root:projectA, value为192.168.1.187. 意为项目projectA当前执行任务的节点为192.168.1.187这台机器上的服务

(4)每次执行任务之前判定下redis中schedular_root:projectA是否为空,如果为空,则设置当前ip进去,设置一定时间的有效期,并执行定时任务;如果不为空,判断是否与本机ip相同,相同则执行定时任务,否则跳过

(5)设置有效期是为了某台机器发生故障时能进行故障转移

2、核心流程代码

  此解决方案非常简单,核心代码也十分容易集成,为了减少耦合度,我们采用了spring的aop进行实现。

// 核心 AOP 实现
@Aspect
@Component
@Log4j
public class QuartzAop {
    public boolean checkStatus(){
        String key = "schedular_root:projectA";
        try {
            // 这个接口必然是并发的,所以加分布式锁
            while (true) {
                // 一秒的超时时间
                boolean lock = RedisUtil.checkLock(key,1);
                if (lock) {
                    // 获取到锁,才能跳出
                    break;
                }
            }
            String ip = InetAddress.getLocalHost().getHostAddress();
            // 获取服务器上的工作ip
            String currentIp = RedisUtil.get(key);
            // 如果为空的时候,设置进去
            if(currentIp == null){
                RedisUtil.setex(key, ip, 10);
                return true;
            }
            // 就是当前机器,则返回true
            if(currentIp.equals(ip)){
                return true;
            }else{
                return false;
            }
        } catch (Exception e) {
            log.error(e);
            return false;
        } finally {
            RedisUtil.unLock(key);
        }
    }
    
    @Around("@annotation(org.springframework.scheduling.annotation.Scheduled)")
    public void around(ProceedingJoinPoint jp) throws Throwable{
        if(checkStatus()){
            String ip = InetAddress.getLocalHost().getHostAddress();
            log.info("现在正在执行"+jp.getSignature()+":"+ip);
            jp.proceed();
        }
    }
}

// RedisUtil中的锁代码
public static boolean checkLock(String key,int second) {
  String lockKey = "lock:" + key;
  try {
   // 1表示之前不存在,设置成功
   if (setnx(lockKey, "lock") == 1) {
    // 设置有限期
    setExpiredTime(lockKey, second);
    return true;
   } else {
    // 50毫秒的延迟,避免过多请求
    try {
     Thread.sleep(50L);
    } catch (InterruptedException e) {
     log.error(e);
    }
    return false;
   }
  } catch (RedisException e) {
   log.error(e);
   return true;
  }
 }

方案四、利用分布式框架

1、ShedLock解决多节点集群定时任务并发

  ShedLock适用场景:保证一个定时任务在多个服务实例之间最多只执行一次。配置相对来说最简单,对TaskName加锁的方式来实现,只需要数据库中新建一张表记录相关锁信息。详细可看这篇文章:https://blog.csdn.net/qq_43530309/article/details/109588488

2、Quartz的集群应用方式

3、Elastic Job:当当网开源的一个分布式调度解决方案,在业界比较通用。

原文地址:https://www.cnblogs.com/goloving/p/15141661.html