坑爹!Quartz 重复调度问题,你遇到过么?(2)

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 坑爹!Quartz 重复调度问题,你遇到过么?

3.2 quartz的调度过程

image.png


QuartzSchedulerThread是调度线程的具体实现,图3-4 是这个线程run()方法的主要内容,图中只提到了正常的情况下,也就是流程中没有出现异常的情况下的处理过程。由图可以看出,调度流程主要分为以下三步:


1)拉取待触发trigger:

调度线程会一次性拉取距离现在,一定时间窗口内的,一定数量内的,即将触发的trigger信息。那么,时间窗口和数量信息如何确定呢,我们先来看一下,以下几个参数:


idleWaitTime: 默认30s,可通过配置属性org.quartz.scheduler.idleWaitTime设置。

availThreadCount:获取可用(空闲)的工作线程数量,总会大于1,因为该方法会一直阻塞,直到有工作线程空闲下来。

maxBatchSize:一次拉取trigger的最大数量,默认是1,可通过org.quartz.scheduler.batchTriggerAcquisitionMaxCount改写

batchTimeWindow:时间窗口调节参数,默认是0,可通过org.quartz.scheduler.batchTriggerAcquisitionFireAheadTimeWindow改写

misfireThreshold: 超过这个时间还未触发的trigger,被认为发生了misfire,默认60s,可通过org.quartz.jobStore.misfireThreshold设置。

调度线程一次会拉取NEXT_FIRE_TIME小于(now + idleWaitTime +batchTimeWindow),大于(now - misfireThreshold)的,min(availThreadCount,maxBatchSize)个triggers,默认情况下,会拉取未来30s,过去60s之间还未fire的1个trigger。随后将这些triggers的状态由WAITING改为ACQUIRED,并插入fired_triggers表。


2)触发trigger:

首先,我们会检查每个trigger的状态是不是ACQUIRED,如果是,则将状态改为EXECUTING,然后更新trigger的NEXT_FIRE_TIME,如果这个trigger的NEXT_FIRE_TIME为空,也就是未来不再触发,就将其状态改为COMPLETE。如果trigger不允许并发执行(即Job的实现类标注了@DisallowConcurrentExecution),则将状态变为BLOCKED,否则就将状态改为WAITING。


3)包装trigger,丢给工作线程池:

遍历triggers,如果其中某个trigger在第二步出错,即返回值里面有exception或者为null,就会做一些triggers表,fired_triggers表的内容修正,跳过这个trigger,继续检查下一个。否则,则根据trigger信息实例化JobRunShell(实现了Thread接口),同时依据JOB_CLASS_NAME实例化Job,随后我们将JobRunShell实例丢入工作线。


在JobRunShell的run()方法,Quartz会在执行job.execute()的前后通知之前绑定的监听器,如果job.execute()执行的过程中有异常抛出,则执行结果jobExEx会保存异常信息,反之如果没有异常抛出,则jobExEx为null。然后根据jobExEx的不同,得到不同的执行指令instCode。


JobRunShell将trigger信息,job信息和执行指令传给triggeredJobComplete()方法来完成最后的数据表更新操作。例如如果job执行过程有异常抛出,就将这个trigger状态变为ERROR,如果是BLOCKED状态,就将其变为WAITING等等,最后从fired_triggers表中删除这个已经执行完成的trigger。注意,这些是在工作线程池异步完成。


3.3 排查问题

在前文,我们可以看到,Quartz的调度过程中有3次(可选的)上锁行为,为什么称为可选?因为这三个步骤虽然在executeInNonManagedTXLock方法的保护下,但executeInNonManagedTXLock方法可以通过设置传入参数lockName为空,取消上锁。在翻阅代码时,我们看到第一步拉取待触发的trigger时:

public List<OperableTrigger> acquireNextTriggers(final long noLaterThan, final int maxCount, final long timeWindow)throws JobPersistenceException {
    String lockName;
    //判断是否需要上锁
    if (isAcquireTriggersWithinLock() || maxCount > 1) {
        lockName = LOCK_TRIGGER_ACCESS;
    } else {
        lockName = null;
    }
    return executeInNonManagedTXLock(lockName,
                                     new TransactionCallback<List<OperableTrigger>>(){
        public List<OperableTrigger> execute(Connection conn) throws JobPersistenceException {
            return acquireNextTrigger(conn, noLaterThan, maxCount, timeWindow);
        }
    }, new TransactionValidator<List<OperableTrigger>>() {
         //省略
    });
}

