Spring BatchのインメモリDBのデータを削除する方法

この記事は弥生 Advent Calendar 2022の13日目のエントリーです。

こんにちは、情報システム部の飯田です。前回前々回で私が担当している課金システムに関する記事を書いており、プロジェクトメンバーへの説明の時に意外と役に立っていました。ということで今回も課金システムで使っているSpring Batchの話を書いていきたいと思います!(内容としては前々回のSpring Batchをジョブの完了を待機して安全に終了する方法の続きとなります。)

記事中のコードはSpring Boot 2.7.6、Spring Batch 4.3.7のものです。

前回のあらすじ

Spring Batchは予め用意されたジョブをバッチ処理し、完了したらアプリも終了しますが、スケジュール機能(@EnableScheduling)を導入することで定期的なジョブ実行ができるようになります。その場合、アプリを終了しようとしたら実行中のジョブを異常終了してしまう恐れがありましたが、独自実装によってジョブの完了まで待機するようになりました。

実は他にも気を付けなければならないことがあり、それはSpring Batchが管理しているジョブデータのメモリサイズです。一体どういうことなのか、詳しく見ていきましょう。

Spring Batchのデータを見てみよう!

Spring Batchではジョブの実行に必要なデータをメタデータテーブルで管理しています。前回プロジェクトを作成する時にH2 Databaseを依存ライブラリに追加していましたが、こうするとSpring Batchが自動でH2 Databaseをインメモリモード(外部のDBに接続するのではなく、メモリ上にDBを作成して使用するモード)で起動し、必要なテーブルを作成してジョブ管理を行うようになるのです。賢いですね!

どのようにデータを管理しているのか見てみたいところですが、DBとはいえメモリ上に展開されているのでインスタンスと同じような扱いであり、通常は外から確認することはできません。そこで出てくるのがH2DBのコンソールで、依存ライブラリにSpring Webを追加して設定を追加するだけでWebブラウザからDBの中身を見られるようになります。

依存ライブラリの追加(build.gradle)

   implementation 'org.springframework.boot:spring-boot-starter-web'  // dependenciesに追加

H2コンソールの有効化(application.yml)

spring:
  datasource:
    url: jdbc:h2:mem:batch
  h2:
    console:
      enabled: true

Gradleビルドを実行した後にアプリを起動し、http://localhost:8080/h2-console にアクセスして以下を入力し「Connect」を押します。

  • JDBC URL:jdbc:h2:mem:batch
  • User Name:sa
  • Password:空のまま

するとインメモリのDBに接続され、様々な操作が行えるコンソールが開きます。これでSpring Batchのデータを見放題です!

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

Spring Batchのメタデータテーブルは以下の6つのテーブルで構成されます。

Spring Batch - リファレンスドキュメントより引用

  1. BATCH_JOB_INSTANCE
    • 名前の通りジョブのインスタンスの情報を格納するテーブルです。
  2. BATCH_JOB_EXECUTION_PARAMS
    • ジョブ実行時に指定するパラメータを格納するテーブルです。
  3. BATCH_JOB_EXECUTION
  4. BATCH_STEP_EXECUTION
    • それぞれジョブとステップの実行情報(STARTEDCOMPLETEDといった状態や開始・終了時刻など)を格納するテーブルです。
  5. BATCH_JOB_EXECUTION_CONTEXT
  6. BATCH_STEP_EXECUTION_CONTEXT
    • ジョブの実行中に情報を格納できるテーブルです。
    • EXECUTION_CONTEXTに格納した情報は全てのステップから参照でき、STEP_EXECUTION_CONTEXTに格納した情報は同じステップからのみ参照できます。

JobLauncher.run(Job, JobParameters)を実行するだけでSpring Batchがうまい感じに処理してくれましたが、裏ではこれだけのテーブルを使ってジョブの実行を細かく管理してくれていたんですね!

ジョブのデータは自動には削除されない!

Spring Batchのジョブの管理方法が分かったところで最初の話に戻りましょう。データのメモリサイズに気を付ける必要があると書きましたが、これはジョブを実行するたびにメタデータテーブルにどんどんレコードが追加されていく一方でジョブが完了しても削除されずに残り続けるため、いつかはOut Of Memory Error(OOME)になってしまうからなのです!

@Component
@Slf4j
@RequiredArgsConstructor
public class SampleBatchLauncher {

