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

作者:Lavender
来源:https://segmentfault.com/a/1190000015492260

1. 引子

公司前期改用quartz做任务调度,一日的调度量均在两百万次以上。随着调度量的增加,突然开始出现job重复调度的情况,且没有规律可循。网上也没有说得较为清楚的解决办法,于是我们开始调试Quartz源码,并最终找到了问题所在。

如果没有耐性看完源码解析,可以直接拉到文章最末,有直接简单的解决办法。
注:本文中使用的quartz版本为2.3.0,且使用JDBC模式存储Job。

2. 准备

首先,因为本文是代码级别的分析文章,因而需要提前了解Quartz的用途和用法,网上还是有很多不错的文章,可以提前自行了解。

其次,在用法之外,我们还需要了解一些Quartz框架的基础概念:

1)Quartz把触发job,叫做 fireTRIGGER_STATE是当前trigger的状态, PREV_FIRE_TIME是上一次触发时间, NEXT_FIRE_TIME是下一次触发时间, misfire是指这个job在某一时刻要触发,却因为某些原因没有触发的情况。

2)Quartz在运行时,会起两类线程(不止两类),一类用于调度job的调度线程(单线程),一类是用于执行job具体业务的工作池。

3)Quartz自带的表里面,本文主要涉及以下3张表:

  • triggers表。triggers表里记录了,某个trigger的PREV_FIRE_TIME(上次触发时间),NEXT_FIRE_TIME(下一次触发时间),TRIGGER_STATE(当前状态)。虽未尽述,但是本文用到的只有这些。
  • locks表。Quartz支持分布式,也就是会存在多个线程同时抢占相同资源的情况,而Quartz正是依赖这张表,处理这种状况,至于如何做到,参见3.1。
  • fired_triggers表,记录正在触发的triggers信息。

4)TRIGGER_STATE,也就是trigger的状态,主要有以下几类:

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

trigger的初始状态是 WAITING,处于 WAITING状态的trigger等待被触发。调度线程会不停地扫triggers表,根据NEXT_FIRE_TIME提前拉取即将触发的trigger,如果这个trigger被该调度线程拉取到,它的状态就会变为 ACQUIRED

因为是提前拉取trigger,并未到达trigger真正的触发时刻,所以调度线程会等到真正触发的时刻,再将trigger状态由 ACQUIRED改为 EXECUTING

如果这个trigger不再执行,就将状态改为 COMPLETE,否则为 WAITING,开始新的周期。如果这个周期中的任何环节抛出异常,trigger的状态会变成 ERROR。如果手动暂停这个trigger,状态会变成 PAUSED

3. 开始排查

3.1分布式状态下的数据访问

前文提到,trigger的状态储存在数据库,Quartz支持分布式,所以如果起了多个quartz服务,会有多个调度线程来抢夺触发同一个trigger。mysql在默认情况下执行select 语句,是不上锁的,那么如果同时有1个以上的调度线程抢到同一个trigger,是否会导致这个trigger重复调度呢?我们来看看,Quartz是如何解决这个问题的。

首先,我们先来看下 JobStoreSupport类的 executeInNonManagedTXLock()方法:

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

这个方法的官方介绍:

/**

*Execute the given callback having acquired the given lock.

*Depending on the JobStore,the surrounding transaction maybe

*assumed to be already present(managed).

*

*@param lockName The name of the lock to acquire,for example

*"TRIGGER_ACCESS".If null, then no lock is acquired ,but the

*lockCallback is still executed in a transaction.

*/

也就是说,传入的callback方法在执行的过程中是携带了指定的锁,并开启了事务,注释也提到,lockName就是指定的锁的名字,如果lockName是空的,那么callback方法的执行不在锁的保护下,但依然在事务中。

这意味着,我们使用这个方法,不仅可以保证事务,还可以选择保证,callback方法的线程安全。

接下来,我们来看一下 executeInNonManagedTXLock(…)中的 obtainLock(conn,lockName)方法,即抢锁的过程。这个方法是在 Semaphore接口中定义的, Semaphore接口通过锁住线程或者资源,来保护资源不被其他线程修改,由于我们的调度信息是存在数据库的,所以现在查看 DBSemaphore.javaobtainLock方法的具体实现:

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

我们通过调试查看 expandedSQLexpandedInsertSQL这两个变量:

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

图3-3可以看出, obtainLock方法通过locks表的一个行锁(lockName确定)来保证callback方法的事务和线程安全。拿到锁后, obtainLock方法将 lockName写入 threadlocal。当然在 releaseLock的时候,会将 lockNamethreadlocal中删除。

总而言之, executeInNonManagedTXLock()方法,保证了在分布式的情况,同一时刻,只有一个线程可以执行这个方法。

3.2 quartz的调度过程

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

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实例丢入工作线。

JobRunShellrun()方法,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 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>(){
        public List execute(Connection conn) throws JobPersistenceException {
            return acquireNextTrigger(conn, noLaterThan, maxCount, timeWindow);
        }
    }, new TransactionValidator>() {
         //省略
    });
}

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

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

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

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