在加锁之前对lockName做了一次判断,而非像其他加锁方法一样,默认传入的就是LOCK_TRIGGER_ACCESS

public List<TriggerFiredResult> triggersFired(final List<OperableTrigger> triggers) throws JobPersistenceException {
    //默认上锁
    return executeInNonManagedTXLock(LOCK_TRIGGER_ACCESS,
        new TransactionCallback<List<TriggerFiredResult>>() {
        //省略
        },new TransactionValidator<List<TriggerFiredResult>>() {
            //省略
           });
}

通过调试发现isAcquireTriggersWithinLock()的值是false,因而导致传入的lockName是null。我在代码中加入日志,可以更清楚的看到这个过程。


image.png


由图3-5可以清楚看到,在拉取待触发的trigger时,默认是不上锁。如果这种默认配置有问题,岂不是会频繁发生重复调度的问题?而事实上并没有,原因在于Quartz默认采取乐观锁,也就是允许多个线程同时拉取同一个trigger。我们看一下Quartz在调度流程的第二步fire trigger的时候做了什么,注意此时是上锁状态:


protected TriggerFiredBundle triggerFired(Connection conn, OperableTrigger trigger)
    throws JobPersistenceException {
    JobDetail job;
    Calendar cal = null;
    // Make sure trigger wasn't deleted, paused, or completed...
    try { // if trigger was deleted, state will be STATE_DELETED
        String state = getDelegate().selectTriggerState(conn,trigger.getKey());
         if (!state.equals(STATE_ACQUIRED)) {
            return null;
        }
    } catch (SQLException e) {
            throw new JobPersistenceException("Couldn't select trigger state: "
                    + e.getMessage(), e);
    }

调度线程如果发现当前trigger的状态不是ACQUIRED,也就是说,这个trigger被其他线程fire了,就会返回null。在3.2,我们提到,在调度流程的第三步,如果发现某个trigger第二步的返回值是null,就会跳过第三步,取消fire。在通常的情况下,乐观锁能保证不发生重复调度,但是难免发生ABA问题,我们看一下这是发生重复调度时的日志:


image.png


在第一步时,也就是quartz在拉取到符合条件的triggers 到将他们的状态由WAITING改为ACQUIRED之间停顿了有超过9ms的时间,而另一台服务器正是趁着这9ms的空档完成了WAITING-->ACQUIRED-->EXECUTING-->WAITING(也就是一个完整的状态变化周期)的全部过程,图示参见图3-6。


image.png


3.4 解决办法

如何去解决这个问题呢?在配置文件加上org.quartz.jobStore.acquireTriggersWithinLock=true,这样,在调度流程的第一步,也就是拉取待即将触发的triggers时,是上锁的状态,即不会同时存在多个线程拉取到相同的trigger的情况,也就避免的重复调度的危险。


3.5 心得

此次排查过程并非一帆风顺,走过一些坑,也有一些非技术相关的体会:


1)学习是一个需要不断打磨,修正的能力。就我个人而言,为了学Quartz,刚开始去翻一个2.4MB大小的源码是毫无头绪,并且效率低下的,所以立刻转换方向,先了解这个框架的运行模式,在做什么,有哪些模块,是怎么做的,再找主线,翻相关的源码。之后在一次次使用中,碰到问题再翻之前没看的源码,就越来越顺利。


之前也听过其他同事的学习方法,感觉并不完全适合自己,可能每个人状态经验不同,学习方法也稍有不同。在平时的学习中,需要去感受自己的学习效率,参考建议,尝试,感受效果,改进,会越来越清晰自己适合什么。这里很感谢我的师父,用简短的话先帮我捋顺了调度流程,这样我再看源码就不那么吃力了。


2)要质疑“经验”和“理所应当”,惯性思维会蒙住你的双眼。在大规模的代码中很容易被习惯迷惑,一开始,我们看到上锁的那个方法的时候,认为这个上锁技巧很棒,这个方法就是为了解决并发的问题,“应该”都上锁了,上锁了就不会有并发的问题了,怎么可能几次与数据库的交互都上锁,突然某一次不上锁呢?直到看到拉取待触发的trigger方法时,觉得有丝丝不对劲,打下日志,才发现实际上是没上锁的。


3)日志很重要。虽然我们可以调试,但是没有日志,我们是无法发现并证明,程序发生了ABA问题。


