Spring Batchを業務で使ってハマったこと


はじめに

業務で開発しているオンライン申請系のシステムで、Spring Batchを利用しています。
Spring Batchを本格的に使ったのは初めてだったのですが、バッチアプリケーションに必要な機能が豊富に提供されており、かなり便利だと感じました。
しかし、仕組みをちゃんと知らずに使ってハマった点があったので、書いてみます。

実行環境

  • Java11
  • Spring Boot 2.4.3
  • Spring Batch 4.3.1
  • PostgreSQL 42.2.18
  • MyBatis 3.5.5

使い方

Spring Batchには、バッチ処理(ジョブ)を起動する方法として、2つの方法がサポートされています。

私達のシステムでは、それぞれ以下のユースケースで利用しています。

  • 夜間バッチなど、スケジューラから起動する処理(from コマンドライン)
  • SaaSなどの外部サービスと通信する処理(from Webコンテナー)

なぜ後者をバッチ処理にしているかと言うと、いわゆる「Retryパターン」を実装できるためです。Spring Batchは、ジョブの実行履歴や実行時のパラメータなどが、全てデータベース等に保存されるため、ジョブのIDを指定して失敗した処理のリトライをかんたんに実装できます。 個人的には、この機能がかなり便利で魅力を感じています。

ハマったコト1:コマンドラインからのジョブ実行でリソース不足

問題

コマンドラインからジョブ起動する方法では、一般的にシェルスクリプトからJavaプロセスを起動することになります。私達のシステムでも、OSのスケジューラ->シェルスクリプト->Javaを起動しています。

ジョブを起動するスクリプトは Java 仮想マシンを開始する必要があるため、プライマリエントリポイントとして機能するメインメソッドを持つクラスが必要です。Spring Batch は、まさにこの目的に役立つ実装を提供します

起動シェル(抜粋)
#!/bin/bash
(略)

# 起動ジョブ名取得
if [ $# = 0 ]; then
  echo "Please enter the job name."
  exit 1
else
  JOBNAME=$1
fi
echo "Batch launch Name is [ $JOBNAME ]"

# 起動パラメータの取得
if [ ! -n "$2" ]; then
  PARAMS=''
  echo "Params is not set."
else
  PARAMS=$2
  echo "Params is $PARAMS"
fi
(略)

# バッチ起動
/usr/bin/java -server -Dspring.main.web-application-type=none -jar ${APP_HOME}/batch-sample-application.jar $JOBNAME $PARAMS

# バッチ処理結果ステータス
EXITCODE=$?
echo "batch exit code is $EXITCODE"
exit $EXITCODE

考えれば当たり前ですが、仮想マシンのリソースやコネクションプールを毎回使用するため、複数のジョブを同時に起動すると、リソース不足に陥りやすくなります。
特に、Spring BootでSpring Batchを動かす場合、コネクションプールHikariCPのプールサイズのデフォルト設定はmaximum-pool-size=minimum-idle=10 となっており、起動のたびに10コネクションを使用するため、一部のプロセスで起動エラーになってしまいました。

起動エラーログ
2021-12-05 01:35:13.735 ERROR [batch-examination,,] 11736 --- [main] com.zaxxer.hikari.pool.HikariPool        : HikariPool-1 - Exception during pool initialization.

org.postgresql.util.PSQLException: 接続試行は失敗しました。
	at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:315)
	at org.postgresql.core.ConnectionFactory.openConnection(ConnectionFactory.java:51)
	at org.postgresql.jdbc.PgConnection.<init>(PgConnection.java:225)
	at org.postgresql.Driver.makeConnection(Driver.java:465)
	at org.postgresql.Driver.connect(Driver.java:264)
	at com.zaxxer.hikari.util.DriverDataSource.getConnection(DriverDataSource.java:138)
	at com.zaxxer.hikari.pool.PoolBase.newConnection(PoolBase.java:358)
	at com.zaxxer.hikari.pool.PoolBase.newPoolEntry(PoolBase.java:206)
	at com.zaxxer.hikari.pool.HikariPool.createPoolEntry(HikariPool.java:477)
	at com.zaxxer.hikari.pool.HikariPool.checkFailFast(HikariPool.java:560)
	at com.zaxxer.hikari.pool.HikariPool.<init>(HikariPool.java:115)
	at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:112)
(略)
	at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:49)
	at org.springframework.boot.loader.Launcher.launch(Launcher.java:107)
	at org.springframework.boot.loader.Launcher.launch(Launcher.java:58)
	at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:88)
Caused by: java.io.EOFException: null
	at org.postgresql.core.PGStream.receiveChar(PGStream.java:443)
	at org.postgresql.core.v3.ConnectionFactoryImpl.doAuthentication(ConnectionFactoryImpl.java:598)
	at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:161)
	at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:213)
	... 97 common frames omitted

解決策(回避策)

  • ジョブの起動タイミングをずらす
  • コネクションプールの数を減らす(minimum-idle=1)
  • ヒープサイズをチューニングする

ハマったコト2:Spring Batchメタデータテーブルの削除

Spring Batchのメタデータテーブル

リファレンスに詳しい説明が記載されていますが、6つのメタデータテーブルから構成されています。これにより、先述したジョブのリトライが可能となっています。


https://spring.pleiades.io/spring-batch/docs/current/reference/html/images/meta-data-erd.png

テーブルのDDLは、各プラットフォームに合わせたsqlファイルがSpring Batchのjarに内包されています。私達はPostgreSQLを使用しているので、以下のsqlをそのまま使用しています。

  • schema-postgresql
  • schema-drop-postgresql.sql

