问题

定时任务在同一时间被执行了多次。发现是该微服务有多个实例。每个实例互不干扰都执行了。

原因

  1. 任务执行时间过长,导致多个线程同时执行任务。这可能会发生在一个任务的执行时间大于任务执行周期的情况下。如果是这种情况,可以考虑将任务的执行时间缩短或者使用分布式锁来解决多线程并发执行的问题。
  2. 服务器时间不准确。如果服务器时间不准确,那么定时任务的执行时间也会不准确。可以通过命令行查看服务器时间,然后将其设置为与世界标准时间一致。
  3. 操作系统时间同步不准确。如果操作系统时间同步不准确,那么定时任务的执行时间也会不准确。可以通过操作系统的时间同步功能来解决这个问题。
  4. 定时任务的时间设置有误,导致在每个执行时间点会执行多次。检查cron表达式的配置是否正确,尤其是秒数是否被指定为0。如果cron表达式没有问题,检查程序没有被多次启动或者存在多个线程在执行相同的任务,这也有可能导致定时任务执行多次的问题。

解决问题

启发另一种思路解决实践举例–分布式锁
要避免定时任务被集群中的多个实例多次执行,可以使用分布式锁来解决这个问题。分布式锁是一种在集群环境下保证只有一个进程/线程执行指定操作的机制。使用分布式锁可以避免多个实例同时执行同一个定时任务的问题,从而确保任务只会被执行一次。
在Java中,可以使用Redisson或ZooKeeper等分布式锁实现库来实现分布式锁。这些库提供了一些简单的API,可以方便地实现分布式锁的获取和释放,从而保证定时任务的顺序执行。
使用分布式锁的一般步骤如下:

  1. 获取分布式锁
  2. 执行定时任务
  3. 释放分布式锁
    以下是使用Redisson实现分布式锁的示例代码:
@Component
public class MyTask {
    private final RedissonClient redissonClient;
    private final String lockKey = "myTaskLock";
     @Autowired
    public MyTask(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }
     @Scheduled(cron = "0 0 8 * * ?")
    public void runTask() throws InterruptedException {
        RLock lock = redissonClient.getLock(lockKey);
        boolean isLocked = lock.tryLock(0, 10, TimeUnit.SECONDS); // 等待10秒尝试获取锁
        if (!isLocked) {
            // 如果获取锁失败,则不执行任务
            return;
        }
         try {
            // 执行定时任务
            // ...
        } finally {
            lock.unlock();
        }
    }
}

在上面的示例代码中,使用Redisson获取一个名为"myTaskLock"的分布式锁,在任务执行前尝试获取锁,如果获取失败则不执行任务,如果获取成功则执行任务并在执行完毕后释放锁。这样就可以保证同一时刻只有一个实例在执行此任务。

另一个思路–rocketMQ

如果你使用了RocketMQ作为消息中间件,可以使用RocketMQ的消息队列来解决多实例执行定时任务的问题。使用消息队列的方式,每个实例都会向消息队列发送一个消息,只有一个实例会收到消息并执行任务,其他实例则不会执行任务。
以下是使用RocketMQ的示例代码:

@Component
public class MyTask {
    @Autowired
    private DefaultMQProducer defaultMQProducer;
     @PostConstruct
    public void registerTask() throws MQClientException {
        // 注册定时任务
        SchedulerFactory schedulerFactory = new StdSchedulerFactory();
        Scheduler scheduler = schedulerFactory.getScheduler();
        JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
                .withIdentity("myTask")
                .build();
        Trigger trigger = TriggerBuilder.newTrigger()
                .withIdentity("myTaskTrigger")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 0 8 * * ?"))
                .build();
        scheduler.scheduleJob(jobDetail, trigger);
        scheduler.start();
    }
     public void executeTask() throws Exception {
        // 发送消息到RocketMQ
        Message message = new Message("myTaskTopic", "myTaskTag", "myTaskKey", "myTaskContent".getBytes());
        SendResult sendResult = defaultMQProducer.send(message);
        System.out.printf("%s%n", sendResult);
    }
}
 @Component
