[原始碼分析] 定時任務排程框架 Quartz 之 故障切換
0x00 摘要
之前在 Celery 的故障切換之中[原始碼解析] 並行分散式框架 Celery 之 容錯機制,提到了 Quartz 的故障切換策略,我們就順便看看 Quartz 如何實現。
大家可以互相印證下,看看這些系統之間的異同和精華所在。
0x01 基礎概念
1.1 分散式
考慮分散式,大致可以從兩個方面考慮:功能方面與儲存方面。
- 從功能方面上看,是集中式管理還是分散式管理?如果是分散式管理,怎麼保證節點之間互動協調?
- 從儲存方面上看,是集中儲存還是分散式儲存?如果是分散式儲存,怎麼可以保證全部加起來提供一個完整的儲存映象?
對於Quartz來說,功能方面是分散式管理,儲存方面是集中儲存。
1.1.1 功能方面
一個Quartz叢集中的每個節點是一個獨立的Quartz應用,每個節點都是獨立的,彼此之間不互動,從理論上說,它是完全獨立的。
但是為了應對叢集,這種完全獨立其實就意味著完全不獨立,即每個節點都需要完成所有管理功能,每個節點都需要管理著其他的節點。於是變成了人人為我,我為人人。
或者說,絕對的自由就意味著絕對的不自由,看起來是獨立的節點,但是其他每個節點都可以管理你。
1.1.2 儲存方面
Quartz是採取了集中方式,把所有資訊都放在資料庫表中,由資料庫表統一提供對外的邏輯。
而且,儲存也起到了協助管理作用。獨立的Quartz節點並不與另一其的節點或是管理節點通訊,而是通過相同的資料庫表來感知到另一Quartz應用的。我雖然不直接管理你,但是其他所有節點都可以通過資料庫來暗自控制你。
1.2 基本概念
需要了解一些Quartz框架的基礎概念:
-
Quartz任務排程的核心元素為:Scheduler——任務排程器、Trigger——觸發器、Job——任務。其中trigger和job是任務排程的後設資料,scheduler是實際執行排程的控制器。
-
Trigger 是用於定義排程時間的元素,即按照什麼時間規則去執行任務。
-
Job 用於表示被排程的任務。
-
Quartz把觸發job叫做fire;
-
Quartz在執行時,會起幾類執行緒,其主要是:一類用於排程job的排程執行緒(單執行緒),一類是用於執行job具體業務的工作池;
-
Quartz自帶的表裡面,有幾張表是和觸發job直接相關:
- triggers表。triggers表裡記錄了某個 trigger 的 PREVFIRETIME(上次觸發時間),NEXT_FIRETIME(下一次觸發時間),TRIGGERSTATE(當前狀態);
- locks表。Quartz支援分散式,也就是會存在多個執行緒同時搶佔相同資源的情況,而Quartz正是依賴這張表處理這種狀況;
- fired_triggers表。記錄正在觸發的triggers資訊;
-
TRIGGER_STATE,也就是trigger的狀態;
1.3 排程執行緒
Scheduler排程執行緒主要有兩個:執行常規排程的執行緒,和執行misfiredtrigger的執行緒。
-
常規排程執行緒輪詢儲存的所有trigger,如果有需要觸發的trigger,即到達了下一次觸發的時間,則從任務執行執行緒池獲取一個空閒執行緒,執行與該trigger關聯的任務。
-
Misfire執行緒是掃描所有的trigger,檢視是否有misfiredtrigger,如果有的話根據misfire的策略分別處理(fire now OR wait for the next fire)。
0x02 故障切換
Quartz在叢集模式下通過故障切換和任務負載均衡來實現任務的高可用(HA High Available)和伸縮性。
Quartz是基於排程記錄表對應排程記錄存在的情況下保證高可用。
-
從本質上來說,叢集上每一個節點通過共享同一個資料庫來工作(Quartz通過啟動兩個維護執行緒來維護資料庫狀態實現叢集管理,一個是檢測節點狀態執行緒,一個是恢復任務執行緒)。
-
Quartz叢集中的多個節點是不會同時工作的,只有一個節點是處於工作狀態,其他節點屬於待命狀態,只有當工作節點掛了,其他節點中的一個才會自動升級為工作節點。
-
故障切換的發生是在當一個節點正在執行一個或者多個任務失敗的時候。當一個節點失敗了,其他的節點會檢測到並且標 識在失敗節點上正在進行的資料庫中的任務。
-
任何被標記為可恢復(任務詳細資訊的"requests recovery"屬性)的任務都會被其他的節點重新執行。沒有標記可恢復的任務只會被釋放出來,將會在下次相關觸發器觸發時執行。
因此,我們下面的思考重點就是:
- 如何發現故障節點;
- 如何轉移失效任務;
0x03 總體思路
Fail-Over
機制工作在叢集環境中,執行recovery工作的執行緒類叫做ClusterManager
,該執行緒類同樣是在排程器初始化時就開啟執行了。
這個執行緒類在執行期間每15s
進行一次check in
操作,所謂check in,就是在資料庫的QRTZ2_SCHEDULER_STATE
表中更新該排程器對應的LAST_CHECKIN_TIME
欄位為當前時間,並且檢視其他排程器例項的該欄位有沒有發生停止更新的情況。
當其中一個節點在執行一個或多個作業期間失敗時發生故障切換(Fail Over
)。當節點出現故障時,其他節點會檢測到該狀況並識別資料庫中在故障節點內正在進行的作業。
如果檢查到有排程器的check in time比當前時間要早約15s(視具體的執行預配置情況而定),那麼就判定該排程例項需要recover
,隨後會啟動該排程器的recovery機制
,獲取目標排程器例項正在觸發的trigger
,並針對每一個trigger臨時新增一個對應的僅執行一次的simpletrigger
。
等到排程流程掃描trigger時,這些trigger會被觸發,這樣就成功的把這些未完整執行的排程以一種特殊trigger的形式納入了普通的排程流程中,只要排程流程在正常執行,這些被recover的trigger就會很快被觸發並執行。
0x04 如何發現故障節點
對於故障節點的發現,大多都是使用定期心跳來檢測。
一般來說,有兩種,就是推拉模型。
-
推:定期心跳,每個節點給管理節點傳送心跳;
-
拉:管理節點定期去每個節點拉取狀態資訊;
因為Quartz沒有管理節點,所以必須採用推模式來模擬心跳。
4.1 資料庫表
表 qrtz_scheduler_state
儲存叢集中node例項資訊,quartz會定時讀取該表的資訊判斷叢集中每個例項的當前狀態。
- instance_name:之前配置檔案中org.quartz.scheduler.instanceId配置的名字,就會寫入該欄位,如果設定為AUTO,quartz會根據物理機名和當前時間產生一個名字;
- last_checkin_time:上次檢查時間;
- checkin_interval:檢查間隔時間;
具體表如下:
create table qrtz_scheduler_state
(
sched_name varchar(120) not null,
instance_name varchar(200) not null,
last_checkin_time longint not null,
checkin_interval longint not null,
primary key (sched_name,instance_name)
);
4.2 叢集管理執行緒
叢集管理執行緒ClusterManager
是由排程例項StdSchedulerFactory
開始啟動排程start()
時建立,也是單獨的執行緒例項。
- 叢集管理執行緒如果是第一次CHECKIN,就看看有沒有故障節點,如果發現故障節點就進行處理。
- 此後,叢集管理執行緒休眠到下次檢測週期(配置檔案
org.quartz.jobStore.clusterCheckinInterval
,預設值是 15000 (即15 秒) )到來,檢測CHECKIN資料庫,遍歷叢集各兄弟節點的例項狀態,檢測叢集各個兄弟節點的健康情況。 - 如果存在故障節點,則更新故障節點的觸發器狀態,並刪除故障節點例項狀態。這樣叢集節點間共享觸發任務資料就可以進行故障切換,並訊號通知排程執行緒。故障節點的任務的排程就交由排程處理執行緒處理了。
其縮減版程式碼如下,可以看出來其將定期做 doCheckin:
class ClusterManager extends Thread {
private volatile boolean shutdown = false;
private int numFails = 0;
private boolean manage() {
boolean res = false;
try {
res = doCheckin(); // 進行 checkin
numFails = 0;
} catch (Exception e) {
if(numFails % 4 == 0) {
getLog().error(
"ClusterManager: Error managing cluster: "
+ e.getMessage(), e);
}
numFails++;
}
return res;
}
@Override
public void run() {
while (!shutdown) {
if (!shutdown) {
long timeToSleep = getClusterCheckinInterval();
long transpiredTime = (System.currentTimeMillis() - lastCheckin);
timeToSleep = timeToSleep - transpiredTime;
if (timeToSleep <= 0) {
timeToSleep = 100L;
}
if(numFails > 0) {
timeToSleep = Math.max(getDbRetryInterval(), timeToSleep);
}
Thread.sleep(timeToSleep);
}
if (!shutdown && this.manage()) { // 定期timer函式
signalSchedulingChangeImmediately(0L);
}
}//while !shutdown
}
}
此時邏輯如下:
+-----------------------------+ +---------------------------------+
| Node A | | Node B |
| | | |
| | | |
| ^ +--> ClusterManager +--> | | ^----> ClusterManager +----> |
| | | | Checkin +----+ Checkin | | | |
| | +---------> | DB | <----------+ | |
| | | | +----+ | | | |
| <-------------------------v | | <----------------------------v |
| timer | | timer |
| | | |
+-----------------------------+ +---------------------------------+
4.2.1 定期 Checkin
此方法是:
- 若不是第一次Checkin,則呼叫clusterCheckIn查詢故障節點;
- 否則在獲取到鎖之後,再次呼叫 findFailedInstances 得到failedRecords(因為獲取鎖之後,情況會有所變化,所以需要再次查詢故障節點);
- 若failedRecords大於0,則嘗試進行clusterRecover;
其程式碼如下:
protected boolean doCheckin() throws JobPersistenceException {
boolean transOwner = false;
boolean transStateOwner = false;
boolean recovered = false;
Connection conn = getNonManagedTXConnection();
try {
// Other than the first time, always checkin first to make sure there is
// work to be done before we acquire the lock (since that is expensive,
// and is almost never necessary). This must be done in a separate
// transaction to prevent a deadlock under recovery conditions.
List<SchedulerStateRecord> failedRecords = null;
if (!firstCheckIn) { // 若不是第一次Checkin
failedRecords = clusterCheckIn(conn);
commitConnection(conn);
}
if (firstCheckIn || (failedRecords.size() > 0)) {
getLockHandler().obtainLock(conn, LOCK_STATE_ACCESS);
transStateOwner = true;
// Now that we own the lock, make sure we still have work to do.
// The first time through, we also need to make sure we update/create our state record
// 否則在獲取到鎖之後,再次呼叫 findFailedInstances 得到failedRecords(因為獲取鎖之後,情況會有所變化,所以需要再次查詢故障節點)
failedRecords = (firstCheckIn) ? clusterCheckIn(conn) : findFailedInstances(conn);
if (failedRecords.size() > 0) {
getLockHandler().obtainLock(conn, LOCK_TRIGGER_ACCESS);
//getLockHandler().obtainLock(conn, LOCK_JOB_ACCESS);
transOwner = true;
clusterRecover(conn, failedRecords); // 嘗試進行clusterRecover
recovered = true;
}
}
commitConnection(conn);
} catch (JobPersistenceException e) {
rollbackConnection(conn);
throw e;
}
firstCheckIn = false;
return recovered;
}
4.2.2 偵測失敗節點
當叢集中一個節點的Scheduler例項執行CHECKIN時,它會檢視是否有其他節點的Scheduler例項在到達它們所預期的時間還未CHECKIN,如果一個或多個節點到了預定時間還沒有檢入,那麼執行中的Scheduler就假定它(們) 失敗了。然後需獲取例項狀態訪問行鎖,進而更新觸發器狀態,刪除故障節點例項狀態等等。
查詢叢集兄弟節點存在故障節點的方法是
org.quartz.impl.jdbcjobstore.JobStoreSupport.findFailedInstances(Connection)
判斷節點是否故障與節點Scheduler例項最後CHECKIN的時間有關,而判斷條件是:
LAST_CHECKIN_TIME + Max(檢測週期,檢測節點現在距上次最後CHECKIN的時間) + 7500ms < currentTime。
邏輯是:
通過檢查SCHEDULER_STATE表 中 某一條 Scheduler記錄在 LAST_CHEDK_TIME列的值是否早於org.quartz.jobStore.clusterCheckinInterval
來確定::
- 讀取 qrtz_scheduler_state 表中所有記錄;
- 遍歷記錄,對於某一條記錄:
- 若是本身節點且是第一次CheckIn,則放入錯誤節點列表;
- 若是其他節點且節點Scheduler例項最後CHECKIN的時間距離目前時間大於7500ms,則放入錯誤節點列表;
- 因為這個 間隔時間,就說明 從 上次checkin 時間 到 本次應該checkin 的時間差大於這個時間間隔,從而說明該列對應的節點沒有按時checkin,該節點失效了;
具體程式碼為:
/**
* Get a list of all scheduler instances in the cluster that may have failed.
* This includes this scheduler if it is checking in for the first time.
*/
protected List<SchedulerStateRecord> findFailedInstances(Connection conn)
throws JobPersistenceException {
try {
List<SchedulerStateRecord> failedInstances = new LinkedList<SchedulerStateRecord>();
boolean foundThisScheduler = false;
long timeNow = System.currentTimeMillis();
// 從資料庫讀取記錄
List<SchedulerStateRecord> states = getDelegate().selectSchedulerStateRecords(conn, null);
for(SchedulerStateRecord rec: states) {
// find own record...
if (rec.getSchedulerInstanceId().equals(getInstanceId())) {
foundThisScheduler = true;
if (firstCheckIn) {
failedInstances.add(rec);
}
} else {
// find failed instances...
// 看看是不是過期了
if (calcFailedIfAfter(rec) < timeNow) {
failedInstances.add(rec);
}
}
}
// The first time through, also check for orphaned fired triggers.
if (firstCheckIn) {
failedInstances.addAll(findOrphanedFailedInstances(conn, states));
}
// If not the first time but we didn't find our own instance, then
// Someone must have done recovery for us.
if ((!foundThisScheduler) && (!firstCheckIn)) {
// FUTURE_TODO: revisit when handle self-failed-out impl'ed (see FUTURE_TODO in clusterCheckIn() below)
}
return failedInstances;
}
}
計算時間為:
protected long calcFailedIfAfter(SchedulerStateRecord rec) {
return rec.getCheckinTimestamp() +
Math.max(rec.getCheckinInterval(),
(System.currentTimeMillis() - lastCheckin)) +
7500L;
}
selectSchedulerStateRecords就是從資料庫中讀取記錄:
public List<SchedulerStateRecord> selectSchedulerStateRecords(Connection conn, String theInstanceId)
throws SQLException {
PreparedStatement ps = null;
ResultSet rs = null;
try {
List<SchedulerStateRecord> lst = new LinkedList<SchedulerStateRecord>();
if (theInstanceId != null) {
ps = conn.prepareStatement(rtp(SELECT_SCHEDULER_STATE));
ps.setString(1, theInstanceId);
} else {
ps = conn.prepareStatement(rtp(SELECT_SCHEDULER_STATES));
}
rs = ps.executeQuery();
while (rs.next()) {
SchedulerStateRecord rec = new SchedulerStateRecord();
rec.setSchedulerInstanceId(rs.getString(COL_INSTANCE_NAME));
rec.setCheckinTimestamp(rs.getLong(COL_LAST_CHECKIN_TIME));
rec.setCheckinInterval(rs.getLong(COL_CHECKIN_INTERVAL));
lst.add(rec);
}
return lst;
} finally {
closeResultSet(rs);
closeStatement(ps);
}
}
具體邏輯如下:
+--------------------------------+ +-----------------------------------------------------------+
| Node A | | DB |
| | | qrtz_scheduler_state |
| | | |
| ^ +--> ClusterManager +--v | | +----------------------------------------------------+ |
| | | | selectSchedulerStateRecords | | | |
| | +-------------------------------> | | | |
| | | | | | Node A, LAST_CHECKIN_TIME, CHECKIN_INTERVAL | |
| | | | | | | |
| | | | calcFailedIfAfter | | Node B, LAST_CHECKIN_TIME, CHECKIN_INTERVAL | |
| | | <----------------------------+ | | | |
| <----------------------- v | | | ...... | |
| timer | | | | |
| | | | Node Z, LAST_CHECKIN_TIME, CHECKIN_INTERVAL | |
+--------------------------------+ | | | |
| +----------------------------------------------------+ |
| |
+-----------------------------------------------------------+
手機如下:
0x05 轉移失效任務
下面我們講講從故障例項中恢復Job。
當一個Sheduler例項在執行某個Job時失敗了,有可能由另一正常工作的Scheduler例項接過這個Job重新執行。
5.1 請求恢復
要實現這種行為,配置給JobDetail物件的Job“請求恢復(requests recovery
)”屬性必須設定為true(job.setRequestsRecovery(true))。
- 如果可恢復屬性被設定為false,當某個Scheduler在執行該job失敗時,它將不會重新執行;而是由另一個Scheduler例項在下一次相關的Triggers觸發時簡單地被釋放以執行。
- 任何標記為恢復true的作業將被剩餘的節點重新執行,從而 達到失效任務 轉移的目的。
5.2 更新觸發器狀態
叢集管理執行緒檢測到故障節點,就會更新觸發器狀態,org.quartz.impl.jdbcjobstore.Constants
常量類定義了觸發器的幾種狀態。
故障節點狀態更新規則如下。
故障節點觸發器更新前狀態 | 更新後狀態 |
---|---|
BLOCKED | WAITING |
PAUSED_BLOCKED | PAUSED |
ACQUIRED | WAITING |
COMPLETE | 無,刪除Trigger |
叢集管理執行緒 在資料庫中(qrtz_scheduler_state
表)刪除了 故障節點的例項狀態,即重置了所有故障節點觸發的任務。原先故障任務和正常任務一樣就交由排程處理執行緒處理了。
5.3 恢復任務
任務恢復 具體由clusterRecover方法完成。
- 遍歷每一個失效節點,對於每一個節點:
- 得到此節點已經得到的任務,遍歷每一個任務
- 對於 blocked triggers,則release,修改其狀態;
- release acquired triggers,修改其狀態;
- 如果需要恢復任務,則進行處理,具體就是新增一個新的trigger:
- 設定其job各種資訊;
- 設定其下一次執行時間
- 插入到資料庫;
- 若此任務不允許併發執行,相應修改其狀態;
- 得到此節點已經得到的任務,遍歷每一個任務
具體程式碼如下:
@SuppressWarnings("ConstantConditions")
protected void clusterRecover(Connection conn, List<SchedulerStateRecord> failedInstances) {
if (failedInstances.size() > 0) {
long recoverIds = System.currentTimeMillis();
try {
// 遍歷每一個失效節點
for (SchedulerStateRecord rec : failedInstances) {
List<FiredTriggerRecord> firedTriggerRecs = getDelegate()
.selectInstancesFiredTriggerRecords(conn,
rec.getSchedulerInstanceId());
Set<TriggerKey> triggerKeys = new HashSet<TriggerKey>();
// 對於失效節點已經得到的任務,遍歷每一個任務
for (FiredTriggerRecord ftRec : firedTriggerRecs) {
TriggerKey tKey = ftRec.getTriggerKey();
JobKey jKey = ftRec.getJobKey();
triggerKeys.add(tKey);
// 對於 blocked triggers,則release,修改其狀態
// release blocked triggers..
if (ftRec.getFireInstanceState().equals(STATE_BLOCKED)) {
getDelegate()
.updateTriggerStatesForJobFromOtherState(
conn, jKey,
STATE_WAITING, STATE_BLOCKED);
} else if (ftRec.getFireInstanceState().equals(STATE_PAUSED_BLOCKED)) {
getDelegate()
.updateTriggerStatesForJobFromOtherState(
conn, jKey,
STATE_PAUSED, STATE_PAUSED_BLOCKED);
}
// release acquired triggers,修改其狀態
// release acquired triggers..
if (ftRec.getFireInstanceState().equals(STATE_ACQUIRED)) {
getDelegate().updateTriggerStateFromOtherState(
conn, tKey, STATE_WAITING,
STATE_ACQUIRED);
acquiredCount++;
} else if (ftRec.isJobRequestsRecovery()) {
// 如果需要恢復任務,則進行處理
// handle jobs marked for recovery that were not fully
// executed..
if (jobExists(conn, jKey)) {
@SuppressWarnings("deprecation")
SimpleTriggerImpl rcvryTrig = new SimpleTriggerImpl(
"recover_"
+ rec.getSchedulerInstanceId()
+ "_"
+ String.valueOf(recoverIds++),
Scheduler.DEFAULT_RECOVERY_GROUP,
new Date(ftRec.getScheduleTimestamp()));
rcvryTrig.setJobName(jKey.getName());
rcvryTrig.setJobGroup(jKey.getGroup());
rcvryTrig.setMisfireInstruction(SimpleTrigger.MISFIRE_INSTRUCTION_IGNORE_MISFIRE_POLICY);
rcvryTrig.setPriority(ftRec.getPriority());
JobDataMap jd = getDelegate().selectTriggerJobDataMap(conn, tKey.getName(), tKey.getGroup());
jd.put(Scheduler.FAILED_JOB_ORIGINAL_TRIGGER_NAME, tKey.getName());
jd.put(Scheduler.FAILED_JOB_ORIGINAL_TRIGGER_GROUP, tKey.getGroup());
jd.put(Scheduler.FAILED_JOB_ORIGINAL_TRIGGER_FIRETIME_IN_MILLISECONDS, String.valueOf(ftRec.getFireTimestamp()));
jd.put(Scheduler.FAILED_JOB_ORIGINAL_TRIGGER_SCHEDULED_FIRETIME_IN_MILLISECONDS, String.valueOf(ftRec.getScheduleTimestamp()));
rcvryTrig.setJobDataMap(jd);
rcvryTrig.computeFirstFireTime(null);
storeTrigger(conn, rcvryTrig, null, false,
STATE_WAITING, false, true);
recoveredCount++;
} else {
otherCount++;
}
} else {
otherCount++;
}
// free up stateful job's triggers
......
}
......
}
}
}
具體計算恢復節點的下一次觸發時間程式碼如下:
/**
* <p>
* Called by the scheduler at the time a <code>Trigger</code> is first
* added to the scheduler, in order to have the <code>Trigger</code>
* compute its first fire time, based on any associated calendar.
* </p>
*
* <p>
* After this method has been called, <code>getNextFireTime()</code>
* should return a valid answer.
* </p>
*
* @return the first time at which the <code>Trigger</code> will be fired
* by the scheduler, which is also the same value <code>getNextFireTime()</code>
* will return (until after the first firing of the <code>Trigger</code>).
* </p>
*/
@Override
public Date computeFirstFireTime(Calendar calendar) {
nextFireTime = getStartTime();
while (nextFireTime != null && calendar != null
&& !calendar.isTimeIncluded(nextFireTime.getTime())) {
nextFireTime = getFireTimeAfter(nextFireTime);
if(nextFireTime == null)
break;
//avoid infinite loop
java.util.Calendar c = java.util.Calendar.getInstance();
c.setTime(nextFireTime);
if (c.get(java.util.Calendar.YEAR) > YEAR_TO_GIVEUP_SCHEDULING_AT) {
return null;
}
}
return nextFireTime;
}
至此,quartz 的故障切換分析完畢。
0xEE 個人資訊
★★★★★★關於生活和技術的思考★★★★★★
微信公眾賬號:羅西的思考
如果您想及時得到個人撰寫文章的訊息推送,或者想看看個人推薦的技術資料,敬請關注。