  private final JobLauncher jobLauncher;
  private final JobExplorer jobExplorer;
  private final Job sampleJob;
  
  private JobParameters jobParameters;
  
  @Scheduled(cron = "*/5 * * * * *")
  public void launchSampleJob() throws JobExecutionException {
    log.info("1000個のジョブの実行を開始します。");
    for(int count = 0; count < 1000; count++) {
      this.jobParameters = this.sampleJob.getJobParametersIncrementer().getNext(this.jobParameters);
      this.jobLauncher.run(this.sampleJob, this.jobParameters);
    }
    log.info("1000個のジョブの実行が完了しました。");
    
    int jobInstanceCount = this.jobExplorer.getJobInstanceCount(sampleJob.getName());
    log.info(jobInstanceCount + "個のジョブインスタンスが存在します。");
  }
}

本当にOOMEになるのか、ジョブを5秒ごとに1,000個ずつ実行するよう前回のコードを変更し、ヒープメモリのサイズを小さくして実行してみましょう。JobExplorerはジョブリポジトリからデータを取得するためのコンポーネントで、これもSpring Batchが自動で生成してくれます。また、STSで実行する場合は構成の「VM引数」でヒープサイズを指定できます。実行してしばらく放置しておくと…。

OOMEになりました。

JDK Mission Controlというツールを使うとヒープメモリのサイズを可視化でき、これぞまさにOOMEというようなグラフになっていますね(ちなみに73,000個弱のジョブを実行したところで発生しました)。

ジョブのデータを定期的に削除したい!

それではデータを削除する方法を書いていきますが、その前にJobRepositoryというこれまたSpring Bootが自動生成してくれるコンポーネントがあります。名前の通りリポジトリなので、もしそこにdeleteメソッドがあればそれを呼び出すだけで解決しそうですが…。

この通りcreate/addとget、updateしか用意されておらず、ここからもデータが削除されないということが分かります。

このJobRepositoryにdeleteを独自実装する方法も考えられますがなかなか大変なので、完了したジョブに関するデータを一括削除する専用のクラスを作ってしまいましょう。

ジョブリポジトリの定期削除処理の実装(新規クラス)

@Component
@Slf4j
@RequiredArgsConstructor
public class JobRepositoryClearExecutor {

  // 1. データ削除に必要なSQL文を用意する
  private static final String SQL_DELETE_BATCH_STEP_EXECUTION_CONTEXT = """
    DELETE FROM BATCH_STEP_EXECUTION_CONTEXT WHERE STEP_EXECUTION_ID IN (
      SELECT STEP_EXECUTION_ID FROM BATCH_STEP_EXECUTION WHERE STATUS = 'COMPLETED'
    )
    """;
  private static final String SQL_DELETE_BATCH_STEP_EXECUTION = """
    DELETE FROM BATCH_STEP_EXECUTION WHERE STATUS = 'COMPLETED'
    """;
  private static final String SQL_DELETE_BATCH_JOB_EXECUTION_CONTEXT = """
    DELETE FROM BATCH_JOB_EXECUTION_CONTEXT WHERE JOB_EXECUTION_ID IN (
      SELECT JOB_EXECUTION_ID FROM BATCH_JOB_EXECUTION WHERE STATUS = 'COMPLETED'
    )
    """;
  private static final String SQL_DELETE_BATCH_JOB_EXECUTION_PARAMS = """
    DELETE FROM BATCH_JOB_EXECUTION_PARAMS WHERE JOB_EXECUTION_ID IN (
      SELECT JOB_EXECUTION_ID FROM BATCH_JOB_EXECUTION WHERE STATUS = 'COMPLETED'
    )
    """;
  private static final String SQL_DELETE_BATCH_JOB_EXECUTION = """
    DELETE FROM BATCH_JOB_EXECUTION WHERE STATUS = 'COMPLETED'
    """;
  private static final String SQL_DELETE_BATCH_JOB_INSTANCE = """
    DELETE FROM BATCH_JOB_INSTANCE WHERE JOB_INSTANCE_ID NOT IN (
      SELECT JOB_INSTANCE_ID FROM BATCH_JOB_EXECUTION
    )
    """;

  private final DataSource dataSource;
  private final JobExplorer jobExplorer;
  private final Job sampleJob;

