<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          坑爹!Quartz 重復(fù)調(diào)度問(wèn)題,你遇到過(guò)么?

          共 9967字,需瀏覽 20分鐘

           ·

          2022-03-09 22:13

          點(diǎn)擊關(guān)注公眾號(hào),回復(fù)“2T”獲取2TB學(xué)習(xí)資源!

          互聯(lián)網(wǎng)架構(gòu)師后臺(tái)回復(fù) 2T 有特別禮包

          上一篇:“35歲門(mén)檻”,公務(wù)員招錄要率先突破

          作者:Lavender
          來(lái)源:https://segmentfault.com/a/1190000015492260

          1. 引子

          公司前期改用quartz做任務(wù)調(diào)度,一日的調(diào)度量均在兩百萬(wàn)次以上。隨著調(diào)度量的增加,突然開(kāi)始出現(xiàn)job重復(fù)調(diào)度的情況,且沒(méi)有規(guī)律可循。網(wǎng)上也沒(méi)有說(shuō)得較為清楚的解決辦法,于是我們開(kāi)始調(diào)試Quartz源碼,并最終找到了問(wèn)題所在。
          如果沒(méi)有耐性看完源碼解析,可以直接拉到文章最末,有直接簡(jiǎn)單的解決辦法。注:本文中使用的quartz版本為2.3.0,且使用JDBC模式存儲(chǔ)Job。

          2. 準(zhǔn)備

          首先,因?yàn)楸疚氖谴a級(jí)別的分析文章,因而需要提前了解Quartz的用途和用法,網(wǎng)上還是有很多不錯(cuò)的文章,可以提前自行了解。
          其次,在用法之外,我們還需要了解一些Quartz框架的基礎(chǔ)概念:
          1)Quartz把觸發(fā)job,叫做fire。TRIGGER_STATE是當(dāng)前trigger的狀態(tài),PREV_FIRE_TIME是上一次觸發(fā)時(shí)間,NEXT_FIRE_TIME是下一次觸發(fā)時(shí)間,misfire是指這個(gè)job在某一時(shí)刻要觸發(fā),卻因?yàn)槟承┰驔](méi)有觸發(fā)的情況。
          2)Quartz在運(yùn)行時(shí),會(huì)起兩類線程(不止兩類),一類用于調(diào)度job的調(diào)度線程(單線程),一類是用于執(zhí)行job具體業(yè)務(wù)的工作池。

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

          • triggers表。triggers表里記錄了,某個(gè)trigger的PREV_FIRE_TIME(上次觸發(fā)時(shí)間),NEXT_FIRE_TIME(下一次觸發(fā)時(shí)間),TRIGGER_STATE(當(dāng)前狀態(tài))。雖未盡述,但是本文用到的只有這些。

          • locks表。Quartz支持分布式,也就是會(huì)存在多個(gè)線程同時(shí)搶占相同資源的情況,而Quartz正是依賴這張表,處理這種狀況,至于如何做到,參見(jiàn)3.1。

          • fired_triggers表,記錄正在觸發(fā)的triggers信息。

          4)TRIGGER_STATE,也就是trigger的狀態(tài),主要有以下幾類:

          trigger的初始狀態(tài)是WAITING,處于WAITING狀態(tài)的trigger等待被觸發(fā)。調(diào)度線程會(huì)不停地掃triggers表,根據(jù)NEXT_FIRE_TIME提前拉取即將觸發(fā)的trigger,如果這個(gè)trigger被該調(diào)度線程拉取到,它的狀態(tài)就會(huì)變?yōu)?strong style="outline: 0px;color: rgb(53, 179, 120);">ACQUIRED。

          因?yàn)槭翘崆袄rigger,并未到達(dá)trigger真正的觸發(fā)時(shí)刻,所以調(diào)度線程會(huì)等到真正觸發(fā)的時(shí)刻,再將trigger狀態(tài)由ACQUIRED改為EXECUTING。
          如果這個(gè)trigger不再執(zhí)行,就將狀態(tài)改為COMPLETE,否則為WAITING,開(kāi)始新的周期。如果這個(gè)周期中的任何環(huán)節(jié)拋出異常,trigger的狀態(tài)會(huì)變成ERROR。如果手動(dòng)暫停這個(gè)trigger,狀態(tài)會(huì)變成PAUSED。

          3. 開(kāi)始排查

          3.1分布式狀態(tài)下的數(shù)據(jù)訪問(wèn)

          前文提到,trigger的狀態(tài)儲(chǔ)存在數(shù)據(jù)庫(kù),Quartz支持分布式,所以如果起了多個(gè)quartz服務(wù),會(huì)有多個(gè)調(diào)度線程來(lái)?yè)寠Z觸發(fā)同一個(gè)trigger。mysql在默認(rèn)情況下執(zhí)行select 語(yǔ)句,是不上鎖的,那么如果同時(shí)有1個(gè)以上的調(diào)度線程搶到同一個(gè)trigger,是否會(huì)導(dǎo)致這個(gè)trigger重復(fù)調(diào)度呢?我們來(lái)看看,Quartz是如何解決這個(gè)問(wèn)題的。
          首先,我們先來(lái)看下JobStoreSupport類的executeInNonManagedTXLock()方法:

          圖3-1 executeInNonManagedTXLock方法的具體實(shí)現(xiàn)

          這個(gè)方法的官方介紹:

          /**

          *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.

          */

          也就是說(shuō),傳入的callback方法在執(zhí)行的過(guò)程中是攜帶了指定的鎖,并開(kāi)啟了事務(wù),注釋也提到,lockName就是指定的鎖的名字,如果lockName是空的,那么callback方法的執(zhí)行不在鎖的保護(hù)下,但依然在事務(wù)中。

          這意味著,我們使用這個(gè)方法,不僅可以保證事務(wù),還可以選擇保證,callback方法的線程安全。

          接下來(lái),我們來(lái)看一下executeInNonManagedTXLock(…)中的obtainLock(conn,lockName)方法,即搶鎖的過(guò)程。這個(gè)方法是在Semaphore接口中定義的,Semaphore接口通過(guò)鎖住線程或者資源,來(lái)保護(hù)資源不被其他線程修改,由于我們的調(diào)度信息是存在數(shù)據(jù)庫(kù)的,所以現(xiàn)在查看DBSemaphore.javaobtainLock方法的具體實(shí)現(xiàn):
          圖3-2 obtainLock方法具體實(shí)現(xiàn)
          我們通過(guò)調(diào)試查看expandedSQLexpandedInsertSQL這兩個(gè)變量:
          圖3-3 expandedSQL和expandedInsertSQL的具體內(nèi)容
          圖3-3可以看出,obtainLock方法通過(guò)locks表的一個(gè)行鎖(lockName確定)來(lái)保證callback方法的事務(wù)和線程安全。拿到鎖后,obtainLock方法將lockName寫(xiě)入threadlocal。當(dāng)然在releaseLock的時(shí)候,會(huì)將lockNamethreadlocal中刪除。
          總而言之,executeInNonManagedTXLock()方法,保證了在分布式的情況,同一時(shí)刻,只有一個(gè)線程可以執(zhí)行這個(gè)方法。

          3.2 quartz的調(diào)度過(guò)程

          圖3-4 Quartz的調(diào)度時(shí)序圖
          QuartzSchedulerThread是調(diào)度線程的具體實(shí)現(xiàn),圖3-4 是這個(gè)線程run()方法的主要內(nèi)容,圖中只提到了正常的情況下,也就是流程中沒(méi)有出現(xiàn)異常的情況下的處理過(guò)程。由圖可以看出,調(diào)度流程主要分為以下三步:

          1)拉取待觸發(fā)trigger:

          調(diào)度線程會(huì)一次性拉取距離現(xiàn)在,一定時(shí)間窗口內(nèi)的,一定數(shù)量?jī)?nèi)的,即將觸發(fā)的trigger信息。那么,時(shí)間窗口和數(shù)量信息如何確定呢,我們先來(lái)看一下,以下幾個(gè)參數(shù):

          • idleWaitTime:默認(rèn)30s,可通過(guò)配置屬性org.quartz.scheduler.idleWaitTime設(shè)置。
          • availThreadCount:獲取可用(空閑)的工作線程數(shù)量,總會(huì)大于1,因?yàn)樵摲椒〞?huì)一直阻塞,直到有工作線程空閑下來(lái)。
          • maxBatchSize:一次拉取trigger的最大數(shù)量,默認(rèn)是1,可通過(guò)org.quartz.scheduler.batchTriggerAcquisitionMaxCount改寫(xiě)
          • batchTimeWindow:時(shí)間窗口調(diào)節(jié)參數(shù),默認(rèn)是0,可通過(guò)org.quartz.scheduler.batchTriggerAcquisitionFireAheadTimeWindow改寫(xiě)
          • misfireThreshold:超過(guò)這個(gè)時(shí)間還未觸發(fā)的trigger,被認(rèn)為發(fā)生了misfire,默認(rèn)60s,可通過(guò)org.quartz.jobStore.misfireThreshold設(shè)置。
          調(diào)度線程一次會(huì)拉取NEXT_FIRE_TIME小于(now + idleWaitTime +batchTimeWindow),大于(now - misfireThreshold)的,min(availThreadCount,maxBatchSize)個(gè)triggers,默認(rèn)情況下,會(huì)拉取未來(lái)30s,過(guò)去60s之間還未fire的1個(gè)trigger。隨后將這些triggers的狀態(tài)由WAITING改為ACQUIRED,并插入fired_triggers表。

          2)觸發(fā)trigger:

          首先,我們會(huì)檢查每個(gè)trigger的狀態(tài)是不是ACQUIRED,如果是,則將狀態(tài)改為EXECUTING,然后更新trigger的NEXT_FIRE_TIME,如果這個(gè)trigger的NEXT_FIRE_TIME為空,也就是未來(lái)不再觸發(fā),就將其狀態(tài)改為COMPLETE。如果trigger不允許并發(fā)執(zhí)行(即Job的實(shí)現(xiàn)類標(biāo)注了@DisallowConcurrentExecution),則將狀態(tài)變?yōu)?strong style="outline: 0px;color: rgb(53, 179, 120);">BLOCKED,否則就將狀態(tài)改為WAITING

          3)包裝trigger,丟給工作線程池:

          遍歷triggers,如果其中某個(gè)trigger在第二步出錯(cuò),即返回值里面有exception或者為null,就會(huì)做一些triggers表,fired_triggers表的內(nèi)容修正,跳過(guò)這個(gè)trigger,繼續(xù)檢查下一個(gè)。否則,則根據(jù)trigger信息實(shí)例化JobRunShell(實(shí)現(xiàn)了Thread接口),同時(shí)依據(jù)JOB_CLASS_NAME實(shí)例化Job,隨后我們將JobRunShell實(shí)例丟入工作線。

          另外,關(guān)注公眾號(hào)互聯(lián)網(wǎng)架構(gòu)師,在后臺(tái)回復(fù):面試,可以獲取我整理的 Java 多線程系列教程,非常齊全。

          JobRunShellrun()方法,Quartz會(huì)在執(zhí)行job.execute()的前后通知之前綁定的監(jiān)聽(tīng)器,如果job.execute()執(zhí)行的過(guò)程中有異常拋出,則執(zhí)行結(jié)果jobExEx會(huì)保存異常信息,反之如果沒(méi)有異常拋出,則jobExEx為null。然后根據(jù)jobExEx的不同,得到不同的執(zhí)行指令instCode
          JobRunShell將trigger信息,job信息和執(zhí)行指令傳給triggeredJobComplete()方法來(lái)完成最后的數(shù)據(jù)表更新操作。例如如果job執(zhí)行過(guò)程有異常拋出,就將這個(gè)trigger狀態(tài)變?yōu)?strong style="outline: 0px;color: rgb(53, 179, 120);">ERROR,如果是BLOCKED狀態(tài),就將其變?yōu)?strong style="outline: 0px;color: rgb(53, 179, 120);">WAITING等等,最后從fired_triggers表中刪除這個(gè)已經(jīng)執(zhí)行完成的trigger。注意,這些是在工作線程池異步完成。

          3.3 排查問(wèn)題

          在前文,我們可以看到,Quartz的調(diào)度過(guò)程中有3次(可選的)上鎖行為,為什么稱為可選?因?yàn)檫@三個(gè)步驟雖然在executeInNonManagedTXLock方法的保護(hù)下,但executeInNonManagedTXLock方法可以通過(guò)設(shè)置傳入?yún)?shù)lockName為空,取消上鎖。在翻閱代碼時(shí),我們看到第一步拉取待觸發(fā)的trigger時(shí):

          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>>() {
                   //省略
              });
          }

          在加鎖之前對(duì)lockName做了一次判斷,而非像其他加鎖方法一樣,默認(rèn)傳入的就是LOCK_TRIGGER_ACCESS

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

          通過(guò)調(diào)試發(fā)現(xiàn)isAcquireTriggersWithinLock()的值是false,因而導(dǎo)致傳入的lockName是null。我在代碼中加入日志,可以更清楚的看到這個(gè)過(guò)程。

          圖3-5 調(diào)度日志

          由圖3-5可以清楚看到,在拉取待觸發(fā)的trigger時(shí),默認(rèn)是不上鎖。如果這種默認(rèn)配置有問(wèn)題,豈不是會(huì)頻繁發(fā)生重復(fù)調(diào)度的問(wèn)題?而事實(shí)上并沒(méi)有,原因在于Quartz默認(rèn)采取樂(lè)觀鎖,也就是允許多個(gè)線程同時(shí)拉取同一個(gè)trigger。我們看一下Quartz在調(diào)度流程的第二步fire trigger的時(shí)候做了什么,注意此時(shí)是上鎖狀態(tài):

          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);
              }

          調(diào)度線程如果發(fā)現(xiàn)當(dāng)前trigger的狀態(tài)不是ACQUIRED,也就是說(shuō),這個(gè)trigger被其他線程fire了,就會(huì)返回null。在3.2,我們提到,在調(diào)度流程的第三步,如果發(fā)現(xiàn)某個(gè)trigger第二步的返回值是null,就會(huì)跳過(guò)第三步,取消fire。在通常的情況下,樂(lè)觀鎖能保證不發(fā)生重復(fù)調(diào)度,但是難免發(fā)生ABA問(wèn)題,我們看一下這是發(fā)生重復(fù)調(diào)度時(shí)的日志:

          圖3-5 重復(fù)調(diào)度的日志
          在第一步時(shí),也就是quartz在拉取到符合條件的triggers 到將他們的狀態(tài)由WAITING改為ACQUIRED之間停頓了有超過(guò)9ms的時(shí)間,而另一臺(tái)服務(wù)器正是趁著這9ms的空檔完成了WAITING-->ACQUIRED-->EXECUTING-->WAITING(也就是一個(gè)完整的狀態(tài)變化周期)的全部過(guò)程,圖示參見(jiàn)圖3-6。
          圖3-6 重復(fù)調(diào)度原因示意圖

          3.4 解決辦法

          如何去解決這個(gè)問(wèn)題呢?在配置文件加上org.quartz.jobStore.acquireTriggersWithinLock=true,這樣,在調(diào)度流程的第一步,也就是拉取待即將觸發(fā)的triggers時(shí),是上鎖的狀態(tài),即不會(huì)同時(shí)存在多個(gè)線程拉取到相同的trigger的情況,也就避免的重復(fù)調(diào)度的危險(xiǎn)。

          3.5 心得

          此次排查過(guò)程并非一帆風(fēng)順,走過(guò)一些坑,也有一些非技術(shù)相關(guān)的體會(huì):

          1)學(xué)習(xí)是一個(gè)需要不斷打磨,修正的能力。就我個(gè)人而言,為了學(xué)Quartz,剛開(kāi)始去翻一個(gè)2.4MB大小的源碼是毫無(wú)頭緒,并且效率低下的,所以立刻轉(zhuǎn)換方向,先了解這個(gè)框架的運(yùn)行模式,在做什么,有哪些模塊,是怎么做的,再找主線,翻相關(guān)的源碼。之后在一次次使用中,碰到問(wèn)題再翻之前沒(méi)看的源碼,就越來(lái)越順利。

          之前也聽(tīng)過(guò)其他同事的學(xué)習(xí)方法,感覺(jué)并不完全適合自己,可能每個(gè)人狀態(tài)經(jīng)驗(yàn)不同,學(xué)習(xí)方法也稍有不同。在平時(shí)的學(xué)習(xí)中,需要去感受自己的學(xué)習(xí)效率,參考建議,嘗試,感受效果,改進(jìn),會(huì)越來(lái)越清晰自己適合什么。這里很感謝我的師父,用簡(jiǎn)短的話先幫我捋順了調(diào)度流程,這樣我再看源碼就不那么吃力了。

          2)要質(zhì)疑“經(jīng)驗(yàn)”和“理所應(yīng)當(dāng)”,慣性思維會(huì)蒙住你的雙眼。在大規(guī)模的代碼中很容易被習(xí)慣迷惑,一開(kāi)始,我們看到上鎖的那個(gè)方法的時(shí)候,認(rèn)為這個(gè)上鎖技巧很棒,這個(gè)方法就是為了解決并發(fā)的問(wèn)題,“應(yīng)該”都上鎖了,上鎖了就不會(huì)有并發(fā)的問(wèn)題了,怎么可能幾次與數(shù)據(jù)庫(kù)的交互都上鎖,突然某一次不上鎖呢?直到看到拉取待觸發(fā)的trigger方法時(shí),覺(jué)得有絲絲不對(duì)勁,打下日志,才發(fā)現(xiàn)實(shí)際上是沒(méi)上鎖的。

          3)日志很重要。雖然我們可以調(diào)試,但是沒(méi)有日志,我們是無(wú)法發(fā)現(xiàn)并證明,程序發(fā)生了ABA問(wèn)題。

          4)最重要的是,不要害怕問(wèn)題,即使是Quartz這樣大型的框架,解決問(wèn)題也不一定需要把2.4MB的源碼通通讀懂。只要有時(shí)間,問(wèn)題都能解決,只是好的技巧能縮短這個(gè)時(shí)間,而我們需要在一次次實(shí)戰(zhàn)中磨練技巧。

          -End-


          最后,關(guān)注公眾號(hào)互聯(lián)網(wǎng)架構(gòu)師,在后臺(tái)回復(fù):2T,可以獲取我整理的 Java 系列面試題和答案,非常齊全。


          正文結(jié)束


          推薦閱讀 ↓↓↓

          1.心態(tài)崩了!稅前2萬(wàn)4,到手1萬(wàn)4,年終獎(jiǎng)扣稅方式1月1日起施行~

          2.深圳一普通中學(xué)老師工資單曝光,秒殺程序員,網(wǎng)友:敢問(wèn)是哪個(gè)學(xué)校畢業(yè)的?

          3.從零開(kāi)始搭建創(chuàng)業(yè)公司后臺(tái)技術(shù)棧

          4.程序員一般可以從什么平臺(tái)接私活?

          5.清華大學(xué):2021 元宇宙研究報(bào)告!

          6.為什么國(guó)內(nèi) 996 干不過(guò)國(guó)外的 955呢?

          7.這封“領(lǐng)導(dǎo)痛批95后下屬”的郵件,句句扎心!

          8.15張圖看懂瞎忙和高效的區(qū)別!

          瀏覽 134
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  日韩福利一区二区三区 | 欧美操逼逼 | 亚洲管视频 | 嫩草午夜少妇在线影视 | 狠狠伊人|