由图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问题,我们看一下这是发生重复调度时的日志:

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

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

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

3.4 解决办法

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

3.5 心得

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

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

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

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

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

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

近期热文推荐:

  1. 1,000+ 道 Java面试题及答案整理(2022最新版)

  2. 劲爆!Java 协程要来了。。。

  3. Spring Boot 2.x 教程,太全了!

  4. 别再写满屏的爆爆爆炸类了,试试装饰器模式,这才是优雅的方式!!

  5. 《Java开发手册(嵩山版)》最新发布,速速下载!

觉得不错,别忘了随手点赞+转发哦!

Original: https://www.cnblogs.com/javastack/p/16736744.html
Author: Java技术栈
Title: 坑爹!Quartz 重复调度问题,你遇到过么?

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/616958/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

  • python 类与对象

    如果需创建一个类,那么可以使用 class 关键字:实例使用名为 a 的属性,创建一个名为 MyClass 的类: class MyClass: a = 6 现在我们可以使用名为 …

    Java 2023年6月9日
    048
  • 回溯法实现全排序Ⅰ

    给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。 示例 1: &…

    Java 2023年6月16日
    080
  • Java避坑宝典《Java业务开发常见错误100例》上线了

    写这个专栏的缘起 之前我写过一篇博客:《朱晔的互联网架构实践心得S2E2:写业务代码最容易掉的10种坑》,引起的关注还是挺多的。后来和极客时间的编辑一拍即合决定以这个为题写一个专栏…

    Java 2023年5月29日
    078
  • 重构聚合支付案例教你如何写出高扩展性易读的代码

    人间清醒 以下代码逻辑为:按照不同的支付方式调用不同支付方式的逻辑流程。痛点: /** * 旧的支付 * * @param mode 模式 * @param payVO 支付签证官…

    Java 2023年6月5日
    083
  • 高峰应对记录

    一、背景 最近这个点9月中旬了,快到双十一了,公司开始对一些较为重要的系统开始进行压测。在这个过程中,需要保证系统的稳定性,以及高吞吐量。所以记录这次的压测过程,自己遇到的各种问题…

    Java 2023年6月15日
    067
  • js console.log打印变量注意事项

    如果是基本类型变量是没有异常的 let str = ‘string’ console.log(str) // string str = ‘改&#x53…

    Java 2023年6月16日
    062
  • Gbase 8t安全技术

    概论: 数据库系统安全&a…

    Java 2023年6月9日
    075
  • 高性能RPC框架gRPC竟恐怖如斯~

    大家好,我是不才陈某~ RPC、gRPC、Thrift、HTTP,大家知道它们之间的联系和区别么?这些都是面试常考的问题,今天带大家先搞懂 RPC 和 gRPC。 在讲述 gRPC…

    Java 2023年6月14日
    091
  • 验证一个小小的问题

    在之前的文章提到过一个问题,而且网上很多文章也是这么说的,前几天有人对这个问题提出了一点不同的意见,抱着谨慎的态度做了一个测试。 问题是这样的:COMPACT格式下,NULL值列表…

    Java 2023年6月13日
    085
  • SpringBoot扩展配置

    一、配置随机数 RandomValuePropertySource可以用于注入随机数,它支持integer、long、uuid、string。 my.secret=${random…

    Java 2023年5月30日
    060
  • MQTT 入门(2)–MQTT 发布订阅详解

    本文主要介绍 MQTT 协议中发布订阅的详细过程。 1、Connect(连接) MQTT 协议基于 TCP/IP 协议,MQTT Broker 和 Client 都有需要有 TCP…

    Java 2023年6月16日
    065
  • jnpf javacloud 微服务配置运气记录

    jnpf过程 服务说明 服务 默认端口 描述 nacos 30099 服务注册、发现和配置中心 sentinel-server 30098 流量控制、熔断降级、系统负载保护 sea…

    Java 2023年5月29日
    062
  • 带研发团队后的日常思考1 初级管理者的困惑

    前言: 本人于2020年4月开始接触管理工作到现在有2年的时间,累计招聘前、后端开发30余人,目前划分为5个小团队,分别负责对应的项目,从刚开始忙的一团糟经常加班,到现在准时下班,…

    Java 2023年6月5日
    071
  • 分享一个网上搜不到的「Redis」实现「聊天回合制」的方案

    前言 为什么说网上搜不到,因为关于聊天回合制的方案作者本人快把百度搜秃噜了也没找到,好在最终是公司一个关系不错的大佬帮提供了点思路,最终作者将其完整实现了出来。 分享出来大家可以收…

    Java 2023年6月9日
    064
  • Spring xml配置文件结合AOP实现 声明式事务

    Original: https://www.cnblogs.com/icemomo/p/16700772.htmlAuthor: 冰莫莫Title: Spring xml配置文件结…

    Java 2023年6月7日
    064
  • jdk自带监控程序jvisualvm的使用

    监控小程序的配置 生产环境tomcat的配置 编辑应用所在的tomcat服务器下的bin目录下的catalina.sh文件,修改如下: 配置如下内容: export JAVA_OP…

    Java 2023年5月30日
    081
亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球