問題

これらのメタデータは、ジョブが実行されるたびに情報がINSERTされていくため、放置すると肥大化してしまいます。
定期的なメンテナンス作業ができないシステムでは、以下のようなテーブルのクリーンアップを実装する必要があるみたいです。

古いジョブの履歴を削除するSQL(MyBatis Mapper)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="xxx.xxx.MetaDataCleanUpMapper">

    <!-- 削除条件 -->
    <sql id="delete_where">
        where
            CREATE_TIME &lt; #{deltime,jdbcType=TIMESTAMP}
    </sql>

    <!-- BATCH_STEP_EXECUTION_CONTEXT の削除 -->
    <sql id="delete_step_execution_context">
        DELETE FROM
            BATCH_STEP_EXECUTION_CONTEXT
        WHERE
            STEP_EXECUTION_ID IN (
	            SELECT
	                STEP_EXECUTION_ID
	            FROM
	                BATCH_STEP_EXECUTION
	            WHERE
	                JOB_EXECUTION_ID IN (
	                    SELECT
	                        JOB_EXECUTION_ID
	                    FROM
	                        BATCH_JOB_EXECUTION
					    <include refid="delete_where"/>
	                )
	        )
    </sql>

    <!-- BATCH_STEP_EXECUTION の削除 -->
    <sql id="delete_step_execution">
        DELETE FROM
		    BATCH_STEP_EXECUTION 
		WHERE
		    JOB_EXECUTION_ID IN (
		        SELECT
		            JOB_EXECUTION_ID
		        FROM
		            BATCH_JOB_EXECUTION
				<include refid="delete_where"/>
        )
    </sql>

    <!-- BATCH_JOB_EXECUTION_CONTEXT の削除 -->
    <sql id="delete_job_execution_context">
        DELETE FROM
		    BATCH_JOB_EXECUTION_CONTEXT
		WHERE
		    JOB_EXECUTION_ID IN (
		        SELECT
		            JOB_EXECUTION_ID
		        FROM
		            BATCH_JOB_EXECUTION
				<include refid="delete_where"/>
        )
    </sql>

    <!-- BATCH_JOB_EXECUTION_PARAMS の削除 -->
    <sql id="delete_job_execution_params">    
        DELETE FROM
		    BATCH_JOB_EXECUTION_PARAMS
		WHERE
		    JOB_EXECUTION_ID IN (
		        SELECT
		            JOB_EXECUTION_ID
		        FROM
		            BATCH_JOB_EXECUTION
				<include refid="delete_where"/>
        )
    </sql>

    <!-- BATCH_JOB_EXECUTION の削除 -->
    <sql id="delete_job_execution">
        DELETE FROM
            BATCH_JOB_EXECUTION
        <include refid="delete_where"/>	
    </sql>

    <!-- BATCH_JOB_INSTANCE の削除 -->
	<sql id="delete_job_instance">  
        DELETE FROM
            BATCH_JOB_INSTANCE
        WHERE
		    JOB_INSTANCE_ID IN (
		        SELECT
		            BATCH_JOB_INSTANCE.JOB_INSTANCE_ID
		        FROM
		            BATCH_JOB_INSTANCE
		            LEFT JOIN
		                BATCH_JOB_EXECUTION
		            ON BATCH_JOB_INSTANCE.JOB_INSTANCE_ID = BATCH_JOB_EXECUTION.JOB_INSTANCE_ID
		        WHERE
		            BATCH_JOB_EXECUTION.JOB_EXECUTION_ID is null
		    )
    </sql>
	
    <!-- メタデータテーブル削除SQL -->
    <delete id="deleteBatchStepExecutionContext" parameterType="map">
        <include refid="delete_step_execution_context"/>
    </delete>

    <delete id="deleteBatchStepExecution" parameterType="map">
        <include refid="delete_step_execution"/>
    </delete>
    
    <delete id="deleteBatchJobExecutionContext" parameterType="map">
        <include refid="delete_job_execution_context"/>
	</delete>
    
    <delete id="deleteBatchJobExecutionParams" parameterType="map">
        <include refid="delete_job_execution_params"/>
    </delete>
    
    <delete id="deleteBatchJobExecution" parameterType="map">
        <include refid="delete_job_execution"/>
    </delete>

    <delete id="deleteBatchJobInstance" parameterType="map">
        <include refid="delete_job_instance"/>
    </delete>

</mapper>

クリーンアップが終わらない…

サービス開始からしばらくすると、クリーンアップに長時間かかるようになりました。1日あたり約6,000〜7,000件ほどのジョブを実行していたのですが、クリーンアップのSQLが24時間経過しても返って来なくなりました。

解決策(回避策)

遅い原因として、BATCH_JOB_EXECUTIONBATCH_JOB_INSTANCEに対する外部キー参照が疑われたので、外部キー参照の無効化(削除でも良い)を行いました。

ALTER TABLE BATCH_JOB_EXECUTION DISABLE TRIGGER ALL;
ALTER TABLE BATCH_JOB_INSTANCE DISABLE TRIGGER ALL;

その結果、数時間かかっていたSQLが、数秒で完了するにようになりました。
この問題については、他の解決方法が分かっていません…

最後に

ということで、実システムでSpring Batchを使ってみた上で、直面した問題や注意点を書いてみました。
他にも注意すべき点や、ここに書いた問題の良い解決策があれば、ぜひご意見いただければ嬉しいです!