4)最重要的是,不要害怕问题,即使是Quartz这样大型的框架,解决问题也不一定需要把2.4MB的源码通通读懂。只要有时间,问题都能解决,只是好的技巧能缩短这个时间,而我们需要在一次次实战中磨练技巧。

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
1月前
|
存储 缓存 监控
线程池夺命十四问
线程池夺命十四问
线程池夺命十四问
|
2月前
|
数据采集 SQL JSON
《花100块做个摸鱼小网站! 》第五篇—通过xxl-job定时获取热搜数据
本文介绍了使用XXL-Job组件优化热搜数据定时更新的方法,实现了包括阿里云服务器部署、代码库下载、表结构初始化及启动等步骤,并详细展示了如何通过注解配置爬虫任务。文中通过具体示例(如抖音热搜)展示了如何将`@Scheduled`注解替换为`@XxlJob`注解,实现更灵活的任务调度。此外,还优化了前端展示,增加了热搜更新时间显示,并提供了B站热搜爬虫的实现方案。通过这些改进,使得热搜组件不仅功能完善,而且更加美观实用。详细代码可在作者提供的代码仓库中查看。
36 7
|
3月前
|
消息中间件 Java Kafka
Kafka不重复消费的终极秘籍!解锁幂等性、偏移量、去重神器,让你的数据流稳如老狗,告别数据混乱时代!
【8月更文挑战第24天】Apache Kafka作为一款领先的分布式流处理平台,凭借其卓越的高吞吐量与低延迟特性,在大数据处理领域中占据重要地位。然而,在利用Kafka进行数据处理时,如何有效避免重复消费成为众多开发者关注的焦点。本文深入探讨了Kafka中可能出现重复消费的原因,并提出了四种实用的解决方案:利用消息偏移量手动控制消费进度;启用幂等性生产者确保消息不被重复发送;在消费者端实施去重机制;以及借助Kafka的事务支持实现精确的一次性处理。通过这些方法,开发者可根据不同的应用场景灵活选择最适合的策略,从而保障数据处理的准确性和一致性。
261 9
|
6月前
|
设计模式 前端开发 JavaScript
一秒开挂!工厂模式让你告别重复代码!
欢迎来到前端入门之旅!这个专栏是为那些对Web开发感兴趣、刚刚开始学习前端的读者们打造的。无论你是初学者还是有一些基础的开发者,我们都会在这里为你提供一个系统而又亲切的学习平台。我们以问答形式更新,为大家呈现精选的前端知识点和最佳实践。通过深入浅出的解释概念,并提供实际案例和练习,让你逐步建立起一个扎实的基础。无论是HTML、CSS、JavaScript还是最新的前端框架和工具,我们都将为你提供丰富的内容和实用技巧,帮助你更好地理解并运用前端开发中的各种技术。
|
存储 安全 Java
坑爹!Quartz 重复调度问题,你遇到过么?(1)
坑爹!Quartz 重复调度问题,你遇到过么?
353 0
坑爹!Quartz 重复调度问题,你遇到过么?(1)
|
调度
【解决方案 二十】作业调度系统cron表达式详解
【解决方案 二十】作业调度系统cron表达式详解
103 0
|
设计模式 XML 开发框架
《我要进大厂》- Spring框架 夺命连环22问,你能坚持到第几问?(Spring高频问题)(二)
《我要进大厂》- Spring框架 夺命连环22问,你能坚持到第几问?(Spring高频问题)
《我要进大厂》- Spring框架 夺命连环22问,你能坚持到第几问?(Spring高频问题)(二)
|
XML 前端开发 Java
《我要进大厂》- Spring框架 夺命连环22问,你能坚持到第几问?(Spring高频问题)(一)
《我要进大厂》- Spring框架 夺命连环22问,你能坚持到第几问?(Spring高频问题)
《我要进大厂》- Spring框架 夺命连环22问,你能坚持到第几问?(Spring高频问题)(一)
|
设计模式 XML 前端开发
《我要进大厂》- Spring框架 夺命连环22问,你能坚持到第几问?(Spring高频问题)(三)
《我要进大厂》- Spring框架 夺命连环22问,你能坚持到第几问?(Spring高频问题)
《我要进大厂》- Spring框架 夺命连环22问,你能坚持到第几问?(Spring高频问题)(三)
|
存储 设计模式 Java
《我要进大厂》- Spring事务 夺命连环8问,你能坚持到第几问?(Spring事务篇)(一)
《我要进大厂》- Spring事务 夺命连环8问,你能坚持到第几问?(Spring事务篇)
《我要进大厂》- Spring事务 夺命连环8问,你能坚持到第几问?(Spring事务篇)(一)