  private JdbcTemplate jdbcTemplate;
  
  @PostConstruct
  public void postConstruct() {
    // 2. データアクセスに必要なオブジェクトを生成する
    this.jdbcTemplate = new JdbcTemplate(this.dataSource);
  }

  @Scheduled(cron = "0 */1 * * * *")
  public void execute() throws NoSuchJobException {

    long jobInstanceCount = this.jobExplorer.getJobInstanceCount(this.sampleJob.getName());
    log.info("削除前のジョブインスタンス数は" +jobInstanceCount + "個です。");

    // 3. Spring Batchのメタデータテーブルのレコードを順番に削除する
    this.jdbcTemplate.update(SQL_DELETE_BATCH_STEP_EXECUTION_CONTEXT);
    this.jdbcTemplate.update(SQL_DELETE_BATCH_STEP_EXECUTION);
    this.jdbcTemplate.update(SQL_DELETE_BATCH_JOB_EXECUTION_CONTEXT);
    this.jdbcTemplate.update(SQL_DELETE_BATCH_JOB_EXECUTION_PARAMS);
    this.jdbcTemplate.update(SQL_DELETE_BATCH_JOB_EXECUTION);
    this.jdbcTemplate.update(SQL_DELETE_BATCH_JOB_INSTANCE);

    jobInstanceCount = this.jobExplorer.getJobInstanceCount(this.sampleJob.getName());
    log.info("削除後のジョブインスタンス数は" + jobInstanceCount + "個です。");
  }
}
  1. 処理が完了した(ステータスが'Completed'の)ジョブに関するデータを各テーブルから削除するSQLです。
  2. データアクセスに必要なJdbcTemplateの作成にはDataSourceが必要で、これもSpring Batchが自動生成してくれます。@PostConstructを付与したメソッドはコンストラクタでの初期化処理が終わった後に呼び出されるので、コンストラクタインジェクションでDataSourceがセットされてからJdbcTemplateを作るようにしています。
  3. JdbcTemplateと用意したSQLを1つずつ実行してデータを削除します。外部キー制約のためこの順番で実行する必要があります。

これを先ほどのジョブ実行と同時に動かしてみます。@Scheduledで1分ごとに実行されるようになっており、その時のログは以下の通りでした。

1000個のジョブの実行を開始します。
1000個のジョブの実行が完了しました。
12000個のジョブインスタンスが存在します。
削除前のジョブインスタンス数は12000個です。
削除後のジョブインスタンス数は0個です。
1000個のジョブの実行を開始します。
1000個のジョブの実行が完了しました。
1000個のジョブインスタンスが存在します。

ジョブインスタンスの数がリセットされているのでちゃんと動いていそうですね!

ヒープメモリのグラフもこの通りで、OOMEも起こらずにいつまでも稼働し続けられそうです。

こんな感じで、Spring BatchのインメモリDBを削除することができるようになりました。実際には最大ヒープサイズをもっと大きくしたりデプロイのために定期的に再起動したりするので削除しなくても大丈夫かも知れませんが、運用上の不安要素を減らせるのは大きいですよね。

…まあ、今回はインメモリDBを使うという前提でしたが、データソースの設定で外部DBを使うようにすれば今回書いた削除クエリを実行するだけで済むんですけどね(笑)。こんな方法もあるんだなという風に見てもらえればと思います!

あとがき

記事ではJobRepositoryにdeleteがないと書きましたが、ネットでJavadocを調べてみると…。

Springドキュメントより引用

deleteメソッドあるやないかい!

調べてみると、なんとこの記事を書く少し前の2022年11月23日にSpring Batch 5.0.0がリリースされており、そこで追加されていたのです。

これではこの記事を書く意味がなくなってしまうと思いつつも、まずは詳細を確認しようと思ってバージョンを上げてみたのですが、どうやらJobRepositoryの他にも色々と変わっているらしく、4.3.7のコードがそのままだと動きませんでした。もちろん動くように修正することは可能でしたが、そうすると前回の記事の実装を変える必要があり説明も長くなりすぎてしまうと思ったので、今回は4.3.7の内容で強行突破することにしました(苦笑)。

5.0.0についてはどのみちプロジェクトで対応する必要があるので、またちゃんと調べてから記事にしたいと思います!

一緒に働く仲間を募集しています

herp.careers