CLOVER🍀

That was when it all began.

Quartzのクラスタリングは、大量の小さなジョブを実行する場合はスケールしないという話

これは、なにをしたくて書いたもの?

前に、Quartzのクラスタリングについてエントリーを書きました。

Quartzのクラスタリングを試してみる - CLOVER🍀

この時に、負荷分散的なものについてちゃんと見ていなかったのと、ドキュメント上の注意事項を見落としていたのでメモとして。

確認しているQuartzのバージョンは、2.3.2です。

Quartzのクラスタリングについて

Quartzのクラスタリングに関するドキュメントは、こちら。

Configure Clustering with JDBC-JobStore

最初の説明を見ると、クラスタリングは可用性とスケーラビリティをもたらすものだと書かれています。

Quartz’s clustering features bring both high availability and scalability to your scheduler via fail-over and load balancing functionality.

なので、クラスタリングをしてノードを追加していくとスケールするような印象を持ちます。

ですが、ドキュメントの後半をちゃんと読むと、長時間実行されるジョブやCPUを大量に使用するジョブをスケールアウトする場合に
最適なものだと書かれています。

The clustering feature works best for scaling out long-running and/or cpu-intensive jobs (distributing the work-load over multiple nodes).

数千の短時間実行(1秒など)のジョブをスケールアウトする場合は、スケジューラー自体を分割することを検討すべきだそうです。

f you need to scale out to support thousands of short-running (e.g 1 second) jobs, consider partitioning the set of jobs by using multiple distinct schedulers (including multiple clustered schedulers for HA).

スケジューラーはクラスター全体のロックを取得するため、ノードを追加していくとむしろパフォーマンスが低下することが書かれています。
3ノードを超えると難しい状態になりそうですね。

The scheduler makes use of a cluster-wide lock, a pattern that degrades performance as you add more nodes (when going beyond about three nodes - depending upon your database’s capabilities, etc.).

ロックを取得しているのは、こういうところですね。

                transOwner = getLockHandler().obtainLock(conn, lockName);

https://github.com/quartz-scheduler/quartz/blob/v2.3.2/quartz-core/src/main/java/org/quartz/impl/jdbcjobstore/JobStoreSupport.java#L3857

SQL文はこちら。

    public static final String SELECT_FOR_LOCK = "SELECT * FROM "
            + TABLE_PREFIX_SUBST + TABLE_LOCKS + " WHERE " + COL_SCHEDULER_NAME + " = " + SCHED_NAME_SUBST
            + " AND " + COL_LOCK_NAME + " = ? FOR UPDATE";

https://github.com/quartz-scheduler/quartz/blob/v2.3.2/quartz-core/src/main/java/org/quartz/impl/jdbcjobstore/StdRowLockSemaphore.java#L42-L44

テーブル名はTABLE_PREFIX_SUBST + TABLE_LOCKSですが、デフォルトではQRTZ_LOCKSとなります。

クラスター化したQuartzが忙しくなるとこちらのselect 〜 for update文をよく見かけることになり、ジョブの実行そのものよりも
ロック待ちしている時間が長くなっていきます。

この処理は、ジョブをトリガーする際に呼び出されます。

                                List<TriggerFiredResult> res = qsRsrcs.getJobStore().triggersFired(triggers);

https://github.com/quartz-scheduler/quartz/blob/v2.3.2/quartz-core/src/main/java/org/quartz/core/QuartzSchedulerThread.java#L353

このロックが獲得できて、初めてジョブを実行するためのトリガーが取得できるという流れです。

    public List<TriggerFiredResult> triggersFired(final List<OperableTrigger> triggers) throws JobPersistenceException {
        return executeInNonManagedTXLock(LOCK_TRIGGER_ACCESS,
                new TransactionCallback<List<TriggerFiredResult>>() {
                    public List<TriggerFiredResult> execute(Connection conn) throws JobPersistenceException {
                        List<TriggerFiredResult> results = new ArrayList<TriggerFiredResult>();

                        TriggerFiredResult result;
                        for (OperableTrigger trigger : triggers) {
                            try {
                              TriggerFiredBundle bundle = triggerFired(conn, trigger);
                              result = new TriggerFiredResult(bundle);

https://github.com/quartz-scheduler/quartz/blob/v2.3.2/quartz-core/src/main/java/org/quartz/impl/jdbcjobstore/JobStoreSupport.java#L2976-L2986

そして、トリガーを獲得した後にジョブをスレッドプール内のスレッドを使って実行します。

                        for (int i = 0; i < bndles.size(); i++) {
                            TriggerFiredResult result =  bndles.get(i);
                            TriggerFiredBundle bndle =  result.getTriggerFiredBundle();
                            Exception exception = result.getException();

                            〜省略〜

                            JobRunShell shell = null;
                            try {
                                shell = qsRsrcs.getJobRunShellFactory().createJobRunShell(bndle);
                                shell.initialize(qs);
                            } catch (SchedulerException se) {
                                qsRsrcs.getJobStore().triggeredJobComplete(triggers.get(i), bndle.getJobDetail(), CompletedExecutionInstruction.SET_ALL_JOB_TRIGGERS_ERROR);
                                continue;
                            }

                            if (qsRsrcs.getThreadPool().runInThread(shell) == false) {
                                // this case should never happen, as it is indicative of the
                                // scheduler being shutdown or a bug in the thread pool or
                                // a thread pool being used concurrently - which the docs
                                // say not to do...
                                getLog().error("ThreadPool.runInThread() return false!");
                                qsRsrcs.getJobStore().triggeredJobComplete(triggers.get(i), bndle.getJobDetail(), CompletedExecutionInstruction.SET_ALL_JOB_TRIGGERS_ERROR);
                            }

                        }

https://github.com/quartz-scheduler/quartz/blob/v2.3.2/quartz-core/src/main/java/org/quartz/core/QuartzSchedulerThread.java#L370-L407

トリガー取得のためのロック競合の緩和としてはバッチサイズの調整が有効ですが、それでも困難な場合はQuartzのインスタンスを
分割することになるようです。このあたりは実際のジョブを、相応の頻度で流して確認しておいた方が良さそうですね。

バッチサイズの調整については、こちら。

Quartzのスレッドプールを有効に活用するには、バッチサイズを調整した方がいいかもという話 - CLOVER🍀

参考)

Performance Tuning on Quartz Scheduler