public class MyJob implements Job {
    @Autowired
    private DefaultMQPushConsumer defaultMQPushConsumer;
     @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // 接收消息并执行任务
        try {
            defaultMQPushConsumer.subscribe("myTaskTopic", "myTaskTag");
            MessageListenerConcurrently messageListener = (msgs, context1) -> {
                for (MessageExt msg : msgs) {
                    try {
                        // 执行定时任务
                        // ...
                        System.out.printf("Task executed by %s%n", RocketMQUtil.getLocalAddress());
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            };
            defaultMQPushConsumer.registerMessageListener(messageListener);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在上面的示例代码中,MyTask类注册了一个定时任务,并在任务执行时发送一个消息到RocketMQ的"myTaskTopic"主题。MyJob类则监听"myTaskTopic"主题,并在接收到消息时执行定时任务。使用RocketMQ的消息队列,可以确保任务只会被一个实例执行,从而避免了多个实例同时执行同一个定时任务的问题。

最终解决问题:使用redis标记

在多实例的情况下,可以通过在Redis中设置标记来保证定时任务只被执行一次。可以使用Redis的setnx命令来实现这个功能。

在每个实例执行定时任务之前,先使用setnx命令尝试在Redis中设置一个标记,如果设置成功则说明当前实例可以执行任务,如果设置失败则说明当前任务已经被其他实例执行,当前实例就不再执行任务。当任务执行完毕之后,再使用del命令删除Redis中的标记。

这样可以保证在分布式环境下,同一个任务只会被执行一次
以下是通过Redis标记来保证定时任务只被执行一次的示例代码:
.setIfAbsent方法

@Component
public class MyTask {
    private final RedisTemplate<String, String> redisTemplate;
    private final String lockKey = "myTaskLock";
     @Autowired
    public MyTask(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
     @Scheduled(cron = "0 0 8 * * ?")
    public void runTask() throws InterruptedException {
        // 尝试获取redis中的key
        Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, "true");
        if (isLocked != null && isLocked) {
            // 如果获取key成功,则执行任务
            // ...
             // 任务执行完毕后删除锁
            redisTemplate.delete(lockKey);
        }
    }
}

在上面的示例代码中,在任务执行前,使用setIfAbsent方法尝试在Redis中设置名为"myTaskLock"的标记,如果设置成功则说明当前实例可以执行任务。任务执行完毕之后,使用delete方法删除Redis中的标记,这样其他实例就可以尝试获取redis中的key并执行任务。

最终解决加点料

多实例的定时任务使用redis标志为当天的年月日,控制只执行一次,并设置过期时间为3秒
可以使用Redis来实现多实例的定时任务,具体实现方式如下:

  1. 在定时任务开始时,先获取当前日期的年月日,并将其作为 Redis 的 key,然后使用 setnx 命令(只有 key 不存在时才设置值)来设置一个值为 1 的标志,表示当前实例将执行该定时任务。
  2. 然后,再使用 expire 命令为该 key 设置过期时间为 3 秒,确保只有 3 秒内才能执行该定时任务。
  3. 定时任务执行完毕后,使用 del 命令删除该 key,释放标志,以便其他实例可以执行该定时任务。
    下面是一个示例代码:
@Component
public class MyTask {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
     @Scheduled(cron = "0 0 8 * * ?")
    public void executeTask() {
        // 获取当前日期的年月日,作为 Redis 的 key
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
        String key = dateFormat.format(new Date());
         // 使用 setnx 命令设置标志
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1");
         // 如果设置成功,则执行定时任务
        if (flag != null && flag) {
            stringRedisTemplate.expire(key, 3, TimeUnit.SECONDS);
            // 执行定时任务
            // ...
             // 任务执行完毕后,删除标志
            stringRedisTemplate.delete(key);
        }
    }
}

在上面的示例代码中,我们使用了 Spring 的 Scheduled 注解来定义定时任务的执行时间。在 executeTask 方法中,先使用 SimpleDateFormat 获取当前日期的年月日,并将其作为 Redis 的 key。然后使用 opsForValue() 方法获取 StringRedisTemplate 的操作接口,并使用 setnx 命令设置一个值为 1 的标志。如果设置成功,则使用 expire 命令为该 key 设置过期时间为 3 秒,表示该实例将执行该定时任务。任务执行完毕后,删除该 key,释放标志。
这样,就可以很方便地使用 Redis 实现多实例的定时任务,并确保每个定时任务只会被一个实例执行。

注意

 

由于spring scheduled默认是所有定时任务都在一个线程中执行!!这是个大坑!!!
也就是说定时任务1一直在执行,定时任务2一直在等待定时任务1执行完成。这就导致了生产上定时任务全部卡死的现象。

另一个解决办法

补充小知识

 

**发现在线程数较少的情况下,并不会分配到多个CPU上,而是在单CPU中执行!**而我们在操作系统相关课程中学过,CPU在执行多个进程(线程可以看作轻量级进程)时,是把一段时间分成很多个时间片来分配的,在这个例子中,我们的多线程创建了4个子线程,CPU把资源分配给这个Java程序后,程序内部有4个线程共享这个时间片,也即子线程轮流被分配更小的时间片来执行,而一个时间片不足以把单个线程跑完,于是要等待下一个时间片的到来。而在单线程模式下,程序获得时间片后,由于程序内部只有1个线程,CPU的时间片全部分给这个主线程,从头到尾执行完毕。期间节约了等待获取时间片和线程切换带来的时间损耗,所以总耗时比多线程更少。
还有一种解释,就是Java中创建的线程并不等价于操作系统中的进程,并不是由操作系统直接进行调用的。要知道,Java程序运行在JVM上,而JVM又是运行在OS之上的一个进程,所以我们创建的线程都包含在JVM这个进程当中。

除非指定在 多CPU的物理内核上进行